@shadow-js/realtime 1.0.1 → 1.0.3

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +35 -0
  3. package/dist/dev/client/core.js +107 -107
  4. package/dist/dev/client/core.js.map +1 -1
  5. package/dist/dev/client/sse.js +33 -24
  6. package/dist/dev/client/sse.js.map +1 -1
  7. package/dist/dev/client/types.d.ts +6 -0
  8. package/dist/dev/client/util.js +2 -2
  9. package/dist/dev/client/util.js.map +1 -1
  10. package/dist/dev/cloudflare/link.js +69 -8
  11. package/dist/dev/cloudflare/link.js.map +1 -1
  12. package/dist/dev/memory.d.ts +11 -0
  13. package/dist/dev/memory.js +38 -10
  14. package/dist/dev/memory.js.map +1 -1
  15. package/dist/dev/protocol.js +13 -12
  16. package/dist/dev/protocol.js.map +1 -1
  17. package/dist/dev/redis/topics.d.ts +6 -1
  18. package/dist/dev/redis/topics.js +4 -1
  19. package/dist/dev/redis/topics.js.map +1 -1
  20. package/dist/dev/server/http.js +37 -24
  21. package/dist/dev/server/http.js.map +1 -1
  22. package/dist/dev/server/hub.d.ts +1 -1
  23. package/dist/dev/server/hub.js +117 -69
  24. package/dist/dev/server/hub.js.map +1 -1
  25. package/dist/dev/server/index.d.ts +2 -2
  26. package/dist/dev/server/index.js.map +1 -1
  27. package/dist/dev/server/limits.d.ts +4 -1
  28. package/dist/dev/server/limits.js.map +1 -1
  29. package/dist/dev/server/rules.js.map +1 -1
  30. package/dist/prod/client/core.js +1 -1
  31. package/dist/prod/client/core.js.map +1 -1
  32. package/dist/prod/client/sse.js +1 -4
  33. package/dist/prod/client/sse.js.map +1 -1
  34. package/dist/prod/client/util.js +1 -1
  35. package/dist/prod/client/util.js.map +1 -1
  36. package/dist/prod/cloudflare/link.js +1 -1
  37. package/dist/prod/cloudflare/link.js.map +1 -1
  38. package/dist/prod/memory.js +1 -1
  39. package/dist/prod/memory.js.map +1 -1
  40. package/dist/prod/protocol.js +1 -1
  41. package/dist/prod/protocol.js.map +1 -1
  42. package/dist/prod/redis/topics.js +1 -1
  43. package/dist/prod/redis/topics.js.map +1 -1
  44. package/dist/prod/server/http.js +1 -1
  45. package/dist/prod/server/http.js.map +1 -1
  46. package/dist/prod/server/hub.js +1 -1
  47. package/dist/prod/server/hub.js.map +1 -1
  48. package/dist/prod/server/index.js.map +1 -1
  49. package/dist/prod/server/limits.js +1 -1
  50. package/dist/prod/server/limits.js.map +1 -1
  51. package/dist/prod/server/rules.js +1 -1
  52. package/dist/prod/server/rules.js.map +1 -1
  53. package/package.json +6 -5
