sync-wire.ts

2.4 KB · raw

1import type { SyncMessage } from "./sync-types";23const textEncoder = new TextEncoder();4const textDecoder = new TextDecoder("utf-8", { fatal: true });56export function encodeSyncMessage(message: SyncMessage): string {7  return base64urlEncode(textEncoder.encode(JSON.stringify(message)));8}910export function decodeSyncMessage(data: string): SyncMessage {11  try {12    const raw = textDecoder.decode(base64urlDecode(data));13    const parsed = JSON.parse(raw) as unknown;14    if (isSyncMessage(parsed)) {15      return parsed;16    }17  } catch {18    // Normalize malformed transport payloads so callers do not branch on parser details.19  }20  throw new Error("invalid sync message");21}2223export function isSyncMessage(value: unknown): value is SyncMessage {24  if (!isRecord(value) || typeof value.type !== "string") return false;2526  switch (value.type) {27    case "SYNC_VAULT_SEND":28      return (29        isString(value.vault_a) &&30        isString(value.vault_b) &&31        isFiniteNumber(value.sent_at)32      );33    case "SYNC_VAULT_ACK":34      return isFiniteNumber(value.received_at);35    case "SYNC_ERROR":36      return isString(value.reason);37    case "PAIR_REQUEST":38      return isString(value.public_key_hex) && isString(value.device_name);39    case "PAIR_ACCEPT":40      return isString(value.public_key_hex) && isString(value.device_name);41    case "PAIR_REJECT":42      return isString(value.reason);43    default:44      return false;45  }46}4748function isRecord(value: unknown): value is Record<string, unknown> {49  return Boolean(value) && typeof value === "object" && !Array.isArray(value);50}5152function isString(value: unknown): value is string {53  return typeof value === "string";54}5556function isFiniteNumber(value: unknown): value is number {57  return typeof value === "number" && Number.isFinite(value);58}5960function base64urlEncode(bytes: Uint8Array): string {61  let binary = "";62  for (const byte of bytes) binary += String.fromCharCode(byte);63  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");64}6566function base64urlDecode(value: string): Uint8Array {67  if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {68    throw new Error("invalid sync message");69  }70  const padded = value71    .replace(/-/g, "+")72    .replace(/_/g, "/")73    .padEnd(Math.ceil(value.length / 4) * 4, "=");74  const binary = atob(padded);75  const out = new Uint8Array(binary.length);76  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);77  return out;78}