sync-wire.ts

3.5 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_HELLO":28      return (29        isString(value.deviceId) &&30        isFiniteNumber(value.clockMs) &&31        isFiniteNumber(value.vaultVersion)32      );33    case "SYNC_MANIFEST":34      return Array.isArray(value.entries) && value.entries.every(isSyncManifestEntry);35    case "SYNC_REQUEST":36      return isStringArray(value.ids);37    case "SYNC_ENTRIES":38      return Array.isArray(value.entries) && value.entries.every(isEncryptedSyncEntry);39    case "SYNC_DONE":40      return true;41    case "SYNC_ERROR":42      return isString(value.code) && isString(value.message);43    case "PAIR_REQUEST":44      return isString(value.edPublicKey) && isString(value.name) && isString(value.code);45    case "PAIR_ACCEPT":46      return isString(value.edPublicKey) && isString(value.name);47    case "PAIR_REJECT":48      return isString(value.reason);49    default:50      return false;51  }52}5354function isSyncManifestEntry(value: unknown): boolean {55  return (56    isRecord(value) &&57    isString(value.id) &&58    isFiniteNumber(value.modifiedAt) &&59    isOptionalNumber(value.deletedAt) &&60    isString(value.hash)61  );62}6364function isEncryptedSyncEntry(value: unknown): boolean {65  return (66    isRecord(value) &&67    isString(value.id) &&68    isFiniteNumber(value.modifiedAt) &&69    isOptionalNumber(value.deletedAt) &&70    isString(value.ciphertext) &&71    isString(value.nonce) &&72    isString(value.tag)73  );74}7576function isRecord(value: unknown): value is Record<string, unknown> {77  return Boolean(value) && typeof value === "object" && !Array.isArray(value);78}7980function isString(value: unknown): value is string {81  return typeof value === "string";82}8384function isStringArray(value: unknown): value is string[] {85  return Array.isArray(value) && value.every(isString);86}8788function isFiniteNumber(value: unknown): value is number {89  return typeof value === "number" && Number.isFinite(value);90}9192function isOptionalNumber(value: unknown): boolean {93  return value === undefined || isFiniteNumber(value);94}9596function base64urlEncode(bytes: Uint8Array): string {97  let binary = "";98  for (const byte of bytes) binary += String.fromCharCode(byte);99  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");100}101102function base64urlDecode(value: string): Uint8Array {103  if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {104    throw new Error("invalid sync message");105  }106  const padded = value107    .replace(/-/g, "+")108    .replace(/_/g, "/")109    .padEnd(Math.ceil(value.length / 4) * 4, "=");110  const binary = atob(padded);111  const out = new Uint8Array(binary.length);112  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);113  return out;114}