@@ -1 +1 @@
1
- {"version":3,"file":"topics.js","names":[],"sources":["../../../src/redis/topics.ts"],"sourcesContent":["import type { Unsubscribe } from \"../adapter\";\nimport type { Member, PresenceEvent, StoredMessage } from \"../protocol\";\nimport type { RedisSubscriber } from \"./client\";\n\n/** Where a failure the adapter handled came from. */\nexport interface RedisErrorContext {\n readonly where: string;\n readonly channel?: string;\n}\n\n/** What the subscription manager needs from the adapter that owns it. */\nexport interface TopicsDeps {\n /** The shared subscriber connection (opened on first use). */\n subscriber(): Promise<RedisSubscriber>;\n /** The retained messages after `after`, at most `limit`, oldest first. */\n history(channel: string, after: number, limit: number): Promise<readonly StoredMessage[]>;\n /** The live presence members of a channel. */\n members(channel: string): Promise<readonly Member[]>;\n /** Extends the lifetime of a channel's numbering. */\n keepAlive(channel: string): Promise<void>;\n /** Parses a pub/sub payload; undefined (after reporting) when it is malformed. */\n parseMessage(text: string, channel: string): StoredMessage | undefined;\n parsePresence(text: string, channel: string): PresenceEvent | undefined;\n messageTopic(channel: string): string;\n presenceTopic(channel: string): string;\n report(error: unknown, context: RedisErrorContext): void;\n /** Turns a failure into the error the adapter's callers see. */\n fail(error: unknown): Error;\n /** Milliseconds between keep-alive rounds. */\n keepAliveMs: number;\n}\n\n/** What the manager returns. */\nexport interface Topics {\n messages(channel: string, listener: (message: StoredMessage) => void): Promise<Unsubscribe>;\n presence(channel: string, listener: (event: PresenceEvent) => void): Promise<Unsubscribe>;\n /** Unsubscribes everything and stops the timers. */\n close(): Promise<void>;\n /** The number of topics currently subscribed (for tests and diagnostics). */\n readonly size: number;\n}\n\n/** Live messages held while a recovery reads history; a larger burst falls back to reading history again. */\nconst CATCH_UP_BUFFER = 1000;\n/** Messages read per history page during recovery. */\nconst CATCH_UP_PAGE = 500;\n/** Delays between attempts to recover after a reconnect. */\nconst RETRY_DELAYS_MS: readonly number[] = [50, 250, 1000];\n/** Topics subscribed again at once after a reconnect. */\nconst RESUBSCRIBE_BATCH = 64;\n/** Members tracked per presence channel; a larger channel is delivered unfiltered and not reconciled after a reconnect. */\nconst MAX_TRACKED_MEMBERS = 5000;\n\ntype MessageListener = (message: StoredMessage) => void;\ntype PresenceListener = (event: PresenceEvent) => void;\n\ninterface TopicBase {\n readonly channel: string;\n readonly topic: string;\n readonly handler: (text: string) => void;\n ready: Promise<void>;\n dead: boolean;\n}\n\ninterface MessageTopic extends TopicBase {\n readonly kind: \"message\";\n readonly listeners: Set<MessageListener>;\n /** The highest seq handed to the listeners: nothing at or below it is delivered again. */\n last: number;\n /** True while history is being read to fill a gap: live messages wait in `buffer`. */\n catching: boolean;\n /** Set when a reconnect happened while a recovery was running: it reads once more. */\n rerun: boolean;\n buffer: StoredMessage[];\n overflow: boolean;\n}\n\ninterface PresenceTopic extends TopicBase {\n readonly kind: \"presence\";\n readonly listeners: Set<PresenceListener>;\n /** The members as the listeners know them, by id. */\n members: Map<string, Member>;\n /** False when the channel is too large to track: events are then delivered unfiltered and not reconciled. */\n tracked: boolean;\n /** Events received while the state is being read; applied on top of it when it arrives. */\n pending: PresenceEvent[] | undefined;\n}\n\ntype Topic = MessageTopic | PresenceTopic;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/** Applies an event to a member map; true when it changed the map (a stale or repeated event does not). */\nfunction apply(members: Map<string, Member>, event: PresenceEvent): boolean {\n const known = members.get(event.member.id);\n if (event.op === \"leave\") return known !== undefined && members.delete(event.member.id);\n if (known !== undefined && known.v >= event.member.v) return false;\n members.set(event.member.id, event.member);\n return true;\n}\n\n/**\n * The shared subscriber connection of one adapter: topics are refcounted (one SUBSCRIBE per topic however many listeners), a\n * listener sees strictly increasing seqs and never a repeat, and after a reconnect the topics are subscribed again and what the\n * connection missed is read back from history (messages) or from the member list (presence).\n */\nexport function createTopics(deps: TopicsDeps): Topics {\n const topics = new Map<string, Topic>();\n let closed = false;\n let timer: ReturnType<typeof setInterval> | undefined;\n let reconnecting = false;\n let again = false;\n let connected: RedisSubscriber | undefined;\n /** Removes the hooks this manager put on the subscriber (a subscriber the caller passed outlives the adapter). */\n const unhook: (() => void)[] = [];\n\n function reportTo(error: unknown, topic: Topic, where: string): void {\n deps.report(error, { where, channel: topic.channel });\n }\n\n // --- delivery ------------------------------------------------------------------------------------------------------\n\n function emit(topic: MessageTopic, message: StoredMessage): void {\n if (topic.dead || message.seq <= topic.last) return;\n topic.last = message.seq;\n for (const listener of [...topic.listeners]) {\n try {\n listener(message);\n } catch (error) {\n reportTo(error, topic, \"listener\");\n }\n }\n }\n\n function onMessage(topic: MessageTopic, text: string): void {\n const message = deps.parseMessage(text, topic.channel);\n if (message === undefined || topic.dead) return;\n if (topic.catching || (topic.last > 0 && message.seq > topic.last + 1)) {\n // Either history is being read, or this message jumped ahead of the cursor (a publish this connection missed): hold it\n // back and fill the gap from history first, so the listeners see every retained message in order.\n if (topic.buffer.length >= CATCH_UP_BUFFER) topic.overflow = true;\n else topic.buffer.push(message);\n if (!topic.catching) void heal(topic);\n return;\n }\n emit(topic, message);\n }\n\n function dispatchPresence(topic: PresenceTopic, event: PresenceEvent): void {\n for (const listener of [...topic.listeners]) {\n try {\n listener(event);\n } catch (error) {\n reportTo(error, topic, \"listener\");\n }\n }\n }\n\n function onPresence(topic: PresenceTopic, text: string): void {\n const event = deps.parsePresence(text, topic.channel);\n if (event === undefined || topic.dead) return;\n if (topic.pending !== undefined) {\n // The state is being read: deliver raw now, and fold the event into the state when it arrives.\n if (topic.pending.length < MAX_TRACKED_MEMBERS) topic.pending.push(event);\n else topic.tracked = false;\n dispatchPresence(topic, event);\n return;\n }\n if (topic.tracked && !apply(topic.members, event)) return;\n dispatchPresence(topic, event);\n }\n\n // --- recovery after a reconnect ------------------------------------------------------------------------------------\n\n /** Reads history from the cursor until it reaches the present, then lets the held live messages through. */\n async function readToPresent(topic: MessageTopic): Promise<void> {\n for (;;) {\n let more = true;\n while (more) {\n const page = await deps.history(topic.channel, topic.last, CATCH_UP_PAGE);\n if (topic.dead) return;\n for (const message of page) emit(topic, message);\n more = page.length >= CATCH_UP_PAGE;\n }\n if (!topic.overflow) return;\n // More live messages arrived than the buffer holds: read history again from the cursor.\n topic.overflow = false;\n topic.buffer.length = 0;\n }\n }\n\n /**\n * Fills a gap of the cursor from history. One at a time per topic; a request that arrives while one runs makes it read once\n * more. Gives up after a few failed attempts (the hub's own recovery is the next net) and lets the held messages through.\n */\n async function heal(topic: MessageTopic): Promise<void> {\n if (topic.catching) {\n topic.rerun = true;\n return;\n }\n topic.catching = true;\n try {\n let attempt = 0;\n while (!topic.dead && !closed) {\n topic.rerun = false;\n try {\n await readToPresent(topic);\n if (!topic.rerun) return;\n } catch (error) {\n reportTo(error, topic, \"recover\");\n const delay = RETRY_DELAYS_MS[attempt];\n attempt += 1;\n if (delay === undefined) return;\n await sleep(delay);\n }\n }\n } finally {\n topic.catching = false;\n topic.rerun = false;\n const held = topic.buffer.splice(0);\n for (const message of held) emit(topic, message);\n }\n }\n\n async function resync(topic: PresenceTopic): Promise<void> {\n if (!topic.tracked) return;\n const before = topic.members;\n topic.pending = [];\n let list: readonly Member[];\n try {\n list = await deps.members(topic.channel);\n } catch (error) {\n const held = topic.pending;\n topic.pending = undefined;\n for (const event of held) if (apply(before, event)) dispatchPresence(topic, event);\n throw error;\n }\n const held = topic.pending;\n topic.pending = undefined;\n if (topic.dead) return;\n const next = new Map<string, Member>(list.map((member) => [member.id, member]));\n for (const event of held) apply(next, event);\n const events: PresenceEvent[] = [];\n for (const [id, member] of before)\n if (!next.has(id)) events.push({ channel: topic.channel, op: \"leave\", member });\n for (const [id, member] of next) {\n const known = before.get(id);\n if (known === undefined) events.push({ channel: topic.channel, op: \"join\", member });\n else if (member.v > known.v) events.push({ channel: topic.channel, op: \"set\", member });\n }\n topic.members = next;\n if (next.size > MAX_TRACKED_MEMBERS) topic.tracked = false;\n for (const event of events) dispatchPresence(topic, event);\n }\n\n async function recover(topic: Topic): Promise<void> {\n for (let attempt = 0; ; attempt += 1) {\n try {\n const subscriber = await deps.subscriber();\n await subscriber.subscribe(topic.topic, topic.handler);\n if (topic.kind === \"message\") {\n if (topic.last > 0) await heal(topic);\n } else {\n await resync(topic);\n }\n return;\n } catch (error) {\n reportTo(error, topic, \"reconnect\");\n const delay = RETRY_DELAYS_MS[attempt];\n if (delay === undefined || topic.dead || closed) return;\n await sleep(delay);\n if (topic.dead || closed) return;\n }\n }\n }\n\n async function reconnect(): Promise<void> {\n if (reconnecting) {\n again = true;\n return;\n }\n reconnecting = true;\n try {\n do {\n again = false;\n const live = [...topics.values()].filter((topic) => !topic.dead);\n for (let from = 0; from < live.length && !closed; from += RESUBSCRIBE_BATCH)\n await Promise.all(live.slice(from, from + RESUBSCRIBE_BATCH).map(recover));\n } while (again && !closed);\n } finally {\n reconnecting = false;\n }\n }\n\n async function hook(): Promise<RedisSubscriber> {\n const subscriber = await deps.subscriber();\n if (connected !== subscriber) {\n connected = subscriber;\n unhook.push(\n subscriber.onReconnect(() => {\n void reconnect();\n }),\n );\n const stopErrors = subscriber.onError?.((error) =>\n deps.report(error, { where: \"subscriber\" }),\n );\n if (stopErrors !== undefined) unhook.push(stopErrors);\n }\n return subscriber;\n }\n\n // --- keeping numbering alive ---------------------------------------------------------------------------------------\n\n async function keepAliveRound(): Promise<void> {\n const channels = [...topics.values()].filter(\n (topic): topic is MessageTopic => topic.kind === \"message\" && !topic.dead,\n );\n for (let from = 0; from < channels.length; from += RESUBSCRIBE_BATCH)\n await Promise.all(\n channels.slice(from, from + RESUBSCRIBE_BATCH).map(async (topic) => {\n try {\n await deps.keepAlive(topic.channel);\n } catch (error) {\n reportTo(error, topic, \"keepAlive\");\n }\n }),\n );\n }\n\n function syncTimer(): void {\n const needed = [...topics.values()].some((topic) => topic.kind === \"message\");\n if (needed && timer === undefined && !closed) {\n timer = setInterval(() => void keepAliveRound(), deps.keepAliveMs);\n (timer as { unref?: () => void }).unref?.();\n } else if (!needed && timer !== undefined) {\n clearInterval(timer);\n timer = undefined;\n }\n }\n\n // --- subscribing and releasing -------------------------------------------------------------------------------------\n\n async function open<T extends Topic>(topic: T): Promise<void> {\n topics.set(topic.topic, topic);\n syncTimer();\n topic.ready = (async () => {\n const subscriber = await hook();\n await subscriber.subscribe(topic.topic, topic.handler);\n if (topic.kind === \"presence\") await seed(topic);\n })();\n topic.ready.catch(() => {\n // The first caller's await reports it; here the half-open topic is dropped.\n topic.dead = true;\n if (topics.get(topic.topic) === topic) topics.delete(topic.topic);\n syncTimer();\n connected?.unsubscribe(topic.topic).catch(() => undefined);\n });\n await topic.ready;\n }\n\n async function seed(topic: PresenceTopic): Promise<void> {\n topic.pending = [];\n let list: readonly Member[];\n try {\n list = await deps.members(topic.channel);\n } catch (error) {\n topic.pending = undefined;\n throw error;\n }\n const held = topic.pending;\n topic.pending = undefined;\n const members = new Map<string, Member>(list.map((member) => [member.id, member]));\n for (const event of held) apply(members, event);\n topic.members = members;\n if (members.size > MAX_TRACKED_MEMBERS) topic.tracked = false;\n }\n\n /**\n * Returns the function that removes one listener. The last listener out drops the topic and sends UNSUBSCRIBE at once (not\n * after an await), so a subscribe of the same topic issued next is sent after it and wins.\n */\n function release(topic: Topic, remove: () => void): Unsubscribe {\n let done = false;\n return async () => {\n if (done) return;\n done = true;\n remove();\n if (topic.listeners.size > 0 || topic.dead) return;\n topic.dead = true;\n if (topics.get(topic.topic) === topic) topics.delete(topic.topic);\n syncTimer();\n if (closed || connected === undefined) return;\n try {\n await connected.unsubscribe(topic.topic);\n } catch (error) {\n reportTo(error, topic, \"unsubscribe\");\n }\n };\n }\n\n async function attach<T extends Topic>(\n topic: T | undefined,\n create: () => T,\n add: (topic: T) => Unsubscribe,\n ): Promise<Unsubscribe> {\n if (closed) throw deps.fail(new Error(\"the adapter is closed.\"));\n const found = topic ?? create();\n const stop = add(found);\n try {\n if (topic === undefined) await open(found);\n else await found.ready;\n } catch (error) {\n void stop();\n throw deps.fail(error);\n }\n if (closed || found.dead) {\n void stop();\n throw deps.fail(new Error(\"the adapter is closed.\"));\n }\n return stop;\n }\n\n return {\n messages(channel, listener) {\n const key = deps.messageTopic(channel);\n const existing = topics.get(key);\n return attach<MessageTopic>(\n existing?.kind === \"message\" ? existing : undefined,\n () => {\n const topic: MessageTopic = {\n kind: \"message\",\n channel,\n topic: key,\n listeners: new Set(),\n last: 0,\n catching: false,\n rerun: false,\n buffer: [],\n overflow: false,\n dead: false,\n ready: Promise.resolve(),\n handler: (text) => onMessage(topic, text),\n };\n return topic;\n },\n (topic) => {\n topic.listeners.add(listener);\n return release(topic, () => topic.listeners.delete(listener));\n },\n );\n },\n presence(channel, listener) {\n const key = deps.presenceTopic(channel);\n const existing = topics.get(key);\n return attach<PresenceTopic>(\n existing?.kind === \"presence\" ? existing : undefined,\n () => {\n const topic: PresenceTopic = {\n kind: \"presence\",\n channel,\n topic: key,\n listeners: new Set(),\n members: new Map(),\n tracked: true,\n pending: undefined,\n dead: false,\n ready: Promise.resolve(),\n handler: (text) => onPresence(topic, text),\n };\n return topic;\n },\n (topic) => {\n topic.listeners.add(listener);\n return release(topic, () => topic.listeners.delete(listener));\n },\n );\n },\n async close() {\n closed = true;\n for (const stop of unhook.splice(0)) stop();\n if (timer !== undefined) clearInterval(timer);\n timer = undefined;\n const open = [...topics.values()];\n topics.clear();\n for (const topic of open) topic.dead = true;\n if (open.length === 0) return;\n const subscriber = connected;\n if (subscriber === undefined) return;\n await Promise.all(\n open.map((topic) =>\n subscriber.unsubscribe(topic.topic).catch((error: unknown) => {\n reportTo(error, topic, \"unsubscribe\");\n }),\n ),\n );\n },\n get size() {\n return topics.size;\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"topics.js","names":[],"sources":["../../../src/redis/topics.ts"],"sourcesContent":["import type { Unsubscribe } from \"../adapter\";\nimport type { Member, PresenceEvent, StoredMessage } from \"../protocol\";\nimport type { RedisSubscriber } from \"./client\";\n\n/**\n * Where a failure the adapter handled came from.\n * @since 1.0.0\n */\nexport interface RedisErrorContext {\n /** The step that failed, for example `\"parse\"`, `\"listener\"`, `\"reconnect\"` or `\"subscriber\"`. */\n readonly where: string;\n /** The channel it concerned, when it concerned one. */\n readonly channel?: string;\n}\n\n/** What the subscription manager needs from the adapter that owns it. */\nexport interface TopicsDeps {\n /** The shared subscriber connection (opened on first use). */\n subscriber(): Promise<RedisSubscriber>;\n /** The retained messages after `after`, at most `limit`, oldest first. */\n history(channel: string, after: number, limit: number): Promise<readonly StoredMessage[]>;\n /** The live presence members of a channel. */\n members(channel: string): Promise<readonly Member[]>;\n /** Extends the lifetime of a channel's numbering. */\n keepAlive(channel: string): Promise<void>;\n /** Parses a pub/sub payload; undefined (after reporting) when it is malformed. */\n parseMessage(text: string, channel: string): StoredMessage | undefined;\n parsePresence(text: string, channel: string): PresenceEvent | undefined;\n messageTopic(channel: string): string;\n presenceTopic(channel: string): string;\n report(error: unknown, context: RedisErrorContext): void;\n /** Turns a failure into the error the adapter's callers see. */\n fail(error: unknown): Error;\n /** Milliseconds between keep-alive rounds. */\n keepAliveMs: number;\n}\n\n/** What the manager returns. */\nexport interface Topics {\n messages(channel: string, listener: (message: StoredMessage) => void): Promise<Unsubscribe>;\n presence(channel: string, listener: (event: PresenceEvent) => void): Promise<Unsubscribe>;\n /** Unsubscribes everything and stops the timers. */\n close(): Promise<void>;\n /** The number of topics currently subscribed (for tests and diagnostics). */\n readonly size: number;\n}\n\n/** Live messages held while a recovery reads history; a larger burst falls back to reading history again. */\nconst CATCH_UP_BUFFER = 1000;\n/** Messages read per history page during recovery. */\nconst CATCH_UP_PAGE = 500;\n/** Delays between attempts to recover after a reconnect. */\nconst RETRY_DELAYS_MS: readonly number[] = [50, 250, 1000];\n/** Topics subscribed again at once after a reconnect. */\nconst RESUBSCRIBE_BATCH = 64;\n/** Members tracked per presence channel; a larger channel is delivered unfiltered and not reconciled after a reconnect. */\nconst MAX_TRACKED_MEMBERS = 5000;\n\ntype MessageListener = (message: StoredMessage) => void;\ntype PresenceListener = (event: PresenceEvent) => void;\n\ninterface TopicBase {\n readonly channel: string;\n readonly topic: string;\n readonly handler: (text: string) => void;\n ready: Promise<void>;\n dead: boolean;\n}\n\ninterface MessageTopic extends TopicBase {\n readonly kind: \"message\";\n readonly listeners: Set<MessageListener>;\n /** The highest seq handed to the listeners: nothing at or below it is delivered again. */\n last: number;\n /** When the message at `last` was accepted: a lower seq accepted later is a restarted numbering, not a repeat. */\n lastAt: number;\n /** True while history is being read to fill a gap: live messages wait in `buffer`. */\n catching: boolean;\n /** Set when a reconnect happened while a recovery was running: it reads once more. */\n rerun: boolean;\n buffer: StoredMessage[];\n overflow: boolean;\n}\n\ninterface PresenceTopic extends TopicBase {\n readonly kind: \"presence\";\n readonly listeners: Set<PresenceListener>;\n /** The members as the listeners know them, by id. */\n members: Map<string, Member>;\n /** False when the channel is too large to track: events are then delivered unfiltered and not reconciled. */\n tracked: boolean;\n /** Events received while the state is being read; applied on top of it when it arrives. */\n pending: PresenceEvent[] | undefined;\n}\n\ntype Topic = MessageTopic | PresenceTopic;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/** Applies an event to a member map; true when it changed the map (a stale or repeated event does not). */\nfunction apply(members: Map<string, Member>, event: PresenceEvent): boolean {\n const known = members.get(event.member.id);\n if (event.op === \"leave\") return known !== undefined && members.delete(event.member.id);\n if (known !== undefined && known.v >= event.member.v) return false;\n members.set(event.member.id, event.member);\n return true;\n}\n\n/**\n * The shared subscriber connection of one adapter: topics are refcounted (one SUBSCRIBE per topic however many listeners), a\n * listener sees strictly increasing seqs and never a repeat, and after a reconnect the topics are subscribed again and what the\n * connection missed is read back from history (messages) or from the member list (presence).\n */\nexport function createTopics(deps: TopicsDeps): Topics {\n const topics = new Map<string, Topic>();\n let closed = false;\n let timer: ReturnType<typeof setInterval> | undefined;\n let reconnecting = false;\n let again = false;\n let connected: RedisSubscriber | undefined;\n /** Removes the hooks this manager put on the subscriber (a subscriber the caller passed outlives the adapter). */\n const unhook: (() => void)[] = [];\n\n function reportTo(error: unknown, topic: Topic, where: string): void {\n deps.report(error, { where, channel: topic.channel });\n }\n\n // --- delivery ------------------------------------------------------------------------------------------------------\n\n function emit(topic: MessageTopic, message: StoredMessage): void {\n if (topic.dead) return;\n // Redis stamps `at` itself, so a repeat carries the time it first had. A lower seq with a later time means the keys were\n // lost (a flush, a failover to a replica that lacked them) and numbering began again: dropping it would silence the\n // channel until the new numbers pass the old ones. The hub tells its subscribers `reset` when it sees one.\n if (message.seq <= topic.last && message.at <= topic.lastAt) return;\n topic.last = message.seq;\n topic.lastAt = Math.max(topic.lastAt, message.at);\n for (const listener of [...topic.listeners]) {\n try {\n listener(message);\n } catch (error) {\n reportTo(error, topic, \"listener\");\n }\n }\n }\n\n function onMessage(topic: MessageTopic, text: string): void {\n const message = deps.parseMessage(text, topic.channel);\n if (message === undefined || topic.dead) return;\n if (topic.catching || (topic.last > 0 && message.seq > topic.last + 1)) {\n // Either history is being read, or this message jumped ahead of the cursor (a publish this connection missed): hold it\n // back and fill the gap from history first, so the listeners see every retained message in order.\n if (topic.buffer.length >= CATCH_UP_BUFFER) topic.overflow = true;\n else topic.buffer.push(message);\n if (!topic.catching) void heal(topic);\n return;\n }\n emit(topic, message);\n }\n\n function dispatchPresence(topic: PresenceTopic, event: PresenceEvent): void {\n for (const listener of [...topic.listeners]) {\n try {\n listener(event);\n } catch (error) {\n reportTo(error, topic, \"listener\");\n }\n }\n }\n\n function onPresence(topic: PresenceTopic, text: string): void {\n const event = deps.parsePresence(text, topic.channel);\n if (event === undefined || topic.dead) return;\n if (topic.pending !== undefined) {\n // The state is being read: deliver raw now, and fold the event into the state when it arrives.\n if (topic.pending.length < MAX_TRACKED_MEMBERS) topic.pending.push(event);\n else topic.tracked = false;\n dispatchPresence(topic, event);\n return;\n }\n if (topic.tracked && !apply(topic.members, event)) return;\n dispatchPresence(topic, event);\n }\n\n // --- recovery after a reconnect ------------------------------------------------------------------------------------\n\n /** Reads history from the cursor until it reaches the present, then lets the held live messages through. */\n async function readToPresent(topic: MessageTopic): Promise<void> {\n for (;;) {\n let more = true;\n while (more) {\n const page = await deps.history(topic.channel, topic.last, CATCH_UP_PAGE);\n if (topic.dead) return;\n for (const message of page) emit(topic, message);\n more = page.length >= CATCH_UP_PAGE;\n }\n if (!topic.overflow) return;\n // More live messages arrived than the buffer holds: read history again from the cursor.\n topic.overflow = false;\n topic.buffer.length = 0;\n }\n }\n\n /**\n * Fills a gap of the cursor from history. One at a time per topic; a request that arrives while one runs makes it read once\n * more. Gives up after a few failed attempts (the hub's own recovery is the next net) and lets the held messages through.\n */\n async function heal(topic: MessageTopic): Promise<void> {\n if (topic.catching) {\n topic.rerun = true;\n return;\n }\n topic.catching = true;\n try {\n let attempt = 0;\n while (!topic.dead && !closed) {\n topic.rerun = false;\n try {\n await readToPresent(topic);\n if (!topic.rerun) return;\n } catch (error) {\n reportTo(error, topic, \"recover\");\n const delay = RETRY_DELAYS_MS[attempt];\n attempt += 1;\n if (delay === undefined) return;\n await sleep(delay);\n }\n }\n } finally {\n topic.catching = false;\n topic.rerun = false;\n const held = topic.buffer.splice(0);\n for (const message of held) emit(topic, message);\n }\n }\n\n async function resync(topic: PresenceTopic): Promise<void> {\n if (!topic.tracked) return;\n const before = topic.members;\n topic.pending = [];\n let list: readonly Member[];\n try {\n list = await deps.members(topic.channel);\n } catch (error) {\n const held = topic.pending;\n topic.pending = undefined;\n for (const event of held) if (apply(before, event)) dispatchPresence(topic, event);\n throw error;\n }\n const held = topic.pending;\n topic.pending = undefined;\n if (topic.dead) return;\n const next = new Map<string, Member>(list.map((member) => [member.id, member]));\n for (const event of held) apply(next, event);\n const events: PresenceEvent[] = [];\n for (const [id, member] of before)\n if (!next.has(id)) events.push({ channel: topic.channel, op: \"leave\", member });\n for (const [id, member] of next) {\n const known = before.get(id);\n if (known === undefined) events.push({ channel: topic.channel, op: \"join\", member });\n else if (member.v > known.v) events.push({ channel: topic.channel, op: \"set\", member });\n }\n topic.members = next;\n if (next.size > MAX_TRACKED_MEMBERS) topic.tracked = false;\n for (const event of events) dispatchPresence(topic, event);\n }\n\n async function recover(topic: Topic): Promise<void> {\n for (let attempt = 0; ; attempt += 1) {\n try {\n const subscriber = await deps.subscriber();\n await subscriber.subscribe(topic.topic, topic.handler);\n if (topic.kind === \"message\") {\n if (topic.last > 0) await heal(topic);\n } else {\n await resync(topic);\n }\n return;\n } catch (error) {\n reportTo(error, topic, \"reconnect\");\n const delay = RETRY_DELAYS_MS[attempt];\n if (delay === undefined || topic.dead || closed) return;\n await sleep(delay);\n if (topic.dead || closed) return;\n }\n }\n }\n\n async function reconnect(): Promise<void> {\n if (reconnecting) {\n again = true;\n return;\n }\n reconnecting = true;\n try {\n do {\n again = false;\n const live = [...topics.values()].filter((topic) => !topic.dead);\n for (let from = 0; from < live.length && !closed; from += RESUBSCRIBE_BATCH)\n await Promise.all(live.slice(from, from + RESUBSCRIBE_BATCH).map(recover));\n } while (again && !closed);\n } finally {\n reconnecting = false;\n }\n }\n\n async function hook(): Promise<RedisSubscriber> {\n const subscriber = await deps.subscriber();\n if (connected !== subscriber) {\n connected = subscriber;\n unhook.push(\n subscriber.onReconnect(() => {\n void reconnect();\n }),\n );\n const stopErrors = subscriber.onError?.((error) =>\n deps.report(error, { where: \"subscriber\" }),\n );\n if (stopErrors !== undefined) unhook.push(stopErrors);\n }\n return subscriber;\n }\n\n // --- keeping numbering alive ---------------------------------------------------------------------------------------\n\n async function keepAliveRound(): Promise<void> {\n const channels = [...topics.values()].filter(\n (topic): topic is MessageTopic => topic.kind === \"message\" && !topic.dead,\n );\n for (let from = 0; from < channels.length; from += RESUBSCRIBE_BATCH)\n await Promise.all(\n channels.slice(from, from + RESUBSCRIBE_BATCH).map(async (topic) => {\n try {\n await deps.keepAlive(topic.channel);\n } catch (error) {\n reportTo(error, topic, \"keepAlive\");\n }\n }),\n );\n }\n\n function syncTimer(): void {\n const needed = [...topics.values()].some((topic) => topic.kind === \"message\");\n if (needed && timer === undefined && !closed) {\n timer = setInterval(() => void keepAliveRound(), deps.keepAliveMs);\n (timer as { unref?: () => void }).unref?.();\n } else if (!needed && timer !== undefined) {\n clearInterval(timer);\n timer = undefined;\n }\n }\n\n // --- subscribing and releasing -------------------------------------------------------------------------------------\n\n async function open<T extends Topic>(topic: T): Promise<void> {\n topics.set(topic.topic, topic);\n syncTimer();\n topic.ready = (async () => {\n const subscriber = await hook();\n await subscriber.subscribe(topic.topic, topic.handler);\n if (topic.kind === \"presence\") await seed(topic);\n })();\n topic.ready.catch(() => {\n // The first caller's await reports it; here the half-open topic is dropped.\n topic.dead = true;\n if (topics.get(topic.topic) === topic) topics.delete(topic.topic);\n syncTimer();\n connected?.unsubscribe(topic.topic).catch(() => undefined);\n });\n await topic.ready;\n }\n\n async function seed(topic: PresenceTopic): Promise<void> {\n topic.pending = [];\n let list: readonly Member[];\n try {\n list = await deps.members(topic.channel);\n } catch (error) {\n topic.pending = undefined;\n throw error;\n }\n const held = topic.pending;\n topic.pending = undefined;\n const members = new Map<string, Member>(list.map((member) => [member.id, member]));\n for (const event of held) apply(members, event);\n topic.members = members;\n if (members.size > MAX_TRACKED_MEMBERS) topic.tracked = false;\n }\n\n /**\n * Returns the function that removes one listener. The last listener out drops the topic and sends UNSUBSCRIBE at once (not\n * after an await), so a subscribe of the same topic issued next is sent after it and wins.\n */\n function release(topic: Topic, remove: () => void): Unsubscribe {\n let done = false;\n return async () => {\n if (done) return;\n done = true;\n remove();\n if (topic.listeners.size > 0 || topic.dead) return;\n topic.dead = true;\n if (topics.get(topic.topic) === topic) topics.delete(topic.topic);\n syncTimer();\n if (closed || connected === undefined) return;\n try {\n await connected.unsubscribe(topic.topic);\n } catch (error) {\n reportTo(error, topic, \"unsubscribe\");\n }\n };\n }\n\n async function attach<T extends Topic>(\n topic: T | undefined,\n create: () => T,\n add: (topic: T) => Unsubscribe,\n ): Promise<Unsubscribe> {\n if (closed) throw deps.fail(new Error(\"the adapter is closed.\"));\n const found = topic ?? create();\n const stop = add(found);\n try {\n if (topic === undefined) await open(found);\n else await found.ready;\n } catch (error) {\n void stop();\n throw deps.fail(error);\n }\n if (closed || found.dead) {\n void stop();\n throw deps.fail(new Error(\"the adapter is closed.\"));\n }\n return stop;\n }\n\n return {\n messages(channel, listener) {\n const key = deps.messageTopic(channel);\n const existing = topics.get(key);\n return attach<MessageTopic>(\n existing?.kind === \"message\" ? existing : undefined,\n () => {\n const topic: MessageTopic = {\n kind: \"message\",\n channel,\n topic: key,\n listeners: new Set(),\n last: 0,\n lastAt: 0,\n catching: false,\n rerun: false,\n buffer: [],\n overflow: false,\n dead: false,\n ready: Promise.resolve(),\n handler: (text) => onMessage(topic, text),\n };\n return topic;\n },\n (topic) => {\n topic.listeners.add(listener);\n return release(topic, () => topic.listeners.delete(listener));\n },\n );\n },\n presence(channel, listener) {\n const key = deps.presenceTopic(channel);\n const existing = topics.get(key);\n return attach<PresenceTopic>(\n existing?.kind === \"presence\" ? existing : undefined,\n () => {\n const topic: PresenceTopic = {\n kind: \"presence\",\n channel,\n topic: key,\n listeners: new Set(),\n members: new Map(),\n tracked: true,\n pending: undefined,\n dead: false,\n ready: Promise.resolve(),\n handler: (text) => onPresence(topic, text),\n };\n return topic;\n },\n (topic) => {\n topic.listeners.add(listener);\n return release(topic, () => topic.listeners.delete(listener));\n },\n );\n },\n async close() {\n closed = true;\n for (const stop of unhook.splice(0)) stop();\n if (timer !== undefined) clearInterval(timer);\n timer = undefined;\n const open = [...topics.values()];\n topics.clear();\n for (const topic of open) topic.dead = true;\n if (open.length === 0) return;\n const subscriber = connected;\n if (subscriber === undefined) return;\n await Promise.all(\n open.map((topic) =>\n subscriber.unsubscribe(topic.topic).catch((error: unknown) => {\n reportTo(error, topic, \"unsubscribe\");\n }),\n ),\n );\n },\n get size() {\n return topics.size;\n },\n };\n}\n"],"mappings":"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"}
@@ -1,16 +1,15 @@
1
1
  import { RealtimeError } from "../errors.js";
