dsh-dispatch-relay 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
4
5
|
import { pathToFileURL } from "url";
|
|
5
6
|
|
|
6
7
|
// src/config.ts
|
|
@@ -587,8 +588,15 @@ async function main() {
|
|
|
587
588
|
});
|
|
588
589
|
}
|
|
589
590
|
}
|
|
590
|
-
|
|
591
|
-
if (argvPath
|
|
591
|
+
function isMainModule(argvPath, moduleUrl) {
|
|
592
|
+
if (argvPath === void 0) return false;
|
|
593
|
+
try {
|
|
594
|
+
return moduleUrl === pathToFileURL(realpathSync(argvPath)).href;
|
|
595
|
+
} catch {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
592
600
|
main().catch((err) => {
|
|
593
601
|
log.error(`fatal: ${errorMessage(err)}`);
|
|
594
602
|
process.exitCode = 1;
|
|
@@ -596,6 +604,7 @@ if (argvPath !== void 0 && import.meta.url === pathToFileURL(argvPath).href) {
|
|
|
596
604
|
}
|
|
597
605
|
export {
|
|
598
606
|
createRelayServer,
|
|
607
|
+
isMainModule,
|
|
599
608
|
loadConfig
|
|
600
609
|
};
|
|
601
610
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/config.ts","../src/log.ts","../src/server.ts","../../shared/src/limits.ts","../src/push-store.ts","../src/frames.ts","../src/rooms.ts","../src/connection.ts","../src/http.ts","../src/push.ts"],"sourcesContent":["// dsh-dispatch relay entrypoint (bin: dsh-dispatch-relay).\n\nimport { pathToFileURL } from 'node:url';\nimport { loadConfig } from './config.js';\nimport { errorMessage, log } from './log.js';\nimport { createRelayServer, type RelayServer } from './server.js';\n\nexport { loadConfig } from './config.js';\nexport { createRelayServer } from './server.js';\nexport type { RelayConfig } from './config.js';\nexport type { RelayServer } from './server.js';\n\nasync function stop(server: RelayServer, signal: string): Promise<void> {\n log.info(`${signal} received, shutting down`);\n await server.close();\n process.exit(0);\n}\n\nasync function main(): Promise<void> {\n const server = await createRelayServer(loadConfig());\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.on(signal, () => {\n stop(server, signal).catch((err: unknown) => {\n log.error(`shutdown failed: ${errorMessage(err)}`);\n process.exit(1);\n });\n });\n }\n}\n\n// Run only when executed as a program, so the module stays importable (tests, embedding).\nconst argvPath = process.argv[1];\nif (argvPath !== undefined && import.meta.url === pathToFileURL(argvPath).href) {\n main().catch((err: unknown) => {\n log.error(`fatal: ${errorMessage(err)}`);\n process.exitCode = 1;\n });\n}\n","// Environment configuration. Invalid values fail loudly at startup rather than\n// being silently coerced — a relay that listens on the wrong port is worse than one that refuses to boot.\n\nimport { resolve } from 'node:path';\n\nexport const DEFAULT_PORT = 8787;\nexport const DEFAULT_DATA_DIR = './relay-data';\n\nexport interface VapidConfig {\n publicKey: string;\n privateKey: string;\n subject: string;\n}\n\nexport interface RelayConfig {\n port: number;\n dataDir: string;\n /** null = Web Push disabled; `pushDisabledReason` says why (logged once at startup). */\n vapid: VapidConfig | null;\n pushDisabledReason: string | null;\n}\n\nfunction parsePort(raw: string | undefined): number {\n if (raw === undefined || raw.trim() === '') return DEFAULT_PORT;\n const port = Number(raw);\n if (!Number.isInteger(port) || port < 0 || port > 65535) {\n throw new Error(`invalid PORT: ${raw} (expected an integer 0-65535)`);\n }\n return port;\n}\n\nfunction readVapid(env: NodeJS.ProcessEnv): Pick<RelayConfig, 'vapid' | 'pushDisabledReason'> {\n const publicKey = env.VAPID_PUBLIC_KEY?.trim();\n const privateKey = env.VAPID_PRIVATE_KEY?.trim();\n const subject = env.VAPID_SUBJECT?.trim();\n if (publicKey && privateKey && subject) {\n return { vapid: { publicKey, privateKey, subject }, pushDisabledReason: null };\n }\n const missing = [\n publicKey ? null : 'VAPID_PUBLIC_KEY',\n privateKey ? null : 'VAPID_PRIVATE_KEY',\n subject ? null : 'VAPID_SUBJECT',\n ].filter((name): name is string => name !== null);\n const reason =\n missing.length === 3 ? 'VAPID env not set' : `VAPID env incomplete (missing ${missing.join(', ')})`;\n return { vapid: null, pushDisabledReason: reason };\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): RelayConfig {\n return {\n port: parsePort(env.PORT),\n dataDir: resolve(env.DATA_DIR?.trim() || DEFAULT_DATA_DIR),\n ...readVapid(env),\n };\n}\n","// Structured-ish single-line logging.\n// INVARIANT (docs/PROTOCOL.md §Security 4): never log payloads, ciphertext or push bodies.\n// Room ids are always shortened via shortRoom() before they reach a log line.\n\nexport type LogLevel = 'silent' | 'error' | 'warn' | 'info';\n\nconst RANK: Record<LogLevel, number> = { silent: 0, error: 1, warn: 2, info: 3 };\n\nfunction initialLevel(): LogLevel {\n const raw = process.env.LOG_LEVEL?.trim().toLowerCase();\n return raw && raw in RANK ? (raw as LogLevel) : 'info';\n}\n\nlet level: LogLevel = initialLevel();\n\nexport function setLogLevel(next: LogLevel): void {\n level = next;\n}\n\nfunction emit(at: LogLevel, write: (line: string) => void, message: string): void {\n if (RANK[at] > RANK[level]) return;\n write(`${new Date().toISOString()} [relay] ${at} ${message}`);\n}\n\nexport const log = {\n info: (message: string) => emit('info', (l) => console.log(l), message),\n warn: (message: string) => emit('warn', (l) => console.warn(l), message),\n error: (message: string) => emit('error', (l) => console.error(l), message),\n};\n\n/** Room ids are opaque to the relay; only a prefix ever reaches the logs. */\nexport function shortRoom(room: string): string {\n return `${room.slice(0, 8)}…`;\n}\n\nexport function errorMessage(err: unknown): string {\n if (err instanceof Error) return err.message;\n return typeof err === 'string' ? err : JSON.stringify(err);\n}\n","// Wiring: HTTP surface + WebSocket router + heartbeat + push store.\n\nimport { createServer, type Server } from 'node:http';\nimport { WebSocketServer, type WebSocket } from 'ws';\nimport type { RelayConfig } from './config.js';\nimport { handleConnection, type ConnectionContext } from './connection.js';\nimport { createRequestHandler, WS_PATH } from './http.js';\nimport { errorMessage, log } from './log.js';\nimport { PushStore } from './push-store.js';\nimport { configureWebPush, Pusher } from './push.js';\nimport { RoomRegistry } from './rooms.js';\n\nexport const PING_INTERVAL_MS = 25_000;\n/** Well above the 16KB envelope limit: we want to answer `too-large` ourselves, not have ws drop the socket. */\nexport const MAX_WS_PAYLOAD_BYTES = 1024 * 1024;\n\ninterface HeartbeatSocket extends WebSocket {\n isAlive?: boolean;\n}\n\nexport interface RelayServer {\n /** Actual listening port (resolves `PORT=0` for tests). */\n port: number;\n rooms: RoomRegistry;\n store: PushStore;\n pushEnabled: boolean;\n close(): Promise<void>;\n}\n\nfunction listen(server: Server, port: number): Promise<void> {\n return new Promise((resolvePromise, rejectPromise) => {\n server.once('error', rejectPromise);\n server.listen(port, () => {\n server.removeListener('error', rejectPromise);\n resolvePromise();\n });\n });\n}\n\nfunction startHeartbeat(wss: WebSocketServer): NodeJS.Timeout {\n const timer = setInterval(() => {\n for (const client of wss.clients as Set<HeartbeatSocket>) {\n if (client.isAlive === false) {\n log.warn('terminating unresponsive socket (missed pong)');\n client.terminate();\n continue;\n }\n client.isAlive = false;\n client.ping();\n }\n }, PING_INTERVAL_MS);\n timer.unref();\n return timer;\n}\n\nfunction shutdown(server: Server, wss: WebSocketServer, heartbeat: NodeJS.Timeout): Promise<void> {\n clearInterval(heartbeat);\n for (const client of wss.clients) client.terminate();\n return new Promise((resolvePromise) => {\n wss.close(() => server.close(() => resolvePromise()));\n });\n}\n\nexport async function createRelayServer(config: RelayConfig): Promise<RelayServer> {\n const store = await PushStore.load(config.dataDir);\n const pushEnabled = configureWebPush(config.vapid, config.pushDisabledReason);\n const ctx: ConnectionContext = {\n rooms: new RoomRegistry(),\n store,\n pusher: new Pusher(store, pushEnabled),\n };\n\n const server = createServer(createRequestHandler(pushEnabled && config.vapid ? config.vapid.publicKey : null));\n const wss = new WebSocketServer({ server, path: WS_PATH, maxPayload: MAX_WS_PAYLOAD_BYTES });\n wss.on('connection', (socket: HeartbeatSocket) => {\n socket.isAlive = true;\n socket.on('pong', () => {\n socket.isAlive = true;\n });\n handleConnection(socket, ctx);\n });\n wss.on('error', (err) => log.error(`websocket server error: ${errorMessage(err)}`));\n server.on('error', (err) => log.error(`http server error: ${errorMessage(err)}`));\n\n const heartbeat = startHeartbeat(wss);\n await listen(server, config.port);\n const address = server.address();\n const port = typeof address === 'object' && address !== null ? address.port : config.port;\n log.info(\n `listening on :${port} ws=${WS_PATH} dataDir=${config.dataDir} push=${pushEnabled ? 'on' : 'off'}`,\n );\n\n return {\n port,\n rooms: ctx.rooms,\n store,\n pushEnabled,\n close: () => shutdown(server, wss, heartbeat),\n };\n}\n","// Payload limits from docs/PROTOCOL.md. The sender truncates BEFORE encryption.\n// The relay imports THIS FILE by subpath (src/limits.js) to keep tweetnacl out of its\n// bundle — if this package ever gains a package.json \"exports\" map, add a matching entry.\n\nexport const MAX_ENVELOPE_BYTES = 16 * 1024;\nexport const MAX_DETAIL_CHARS = 8 * 1024;\nexport const MAX_SUMMARY_CHARS = 4 * 1024;\n// Char limits alone don't bound the envelope: CJK is ~3 bytes/char in UTF-8.\n// Free-text fields are clamped by chars first, then by bytes (clampBytes) before seal.\nexport const MAX_PROMPT_CHARS = 8 * 1024;\nexport const MAX_PROMPT_BYTES = 10 * 1024;\nexport const MAX_FIELD_BYTES = 10 * 1024;\nexport const MAX_PUSH_BYTES = 3 * 1024;\nexport const TRUNCATION_SUFFIX = '…[truncated]';\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return text.slice(0, maxChars - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;\n}\n\nconst byteEncoder = new TextEncoder();\n\nexport function utf8Length(text: string): number {\n return byteEncoder.encode(text).length;\n}\n\n// Clamp to a UTF-8 byte budget without splitting a multi-byte char or surrogate pair.\nexport function clampBytes(text: string, maxBytes: number): string {\n if (utf8Length(text) <= maxBytes) return text;\n const budget = maxBytes - utf8Length(TRUNCATION_SUFFIX);\n let low = 0;\n let high = text.length;\n while (low < high) {\n const mid = Math.ceil((low + high) / 2);\n if (utf8Length(text.slice(0, mid)) <= budget) low = mid;\n else high = mid - 1;\n }\n const tail = text.charCodeAt(low - 1);\n if (tail >= 0xd800 && tail <= 0xdbff) low -= 1;\n return text.slice(0, low) + TRUNCATION_SUFFIX;\n}\n","// Web Push subscriptions, keyed by (room, endpoint), persisted write-through to\n// DATA_DIR/push-subs.json. A subscription is metadata (endpoint + browser keys), never content.\n\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { PushSubscriptionJson } from '@dsh-dispatch/shared';\nimport { errorMessage, log, shortRoom } from './log.js';\n\nexport const STORE_FILENAME = 'push-subs.json';\n\ninterface StoreFile {\n v: 1;\n rooms: Record<string, Record<string, PushSubscriptionJson>>;\n}\n\nexport function isPushSubscription(value: unknown): value is PushSubscriptionJson {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } };\n return (\n typeof candidate.endpoint === 'string' &&\n candidate.endpoint.length > 0 &&\n candidate.endpoint.length <= 2048 &&\n typeof candidate.keys === 'object' &&\n candidate.keys !== null &&\n typeof candidate.keys.p256dh === 'string' &&\n typeof candidate.keys.auth === 'string'\n );\n}\n\nfunction parseStoreFile(raw: string): Map<string, Map<string, PushSubscriptionJson>> {\n const parsed = JSON.parse(raw) as Partial<StoreFile>;\n if (parsed.v !== 1 || typeof parsed.rooms !== 'object' || parsed.rooms === null) {\n throw new Error('unexpected shape (want {\"v\":1,\"rooms\":{…}})');\n }\n const rooms = new Map<string, Map<string, PushSubscriptionJson>>();\n for (const [room, subs] of Object.entries(parsed.rooms)) {\n const byEndpoint = new Map<string, PushSubscriptionJson>();\n for (const sub of Object.values(subs ?? {})) {\n if (isPushSubscription(sub)) byEndpoint.set(sub.endpoint, sub);\n }\n if (byEndpoint.size > 0) rooms.set(room, byEndpoint);\n }\n return rooms;\n}\n\nexport class PushStore {\n private writes: Promise<void> = Promise.resolve();\n\n private constructor(\n private readonly file: string,\n private readonly rooms: Map<string, Map<string, PushSubscriptionJson>>,\n ) {}\n\n /** Never throws: a missing or corrupt store starts empty, loudly. */\n static async load(dataDir: string): Promise<PushStore> {\n const file = join(dataDir, STORE_FILENAME);\n try {\n const rooms = parseStoreFile(await readFile(file, 'utf8'));\n const total = [...rooms.values()].reduce((n, subs) => n + subs.size, 0);\n log.info(`push store loaded: ${total} subscription(s) in ${rooms.size} room(s)`);\n return new PushStore(file, rooms);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') log.info(`push store empty: no ${file} yet`);\n else log.warn(`push store unreadable, starting empty: ${errorMessage(err)}`);\n return new PushStore(file, new Map());\n }\n }\n\n list(room: string): PushSubscriptionJson[] {\n return [...(this.rooms.get(room)?.values() ?? [])];\n }\n\n count(room?: string): number {\n if (room !== undefined) return this.rooms.get(room)?.size ?? 0;\n return [...this.rooms.values()].reduce((n, subs) => n + subs.size, 0);\n }\n\n async add(room: string, subscription: PushSubscriptionJson): Promise<void> {\n const byEndpoint = this.rooms.get(room) ?? new Map<string, PushSubscriptionJson>();\n byEndpoint.set(subscription.endpoint, subscription);\n this.rooms.set(room, byEndpoint);\n log.info(`push-subscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);\n await this.persist();\n }\n\n async remove(room: string, endpoint: string): Promise<boolean> {\n const byEndpoint = this.rooms.get(room);\n if (byEndpoint === undefined || !byEndpoint.delete(endpoint)) return false;\n if (byEndpoint.size === 0) this.rooms.delete(room);\n log.info(`push-unsubscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);\n await this.persist();\n return true;\n }\n\n private snapshot(): StoreFile {\n const rooms: StoreFile['rooms'] = {};\n for (const [room, subs] of this.rooms) rooms[room] = Object.fromEntries(subs);\n return { v: 1, rooms };\n }\n\n /** Serialized write-through: last-writer-wins on the full snapshot, atomic via rename. */\n private persist(): Promise<void> {\n this.writes = this.writes.then(async () => {\n const body = JSON.stringify(this.snapshot());\n const tmp = `${this.file}.tmp`;\n try {\n await mkdir(join(this.file, '..'), { recursive: true });\n await writeFile(tmp, body, 'utf8');\n await rename(tmp, this.file);\n } catch (err) {\n // Visible failure: subscriptions stay in memory, but survive no restart. Say so.\n log.error(`push store write failed (subscriptions are memory-only now): ${errorMessage(err)}`);\n }\n });\n return this.writes;\n }\n}\n","// Wire-frame validation and low-level send helpers. Source of truth: docs/PROTOCOL.md.\n// The relay validates only the envelope — `payload` stays an opaque base64 blob.\n\nimport type { HelloFrame, MsgFrame, PushSubscribeFrame, PushUnsubscribeFrame, RelayFrame, Role } from '@dsh-dispatch/shared';\nimport type { RawData, WebSocket } from 'ws';\nimport { isPushSubscription } from './push-store.js';\n\n/** Room ids are `base64url(sha512(...)[0..16])` = 22 chars; the bound just caps abuse. */\nexport const ROOM_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport type ErrorCode =\n | 'bad-hello'\n | 'bad-frame'\n | 'too-large'\n | 'push-disabled'\n | 'push-no-subscribers'\n | 'push-failed';\n\nexport interface ValidPushHint {\n payload: string;\n tag?: string;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction isRoom(value: unknown): value is string {\n return typeof value === 'string' && ROOM_PATTERN.test(value);\n}\n\nexport function isRole(value: unknown): value is Role {\n return value === 'machine' || value === 'phone';\n}\n\nexport function isHelloFrame(frame: unknown): frame is HelloFrame {\n return isRecord(frame) && frame.kind === 'hello' && isRoom(frame.room) && isRole(frame.role);\n}\n\nexport function isMsgFrame(frame: unknown): frame is MsgFrame {\n return (\n isRecord(frame) && frame.kind === 'msg' && isRoom(frame.room) && typeof frame.payload === 'string'\n );\n}\n\nexport function isPushHint(hint: unknown): hint is ValidPushHint {\n return (\n isRecord(hint) &&\n typeof hint.payload === 'string' &&\n hint.payload.length > 0 &&\n (hint.tag === undefined || typeof hint.tag === 'string')\n );\n}\n\nexport function isPushSubscribeFrame(frame: unknown): frame is PushSubscribeFrame {\n return (\n isRecord(frame) &&\n frame.kind === 'push-subscribe' &&\n isRoom(frame.room) &&\n isPushSubscription(frame.subscription)\n );\n}\n\nexport function isPushUnsubscribeFrame(frame: unknown): frame is PushUnsubscribeFrame {\n return (\n isRecord(frame) &&\n frame.kind === 'push-unsubscribe' &&\n isRoom(frame.room) &&\n typeof frame.endpoint === 'string'\n );\n}\n\nexport function frameSize(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);\n if (Buffer.isBuffer(data)) return data.length;\n return data.byteLength;\n}\n\nexport function frameText(data: RawData): string {\n if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');\n if (Buffer.isBuffer(data)) return data.toString('utf8');\n return Buffer.from(data).toString('utf8');\n}\n\nconst OPEN = 1; // ws.OPEN — imported as a value would pull the runtime class into type-only modules.\n\nexport function sendRaw(socket: WebSocket, text: string): void {\n if (socket.readyState !== OPEN) return;\n socket.send(text);\n}\n\nexport function send(socket: WebSocket, frame: RelayFrame): void {\n sendRaw(socket, JSON.stringify(frame));\n}\n\nexport function sendError(socket: WebSocket, code: ErrorCode, message?: string): void {\n send(socket, message === undefined ? { kind: 'error', code } : { kind: 'error', code, message });\n}\n","// In-memory room registry. This is the entire persistent-ish state of the relay besides\n// push subscriptions: who is currently connected, per room, per role. No content, ever.\n\nimport type { Role } from '@dsh-dispatch/shared';\nimport type { WebSocket } from 'ws';\n\nexport type PeerCounts = Record<Role, number>;\n\ninterface Room {\n machine: Set<WebSocket>;\n phone: Set<WebSocket>;\n}\n\nexport function otherRole(role: Role): Role {\n return role === 'machine' ? 'phone' : 'machine';\n}\n\nexport class RoomRegistry {\n private readonly rooms = new Map<string, Room>();\n\n /** @returns true when the socket was not already registered for (room, role). */\n join(room: string, role: Role, socket: WebSocket): boolean {\n let entry = this.rooms.get(room);\n if (entry === undefined) {\n entry = { machine: new Set(), phone: new Set() };\n this.rooms.set(room, entry);\n }\n if (entry[role].has(socket)) return false;\n entry[role].add(socket);\n return true;\n }\n\n /** @returns true when this was the last socket of that role in the room (role went offline). */\n leave(room: string, role: Role, socket: WebSocket): boolean {\n const entry = this.rooms.get(room);\n if (entry === undefined) return false;\n const removed = entry[role].delete(socket);\n if (entry.machine.size === 0 && entry.phone.size === 0) this.rooms.delete(room);\n return removed && entry[role].size === 0;\n }\n\n peers(room: string): PeerCounts {\n const entry = this.rooms.get(room);\n return { machine: entry?.machine.size ?? 0, phone: entry?.phone.size ?? 0 };\n }\n\n /** Snapshot copy: callers send to it while sockets may close mid-iteration. */\n socketsOf(room: string, role: Role): WebSocket[] {\n const entry = this.rooms.get(room);\n return entry === undefined ? [] : [...entry[role]];\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n}\n","// Per-socket state machine: hello → (msg | hello | push-subscribe | push-unsubscribe)*.\n//\n// Framing rules (docs/PROTOCOL.md + PRODUCT.md \"失败可见\"):\n// - the FIRST frame must be a valid hello, otherwise `bad-hello` and close;\n// - after that the socket is never closed for a bad frame — unknown kinds are a\n// forward-compat signal, not an attack, so they get `bad-frame` and stay connected;\n// - every rejection produces an error frame AND a log line. Nothing is dropped in silence.\n\n// Limits come from the shared package's `limits` module directly, not from its barrel:\n// the barrel re-exports tweetnacl-backed crypto, and a zero-knowledge relay must never\n// link the crypto code into its bundle. Every other shared import here is type-only (erased).\nimport { MAX_ENVELOPE_BYTES, MAX_PUSH_BYTES } from '@dsh-dispatch/shared/src/limits.js';\nimport type { HelloFrame, MsgFrame, PresenceFrame, PushSubscribeFrame, PushUnsubscribeFrame, Role } from '@dsh-dispatch/shared';\nimport type { RawData, WebSocket } from 'ws';\nimport {\n frameSize,\n frameText,\n isHelloFrame,\n isMsgFrame,\n isPushHint,\n isPushSubscribeFrame,\n isPushUnsubscribeFrame,\n send,\n sendError,\n sendRaw,\n} from './frames.js';\nimport { errorMessage, log, shortRoom } from './log.js';\nimport type { PushStore } from './push-store.js';\nimport type { Pusher } from './push.js';\nimport { otherRole, type RoomRegistry } from './rooms.js';\n\nexport const CLOSE_POLICY_VIOLATION = 1008;\n\nexport interface ConnectionContext {\n rooms: RoomRegistry;\n store: PushStore;\n pusher: Pusher;\n}\n\n/** Attacker-controlled strings never reach a log line raw. */\nfunction safeLabel(value: unknown): string {\n return String(value).replace(/[^\\w.-]/g, '').slice(0, 32) || 'none';\n}\n\nexport function handleConnection(socket: WebSocket, ctx: ConnectionContext): void {\n const connection = new Connection(socket, ctx);\n socket.on('message', (data: RawData) => connection.onMessage(data));\n socket.on('close', () => connection.onClose());\n socket.on('error', (err) => {\n log.warn(`socket error: ${errorMessage(err)}`);\n connection.onClose();\n });\n}\n\nclass Connection {\n private readonly joined = new Map<string, Role>();\n private helloDone = false;\n\n constructor(\n private readonly socket: WebSocket,\n private readonly ctx: ConnectionContext,\n ) {}\n\n onMessage(data: RawData): void {\n const size = frameSize(data);\n if (size > MAX_ENVELOPE_BYTES) {\n log.info(`too-large rejected: ${size}B > ${MAX_ENVELOPE_BYTES}B`);\n if (!this.helloDone) return this.closeBadHello('first frame exceeds the envelope limit');\n sendError(this.socket, 'too-large', `frame is ${size}B, limit is ${MAX_ENVELOPE_BYTES}B`);\n return;\n }\n const text = frameText(data);\n let frame: unknown;\n try {\n frame = JSON.parse(text) as unknown;\n } catch {\n if (!this.helloDone) return this.closeBadHello('first frame is not valid JSON');\n log.info('bad-frame: invalid JSON');\n sendError(this.socket, 'bad-frame', 'invalid JSON');\n return;\n }\n void this.dispatch(frame, text).catch((err: unknown) => {\n log.error(`frame handling failed: ${errorMessage(err)}`);\n sendError(this.socket, 'bad-frame', 'relay failed to handle this frame');\n });\n }\n\n private async dispatch(frame: unknown, text: string): Promise<void> {\n if (!this.helloDone) {\n if (!isHelloFrame(frame)) return this.closeBadHello('first frame must be a valid hello');\n this.helloDone = true;\n return this.handleHello(frame);\n }\n if (isHelloFrame(frame)) return this.handleHello(frame);\n if (isMsgFrame(frame)) return this.handleMsg(frame, text);\n if (isPushSubscribeFrame(frame)) return this.handleSubscribe(frame);\n if (isPushUnsubscribeFrame(frame)) return this.handleUnsubscribe(frame);\n this.rejectFrame(frame);\n }\n\n private handleHello(frame: HelloFrame): void {\n const existing = this.joined.get(frame.room);\n if (existing !== undefined && existing !== frame.role) {\n log.info(`bad-hello room=${shortRoom(frame.room)}: role conflict`);\n sendError(this.socket, 'bad-hello', 'this socket already joined that room with another role');\n return;\n }\n const added = this.ctx.rooms.join(frame.room, frame.role, this.socket);\n this.joined.set(frame.room, frame.role);\n const peers = this.ctx.rooms.peers(frame.room);\n send(this.socket, { kind: 'hello-ok', peers });\n if (!added) return;\n log.info(\n `join room=${shortRoom(frame.room)} role=${frame.role} peers=${peers.machine}m/${peers.phone}p`,\n );\n this.broadcastPresence(frame.room, frame.role, true);\n }\n\n private handleMsg(frame: MsgFrame, text: string): Promise<void> | void {\n const role = this.joined.get(frame.room);\n if (role === undefined) {\n log.info(`bad-frame: msg for a room this socket never joined (${shortRoom(frame.room)})`);\n sendError(this.socket, 'bad-frame', 'not joined to this room');\n return;\n }\n // Verbatim forward: the original text, so unknown fields survive for forward compat.\n const targets = this.ctx.rooms.socketsOf(frame.room, otherRole(role));\n for (const peer of targets) sendRaw(peer, text);\n\n if (frame.push === undefined || frame.push === null) return;\n if (role !== 'machine') {\n sendError(this.socket, 'bad-frame', 'push hints are machine→relay only');\n return;\n }\n if (targets.length > 0) return; // a phone is live in this room; it already got the msg\n return this.deliverPush(frame.room, frame.push);\n }\n\n private async deliverPush(room: string, hint: unknown): Promise<void> {\n if (!isPushHint(hint)) {\n log.info(`bad-frame: malformed push hint room=${shortRoom(room)}`);\n sendError(this.socket, 'bad-frame', 'invalid push hint');\n return;\n }\n const bytes = Buffer.byteLength(hint.payload, 'utf8');\n if (bytes > MAX_PUSH_BYTES) {\n log.info(`push rejected room=${shortRoom(room)}: ${bytes}B > ${MAX_PUSH_BYTES}B`);\n sendError(this.socket, 'too-large', `push payload is ${bytes}B, limit is ${MAX_PUSH_BYTES}B`);\n return;\n }\n if (!this.ctx.pusher.enabled) {\n log.warn(`push dropped room=${shortRoom(room)}: web push is disabled on this relay`);\n sendError(this.socket, 'push-disabled', 'relay has no VAPID keys configured');\n return;\n }\n const { sent, dropped, failed } = await this.ctx.pusher.deliver(room, hint.payload, hint.tag);\n if (sent > 0) return;\n if (failed > 0 || dropped > 0) {\n sendError(this.socket, 'push-failed', `0 delivered, ${failed} failed, ${dropped} expired`);\n return;\n }\n sendError(this.socket, 'push-no-subscribers', 'no phone online and no push subscription stored');\n }\n\n private async handleSubscribe(frame: PushSubscribeFrame): Promise<void> {\n if (!this.requiresPhoneRole(frame.room, 'push-subscribe')) return;\n await this.ctx.store.add(frame.room, frame.subscription);\n }\n\n private async handleUnsubscribe(frame: PushUnsubscribeFrame): Promise<void> {\n if (!this.requiresPhoneRole(frame.room, 'push-unsubscribe')) return;\n await this.ctx.store.remove(frame.room, frame.endpoint);\n }\n\n private requiresPhoneRole(room: string, what: string): boolean {\n if (this.joined.get(room) === 'phone') return true;\n log.info(`bad-frame: ${what} without a phone hello for room=${shortRoom(room)}`);\n sendError(this.socket, 'bad-frame', `${what} requires a phone hello for this room`);\n return false;\n }\n\n private rejectFrame(frame: unknown): void {\n const kind = safeLabel(typeof frame === 'object' && frame !== null ? (frame as { kind?: unknown }).kind : frame);\n log.info(`bad-frame kind=${kind}`);\n sendError(this.socket, 'bad-frame', `unsupported or malformed frame (kind=${kind})`);\n }\n\n private broadcastPresence(room: string, role: Role, online: boolean): void {\n const frame: PresenceFrame = { kind: 'presence', room, role, online };\n for (const peer of this.ctx.rooms.socketsOf(room, otherRole(role))) send(peer, frame);\n }\n\n private closeBadHello(reason: string): void {\n log.info(`bad-hello: ${reason}`);\n sendError(this.socket, 'bad-hello', reason);\n this.socket.close(CLOSE_POLICY_VIOLATION, 'bad-hello');\n }\n\n onClose(): void {\n for (const [room, role] of this.joined) {\n const wentOffline = this.ctx.rooms.leave(room, role, this.socket);\n log.info(`leave room=${shortRoom(room)} role=${role} last=${wentOffline}`);\n if (wentOffline) this.broadcastPresence(room, role, false);\n }\n this.joined.clear();\n }\n}\n","// Plain HTTP surface: health probe + VAPID public key discovery. Everything else is the /ws upgrade.\n\nimport type { IncomingMessage, ServerResponse } from 'node:http';\n\nexport const WS_PATH = '/ws';\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n const text = JSON.stringify(body);\n res.writeHead(status, {\n 'content-type': 'application/json; charset=utf-8',\n 'content-length': Buffer.byteLength(text),\n 'cache-control': 'no-store',\n // The PWA fetches /vapid cross-origin; both endpoints expose only public data.\n 'access-control-allow-origin': '*',\n });\n res.end(text);\n}\n\n/**\n * @param vapidPublicKey public VAPID key, or null when push is disabled — in which case\n * /vapid answers 404 {\"error\":\"push-disabled\"} so the PWA can show \"push unavailable\"\n * instead of silently failing to subscribe.\n */\nexport function createRequestHandler(\n vapidPublicKey: string | null,\n): (req: IncomingMessage, res: ServerResponse) => void {\n return (req, res) => {\n const path = (req.url ?? '/').split('?')[0];\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n sendJson(res, 405, { error: 'method-not-allowed' });\n return;\n }\n if (path === '/healthz') {\n sendJson(res, 200, { ok: true });\n return;\n }\n if (path === '/vapid') {\n if (vapidPublicKey === null) sendJson(res, 404, { error: 'push-disabled' });\n else sendJson(res, 200, { publicKey: vapidPublicKey });\n return;\n }\n if (path === WS_PATH) {\n sendJson(res, 426, { error: 'upgrade-required' });\n return;\n }\n sendJson(res, 404, { error: 'not-found' });\n };\n}\n","// Web Push fan-out. The relay forwards an already-encrypted payload produced by the machine;\n// it never composes, inspects or logs notification content.\n\nimport webpush from 'web-push';\nimport type { PushSubscriptionJson } from '@dsh-dispatch/shared';\nimport type { VapidConfig } from './config.js';\nimport { errorMessage, log, shortRoom } from './log.js';\nimport type { PushStore } from './push-store.js';\n\nexport const PUSH_TTL_SECONDS = 60;\n\n/** RFC 8030 Topic header: ≤32 chars of the base64url alphabet. Anything else is dropped, not mangled. */\nconst TOPIC_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;\n\nexport interface PushDelivery {\n sent: number;\n /** Subscriptions the push service reported as gone (404/410); pruned from the store. */\n dropped: number;\n failed: number;\n}\n\n/**\n * Applies VAPID details to the web-push singleton.\n * @returns true when push is usable. Never throws: a bad key pair disables push with one log line.\n */\nexport function configureWebPush(vapid: VapidConfig | null, disabledReason: string | null): boolean {\n if (vapid === null) {\n log.warn(`web push disabled: ${disabledReason ?? 'VAPID env not set'}`);\n return false;\n }\n try {\n webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey);\n log.info('web push enabled');\n return true;\n } catch (err) {\n log.warn(`web push disabled: invalid VAPID config: ${errorMessage(err)}`);\n return false;\n }\n}\n\nexport class Pusher {\n constructor(\n private readonly store: PushStore,\n readonly enabled: boolean,\n ) {}\n\n async deliver(room: string, payload: string, tag: string | undefined): Promise<PushDelivery> {\n const result: PushDelivery = { sent: 0, dropped: 0, failed: 0 };\n const options: webpush.RequestOptions = { TTL: PUSH_TTL_SECONDS };\n if (tag !== undefined && TOPIC_PATTERN.test(tag)) options.topic = tag;\n\n for (const subscription of this.store.list(room)) {\n await this.send(room, subscription, payload, options, result);\n }\n log.info(\n `push room=${shortRoom(room)} sent=${result.sent} dropped=${result.dropped} failed=${result.failed}`,\n );\n return result;\n }\n\n private async send(\n room: string,\n subscription: PushSubscriptionJson,\n payload: string,\n options: webpush.RequestOptions,\n result: PushDelivery,\n ): Promise<void> {\n try {\n await webpush.sendNotification(subscription, payload, options);\n result.sent += 1;\n } catch (err) {\n const status = (err as { statusCode?: number }).statusCode;\n if (status === 404 || status === 410) {\n result.dropped += 1;\n await this.store.remove(room, subscription.endpoint);\n log.info(`push subscription gone (${status}) room=${shortRoom(room)}, pruned`);\n return;\n }\n result.failed += 1;\n log.error(`push failed room=${shortRoom(room)} status=${status ?? 'none'}: ${errorMessage(err)}`);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/config.ts","../src/log.ts","../src/server.ts","../../shared/src/limits.ts","../src/push-store.ts","../src/frames.ts","../src/rooms.ts","../src/connection.ts","../src/http.ts","../src/push.ts"],"sourcesContent":["// dsh-dispatch relay entrypoint (bin: dsh-dispatch-relay).\n\nimport { realpathSync } from 'node:fs';\nimport { pathToFileURL } from 'node:url';\nimport { loadConfig } from './config.js';\nimport { errorMessage, log } from './log.js';\nimport { createRelayServer, type RelayServer } from './server.js';\n\nexport { loadConfig } from './config.js';\nexport { createRelayServer } from './server.js';\nexport type { RelayConfig } from './config.js';\nexport type { RelayServer } from './server.js';\n\nasync function stop(server: RelayServer, signal: string): Promise<void> {\n log.info(`${signal} received, shutting down`);\n await server.close();\n process.exit(0);\n}\n\nasync function main(): Promise<void> {\n const server = await createRelayServer(loadConfig());\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.on(signal, () => {\n stop(server, signal).catch((err: unknown) => {\n log.error(`shutdown failed: ${errorMessage(err)}`);\n process.exit(1);\n });\n });\n }\n}\n\n// Run only when executed as a program, so the module stays importable (tests, embedding).\n// argv[1] is a symlink under npm/pnpm bin, so it must be realpath'd before comparing —\n// otherwise `npx dsh-dispatch-relay` exits silently without starting the server.\nexport function isMainModule(argvPath: string | undefined, moduleUrl: string): boolean {\n if (argvPath === undefined) return false;\n try {\n return moduleUrl === pathToFileURL(realpathSync(argvPath)).href;\n } catch {\n return false;\n }\n}\n\nif (isMainModule(process.argv[1], import.meta.url)) {\n main().catch((err: unknown) => {\n log.error(`fatal: ${errorMessage(err)}`);\n process.exitCode = 1;\n });\n}\n","// Environment configuration. Invalid values fail loudly at startup rather than\n// being silently coerced — a relay that listens on the wrong port is worse than one that refuses to boot.\n\nimport { resolve } from 'node:path';\n\nexport const DEFAULT_PORT = 8787;\nexport const DEFAULT_DATA_DIR = './relay-data';\n\nexport interface VapidConfig {\n publicKey: string;\n privateKey: string;\n subject: string;\n}\n\nexport interface RelayConfig {\n port: number;\n dataDir: string;\n /** null = Web Push disabled; `pushDisabledReason` says why (logged once at startup). */\n vapid: VapidConfig | null;\n pushDisabledReason: string | null;\n}\n\nfunction parsePort(raw: string | undefined): number {\n if (raw === undefined || raw.trim() === '') return DEFAULT_PORT;\n const port = Number(raw);\n if (!Number.isInteger(port) || port < 0 || port > 65535) {\n throw new Error(`invalid PORT: ${raw} (expected an integer 0-65535)`);\n }\n return port;\n}\n\nfunction readVapid(env: NodeJS.ProcessEnv): Pick<RelayConfig, 'vapid' | 'pushDisabledReason'> {\n const publicKey = env.VAPID_PUBLIC_KEY?.trim();\n const privateKey = env.VAPID_PRIVATE_KEY?.trim();\n const subject = env.VAPID_SUBJECT?.trim();\n if (publicKey && privateKey && subject) {\n return { vapid: { publicKey, privateKey, subject }, pushDisabledReason: null };\n }\n const missing = [\n publicKey ? null : 'VAPID_PUBLIC_KEY',\n privateKey ? null : 'VAPID_PRIVATE_KEY',\n subject ? null : 'VAPID_SUBJECT',\n ].filter((name): name is string => name !== null);\n const reason =\n missing.length === 3 ? 'VAPID env not set' : `VAPID env incomplete (missing ${missing.join(', ')})`;\n return { vapid: null, pushDisabledReason: reason };\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): RelayConfig {\n return {\n port: parsePort(env.PORT),\n dataDir: resolve(env.DATA_DIR?.trim() || DEFAULT_DATA_DIR),\n ...readVapid(env),\n };\n}\n","// Structured-ish single-line logging.\n// INVARIANT (docs/PROTOCOL.md §Security 4): never log payloads, ciphertext or push bodies.\n// Room ids are always shortened via shortRoom() before they reach a log line.\n\nexport type LogLevel = 'silent' | 'error' | 'warn' | 'info';\n\nconst RANK: Record<LogLevel, number> = { silent: 0, error: 1, warn: 2, info: 3 };\n\nfunction initialLevel(): LogLevel {\n const raw = process.env.LOG_LEVEL?.trim().toLowerCase();\n return raw && raw in RANK ? (raw as LogLevel) : 'info';\n}\n\nlet level: LogLevel = initialLevel();\n\nexport function setLogLevel(next: LogLevel): void {\n level = next;\n}\n\nfunction emit(at: LogLevel, write: (line: string) => void, message: string): void {\n if (RANK[at] > RANK[level]) return;\n write(`${new Date().toISOString()} [relay] ${at} ${message}`);\n}\n\nexport const log = {\n info: (message: string) => emit('info', (l) => console.log(l), message),\n warn: (message: string) => emit('warn', (l) => console.warn(l), message),\n error: (message: string) => emit('error', (l) => console.error(l), message),\n};\n\n/** Room ids are opaque to the relay; only a prefix ever reaches the logs. */\nexport function shortRoom(room: string): string {\n return `${room.slice(0, 8)}…`;\n}\n\nexport function errorMessage(err: unknown): string {\n if (err instanceof Error) return err.message;\n return typeof err === 'string' ? err : JSON.stringify(err);\n}\n","// Wiring: HTTP surface + WebSocket router + heartbeat + push store.\n\nimport { createServer, type Server } from 'node:http';\nimport { WebSocketServer, type WebSocket } from 'ws';\nimport type { RelayConfig } from './config.js';\nimport { handleConnection, type ConnectionContext } from './connection.js';\nimport { createRequestHandler, WS_PATH } from './http.js';\nimport { errorMessage, log } from './log.js';\nimport { PushStore } from './push-store.js';\nimport { configureWebPush, Pusher } from './push.js';\nimport { RoomRegistry } from './rooms.js';\n\nexport const PING_INTERVAL_MS = 25_000;\n/** Well above the 16KB envelope limit: we want to answer `too-large` ourselves, not have ws drop the socket. */\nexport const MAX_WS_PAYLOAD_BYTES = 1024 * 1024;\n\ninterface HeartbeatSocket extends WebSocket {\n isAlive?: boolean;\n}\n\nexport interface RelayServer {\n /** Actual listening port (resolves `PORT=0` for tests). */\n port: number;\n rooms: RoomRegistry;\n store: PushStore;\n pushEnabled: boolean;\n close(): Promise<void>;\n}\n\nfunction listen(server: Server, port: number): Promise<void> {\n return new Promise((resolvePromise, rejectPromise) => {\n server.once('error', rejectPromise);\n server.listen(port, () => {\n server.removeListener('error', rejectPromise);\n resolvePromise();\n });\n });\n}\n\nfunction startHeartbeat(wss: WebSocketServer): NodeJS.Timeout {\n const timer = setInterval(() => {\n for (const client of wss.clients as Set<HeartbeatSocket>) {\n if (client.isAlive === false) {\n log.warn('terminating unresponsive socket (missed pong)');\n client.terminate();\n continue;\n }\n client.isAlive = false;\n client.ping();\n }\n }, PING_INTERVAL_MS);\n timer.unref();\n return timer;\n}\n\nfunction shutdown(server: Server, wss: WebSocketServer, heartbeat: NodeJS.Timeout): Promise<void> {\n clearInterval(heartbeat);\n for (const client of wss.clients) client.terminate();\n return new Promise((resolvePromise) => {\n wss.close(() => server.close(() => resolvePromise()));\n });\n}\n\nexport async function createRelayServer(config: RelayConfig): Promise<RelayServer> {\n const store = await PushStore.load(config.dataDir);\n const pushEnabled = configureWebPush(config.vapid, config.pushDisabledReason);\n const ctx: ConnectionContext = {\n rooms: new RoomRegistry(),\n store,\n pusher: new Pusher(store, pushEnabled),\n };\n\n const server = createServer(createRequestHandler(pushEnabled && config.vapid ? config.vapid.publicKey : null));\n const wss = new WebSocketServer({ server, path: WS_PATH, maxPayload: MAX_WS_PAYLOAD_BYTES });\n wss.on('connection', (socket: HeartbeatSocket) => {\n socket.isAlive = true;\n socket.on('pong', () => {\n socket.isAlive = true;\n });\n handleConnection(socket, ctx);\n });\n wss.on('error', (err) => log.error(`websocket server error: ${errorMessage(err)}`));\n server.on('error', (err) => log.error(`http server error: ${errorMessage(err)}`));\n\n const heartbeat = startHeartbeat(wss);\n await listen(server, config.port);\n const address = server.address();\n const port = typeof address === 'object' && address !== null ? address.port : config.port;\n log.info(\n `listening on :${port} ws=${WS_PATH} dataDir=${config.dataDir} push=${pushEnabled ? 'on' : 'off'}`,\n );\n\n return {\n port,\n rooms: ctx.rooms,\n store,\n pushEnabled,\n close: () => shutdown(server, wss, heartbeat),\n };\n}\n","// Payload limits from docs/PROTOCOL.md. The sender truncates BEFORE encryption.\n// The relay imports THIS FILE by subpath (src/limits.js) to keep tweetnacl out of its\n// bundle — if this package ever gains a package.json \"exports\" map, add a matching entry.\n\nexport const MAX_ENVELOPE_BYTES = 16 * 1024;\nexport const MAX_DETAIL_CHARS = 8 * 1024;\nexport const MAX_SUMMARY_CHARS = 4 * 1024;\n// Char limits alone don't bound the envelope: CJK is ~3 bytes/char in UTF-8.\n// Free-text fields are clamped by chars first, then by bytes (clampBytes) before seal.\nexport const MAX_PROMPT_CHARS = 8 * 1024;\nexport const MAX_PROMPT_BYTES = 10 * 1024;\nexport const MAX_FIELD_BYTES = 10 * 1024;\nexport const MAX_PUSH_BYTES = 3 * 1024;\nexport const TRUNCATION_SUFFIX = '…[truncated]';\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return text.slice(0, maxChars - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;\n}\n\nconst byteEncoder = new TextEncoder();\n\nexport function utf8Length(text: string): number {\n return byteEncoder.encode(text).length;\n}\n\n// Clamp to a UTF-8 byte budget without splitting a multi-byte char or surrogate pair.\nexport function clampBytes(text: string, maxBytes: number): string {\n if (utf8Length(text) <= maxBytes) return text;\n const budget = maxBytes - utf8Length(TRUNCATION_SUFFIX);\n let low = 0;\n let high = text.length;\n while (low < high) {\n const mid = Math.ceil((low + high) / 2);\n if (utf8Length(text.slice(0, mid)) <= budget) low = mid;\n else high = mid - 1;\n }\n const tail = text.charCodeAt(low - 1);\n if (tail >= 0xd800 && tail <= 0xdbff) low -= 1;\n return text.slice(0, low) + TRUNCATION_SUFFIX;\n}\n","// Web Push subscriptions, keyed by (room, endpoint), persisted write-through to\n// DATA_DIR/push-subs.json. A subscription is metadata (endpoint + browser keys), never content.\n\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { PushSubscriptionJson } from '@dsh-dispatch/shared';\nimport { errorMessage, log, shortRoom } from './log.js';\n\nexport const STORE_FILENAME = 'push-subs.json';\n\ninterface StoreFile {\n v: 1;\n rooms: Record<string, Record<string, PushSubscriptionJson>>;\n}\n\nexport function isPushSubscription(value: unknown): value is PushSubscriptionJson {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } };\n return (\n typeof candidate.endpoint === 'string' &&\n candidate.endpoint.length > 0 &&\n candidate.endpoint.length <= 2048 &&\n typeof candidate.keys === 'object' &&\n candidate.keys !== null &&\n typeof candidate.keys.p256dh === 'string' &&\n typeof candidate.keys.auth === 'string'\n );\n}\n\nfunction parseStoreFile(raw: string): Map<string, Map<string, PushSubscriptionJson>> {\n const parsed = JSON.parse(raw) as Partial<StoreFile>;\n if (parsed.v !== 1 || typeof parsed.rooms !== 'object' || parsed.rooms === null) {\n throw new Error('unexpected shape (want {\"v\":1,\"rooms\":{…}})');\n }\n const rooms = new Map<string, Map<string, PushSubscriptionJson>>();\n for (const [room, subs] of Object.entries(parsed.rooms)) {\n const byEndpoint = new Map<string, PushSubscriptionJson>();\n for (const sub of Object.values(subs ?? {})) {\n if (isPushSubscription(sub)) byEndpoint.set(sub.endpoint, sub);\n }\n if (byEndpoint.size > 0) rooms.set(room, byEndpoint);\n }\n return rooms;\n}\n\nexport class PushStore {\n private writes: Promise<void> = Promise.resolve();\n\n private constructor(\n private readonly file: string,\n private readonly rooms: Map<string, Map<string, PushSubscriptionJson>>,\n ) {}\n\n /** Never throws: a missing or corrupt store starts empty, loudly. */\n static async load(dataDir: string): Promise<PushStore> {\n const file = join(dataDir, STORE_FILENAME);\n try {\n const rooms = parseStoreFile(await readFile(file, 'utf8'));\n const total = [...rooms.values()].reduce((n, subs) => n + subs.size, 0);\n log.info(`push store loaded: ${total} subscription(s) in ${rooms.size} room(s)`);\n return new PushStore(file, rooms);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') log.info(`push store empty: no ${file} yet`);\n else log.warn(`push store unreadable, starting empty: ${errorMessage(err)}`);\n return new PushStore(file, new Map());\n }\n }\n\n list(room: string): PushSubscriptionJson[] {\n return [...(this.rooms.get(room)?.values() ?? [])];\n }\n\n count(room?: string): number {\n if (room !== undefined) return this.rooms.get(room)?.size ?? 0;\n return [...this.rooms.values()].reduce((n, subs) => n + subs.size, 0);\n }\n\n async add(room: string, subscription: PushSubscriptionJson): Promise<void> {\n const byEndpoint = this.rooms.get(room) ?? new Map<string, PushSubscriptionJson>();\n byEndpoint.set(subscription.endpoint, subscription);\n this.rooms.set(room, byEndpoint);\n log.info(`push-subscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);\n await this.persist();\n }\n\n async remove(room: string, endpoint: string): Promise<boolean> {\n const byEndpoint = this.rooms.get(room);\n if (byEndpoint === undefined || !byEndpoint.delete(endpoint)) return false;\n if (byEndpoint.size === 0) this.rooms.delete(room);\n log.info(`push-unsubscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);\n await this.persist();\n return true;\n }\n\n private snapshot(): StoreFile {\n const rooms: StoreFile['rooms'] = {};\n for (const [room, subs] of this.rooms) rooms[room] = Object.fromEntries(subs);\n return { v: 1, rooms };\n }\n\n /** Serialized write-through: last-writer-wins on the full snapshot, atomic via rename. */\n private persist(): Promise<void> {\n this.writes = this.writes.then(async () => {\n const body = JSON.stringify(this.snapshot());\n const tmp = `${this.file}.tmp`;\n try {\n await mkdir(join(this.file, '..'), { recursive: true });\n await writeFile(tmp, body, 'utf8');\n await rename(tmp, this.file);\n } catch (err) {\n // Visible failure: subscriptions stay in memory, but survive no restart. Say so.\n log.error(`push store write failed (subscriptions are memory-only now): ${errorMessage(err)}`);\n }\n });\n return this.writes;\n }\n}\n","// Wire-frame validation and low-level send helpers. Source of truth: docs/PROTOCOL.md.\n// The relay validates only the envelope — `payload` stays an opaque base64 blob.\n\nimport type { HelloFrame, MsgFrame, PushSubscribeFrame, PushUnsubscribeFrame, RelayFrame, Role } from '@dsh-dispatch/shared';\nimport type { RawData, WebSocket } from 'ws';\nimport { isPushSubscription } from './push-store.js';\n\n/** Room ids are `base64url(sha512(...)[0..16])` = 22 chars; the bound just caps abuse. */\nexport const ROOM_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport type ErrorCode =\n | 'bad-hello'\n | 'bad-frame'\n | 'too-large'\n | 'push-disabled'\n | 'push-no-subscribers'\n | 'push-failed';\n\nexport interface ValidPushHint {\n payload: string;\n tag?: string;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction isRoom(value: unknown): value is string {\n return typeof value === 'string' && ROOM_PATTERN.test(value);\n}\n\nexport function isRole(value: unknown): value is Role {\n return value === 'machine' || value === 'phone';\n}\n\nexport function isHelloFrame(frame: unknown): frame is HelloFrame {\n return isRecord(frame) && frame.kind === 'hello' && isRoom(frame.room) && isRole(frame.role);\n}\n\nexport function isMsgFrame(frame: unknown): frame is MsgFrame {\n return (\n isRecord(frame) && frame.kind === 'msg' && isRoom(frame.room) && typeof frame.payload === 'string'\n );\n}\n\nexport function isPushHint(hint: unknown): hint is ValidPushHint {\n return (\n isRecord(hint) &&\n typeof hint.payload === 'string' &&\n hint.payload.length > 0 &&\n (hint.tag === undefined || typeof hint.tag === 'string')\n );\n}\n\nexport function isPushSubscribeFrame(frame: unknown): frame is PushSubscribeFrame {\n return (\n isRecord(frame) &&\n frame.kind === 'push-subscribe' &&\n isRoom(frame.room) &&\n isPushSubscription(frame.subscription)\n );\n}\n\nexport function isPushUnsubscribeFrame(frame: unknown): frame is PushUnsubscribeFrame {\n return (\n isRecord(frame) &&\n frame.kind === 'push-unsubscribe' &&\n isRoom(frame.room) &&\n typeof frame.endpoint === 'string'\n );\n}\n\nexport function frameSize(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);\n if (Buffer.isBuffer(data)) return data.length;\n return data.byteLength;\n}\n\nexport function frameText(data: RawData): string {\n if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');\n if (Buffer.isBuffer(data)) return data.toString('utf8');\n return Buffer.from(data).toString('utf8');\n}\n\nconst OPEN = 1; // ws.OPEN — imported as a value would pull the runtime class into type-only modules.\n\nexport function sendRaw(socket: WebSocket, text: string): void {\n if (socket.readyState !== OPEN) return;\n socket.send(text);\n}\n\nexport function send(socket: WebSocket, frame: RelayFrame): void {\n sendRaw(socket, JSON.stringify(frame));\n}\n\nexport function sendError(socket: WebSocket, code: ErrorCode, message?: string): void {\n send(socket, message === undefined ? { kind: 'error', code } : { kind: 'error', code, message });\n}\n","// In-memory room registry. This is the entire persistent-ish state of the relay besides\n// push subscriptions: who is currently connected, per room, per role. No content, ever.\n\nimport type { Role } from '@dsh-dispatch/shared';\nimport type { WebSocket } from 'ws';\n\nexport type PeerCounts = Record<Role, number>;\n\ninterface Room {\n machine: Set<WebSocket>;\n phone: Set<WebSocket>;\n}\n\nexport function otherRole(role: Role): Role {\n return role === 'machine' ? 'phone' : 'machine';\n}\n\nexport class RoomRegistry {\n private readonly rooms = new Map<string, Room>();\n\n /** @returns true when the socket was not already registered for (room, role). */\n join(room: string, role: Role, socket: WebSocket): boolean {\n let entry = this.rooms.get(room);\n if (entry === undefined) {\n entry = { machine: new Set(), phone: new Set() };\n this.rooms.set(room, entry);\n }\n if (entry[role].has(socket)) return false;\n entry[role].add(socket);\n return true;\n }\n\n /** @returns true when this was the last socket of that role in the room (role went offline). */\n leave(room: string, role: Role, socket: WebSocket): boolean {\n const entry = this.rooms.get(room);\n if (entry === undefined) return false;\n const removed = entry[role].delete(socket);\n if (entry.machine.size === 0 && entry.phone.size === 0) this.rooms.delete(room);\n return removed && entry[role].size === 0;\n }\n\n peers(room: string): PeerCounts {\n const entry = this.rooms.get(room);\n return { machine: entry?.machine.size ?? 0, phone: entry?.phone.size ?? 0 };\n }\n\n /** Snapshot copy: callers send to it while sockets may close mid-iteration. */\n socketsOf(room: string, role: Role): WebSocket[] {\n const entry = this.rooms.get(room);\n return entry === undefined ? [] : [...entry[role]];\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n}\n","// Per-socket state machine: hello → (msg | hello | push-subscribe | push-unsubscribe)*.\n//\n// Framing rules (docs/PROTOCOL.md + PRODUCT.md \"失败可见\"):\n// - the FIRST frame must be a valid hello, otherwise `bad-hello` and close;\n// - after that the socket is never closed for a bad frame — unknown kinds are a\n// forward-compat signal, not an attack, so they get `bad-frame` and stay connected;\n// - every rejection produces an error frame AND a log line. Nothing is dropped in silence.\n\n// Limits come from the shared package's `limits` module directly, not from its barrel:\n// the barrel re-exports tweetnacl-backed crypto, and a zero-knowledge relay must never\n// link the crypto code into its bundle. Every other shared import here is type-only (erased).\nimport { MAX_ENVELOPE_BYTES, MAX_PUSH_BYTES } from '@dsh-dispatch/shared/src/limits.js';\nimport type { HelloFrame, MsgFrame, PresenceFrame, PushSubscribeFrame, PushUnsubscribeFrame, Role } from '@dsh-dispatch/shared';\nimport type { RawData, WebSocket } from 'ws';\nimport {\n frameSize,\n frameText,\n isHelloFrame,\n isMsgFrame,\n isPushHint,\n isPushSubscribeFrame,\n isPushUnsubscribeFrame,\n send,\n sendError,\n sendRaw,\n} from './frames.js';\nimport { errorMessage, log, shortRoom } from './log.js';\nimport type { PushStore } from './push-store.js';\nimport type { Pusher } from './push.js';\nimport { otherRole, type RoomRegistry } from './rooms.js';\n\nexport const CLOSE_POLICY_VIOLATION = 1008;\n\nexport interface ConnectionContext {\n rooms: RoomRegistry;\n store: PushStore;\n pusher: Pusher;\n}\n\n/** Attacker-controlled strings never reach a log line raw. */\nfunction safeLabel(value: unknown): string {\n return String(value).replace(/[^\\w.-]/g, '').slice(0, 32) || 'none';\n}\n\nexport function handleConnection(socket: WebSocket, ctx: ConnectionContext): void {\n const connection = new Connection(socket, ctx);\n socket.on('message', (data: RawData) => connection.onMessage(data));\n socket.on('close', () => connection.onClose());\n socket.on('error', (err) => {\n log.warn(`socket error: ${errorMessage(err)}`);\n connection.onClose();\n });\n}\n\nclass Connection {\n private readonly joined = new Map<string, Role>();\n private helloDone = false;\n\n constructor(\n private readonly socket: WebSocket,\n private readonly ctx: ConnectionContext,\n ) {}\n\n onMessage(data: RawData): void {\n const size = frameSize(data);\n if (size > MAX_ENVELOPE_BYTES) {\n log.info(`too-large rejected: ${size}B > ${MAX_ENVELOPE_BYTES}B`);\n if (!this.helloDone) return this.closeBadHello('first frame exceeds the envelope limit');\n sendError(this.socket, 'too-large', `frame is ${size}B, limit is ${MAX_ENVELOPE_BYTES}B`);\n return;\n }\n const text = frameText(data);\n let frame: unknown;\n try {\n frame = JSON.parse(text) as unknown;\n } catch {\n if (!this.helloDone) return this.closeBadHello('first frame is not valid JSON');\n log.info('bad-frame: invalid JSON');\n sendError(this.socket, 'bad-frame', 'invalid JSON');\n return;\n }\n void this.dispatch(frame, text).catch((err: unknown) => {\n log.error(`frame handling failed: ${errorMessage(err)}`);\n sendError(this.socket, 'bad-frame', 'relay failed to handle this frame');\n });\n }\n\n private async dispatch(frame: unknown, text: string): Promise<void> {\n if (!this.helloDone) {\n if (!isHelloFrame(frame)) return this.closeBadHello('first frame must be a valid hello');\n this.helloDone = true;\n return this.handleHello(frame);\n }\n if (isHelloFrame(frame)) return this.handleHello(frame);\n if (isMsgFrame(frame)) return this.handleMsg(frame, text);\n if (isPushSubscribeFrame(frame)) return this.handleSubscribe(frame);\n if (isPushUnsubscribeFrame(frame)) return this.handleUnsubscribe(frame);\n this.rejectFrame(frame);\n }\n\n private handleHello(frame: HelloFrame): void {\n const existing = this.joined.get(frame.room);\n if (existing !== undefined && existing !== frame.role) {\n log.info(`bad-hello room=${shortRoom(frame.room)}: role conflict`);\n sendError(this.socket, 'bad-hello', 'this socket already joined that room with another role');\n return;\n }\n const added = this.ctx.rooms.join(frame.room, frame.role, this.socket);\n this.joined.set(frame.room, frame.role);\n const peers = this.ctx.rooms.peers(frame.room);\n send(this.socket, { kind: 'hello-ok', peers });\n if (!added) return;\n log.info(\n `join room=${shortRoom(frame.room)} role=${frame.role} peers=${peers.machine}m/${peers.phone}p`,\n );\n this.broadcastPresence(frame.room, frame.role, true);\n }\n\n private handleMsg(frame: MsgFrame, text: string): Promise<void> | void {\n const role = this.joined.get(frame.room);\n if (role === undefined) {\n log.info(`bad-frame: msg for a room this socket never joined (${shortRoom(frame.room)})`);\n sendError(this.socket, 'bad-frame', 'not joined to this room');\n return;\n }\n // Verbatim forward: the original text, so unknown fields survive for forward compat.\n const targets = this.ctx.rooms.socketsOf(frame.room, otherRole(role));\n for (const peer of targets) sendRaw(peer, text);\n\n if (frame.push === undefined || frame.push === null) return;\n if (role !== 'machine') {\n sendError(this.socket, 'bad-frame', 'push hints are machine→relay only');\n return;\n }\n if (targets.length > 0) return; // a phone is live in this room; it already got the msg\n return this.deliverPush(frame.room, frame.push);\n }\n\n private async deliverPush(room: string, hint: unknown): Promise<void> {\n if (!isPushHint(hint)) {\n log.info(`bad-frame: malformed push hint room=${shortRoom(room)}`);\n sendError(this.socket, 'bad-frame', 'invalid push hint');\n return;\n }\n const bytes = Buffer.byteLength(hint.payload, 'utf8');\n if (bytes > MAX_PUSH_BYTES) {\n log.info(`push rejected room=${shortRoom(room)}: ${bytes}B > ${MAX_PUSH_BYTES}B`);\n sendError(this.socket, 'too-large', `push payload is ${bytes}B, limit is ${MAX_PUSH_BYTES}B`);\n return;\n }\n if (!this.ctx.pusher.enabled) {\n log.warn(`push dropped room=${shortRoom(room)}: web push is disabled on this relay`);\n sendError(this.socket, 'push-disabled', 'relay has no VAPID keys configured');\n return;\n }\n const { sent, dropped, failed } = await this.ctx.pusher.deliver(room, hint.payload, hint.tag);\n if (sent > 0) return;\n if (failed > 0 || dropped > 0) {\n sendError(this.socket, 'push-failed', `0 delivered, ${failed} failed, ${dropped} expired`);\n return;\n }\n sendError(this.socket, 'push-no-subscribers', 'no phone online and no push subscription stored');\n }\n\n private async handleSubscribe(frame: PushSubscribeFrame): Promise<void> {\n if (!this.requiresPhoneRole(frame.room, 'push-subscribe')) return;\n await this.ctx.store.add(frame.room, frame.subscription);\n }\n\n private async handleUnsubscribe(frame: PushUnsubscribeFrame): Promise<void> {\n if (!this.requiresPhoneRole(frame.room, 'push-unsubscribe')) return;\n await this.ctx.store.remove(frame.room, frame.endpoint);\n }\n\n private requiresPhoneRole(room: string, what: string): boolean {\n if (this.joined.get(room) === 'phone') return true;\n log.info(`bad-frame: ${what} without a phone hello for room=${shortRoom(room)}`);\n sendError(this.socket, 'bad-frame', `${what} requires a phone hello for this room`);\n return false;\n }\n\n private rejectFrame(frame: unknown): void {\n const kind = safeLabel(typeof frame === 'object' && frame !== null ? (frame as { kind?: unknown }).kind : frame);\n log.info(`bad-frame kind=${kind}`);\n sendError(this.socket, 'bad-frame', `unsupported or malformed frame (kind=${kind})`);\n }\n\n private broadcastPresence(room: string, role: Role, online: boolean): void {\n const frame: PresenceFrame = { kind: 'presence', room, role, online };\n for (const peer of this.ctx.rooms.socketsOf(room, otherRole(role))) send(peer, frame);\n }\n\n private closeBadHello(reason: string): void {\n log.info(`bad-hello: ${reason}`);\n sendError(this.socket, 'bad-hello', reason);\n this.socket.close(CLOSE_POLICY_VIOLATION, 'bad-hello');\n }\n\n onClose(): void {\n for (const [room, role] of this.joined) {\n const wentOffline = this.ctx.rooms.leave(room, role, this.socket);\n log.info(`leave room=${shortRoom(room)} role=${role} last=${wentOffline}`);\n if (wentOffline) this.broadcastPresence(room, role, false);\n }\n this.joined.clear();\n }\n}\n","// Plain HTTP surface: health probe + VAPID public key discovery. Everything else is the /ws upgrade.\n\nimport type { IncomingMessage, ServerResponse } from 'node:http';\n\nexport const WS_PATH = '/ws';\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n const text = JSON.stringify(body);\n res.writeHead(status, {\n 'content-type': 'application/json; charset=utf-8',\n 'content-length': Buffer.byteLength(text),\n 'cache-control': 'no-store',\n // The PWA fetches /vapid cross-origin; both endpoints expose only public data.\n 'access-control-allow-origin': '*',\n });\n res.end(text);\n}\n\n/**\n * @param vapidPublicKey public VAPID key, or null when push is disabled — in which case\n * /vapid answers 404 {\"error\":\"push-disabled\"} so the PWA can show \"push unavailable\"\n * instead of silently failing to subscribe.\n */\nexport function createRequestHandler(\n vapidPublicKey: string | null,\n): (req: IncomingMessage, res: ServerResponse) => void {\n return (req, res) => {\n const path = (req.url ?? '/').split('?')[0];\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n sendJson(res, 405, { error: 'method-not-allowed' });\n return;\n }\n if (path === '/healthz') {\n sendJson(res, 200, { ok: true });\n return;\n }\n if (path === '/vapid') {\n if (vapidPublicKey === null) sendJson(res, 404, { error: 'push-disabled' });\n else sendJson(res, 200, { publicKey: vapidPublicKey });\n return;\n }\n if (path === WS_PATH) {\n sendJson(res, 426, { error: 'upgrade-required' });\n return;\n }\n sendJson(res, 404, { error: 'not-found' });\n };\n}\n","// Web Push fan-out. The relay forwards an already-encrypted payload produced by the machine;\n// it never composes, inspects or logs notification content.\n\nimport webpush from 'web-push';\nimport type { PushSubscriptionJson } from '@dsh-dispatch/shared';\nimport type { VapidConfig } from './config.js';\nimport { errorMessage, log, shortRoom } from './log.js';\nimport type { PushStore } from './push-store.js';\n\nexport const PUSH_TTL_SECONDS = 60;\n\n/** RFC 8030 Topic header: ≤32 chars of the base64url alphabet. Anything else is dropped, not mangled. */\nconst TOPIC_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;\n\nexport interface PushDelivery {\n sent: number;\n /** Subscriptions the push service reported as gone (404/410); pruned from the store. */\n dropped: number;\n failed: number;\n}\n\n/**\n * Applies VAPID details to the web-push singleton.\n * @returns true when push is usable. Never throws: a bad key pair disables push with one log line.\n */\nexport function configureWebPush(vapid: VapidConfig | null, disabledReason: string | null): boolean {\n if (vapid === null) {\n log.warn(`web push disabled: ${disabledReason ?? 'VAPID env not set'}`);\n return false;\n }\n try {\n webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey);\n log.info('web push enabled');\n return true;\n } catch (err) {\n log.warn(`web push disabled: invalid VAPID config: ${errorMessage(err)}`);\n return false;\n }\n}\n\nexport class Pusher {\n constructor(\n private readonly store: PushStore,\n readonly enabled: boolean,\n ) {}\n\n async deliver(room: string, payload: string, tag: string | undefined): Promise<PushDelivery> {\n const result: PushDelivery = { sent: 0, dropped: 0, failed: 0 };\n const options: webpush.RequestOptions = { TTL: PUSH_TTL_SECONDS };\n if (tag !== undefined && TOPIC_PATTERN.test(tag)) options.topic = tag;\n\n for (const subscription of this.store.list(room)) {\n await this.send(room, subscription, payload, options, result);\n }\n log.info(\n `push room=${shortRoom(room)} sent=${result.sent} dropped=${result.dropped} failed=${result.failed}`,\n );\n return result;\n }\n\n private async send(\n room: string,\n subscription: PushSubscriptionJson,\n payload: string,\n options: webpush.RequestOptions,\n result: PushDelivery,\n ): Promise<void> {\n try {\n await webpush.sendNotification(subscription, payload, options);\n result.sent += 1;\n } catch (err) {\n const status = (err as { statusCode?: number }).statusCode;\n if (status === 404 || status === 410) {\n result.dropped += 1;\n await this.store.remove(room, subscription.endpoint);\n log.info(`push subscription gone (${status}) room=${shortRoom(room)}, pruned`);\n return;\n }\n result.failed += 1;\n log.error(`push failed room=${shortRoom(room)} status=${status ?? 'none'}: ${errorMessage(err)}`);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dispatch-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Zero-knowledge WebSocket relay for dsh-dispatch: routes end-to-end encrypted frames between DeepSeek Harness (dsh) machines and phones, and fans out Web Push when the phone is offline.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"tsx": "^4.20.3",
|
|
45
45
|
"typescript": "^7.0.2",
|
|
46
46
|
"vitest": "^4.1.11",
|
|
47
|
-
"@dsh-dispatch/shared": "0.
|
|
47
|
+
"@dsh-dispatch/shared": "0.2.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "tsup",
|