2
- import { byteLength } from "../protocol.js";
3
2
  import { TokenBucket } from "./limits.js";
4
3
  // src/server/http.ts
5
4
  const KEY = "$realtime";
5
+ /** Marks a socket that closed before its connection existed. */
6
+ const GONE = "$realtime:gone";
6
7
  const MAX_FRAMES_PER_POST = 64;
7
8
  const MAX_SUBSCRIPTIONS_IN_QUERY = 64;
8
9
  const MAX_BUCKETS = 1e4;
9
- function refuse(status, code, message) {
10
- return new Response(JSON.stringify({ error: {
11
- code,
12
- message
13
- } }), {
10
+ /** A JSON answer that no cache may keep. */
11
+ function jsonResponse(status, body) {
12
+ return new Response(body, {
14
13
  status,
15
14
  headers: {
16
15
  "content-type": "application/json",
@@ -18,6 +17,27 @@ function refuse(status, code, message) {
18
17
  }
19
18
  });
20
19
  }
20
+ function refuse(status, code, message) {
21
+ return jsonResponse(status, JSON.stringify({ error: {
22
+ code,
23
+ message
24
+ } }));
25
+ }
26
+ /**
27
+ * Reads a request body as text, stopping at `max` bytes: a body that never ends must not be held in memory before it is
28
+ * refused. Undefined when the body is larger than `max`.
29
+ */
30
+ async function readBounded(request, max) {
31
+ const decoder = new TextDecoder();
32
+ let text = "";
33
+ let bytes = 0;
34
+ for await (const chunk of request.body ?? []) {
35
+ bytes += chunk.byteLength;
36
+ if (bytes > max) return void 0;
37
+ text += decoder.decode(chunk, { stream: true });
38
+ }
39
+ return text + decoder.decode();
40
+ }
21
41
  /** Whether the request's `Origin` header is acceptable. A missing header is allowed (see `HubRouteOptions.allowedOrigins`). */
22
42
  function originAllowed(request, allowed) {
23
43
  const origin = request.headers.get("origin");
@@ -57,6 +77,7 @@ function websocketRoute(hub, options = {}) {
57
77
  },
58
78
  async open(ws, event) {
59
79
  const result = await identify(options, event);
80
+ if (ws.signal.aborted || ws.data[GONE] === true) return;
60
81
  if ("refusal" in result) {
61
82
  ws.close(1008, "unauthorized");
62
83
  return;
@@ -77,6 +98,7 @@ function websocketRoute(hub, options = {}) {
77
98
  connection.receive(message);
78
99
  },
79
100
  close(ws) {
101
+ ws.data[GONE] = true;
80
102
  ws.data[KEY]?.closed();
81
103
  },
82
104
  error(ws, error) {
@@ -137,13 +159,9 @@ function sseRoute(hub, options = {}) {
137
159
  return;
138
160
  }
139
161
  const result = await identify(options, event);
140
- if ("refusal" in result) {
141
- await refuseStream(stream, "unauthorized");
142
- return;
143
- }
144
- const entries = declared(new URL(event.request.url));
145
- if (entries === void 0) {
146
- await refuseStream(stream, "bad subscription list");
162
+ const entries = "refusal" in result ? void 0 : declared(new URL(event.request.url));
163
+ if ("refusal" in result || entries === void 0) {
164
+ await refuseStream(stream, "refusal" in result ? "unauthorized" : "bad subscription list");
147
165
  return;
148
166
  }
149
167
  let pendingBytes = 0;
@@ -167,9 +185,9 @@ function sseRoute(hub, options = {}) {
167
185
  }, result.identity);
168
186
  entries.forEach((entry, at) => {
169
187
  connection.receive(JSON.stringify({
188
+ ...entry,
170
189
  t: "sub",
171
- n: at + 1,
172
- ...entry
190
+ n: at + 1
173
191
  }));
174
192
  });
175
193
  if (!stream.signal.aborted) await new Promise((resolve) => {
@@ -216,8 +234,8 @@ function postRoute(hub, options = {}) {
216
234
  if (!originAllowed(request, options.allowedOrigins)) return refuse(403, "forbidden", "origin not allowed");
217
235
  const result = await identify(options, event);
218
236
  if ("refusal" in result) return result.refusal;
219
- const text = await request.text();
220
- if (byteLength(text) > hub.limits.maxMessageBytes * 8) return refuse(413, "too_large", "the request is too large");
237
+ const text = await readBounded(request, hub.limits.maxMessageBytes * 8);
238
+ if (text === void 0) return refuse(413, "too_large", "the request is too large");
221
239
  let body;
222
240
  try {
223
241
  body = JSON.parse(text);
@@ -247,6 +265,7 @@ function postRoute(hub, options = {}) {
247
265
  const item = frame;
248
266
  try {
249
267
  if (typeof item.c !== "string" || typeof cid !== "string") throw new RealtimeError("bad_frame", "pres needs c and the connection id");
268
+ if (!bucketFor(key).take(Date.now())) throw new RealtimeError("rate_limited");
250
269
  await hub.setMemberState(result.identity, item.c, cid, item.s ?? null);
251
270
  } catch (error) {
252
271
  const code = error instanceof RealtimeError ? error.code : "unavailable";
@@ -271,13 +290,7 @@ function postRoute(hub, options = {}) {
271
290
  } finally {
272
291
  connection.closed();
273
292
  }
274
- return new Response(`{"frames":[${answers.join(",")}]}`, {
275
- status: 200,
276
- headers: {
277
- "content-type": "application/json",
278
- "cache-control": "no-store"
279
- }
280
- });
293
+ return jsonResponse(200, `{"frames":[${answers.join(",")}]}`);
281
294
  };
282
295
  }
283
296
  export { postRoute, sseRoute, websocketRoute };
@@ -1 +1 @@
1
- {"version":3,"file":"http.js","names":[],"sources":["../../../src/server/http.ts"],"sourcesContent":["import { RealtimeError } from \"../errors\";\nimport { byteLength, type Json } from \"../protocol\";\nimport type { HubConnection, HubCore, Transport } from \"./hub\";\nimport { TokenBucket } from \"./limits\";\nimport type { Identity } from \"./rules\";\n\n/** What a hub needs from the framework's request event. @since 1.0.0 */\nexport interface RequestLike {\n readonly request: Request;\n}\n\n/** How a connection is identified: an identity, or a response that refuses it. @since 1.0.0 */\nexport type AuthenticateResult = Identity | undefined | Response;\n\n/** Options shared by the transport routes. @since 1.0.0 */\nexport interface HubRouteOptions<E extends RequestLike> {\n /**\n * Reads the identity from the request (the session cookie, a bearer token). Return a `Response` to refuse (a 401). Return\n * nothing for an anonymous connection. Default: everyone is anonymous, and channel rules decide.\n */\n readonly authenticate?: (event: E) => AuthenticateResult | Promise<AuthenticateResult>;\n /**\n * Origins allowed to open a stream or post (full origins such as `https://app.example.com`). Default: the request's own\n * origin only. A request with no `Origin` header (same-origin GET, a non-browser client) is allowed.\n */\n readonly allowedOrigins?: readonly string[];\n}\n\n/** The WebSocket connection the framework hands to handlers (the part this package uses). @since 1.0.0 */\nexport interface WebSocketLike {\n readonly data: Record<string, unknown>;\n readonly signal: AbortSignal;\n send(message: string | Uint8Array): boolean;\n close(code?: number, reason?: string): void;\n}\n\n/** The handlers of a WebSocket route, shaped like Dusk's `WS` export. @since 1.0.0 */\nexport interface WebSocketRoute<E extends RequestLike> {\n upgrade(event: E): Promise<Response | undefined>;\n open(ws: WebSocketLike, event: E): Promise<void>;\n message(ws: WebSocketLike, message: string | Uint8Array, event: E): void;\n close(ws: WebSocketLike): void;\n error(ws: WebSocketLike, error: unknown, event: E): void;\n}\n\n/** The stream a server-sent events route writes to (the part this package uses). @since 1.0.0 */\nexport interface SseLike {\n send(message: { readonly event: string; readonly data: string }): Promise<void>;\n close(): void;\n readonly signal: AbortSignal;\n readonly closed: boolean;\n}\n\nconst KEY = \"$realtime\";\nconst MAX_FRAMES_PER_POST = 64;\nconst MAX_SUBSCRIPTIONS_IN_QUERY = 64;\nconst MAX_BUCKETS = 10_000;\n\nfunction refuse(status: number, code: RealtimeError[\"code\"], message: string): Response {\n return new Response(JSON.stringify({ error: { code, message } }), {\n status,\n headers: { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" },\n });\n}\n\n/** Whether the request's `Origin` header is acceptable. A missing header is allowed (see `HubRouteOptions.allowedOrigins`). */\nfunction originAllowed(request: Request, allowed: readonly string[] | undefined): boolean {\n const origin = request.headers.get(\"origin\");\n if (origin === null) return true;\n if (allowed !== undefined) return allowed.includes(origin);\n try {\n return origin === new URL(request.url).origin;\n } catch {\n return false;\n }\n}\n\nasync function identify<E extends RequestLike>(\n options: HubRouteOptions<E>,\n event: E,\n): Promise<{ readonly identity: Identity } | { readonly refusal: Response }> {\n const result = await options.authenticate?.(event);\n if (result instanceof Response) return { refusal: result };\n return { identity: result ?? {} };\n}\n\n/**\n * The WebSocket route of a hub: `export const WS = hub.websocket({ authenticate })` in a Dusk stream file. The upgrade is decided\n * by `authenticate` (a `Response` refuses it) and Dusk's own checks (origin, caps, size); each text frame goes to the hub.\n * Binary frames close the connection with 1003.\n *\n * @param hub The hub.\n * @param options How to identify the connection.\n * @returns Handlers shaped like Dusk's `WS` export.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const WS = hub.websocket();\n * @since 1.0.0\n */\nexport function websocketRoute<E extends RequestLike>(\n hub: HubCore,\n options: Pick<HubRouteOptions<E>, \"authenticate\"> = {},\n): WebSocketRoute<E> {\n return {\n async upgrade(event) {\n const result = await identify(options, event);\n return \"refusal\" in result ? result.refusal : undefined;\n },\n async open(ws, event) {\n const result = await identify(options, event);\n if (\"refusal\" in result) {\n ws.close(1008, \"unauthorized\");\n return;\n }\n const transport: Transport = {\n send: (frame) => ws.send(frame),\n close: (code, reason) => ws.close(code, reason),\n };\n ws.data[KEY] = hub.connect(transport, result.identity);\n },\n message(ws, message) {\n const connection = ws.data[KEY] as HubConnection | undefined;\n if (connection === undefined) return;\n if (typeof message !== \"string\") {\n ws.close(1003, \"binary frames are not supported\");\n return;\n }\n connection.receive(message);\n },\n close(ws) {\n (ws.data[KEY] as HubConnection | undefined)?.closed();\n },\n error(ws, error) {\n hub.report(error, { where: \"websocket\" });\n (ws.data[KEY] as HubConnection | undefined)?.closed();\n },\n };\n}\n\n/**\n * Refuses a stream that has already started (the status 200 is sent before the handler runs): a `bye` with the policy code the\n * client treats as fatal, so it gives up instead of reconnecting for ever, then the end of the stream.\n */\nasync function refuseStream(stream: SseLike, why: string): Promise<void> {\n try {\n await stream.send({ event: \"rt\", data: JSON.stringify({ t: \"bye\", code: 1008, why }) });\n } catch {\n // A client that already left needs no answer.\n }\n stream.close();\n}\n\n/** Reads the subscriptions a stream request declares: `?s=[{\"c\":\"chat:lobby\",\"after\":12}]`. */\nfunction declared(url: URL): readonly Record<string, unknown>[] | undefined {\n const text = url.searchParams.get(\"s\");\n if (text === null) return [];\n try {\n const value: unknown = JSON.parse(text);\n if (!Array.isArray(value) || value.length > MAX_SUBSCRIPTIONS_IN_QUERY) return undefined;\n return value.map((item) =>\n item !== null && typeof item === \"object\" ? (item as Record<string, unknown>) : {},\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * The server-sent events route of a hub, for hosts that cannot hold a WebSocket: `export const SSE = hub.sse()`. The channels\n * are declared in the request (`?s=[{\"c\":\"chat:lobby\",\"after\":12,\"echo\":false,\"presence\":{\"typing\":false}}]`); to change them the\n * client opens a new stream. Frames go out as events named `rt`; everything the client sends goes to {@link postRoute}.\n *\n * @param hub The hub.\n * @param options How to identify the connection and which origins may connect.\n * @returns A handler shaped like Dusk's `SSE` export.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const SSE = hub.sse();\n * @since 1.0.0\n */\nexport function sseRoute<E extends RequestLike>(\n hub: HubCore,\n options: HubRouteOptions<E> = {},\n): (event: E, stream: SseLike) => Promise<void> {\n return async (event, stream) => {\n if (!originAllowed(event.request, options.allowedOrigins)) {\n hub.report(new RealtimeError(\"forbidden\", \"origin refused\"), { where: \"sse.origin\" });\n await refuseStream(stream, \"origin not allowed\");\n return;\n }\n const result = await identify(options, event);\n if (\"refusal\" in result) {\n await refuseStream(stream, \"unauthorized\");\n return;\n }\n const entries = declared(new URL(event.request.url));\n if (entries === undefined) {\n await refuseStream(stream, \"bad subscription list\");\n return;\n }\n let pendingBytes = 0;\n const transport: Transport = {\n send(frame) {\n if (stream.closed) return false;\n pendingBytes += frame.length;\n stream.send({ event: \"rt\", data: frame }).then(\n () => {\n pendingBytes -= frame.length;\n },\n () => {\n pendingBytes = Number.MAX_SAFE_INTEGER;\n connection.closed();\n },\n );\n return true;\n },\n close: () => stream.close(),\n buffered: () => pendingBytes,\n };\n const connection = hub.connect(transport, result.identity);\n entries.forEach((entry, at) => {\n connection.receive(JSON.stringify({ t: \"sub\", n: at + 1, ...entry }));\n });\n if (!stream.signal.aborted)\n await new Promise<void>((resolve) => {\n stream.signal.addEventListener(\"abort\", () => resolve(), { once: true });\n });\n connection.closed();\n await connection.done;\n };\n}\n\n/**\n * The HTTP route that carries what an SSE client sends: `export const POST = hub.post()`. The body is\n * `{\"cid\": \"<connection id>\", \"frames\": [ ... ]}` (at most 64 frames of the types `pub`, `req`, `pres`); the answer is\n * `{\"frames\": [ ... ]}` with the acks, replies and errors. It keeps nothing between requests, so any server can answer it.\n *\n * The request rate of one caller is limited by `key` (default: the user, else all anonymous callers together) with the hub's\n * publish limits; put Dusk's rate limiting in front of the route for per-address limits.\n *\n * @param hub The hub.\n * @param options Identity, origins and the rate-limit key.\n * @returns A handler for a Dusk API route.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const POST = hub.post();\n * @since 1.0.0\n */\nexport function postRoute<E extends RequestLike>(\n hub: HubCore,\n options: HubRouteOptions<E> & { readonly key?: (event: E, identity: Identity) => string } = {},\n): (event: E) => Promise<Response> {\n const buckets = new Map<string, TokenBucket>();\n const bucketFor = (key: string): TokenBucket => {\n let bucket = buckets.get(key);\n if (bucket === undefined) {\n if (buckets.size >= MAX_BUCKETS) {\n const oldest = buckets.keys().next().value;\n if (oldest !== undefined) buckets.delete(oldest);\n }\n bucket = new TokenBucket(hub.limits.publishesPerSecond, hub.limits.publishBurst, Date.now());\n buckets.set(key, bucket);\n }\n return bucket;\n };\n return async (event) => {\n const { request } = event;\n if (!originAllowed(request, options.allowedOrigins))\n return refuse(403, \"forbidden\", \"origin not allowed\");\n const result = await identify(options, event);\n if (\"refusal\" in result) return result.refusal;\n const text = await request.text();\n if (byteLength(text) > hub.limits.maxMessageBytes * 8)\n return refuse(413, \"too_large\", \"the request is too large\");\n let body: unknown;\n try {\n body = JSON.parse(text);\n } catch {\n return refuse(400, \"bad_frame\", \"the body is not JSON\");\n }\n const frames = (body as { frames?: unknown } | null)?.frames;\n const cid = (body as { cid?: unknown } | null)?.cid;\n if (!Array.isArray(frames) || frames.length === 0 || frames.length > MAX_FRAMES_PER_POST)\n return refuse(400, \"bad_frame\", `frames must hold 1..${MAX_FRAMES_PER_POST} frames`);\n const key = options.key?.(event, result.identity) ?? result.identity.user ?? \"anonymous\";\n const answers: string[] = [];\n const transport: Transport = {\n send(frame) {\n if (!frame.startsWith('{\"t\":\"ready\"')) answers.push(frame);\n return true;\n },\n close: () => undefined,\n };\n const connection = hub.connect(transport, result.identity, {\n stateless: true,\n publishBucket: bucketFor(key),\n ...(typeof cid === \"string\" ? { actingFor: cid } : {}),\n });\n try {\n for (const frame of frames) {\n const type = (frame as { t?: unknown } | null)?.t;\n if (type === \"pres\") {\n const item = frame as { c?: unknown; s?: unknown };\n try {\n if (typeof item.c !== \"string\" || typeof cid !== \"string\")\n throw new RealtimeError(\"bad_frame\", \"pres needs c and the connection id\");\n await hub.setMemberState(result.identity, item.c, cid, (item.s ?? null) as Json);\n } catch (error) {\n const code = error instanceof RealtimeError ? error.code : \"unavailable\";\n answers.push(JSON.stringify({ t: \"err\", code }));\n }\n continue;\n }\n if (type !== \"pub\" && type !== \"req\") {\n answers.push(\n JSON.stringify({ t: \"err\", code: \"bad_frame\", msg: \"only pub, req and pres\" }),\n );\n continue;\n }\n connection.receive(JSON.stringify(frame));\n }\n await connection.settled();\n } finally {\n connection.closed();\n }\n return new Response(`{\"frames\":[${answers.join(\",\")}]}`, {\n status: 200,\n headers: { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" },\n });\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"http.js","names":[],"sources":["../../../src/server/http.ts"],"sourcesContent":["import { RealtimeError } from \"../errors\";\nimport type { Json } from \"../protocol\";\nimport type { HubConnection, HubCore, Transport } from \"./hub\";\nimport { TokenBucket } from \"./limits\";\nimport type { Identity } from \"./rules\";\n\n/** What a hub needs from the framework's request event. @since 1.0.0 */\nexport interface RequestLike {\n readonly request: Request;\n}\n\n/** How a connection is identified: an identity, or a response that refuses it. @since 1.0.0 */\nexport type AuthenticateResult = Identity | undefined | Response;\n\n/** Options shared by the transport routes. @since 1.0.0 */\nexport interface HubRouteOptions<E extends RequestLike> {\n /**\n * Reads the identity from the request (the session cookie, a bearer token). Return a `Response` to refuse (a 401). Return\n * nothing for an anonymous connection. Default: everyone is anonymous, and channel rules decide.\n */\n readonly authenticate?: (event: E) => AuthenticateResult | Promise<AuthenticateResult>;\n /**\n * Origins allowed to open a stream or post (full origins such as `https://app.example.com`). Default: the request's own\n * origin only. A request with no `Origin` header (same-origin GET, a non-browser client) is allowed.\n */\n readonly allowedOrigins?: readonly string[];\n}\n\n/** The WebSocket connection the framework hands to handlers (the part this package uses). @since 1.0.0 */\nexport interface WebSocketLike {\n readonly data: Record<string, unknown>;\n readonly signal: AbortSignal;\n send(message: string | Uint8Array): boolean;\n close(code?: number, reason?: string): void;\n}\n\n/** The handlers of a WebSocket route, shaped like Dusk's `WS` export. @since 1.0.0 */\nexport interface WebSocketRoute<E extends RequestLike> {\n upgrade(event: E): Promise<Response | undefined>;\n open(ws: WebSocketLike, event: E): Promise<void>;\n message(ws: WebSocketLike, message: string | Uint8Array, event: E): void;\n close(ws: WebSocketLike): void;\n error(ws: WebSocketLike, error: unknown, event: E): void;\n}\n\n/** The stream a server-sent events route writes to (the part this package uses). @since 1.0.0 */\nexport interface SseLike {\n send(message: { readonly event: string; readonly data: string }): Promise<void>;\n close(): void;\n readonly signal: AbortSignal;\n readonly closed: boolean;\n}\n\nconst KEY = \"$realtime\";\n/** Marks a socket that closed before its connection existed. */\nconst GONE = \"$realtime:gone\";\nconst MAX_FRAMES_PER_POST = 64;\nconst MAX_SUBSCRIPTIONS_IN_QUERY = 64;\nconst MAX_BUCKETS = 10_000;\n\n/** A JSON answer that no cache may keep. */\nfunction jsonResponse(status: number, body: string): Response {\n return new Response(body, {\n status,\n headers: { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" },\n });\n}\n\nfunction refuse(\n status: number,\n code: RealtimeError[\"code\"],\n message: string | undefined,\n): Response {\n return jsonResponse(status, JSON.stringify({ error: { code, message } }));\n}\n\n/**\n * Reads a request body as text, stopping at `max` bytes: a body that never ends must not be held in memory before it is\n * refused. Undefined when the body is larger than `max`.\n */\nasync function readBounded(request: Request, max: number): Promise<string | undefined> {\n const decoder = new TextDecoder();\n let text = \"\";\n let bytes = 0;\n // Leaving the loop early cancels the stream, so the rest of an oversized body is never pulled.\n for await (const chunk of (request.body ?? []) as AsyncIterable<Uint8Array>) {\n bytes += chunk.byteLength;\n if (bytes > max) return undefined;\n text += decoder.decode(chunk, { stream: true });\n }\n return text + decoder.decode();\n}\n\n/** Whether the request's `Origin` header is acceptable. A missing header is allowed (see `HubRouteOptions.allowedOrigins`). */\nfunction originAllowed(request: Request, allowed: readonly string[] | undefined): boolean {\n const origin = request.headers.get(\"origin\");\n if (origin === null) return true;\n if (allowed !== undefined) return allowed.includes(origin);\n try {\n return origin === new URL(request.url).origin;\n } catch {\n return false;\n }\n}\n\nasync function identify<E extends RequestLike>(\n options: HubRouteOptions<E>,\n event: E,\n): Promise<{ readonly identity: Identity } | { readonly refusal: Response }> {\n const result = await options.authenticate?.(event);\n if (result instanceof Response) return { refusal: result };\n return { identity: result ?? {} };\n}\n\n/**\n * The WebSocket route of a hub: `export const WS = hub.websocket({ authenticate })` in a Dusk stream file. The upgrade is decided\n * by `authenticate` (a `Response` refuses it) and Dusk's own checks (origin, caps, size); each text frame goes to the hub.\n * Binary frames close the connection with 1003.\n *\n * @param hub The hub.\n * @param options How to identify the connection.\n * @returns Handlers shaped like Dusk's `WS` export.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const WS = hub.websocket();\n * @since 1.0.0\n */\nexport function websocketRoute<E extends RequestLike>(\n hub: HubCore,\n options: Pick<HubRouteOptions<E>, \"authenticate\"> = {},\n): WebSocketRoute<E> {\n return {\n async upgrade(event) {\n const result = await identify(options, event);\n return \"refusal\" in result ? result.refusal : undefined;\n },\n async open(ws, event) {\n const result = await identify(options, event);\n // `authenticate` can take a while: a peer that left in the meantime already had its `close`, which found no connection to\n // release, so connecting now would leave one in the hub for good.\n if (ws.signal.aborted || ws.data[GONE] === true) return;\n if (\"refusal\" in result) {\n ws.close(1008, __DEV__ ? \"unauthorized\" : \"\");\n return;\n }\n const transport: Transport = {\n send: (frame) => ws.send(frame),\n close: (code, reason) => ws.close(code, reason),\n };\n ws.data[KEY] = hub.connect(transport, result.identity);\n },\n message(ws, message) {\n const connection = ws.data[KEY] as HubConnection | undefined;\n if (connection === undefined) return;\n if (typeof message !== \"string\") {\n ws.close(1003, __DEV__ ? \"binary frames are not supported\" : \"\");\n return;\n }\n connection.receive(message);\n },\n close(ws) {\n ws.data[GONE] = true;\n (ws.data[KEY] as HubConnection | undefined)?.closed();\n },\n error(ws, error) {\n hub.report(error, { where: \"websocket\" });\n (ws.data[KEY] as HubConnection | undefined)?.closed();\n },\n };\n}\n\n/**\n * Refuses a stream that has already started (the status 200 is sent before the handler runs): a `bye` with the policy code the\n * client treats as fatal, so it gives up instead of reconnecting for ever, then the end of the stream.\n */\nasync function refuseStream(stream: SseLike, why: string): Promise<void> {\n try {\n await stream.send({ event: \"rt\", data: JSON.stringify({ t: \"bye\", code: 1008, why }) });\n } catch {\n // A client that already left needs no answer.\n }\n stream.close();\n}\n\n/** Reads the subscriptions a stream request declares: `?s=[{\"c\":\"chat:lobby\",\"after\":12}]`. */\nfunction declared(url: URL): readonly Record<string, unknown>[] | undefined {\n const text = url.searchParams.get(\"s\");\n if (text === null) return [];\n try {\n const value: unknown = JSON.parse(text);\n if (!Array.isArray(value) || value.length > MAX_SUBSCRIPTIONS_IN_QUERY) return undefined;\n return value.map((item) =>\n item !== null && typeof item === \"object\" ? (item as Record<string, unknown>) : {},\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * The server-sent events route of a hub, for hosts that cannot hold a WebSocket: `export const SSE = hub.sse()`. The channels\n * are declared in the request (`?s=[{\"c\":\"chat:lobby\",\"after\":12,\"echo\":false,\"presence\":{\"typing\":false}}]`); to change them the\n * client opens a new stream. Frames go out as events named `rt`; everything the client sends goes to {@link postRoute}.\n *\n * @param hub The hub.\n * @param options How to identify the connection and which origins may connect.\n * @returns A handler shaped like Dusk's `SSE` export.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const SSE = hub.sse();\n * @since 1.0.0\n */\nexport function sseRoute<E extends RequestLike>(\n hub: HubCore,\n options: HubRouteOptions<E> = {},\n): (event: E, stream: SseLike) => Promise<void> {\n return async (event, stream) => {\n if (!originAllowed(event.request, options.allowedOrigins)) {\n hub.report(new RealtimeError(\"forbidden\", __DEV__ ? \"origin refused\" : undefined), {\n where: \"sse.origin\",\n });\n await refuseStream(stream, __DEV__ ? \"origin not allowed\" : \"\");\n return;\n }\n const result = await identify(options, event);\n // The list is read only for an identified connection; either refusal ends the stream the same way.\n const entries = \"refusal\" in result ? undefined : declared(new URL(event.request.url));\n if (\"refusal\" in result || entries === undefined) {\n await refuseStream(\n stream,\n __DEV__ ? (\"refusal\" in result ? \"unauthorized\" : \"bad subscription list\") : \"\",\n );\n return;\n }\n let pendingBytes = 0;\n const transport: Transport = {\n send(frame) {\n if (stream.closed) return false;\n pendingBytes += frame.length;\n stream.send({ event: \"rt\", data: frame }).then(\n () => {\n pendingBytes -= frame.length;\n },\n () => {\n pendingBytes = Number.MAX_SAFE_INTEGER;\n connection.closed();\n },\n );\n return true;\n },\n close: () => stream.close(),\n buffered: () => pendingBytes,\n };\n const connection = hub.connect(transport, result.identity);\n entries.forEach((entry, at) => {\n // The type and the number come last: an entry that names its own `t` must not turn a declaration into a publish.\n connection.receive(JSON.stringify({ ...entry, t: \"sub\", n: at + 1 }));\n });\n if (!stream.signal.aborted)\n await new Promise<void>((resolve) => {\n stream.signal.addEventListener(\"abort\", () => resolve(), { once: true });\n });\n connection.closed();\n await connection.done;\n };\n}\n\n/**\n * The HTTP route that carries what an SSE client sends: `export const POST = hub.post()`. The body is\n * `{\"cid\": \"<connection id>\", \"frames\": [ ... ]}` (at most 64 frames of the types `pub`, `req`, `pres`); the answer is\n * `{\"frames\": [ ... ]}` with the acks, replies and errors. It keeps nothing between requests, so any server can answer it.\n *\n * The request rate of one caller is limited by `key` (default: the user, else all anonymous callers together) with the hub's\n * publish limits; put Dusk's rate limiting in front of the route for per-address limits.\n *\n * @param hub The hub.\n * @param options Identity, origins and the rate-limit key.\n * @returns A handler for a Dusk API route.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const POST = hub.post();\n * @since 1.0.0\n */\nexport function postRoute<E extends RequestLike>(\n hub: HubCore,\n options: HubRouteOptions<E> & { readonly key?: (event: E, identity: Identity) => string } = {},\n): (event: E) => Promise<Response> {\n const buckets = new Map<string, TokenBucket>();\n const bucketFor = (key: string): TokenBucket => {\n let bucket = buckets.get(key);\n if (bucket === undefined) {\n if (buckets.size >= MAX_BUCKETS) {\n const oldest = buckets.keys().next().value;\n if (oldest !== undefined) buckets.delete(oldest);\n }\n bucket = new TokenBucket(hub.limits.publishesPerSecond, hub.limits.publishBurst, Date.now());\n buckets.set(key, bucket);\n }\n return bucket;\n };\n return async (event) => {\n const { request } = event;\n if (!originAllowed(request, options.allowedOrigins))\n return refuse(403, \"forbidden\", __DEV__ ? \"origin not allowed\" : undefined);\n const result = await identify(options, event);\n if (\"refusal\" in result) return result.refusal;\n const text = await readBounded(request, hub.limits.maxMessageBytes * 8);\n if (text === undefined)\n return refuse(413, \"too_large\", __DEV__ ? \"the request is too large\" : undefined);\n let body: unknown;\n try {\n body = JSON.parse(text);\n } catch {\n return refuse(400, \"bad_frame\", __DEV__ ? \"the body is not JSON\" : undefined);\n }\n const frames = (body as { frames?: unknown } | null)?.frames;\n const cid = (body as { cid?: unknown } | null)?.cid;\n if (!Array.isArray(frames) || frames.length === 0 || frames.length > MAX_FRAMES_PER_POST)\n return refuse(\n 400,\n \"bad_frame\",\n __DEV__ ? `frames must hold 1..${MAX_FRAMES_PER_POST} frames` : undefined,\n );\n const key = options.key?.(event, result.identity) ?? result.identity.user ?? \"anonymous\";\n const answers: string[] = [];\n const transport: Transport = {\n send(frame) {\n if (!frame.startsWith('{\"t\":\"ready\"')) answers.push(frame);\n return true;\n },\n close: () => undefined,\n };\n const connection = hub.connect(transport, result.identity, {\n stateless: true,\n publishBucket: bucketFor(key),\n ...(typeof cid === \"string\" ? { actingFor: cid } : {}),\n });\n try {\n for (const frame of frames) {\n const type = (frame as { t?: unknown } | null)?.t;\n if (type === \"pres\") {\n const item = frame as { c?: unknown; s?: unknown };\n try {\n if (typeof item.c !== \"string\" || typeof cid !== \"string\")\n throw new RealtimeError(\n \"bad_frame\",\n __DEV__ ? \"pres needs c and the connection id\" : undefined,\n );\n // Each change costs the adapter two calls: it spends from the same bucket as a publish, as it does on a socket.\n if (!bucketFor(key).take(Date.now())) throw new RealtimeError(\"rate_limited\");\n await hub.setMemberState(result.identity, item.c, cid, (item.s ?? null) as Json);\n } catch (error) {\n const code = error instanceof RealtimeError ? error.code : \"unavailable\";\n answers.push(JSON.stringify({ t: \"err\", code }));\n }\n continue;\n }\n if (type !== \"pub\" && type !== \"req\") {\n answers.push(\n JSON.stringify({\n t: \"err\",\n code: \"bad_frame\",\n msg: __DEV__ ? \"only pub, req and pres\" : undefined,\n }),\n );\n continue;\n }\n connection.receive(JSON.stringify(frame));\n }\n await connection.settled();\n } finally {\n connection.closed();\n }\n return jsonResponse(200, `{\"frames\":[${answers.join(\",\")}]}`);\n };\n}\n"],"mappings":"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"}
@@ -87,7 +87,7 @@ export interface HubCore {
87
87
  publish(channel: string, event: string, data: unknown, options?: {
88
88
  readonly key?: string;
89
89
  }): Promise<PublishResult>;
90
- /** Removes matching subscriptions; each subscriber gets `unsubbed why:"revoked"`. Returns how many were removed. */
90
+ /** Removes matching subscriptions (a subscribe still awaiting its rule is refused); each subscriber gets `unsubbed why:"revoked"`. Returns how many were removed. */
91
91
  revoke(target: RevokeTarget): number;
92
92
  /** Counters. */
93
93
  stats(): HubStats;