@shadow-js/realtime 1.0.0 → 1.0.2
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/CHANGELOG.md +26 -0
- package/README.md +35 -0
- package/dist/dev/adapter.d.ts +0 -2
- package/dist/dev/client/core.d.ts +0 -2
- package/dist/dev/client/core.js +105 -108
- package/dist/dev/client/core.js.map +1 -1
- package/dist/dev/client/presence.js +1 -2
- package/dist/dev/client/sse.js +34 -26
- package/dist/dev/client/sse.js.map +1 -1
- package/dist/dev/client/types.d.ts +6 -2
- package/dist/dev/client/util.js +3 -4
- package/dist/dev/client/util.js.map +1 -1
- package/dist/dev/client/ws.js +1 -2
- package/dist/dev/cloudflare/adapter.d.ts +0 -2
- package/dist/dev/cloudflare/adapter.js +1 -2
- package/dist/dev/cloudflare/core.js +1 -2
- package/dist/dev/cloudflare/link.js +70 -10
- package/dist/dev/cloudflare/link.js.map +1 -1
- package/dist/dev/cloudflare/object.d.ts +0 -2
- package/dist/dev/cloudflare/object.js +1 -2
- package/dist/dev/cloudflare/types.d.ts +0 -2
- package/dist/dev/cloudflare/wire.js +1 -2
- package/dist/dev/errors.d.ts +0 -2
- package/dist/dev/errors.js +1 -2
- package/dist/dev/hooks.d.ts +0 -2
- package/dist/dev/hooks.js +1 -2
- package/dist/dev/memory.d.ts +11 -2
- package/dist/dev/memory.js +39 -12
- package/dist/dev/memory.js.map +1 -1
- package/dist/dev/protocol.d.ts +0 -2
- package/dist/dev/protocol.js +14 -14
- package/dist/dev/protocol.js.map +1 -1
- package/dist/dev/redis/client.d.ts +0 -2
- package/dist/dev/redis/client.js +1 -2
- package/dist/dev/redis/scripts.js +1 -2
- package/dist/dev/redis/topics.d.ts +6 -3
- package/dist/dev/redis/topics.js +5 -3
- package/dist/dev/redis/topics.js.map +1 -1
- package/dist/dev/redis.d.ts +0 -2
- package/dist/dev/redis.js +1 -2
- package/dist/dev/server/http.d.ts +0 -2
- package/dist/dev/server/http.js +38 -26
- package/dist/dev/server/http.js.map +1 -1
- package/dist/dev/server/hub.d.ts +0 -2
- package/dist/dev/server/hub.js +67 -41
- package/dist/dev/server/hub.js.map +1 -1
- package/dist/dev/server/index.d.ts +2 -4
- package/dist/dev/server/index.js +1 -2
- package/dist/dev/server/index.js.map +1 -1
- package/dist/dev/server/limits.d.ts +4 -3
- package/dist/dev/server/limits.js +1 -2
- package/dist/dev/server/limits.js.map +1 -1
- package/dist/dev/server/rules.d.ts +0 -2
- package/dist/dev/server/rules.js +1 -2
- package/dist/dev/server/rules.js.map +1 -1
- package/dist/dev/testing.d.ts +0 -2
- package/dist/dev/testing.js +1 -2
- package/dist/prod/client/core.js +1 -1
- package/dist/prod/client/core.js.map +1 -1
- package/dist/prod/client/sse.js +1 -4
- package/dist/prod/client/sse.js.map +1 -1
- package/dist/prod/client/util.js +1 -1
- package/dist/prod/client/util.js.map +1 -1
- package/dist/prod/cloudflare/link.js +1 -1
- package/dist/prod/cloudflare/link.js.map +1 -1
- package/dist/prod/memory.js +1 -1
- package/dist/prod/memory.js.map +1 -1
- package/dist/prod/protocol.js +1 -1
- package/dist/prod/protocol.js.map +1 -1
- package/dist/prod/redis/topics.js +1 -1
- package/dist/prod/redis/topics.js.map +1 -1
- package/dist/prod/server/http.js +1 -1
- package/dist/prod/server/http.js.map +1 -1
- package/dist/prod/server/hub.js +1 -1
- package/dist/prod/server/hub.js.map +1 -1
- package/dist/prod/server/index.js.map +1 -1
- package/dist/prod/server/limits.js +1 -1
- package/dist/prod/server/limits.js.map +1 -1
- package/dist/prod/server/rules.js +1 -1
- package/dist/prod/server/rules.js.map +1 -1
- package/package.json +6 -5
|
@@ -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":";;;;AAqDA,MAAM,MAAM;AACZ,MAAM,sBAAsB;AAC5B,MAAM,6BAA6B;AACnC,MAAM,cAAc;AAEpB,SAAS,OAAO,QAAgB,MAA6B,SAA2B;CACtF,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO;EAAE;EAAM;CAAQ,EAAE,CAAC,GAAG;EAChE;EACA,SAAS;GAAE,gBAAgB;GAAoB,iBAAiB;EAAW;CAC7E,CAAC;AACH;;AAGA,SAAS,cAAc,SAAkB,SAAiD;CACxF,MAAM,SAAS,QAAQ,QAAQ,IAAI,QAAQ;CAC3C,IAAI,WAAW,MAAM,OAAO;CAC5B,IAAI,YAAY,KAAA,GAAW,OAAO,QAAQ,SAAS,MAAM;CACzD,IAAI;EACF,OAAO,WAAW,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC;CACzC,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,SACb,SACA,OAC2E;CAC3E,MAAM,SAAS,MAAM,QAAQ,eAAe,KAAK;CACjD,IAAI,kBAAkB,UAAU,OAAO,EAAE,SAAS,OAAO;CACzD,OAAO,EAAE,UAAU,UAAU,CAAC,EAAE;AAClC;;;;;;;;;;;;;;;;AAiBA,SAAgB,eACd,KACA,UAAoD,CAAC,GAClC;CACnB,OAAO;EACL,MAAM,QAAQ,OAAO;GACnB,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;GAC5C,OAAO,aAAa,SAAS,OAAO,UAAU,KAAA;EAChD;EACA,MAAM,KAAK,IAAI,OAAO;GACpB,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;GAC5C,IAAI,aAAa,QAAQ;IACvB,GAAG,MAAM,MAAM,cAAc;IAC7B;GACF;GACA,MAAM,YAAuB;IAC3B,OAAO,UAAU,GAAG,KAAK,KAAK;IAC9B,QAAQ,MAAM,WAAW,GAAG,MAAM,MAAM,MAAM;GAChD;GACA,GAAG,KAAK,OAAO,IAAI,QAAQ,WAAW,OAAO,QAAQ;EACvD;EACA,QAAQ,IAAI,SAAS;GACnB,MAAM,aAAa,GAAG,KAAK;GAC3B,IAAI,eAAe,KAAA,GAAW;GAC9B,IAAI,OAAO,YAAY,UAAU;IAC/B,GAAG,MAAM,MAAM,iCAAiC;IAChD;GACF;GACA,WAAW,QAAQ,OAAO;EAC5B;EACA,MAAM,IAAI;GACR,GAAI,KAAK,IAAI,EAAgC,OAAO;EACtD;EACA,MAAM,IAAI,OAAO;GACf,IAAI,OAAO,OAAO,EAAE,OAAO,YAAY,CAAC;GACxC,GAAI,KAAK,IAAI,EAAgC,OAAO;EACtD;CACF;AACF;;;;;AAMA,eAAe,aAAa,QAAiB,KAA4B;CACvE,IAAI;EACF,MAAM,OAAO,KAAK;GAAE,OAAO;GAAM,MAAM,KAAK,UAAU;IAAE,GAAG;IAAO,MAAM;IAAM;GAAI,CAAC;EAAE,CAAC;CACxF,QAAQ,CAER;CACA,OAAO,MAAM;AACf;;AAGA,SAAS,SAAS,KAA0D;CAC1E,MAAM,OAAO,IAAI,aAAa,IAAI,GAAG;CACrC,IAAI,SAAS,MAAM,OAAO,CAAC;CAC3B,IAAI;EACF,MAAM,QAAiB,KAAK,MAAM,IAAI;EACtC,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,4BAA4B,OAAO,KAAA;EAC/E,OAAO,MAAM,KAAK,SAChB,SAAS,QAAQ,OAAO,SAAS,WAAY,OAAmC,CAAC,CACnF;CACF,QAAQ;EACN;CACF;AACF;;;;;;;;;;;;;;;;AAiBA,SAAgB,SACd,KACA,UAA8B,CAAC,GACe;CAC9C,OAAO,OAAO,OAAO,WAAW;EAC9B,IAAI,CAAC,cAAc,MAAM,SAAS,QAAQ,cAAc,GAAG;GACzD,IAAI,OAAO,IAAI,cAAc,aAAa,gBAAgB,GAAG,EAAE,OAAO,aAAa,CAAC;GACpF,MAAM,aAAa,QAAQ,oBAAoB;GAC/C;EACF;EACA,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;EAC5C,IAAI,aAAa,QAAQ;GACvB,MAAM,aAAa,QAAQ,cAAc;GACzC;EACF;EACA,MAAM,UAAU,SAAS,IAAI,IAAI,MAAM,QAAQ,GAAG,CAAC;EACnD,IAAI,YAAY,KAAA,GAAW;GACzB,MAAM,aAAa,QAAQ,uBAAuB;GAClD;EACF;EACA,IAAI,eAAe;EAmBnB,MAAM,aAAa,IAAI,QAAQ;GAjB7B,KAAK,OAAO;IACV,IAAI,OAAO,QAAQ,OAAO;IAC1B,gBAAgB,MAAM;IACtB,OAAO,KAAK;KAAE,OAAO;KAAM,MAAM;IAAM,CAAC,CAAC,CAAC,WAClC;KACJ,gBAAgB,MAAM;IACxB,SACM;KACJ,eAAe,OAAO;KACtB,WAAW,OAAO;IACpB,CACF;IACA,OAAO;GACT;GACA,aAAa,OAAO,MAAM;GAC1B,gBAAgB;EAEqB,GAAG,OAAO,QAAQ;EACzD,QAAQ,SAAS,OAAO,OAAO;GAC7B,WAAW,QAAQ,KAAK,UAAU;IAAE,GAAG;IAAO,GAAG,KAAK;IAAG,GAAG;GAAM,CAAC,CAAC;EACtE,CAAC;EACD,IAAI,CAAC,OAAO,OAAO,SACjB,MAAM,IAAI,SAAe,YAAY;GACnC,OAAO,OAAO,iBAAiB,eAAe,QAAQ,GAAG,EAAE,MAAM,KAAK,CAAC;EACzE,CAAC;EACH,WAAW,OAAO;EAClB,MAAM,WAAW;CACnB;AACF;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,UACd,KACA,UAA4F,CAAC,GAC5D;CACjC,MAAM,0BAAU,IAAI,IAAyB;CAC7C,MAAM,aAAa,QAA6B;EAC9C,IAAI,SAAS,QAAQ,IAAI,GAAG;EAC5B,IAAI,WAAW,KAAA,GAAW;GACxB,IAAI,QAAQ,QAAQ,aAAa;IAC/B,MAAM,SAAS,QAAQ,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;IACrC,IAAI,WAAW,KAAA,GAAW,QAAQ,OAAO,MAAM;GACjD;GACA,SAAS,IAAI,YAAY,IAAI,OAAO,oBAAoB,IAAI,OAAO,cAAc,KAAK,IAAI,CAAC;GAC3F,QAAQ,IAAI,KAAK,MAAM;EACzB;EACA,OAAO;CACT;CACA,OAAO,OAAO,UAAU;EACtB,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,cAAc,SAAS,QAAQ,cAAc,GAChD,OAAO,OAAO,KAAK,aAAa,oBAAoB;EACtD,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;EAC5C,IAAI,aAAa,QAAQ,OAAO,OAAO;EACvC,MAAM,OAAO,MAAM,QAAQ,KAAK;EAChC,IAAI,WAAW,IAAI,IAAI,IAAI,OAAO,kBAAkB,GAClD,OAAO,OAAO,KAAK,aAAa,0BAA0B;EAC5D,IAAI;EACJ,IAAI;GACF,OAAO,KAAK,MAAM,IAAI;EACxB,QAAQ;GACN,OAAO,OAAO,KAAK,aAAa,sBAAsB;EACxD;EACA,MAAM,SAAU,MAAsC;EACtD,MAAM,MAAO,MAAmC;EAChD,IAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,OAAO,WAAW,KAAK,OAAO,SAAS,qBACnE,OAAO,OAAO,KAAK,aAAa,uBAAuB,oBAAoB,QAAQ;EACrF,MAAM,MAAM,QAAQ,MAAM,OAAO,OAAO,QAAQ,KAAK,OAAO,SAAS,QAAQ;EAC7E,MAAM,UAAoB,CAAC;EAQ3B,MAAM,aAAa,IAAI,QAAQ;GAN7B,KAAK,OAAO;IACV,IAAI,CAAC,MAAM,WAAW,kBAAc,GAAG,QAAQ,KAAK,KAAK;IACzD,OAAO;GACT;GACA,aAAa,KAAA;EAEwB,GAAG,OAAO,UAAU;GACzD,WAAW;GACX,eAAe,UAAU,GAAG;GAC5B,GAAI,OAAO,QAAQ,WAAW,EAAE,WAAW,IAAI,IAAI,CAAC;EACtD,CAAC;EACD,IAAI;GACF,KAAK,MAAM,SAAS,QAAQ;IAC1B,MAAM,OAAQ,OAAkC;IAChD,IAAI,SAAS,QAAQ;KACnB,MAAM,OAAO;KACb,IAAI;MACF,IAAI,OAAO,KAAK,MAAM,YAAY,OAAO,QAAQ,UAC/C,MAAM,IAAI,cAAc,aAAa,oCAAoC;MAC3E,MAAM,IAAI,eAAe,OAAO,UAAU,KAAK,GAAG,KAAM,KAAK,KAAK,IAAa;KACjF,SAAS,OAAO;MACd,MAAM,OAAO,iBAAiB,gBAAgB,MAAM,OAAO;MAC3D,QAAQ,KAAK,KAAK,UAAU;OAAE,GAAG;OAAO;MAAK,CAAC,CAAC;KACjD;KACA;IACF;IACA,IAAI,SAAS,SAAS,SAAS,OAAO;KACpC,QAAQ,KACN,KAAK,UAAU;MAAE,GAAG;MAAO,MAAM;MAAa,KAAK;KAAyB,CAAC,CAC/E;KACA;IACF;IACA,WAAW,QAAQ,KAAK,UAAU,KAAK,CAAC;GAC1C;GACA,MAAM,WAAW,QAAQ;EAC3B,UAAU;GACR,WAAW,OAAO;EACpB;EACA,OAAO,IAAI,SAAS,cAAc,QAAQ,KAAK,GAAG,EAAE,KAAK;GACvD,QAAQ;GACR,SAAS;IAAE,gBAAgB;IAAoB,iBAAiB;GAAW;EAC7E,CAAC;CACH;AACF"}
|
|
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":";;;AAqDA,MAAM,MAAM;;AAEZ,MAAM,OAAO;AACb,MAAM,sBAAsB;AAC5B,MAAM,6BAA6B;AACnC,MAAM,cAAc;;AAGpB,SAAS,aAAa,QAAgB,MAAwB;CAC5D,OAAO,IAAI,SAAS,MAAM;EACxB;EACA,SAAS;GAAE,gBAAgB;GAAoB,iBAAiB;EAAW;CAC7E,CAAC;AACH;AAEA,SAAS,OACP,QACA,MACA,SACU;CACV,OAAO,aAAa,QAAQ,KAAK,UAAU,EAAE,OAAO;EAAE;EAAM;CAAQ,EAAE,CAAC,CAAC;AAC1E;;;;;AAMA,eAAe,YAAY,SAAkB,KAA0C;CACrF,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,OAAO;CACX,IAAI,QAAQ;CAEZ,WAAW,MAAM,SAAU,QAAQ,QAAQ,CAAC,GAAiC;EAC3E,SAAS,MAAM;EACf,IAAI,QAAQ,KAAK,OAAO,KAAA;EACxB,QAAQ,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;CAChD;CACA,OAAO,OAAO,QAAQ,OAAO;AAC/B;;AAGA,SAAS,cAAc,SAAkB,SAAiD;CACxF,MAAM,SAAS,QAAQ,QAAQ,IAAI,QAAQ;CAC3C,IAAI,WAAW,MAAM,OAAO;CAC5B,IAAI,YAAY,KAAA,GAAW,OAAO,QAAQ,SAAS,MAAM;CACzD,IAAI;EACF,OAAO,WAAW,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC;CACzC,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,SACb,SACA,OAC2E;CAC3E,MAAM,SAAS,MAAM,QAAQ,eAAe,KAAK;CACjD,IAAI,kBAAkB,UAAU,OAAO,EAAE,SAAS,OAAO;CACzD,OAAO,EAAE,UAAU,UAAU,CAAC,EAAE;AAClC;;;;;;;;;;;;;;;;AAiBA,SAAgB,eACd,KACA,UAAoD,CAAC,GAClC;CACnB,OAAO;EACL,MAAM,QAAQ,OAAO;GACnB,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;GAC5C,OAAO,aAAa,SAAS,OAAO,UAAU,KAAA;EAChD;EACA,MAAM,KAAK,IAAI,OAAO;GACpB,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;GAG5C,IAAI,GAAG,OAAO,WAAW,GAAG,KAAK,UAAU,MAAM;GACjD,IAAI,aAAa,QAAQ;IACvB,GAAG,MAAM,MAAgB,cAAmB;IAC5C;GACF;GACA,MAAM,YAAuB;IAC3B,OAAO,UAAU,GAAG,KAAK,KAAK;IAC9B,QAAQ,MAAM,WAAW,GAAG,MAAM,MAAM,MAAM;GAChD;GACA,GAAG,KAAK,OAAO,IAAI,QAAQ,WAAW,OAAO,QAAQ;EACvD;EACA,QAAQ,IAAI,SAAS;GACnB,MAAM,aAAa,GAAG,KAAK;GAC3B,IAAI,eAAe,KAAA,GAAW;GAC9B,IAAI,OAAO,YAAY,UAAU;IAC/B,GAAG,MAAM,MAAgB,iCAAsC;IAC/D;GACF;GACA,WAAW,QAAQ,OAAO;EAC5B;EACA,MAAM,IAAI;GACR,GAAG,KAAK,QAAQ;GAChB,GAAI,KAAK,IAAI,EAAgC,OAAO;EACtD;EACA,MAAM,IAAI,OAAO;GACf,IAAI,OAAO,OAAO,EAAE,OAAO,YAAY,CAAC;GACxC,GAAI,KAAK,IAAI,EAAgC,OAAO;EACtD;CACF;AACF;;;;;AAMA,eAAe,aAAa,QAAiB,KAA4B;CACvE,IAAI;EACF,MAAM,OAAO,KAAK;GAAE,OAAO;GAAM,MAAM,KAAK,UAAU;IAAE,GAAG;IAAO,MAAM;IAAM;GAAI,CAAC;EAAE,CAAC;CACxF,QAAQ,CAER;CACA,OAAO,MAAM;AACf;;AAGA,SAAS,SAAS,KAA0D;CAC1E,MAAM,OAAO,IAAI,aAAa,IAAI,GAAG;CACrC,IAAI,SAAS,MAAM,OAAO,CAAC;CAC3B,IAAI;EACF,MAAM,QAAiB,KAAK,MAAM,IAAI;EACtC,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,4BAA4B,OAAO,KAAA;EAC/E,OAAO,MAAM,KAAK,SAChB,SAAS,QAAQ,OAAO,SAAS,WAAY,OAAmC,CAAC,CACnF;CACF,QAAQ;EACN;CACF;AACF;;;;;;;;;;;;;;;;AAiBA,SAAgB,SACd,KACA,UAA8B,CAAC,GACe;CAC9C,OAAO,OAAO,OAAO,WAAW;EAC9B,IAAI,CAAC,cAAc,MAAM,SAAS,QAAQ,cAAc,GAAG;GACzD,IAAI,OAAO,IAAI,cAAc,aAAuB,gBAA4B,GAAG,EACjF,OAAO,aACT,CAAC;GACD,MAAM,aAAa,QAAkB,oBAAyB;GAC9D;EACF;EACA,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;EAE5C,MAAM,UAAU,aAAa,SAAS,KAAA,IAAY,SAAS,IAAI,IAAI,MAAM,QAAQ,GAAG,CAAC;EACrF,IAAI,aAAa,UAAU,YAAY,KAAA,GAAW;GAChD,MAAM,aACJ,QACW,aAAa,SAAS,iBAAiB,uBACpD;GACA;EACF;EACA,IAAI,eAAe;EAmBnB,MAAM,aAAa,IAAI,QAAQ;GAjB7B,KAAK,OAAO;IACV,IAAI,OAAO,QAAQ,OAAO;IAC1B,gBAAgB,MAAM;IACtB,OAAO,KAAK;KAAE,OAAO;KAAM,MAAM;IAAM,CAAC,CAAC,CAAC,WAClC;KACJ,gBAAgB,MAAM;IACxB,SACM;KACJ,eAAe,OAAO;KACtB,WAAW,OAAO;IACpB,CACF;IACA,OAAO;GACT;GACA,aAAa,OAAO,MAAM;GAC1B,gBAAgB;EAEqB,GAAG,OAAO,QAAQ;EACzD,QAAQ,SAAS,OAAO,OAAO;GAE7B,WAAW,QAAQ,KAAK,UAAU;IAAE,GAAG;IAAO,GAAG;IAAO,GAAG,KAAK;GAAE,CAAC,CAAC;EACtE,CAAC;EACD,IAAI,CAAC,OAAO,OAAO,SACjB,MAAM,IAAI,SAAe,YAAY;GACnC,OAAO,OAAO,iBAAiB,eAAe,QAAQ,GAAG,EAAE,MAAM,KAAK,CAAC;EACzE,CAAC;EACH,WAAW,OAAO;EAClB,MAAM,WAAW;CACnB;AACF;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,UACd,KACA,UAA4F,CAAC,GAC5D;CACjC,MAAM,0BAAU,IAAI,IAAyB;CAC7C,MAAM,aAAa,QAA6B;EAC9C,IAAI,SAAS,QAAQ,IAAI,GAAG;EAC5B,IAAI,WAAW,KAAA,GAAW;GACxB,IAAI,QAAQ,QAAQ,aAAa;IAC/B,MAAM,SAAS,QAAQ,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;IACrC,IAAI,WAAW,KAAA,GAAW,QAAQ,OAAO,MAAM;GACjD;GACA,SAAS,IAAI,YAAY,IAAI,OAAO,oBAAoB,IAAI,OAAO,cAAc,KAAK,IAAI,CAAC;GAC3F,QAAQ,IAAI,KAAK,MAAM;EACzB;EACA,OAAO;CACT;CACA,OAAO,OAAO,UAAU;EACtB,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,cAAc,SAAS,QAAQ,cAAc,GAChD,OAAO,OAAO,KAAK,aAAuB,oBAAgC;EAC5E,MAAM,SAAS,MAAM,SAAS,SAAS,KAAK;EAC5C,IAAI,aAAa,QAAQ,OAAO,OAAO;EACvC,MAAM,OAAO,MAAM,YAAY,SAAS,IAAI,OAAO,kBAAkB,CAAC;EACtE,IAAI,SAAS,KAAA,GACX,OAAO,OAAO,KAAK,aAAuB,0BAAsC;EAClF,IAAI;EACJ,IAAI;GACF,OAAO,KAAK,MAAM,IAAI;EACxB,QAAQ;GACN,OAAO,OAAO,KAAK,aAAuB,sBAAkC;EAC9E;EACA,MAAM,SAAU,MAAsC;EACtD,MAAM,MAAO,MAAmC;EAChD,IAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,OAAO,WAAW,KAAK,OAAO,SAAS,qBACnE,OAAO,OACL,KACA,aACU,uBAAuB,oBAAoB,QACvD;EACF,MAAM,MAAM,QAAQ,MAAM,OAAO,OAAO,QAAQ,KAAK,OAAO,SAAS,QAAQ;EAC7E,MAAM,UAAoB,CAAC;EAQ3B,MAAM,aAAa,IAAI,QAAQ;GAN7B,KAAK,OAAO;IACV,IAAI,CAAC,MAAM,WAAW,kBAAc,GAAG,QAAQ,KAAK,KAAK;IACzD,OAAO;GACT;GACA,aAAa,KAAA;EAEwB,GAAG,OAAO,UAAU;GACzD,WAAW;GACX,eAAe,UAAU,GAAG;GAC5B,GAAI,OAAO,QAAQ,WAAW,EAAE,WAAW,IAAI,IAAI,CAAC;EACtD,CAAC;EACD,IAAI;GACF,KAAK,MAAM,SAAS,QAAQ;IAC1B,MAAM,OAAQ,OAAkC;IAChD,IAAI,SAAS,QAAQ;KACnB,MAAM,OAAO;KACb,IAAI;MACF,IAAI,OAAO,KAAK,MAAM,YAAY,OAAO,QAAQ,UAC/C,MAAM,IAAI,cACR,aACU,oCACZ;MAEF,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,KAAK,KAAK,IAAI,CAAC,GAAG,MAAM,IAAI,cAAc,cAAc;MAC5E,MAAM,IAAI,eAAe,OAAO,UAAU,KAAK,GAAG,KAAM,KAAK,KAAK,IAAa;KACjF,SAAS,OAAO;MACd,MAAM,OAAO,iBAAiB,gBAAgB,MAAM,OAAO;MAC3D,QAAQ,KAAK,KAAK,UAAU;OAAE,GAAG;OAAO;MAAK,CAAC,CAAC;KACjD;KACA;IACF;IACA,IAAI,SAAS,SAAS,SAAS,OAAO;KACpC,QAAQ,KACN,KAAK,UAAU;MACb,GAAG;MACH,MAAM;MACN,KAAe;KACjB,CAAC,CACH;KACA;IACF;IACA,WAAW,QAAQ,KAAK,UAAU,KAAK,CAAC;GAC1C;GACA,MAAM,WAAW,QAAQ;EAC3B,UAAU;GACR,WAAW,OAAO;EACpB;EACA,OAAO,aAAa,KAAK,cAAc,QAAQ,KAAK,GAAG,EAAE,GAAG;CAC9D;AACF"}
|
package/dist/dev/server/hub.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { Json } from "../protocol.js";
|
|
|
2
2
|
import { Adapter, PublishResult } from "../adapter.js";
|
|
3
3
|
import { ChannelRule, Identity, RuleContext } from "./rules.js";
|
|
4
4
|
import { HubLimits, TokenBucket } from "./limits.js";
|
|
5
|
-
//#region src/server/hub.d.ts
|
|
6
5
|
/**
|
|
7
6
|
* What a hub needs from a connection's transport. A WebSocket, an SSE stream and an in-memory test client all fit.
|
|
8
7
|
* @since 1.0.0
|
|
@@ -110,5 +109,4 @@ export interface HubCore {
|
|
|
110
109
|
/** @internal Evaluates a rule permission for an identity. */
|
|
111
110
|
allowed(rule: ChannelRule, op: RuleContext["op"], channel: string, params: Record<string, string>, identity: Identity): Promise<boolean>;
|
|
112
111
|
}
|
|
113
|
-
//#endregion
|
|
114
112
|
//# sourceMappingURL=hub.d.ts.map
|
package/dist/dev/server/hub.js
CHANGED
|
@@ -3,17 +3,14 @@ import { byteLength, decodeClientFrame, isChannelName, isEventName, isIdempotenc
|
|
|
3
3
|
import { TokenBucket, WindowCounter, resolveLimits } from "./limits.js";
|
|
4
4
|
import { memoryAdapter } from "../memory.js";
|
|
5
5
|
import { compileRule, matchRule } from "./rules.js";
|
|
6
|
-
|
|
6
|
+
// src/server/hub.ts
|
|
7
7
|
const REPLAY_PAGE = 500;
|
|
8
8
|
const MAX_PUMP_BUFFER = 1e4;
|
|
9
9
|
const ID_BYTES = 16;
|
|
10
10
|
/** A random id: 128 bits, base64url. */
|
|
11
11
|
function randomId() {
|
|
12
|
-
const bytes = new Uint8Array(ID_BYTES);
|
|
13
|
-
|
|
14
|
-
let text = "";
|
|
15
|
-
for (const byte of bytes) text += String.fromCharCode(byte);
|
|
16
|
-
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
12
|
+
const bytes = crypto.getRandomValues(new Uint8Array(ID_BYTES));
|
|
13
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
17
14
|
}
|
|
18
15
|
function encodeFrame(frame) {
|
|
19
16
|
return JSON.stringify(frame);
|
|
@@ -43,7 +40,13 @@ function createHubCore(options = {}) {
|
|
|
43
40
|
const limits = resolveLimits(options.limits);
|
|
44
41
|
const now = options.now ?? Date.now;
|
|
45
42
|
const ownsAdapter = options.adapter === void 0;
|
|
46
|
-
const adapter = options.adapter ?? memoryAdapter({
|
|
43
|
+
const adapter = options.adapter ?? memoryAdapter({
|
|
44
|
+
now,
|
|
45
|
+
onError: (error, context) => report(error, {
|
|
46
|
+
where: `memory.${context.where}`,
|
|
47
|
+
channel: context.channel
|
|
48
|
+
})
|
|
49
|
+
});
|
|
47
50
|
const rules = [];
|
|
48
51
|
const conns = /* @__PURE__ */ new Map();
|
|
49
52
|
const chans = /* @__PURE__ */ new Map();
|
|
@@ -104,9 +107,21 @@ function createHubCore(options = {}) {
|
|
|
104
107
|
where: "transport.send",
|
|
105
108
|
connection: conn.id
|
|
106
109
|
});
|
|
110
|
+
onTransport(conn, "transport.close", () => conn.transport.close(1011, "write failed"));
|
|
107
111
|
}
|
|
108
112
|
closeConnection(conn);
|
|
109
113
|
}
|
|
114
|
+
/** Runs a call on the transport; a transport that is already gone throws, which is only reported. */
|
|
115
|
+
function onTransport(conn, where, call) {
|
|
116
|
+
try {
|
|
117
|
+
call();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
report(error, {
|
|
120
|
+
where,
|
|
121
|
+
connection: conn.id
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
110
125
|
function sendFrame(conn, frame) {
|
|
111
126
|
send(conn, encodeFrame(frame));
|
|
112
127
|
}
|
|
@@ -117,22 +132,18 @@ function createHubCore(options = {}) {
|
|
|
117
132
|
...n === void 0 ? {} : { n },
|
|
118
133
|
...channel === void 0 ? {} : { c: channel },
|
|
119
134
|
code,
|
|
120
|
-
...message === void 0 ? {} : { msg: message }
|
|
135
|
+
...message === void 0 || message === "" ? {} : { msg: message }
|
|
121
136
|
});
|
|
122
137
|
}
|
|
123
138
|
/** Sends a final frame, closes the transport and releases the connection. */
|
|
124
139
|
function drop(conn, code, reason) {
|
|
125
140
|
if (conn.closed) return;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} catch {}
|
|
133
|
-
try {
|
|
134
|
-
conn.transport.close(code, reason);
|
|
135
|
-
} catch {}
|
|
141
|
+
onTransport(conn, "transport.send", () => conn.transport.send(encodeFrame({
|
|
142
|
+
t: "bye",
|
|
143
|
+
code,
|
|
144
|
+
why: reason
|
|
145
|
+
})));
|
|
146
|
+
onTransport(conn, "transport.close", () => conn.transport.close(code, reason));
|
|
136
147
|
closeConnection(conn);
|
|
137
148
|
}
|
|
138
149
|
function channelState(name) {
|
|
@@ -151,23 +162,16 @@ function createHubCore(options = {}) {
|
|
|
151
162
|
}
|
|
152
163
|
return chan;
|
|
153
164
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await chan.adapterSub;
|
|
162
|
-
}
|
|
163
|
-
async function ensurePresenceSub(chan) {
|
|
164
|
-
if (chan.presenceSub === void 0) {
|
|
165
|
-
chan.presenceSub = adapter.presenceSubscribe(chan.name, (event) => onPresence(chan, event));
|
|
166
|
-
chan.presenceSub.catch(() => {
|
|
167
|
-
chan.presenceSub = void 0;
|
|
165
|
+
/** Opens one of the channel's two adapter feeds once; a failed attempt is forgotten so the next caller retries. */
|
|
166
|
+
async function ensureFeed(chan, key, open) {
|
|
167
|
+
if (chan[key] === void 0) {
|
|
168
|
+
const opened = open();
|
|
169
|
+
chan[key] = opened;
|
|
170
|
+
opened.catch(() => {
|
|
171
|
+
chan[key] = void 0;
|
|
168
172
|
});
|
|
169
173
|
}
|
|
170
|
-
await chan
|
|
174
|
+
await chan[key];
|
|
171
175
|
}
|
|
172
176
|
function onMessage(chan, message) {
|
|
173
177
|
for (const sub of chan.subs) {
|
|
@@ -250,6 +254,10 @@ function createHubCore(options = {}) {
|
|
|
250
254
|
}
|
|
251
255
|
function onPresence(chan, event) {
|
|
252
256
|
for (const sub of chan.subs) {
|
|
257
|
+
if (sub.joined && event.op !== "leave" && event.member.id === sub.conn.id && event.member.v > sub.version) {
|
|
258
|
+
sub.version = event.member.v;
|
|
259
|
+
sub.state = event.member.s;
|
|
260
|
+
}
|
|
253
261
|
if (!sub.active || !sub.joined && !sub.watch) continue;
|
|
254
262
|
if (sub.presenceQueue !== void 0) sub.presenceQueue.push(event);
|
|
255
263
|
else sendPresence(sub, event);
|
|
@@ -338,6 +346,7 @@ function createHubCore(options = {}) {
|
|
|
338
346
|
watch: frame.watch === true,
|
|
339
347
|
joined: false,
|
|
340
348
|
version: 0,
|
|
349
|
+
state: null,
|
|
341
350
|
lastSeq: 0,
|
|
342
351
|
lastAt: now(),
|
|
343
352
|
pumping: true,
|
|
@@ -350,9 +359,9 @@ function createHubCore(options = {}) {
|
|
|
350
359
|
conn.subs.set(name, sub);
|
|
351
360
|
chan.subs.add(sub);
|
|
352
361
|
try {
|
|
353
|
-
await
|
|
362
|
+
await ensureFeed(chan, "adapterSub", () => adapter.subscribe(chan.name, (message) => onMessage(chan, message)));
|
|
354
363
|
if (wantsPresence) {
|
|
355
|
-
await
|
|
364
|
+
await ensureFeed(chan, "presenceSub", () => adapter.presenceSubscribe(chan.name, (event) => onPresence(chan, event)));
|
|
356
365
|
startHeartbeat();
|
|
357
366
|
}
|
|
358
367
|
const probe = await adapter.history(name, Number.MAX_SAFE_INTEGER, 0);
|
|
@@ -401,6 +410,7 @@ function createHubCore(options = {}) {
|
|
|
401
410
|
async function joinPresence(sub, state) {
|
|
402
411
|
if ((await adapter.presenceList(sub.channel.name)).length >= limits.maxPresenceMembers) throw new RealtimeError("limit", "the presence set is full");
|
|
403
412
|
sub.version = 1;
|
|
413
|
+
sub.state = state;
|
|
404
414
|
sub.joined = true;
|
|
405
415
|
startHeartbeat();
|
|
406
416
|
const member = {
|
|
@@ -435,6 +445,7 @@ function createHubCore(options = {}) {
|
|
|
435
445
|
if (byteLength(JSON.stringify(frame.s)) > limits.maxStateBytes) return fail(conn, void 0, "too_large", frame.c, "the presence state is too large");
|
|
436
446
|
if (!conn.publishBucket.take(now())) return limit(conn, void 0, frame.c);
|
|
437
447
|
sub.version += 1;
|
|
448
|
+
sub.state = frame.s;
|
|
438
449
|
const member = {
|
|
439
450
|
id: conn.id,
|
|
440
451
|
...conn.identity.user === void 0 ? {} : { u: conn.identity.user },
|
|
@@ -541,14 +552,20 @@ function createHubCore(options = {}) {
|
|
|
541
552
|
}
|
|
542
553
|
})();
|
|
543
554
|
}
|
|
555
|
+
/** Answers a frame that cannot be handled; a peer that keeps sending them is dropped. */
|
|
556
|
+
function refuseFrame(conn, n, code, why) {
|
|
557
|
+
fail(conn, n, code, void 0, why);
|
|
558
|
+
if (conn.invalid.hit(now())) drop(conn, 1008, "too many invalid frames");
|
|
559
|
+
}
|
|
544
560
|
async function handle(conn, text) {
|
|
545
561
|
const decoded = decodeClientFrame(text, limits.maxMessageBytes);
|
|
546
562
|
if (!decoded.ok) {
|
|
547
|
-
|
|
548
|
-
if (conn.invalid.hit(now())) drop(conn, 1008, "too many invalid frames");
|
|
563
|
+
refuseFrame(conn, decoded.n, decoded.code, decoded.why);
|
|
549
564
|
return;
|
|
550
565
|
}
|
|
551
566
|
const frame = decoded.frame;
|
|
567
|
+
const n = "n" in frame ? frame.n : void 0;
|
|
568
|
+
if ((frame.t === "sub" || frame.t === "unsub") && !conn.controlBucket.take(now()) && frame.t === "sub") return limit(conn, n, frame.c);
|
|
552
569
|
try {
|
|
553
570
|
switch (frame.t) {
|
|
554
571
|
case "hello": return;
|
|
@@ -570,7 +587,6 @@ function createHubCore(options = {}) {
|
|
|
570
587
|
case "pres": return await setPresence(conn, frame);
|
|
571
588
|
}
|
|
572
589
|
} catch (error) {
|
|
573
|
-
const n = "n" in frame ? frame.n : void 0;
|
|
574
590
|
const channel = "c" in frame ? frame.c : void 0;
|
|
575
591
|
if (error instanceof RealtimeError) fail(conn, n, error.code, channel);
|
|
576
592
|
else {
|
|
@@ -667,7 +683,7 @@ function createHubCore(options = {}) {
|
|
|
667
683
|
await adapter.presenceSet(ref.channel, {
|
|
668
684
|
id: ref.id,
|
|
669
685
|
...sub.conn.identity.user === void 0 ? {} : { u: sub.conn.identity.user },
|
|
670
|
-
s:
|
|
686
|
+
s: sub.state,
|
|
671
687
|
v: sub.version
|
|
672
688
|
}, limits.presenceTtlMs);
|
|
673
689
|
}
|
|
@@ -702,6 +718,7 @@ function createHubCore(options = {}) {
|
|
|
702
718
|
subs: /* @__PURE__ */ new Map(),
|
|
703
719
|
queue: [],
|
|
704
720
|
publishBucket: connectOptions.publishBucket ?? new TokenBucket(limits.publishesPerSecond, limits.publishBurst, now()),
|
|
721
|
+
controlBucket: new TokenBucket(limits.publishesPerSecond, Math.max(limits.publishBurst, limits.maxChannelsPerConnection), now()),
|
|
705
722
|
stateless: connectOptions.stateless === true,
|
|
706
723
|
waiters: [],
|
|
707
724
|
invalid: new WindowCounter(10, 6e4),
|
|
@@ -726,6 +743,10 @@ function createHubCore(options = {}) {
|
|
|
726
743
|
done,
|
|
727
744
|
receive(text) {
|
|
728
745
|
if (conn.closed) return;
|
|
746
|
+
if (byteLength(text) > limits.maxMessageBytes) {
|
|
747
|
+
refuseFrame(conn, void 0, "too_large");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
729
750
|
if (conn.queue.length >= limits.maxQueuedFrames) {
|
|
730
751
|
drop(conn, 1013, "too many frames waiting");
|
|
731
752
|
return;
|
|
@@ -776,11 +797,17 @@ function createHubCore(options = {}) {
|
|
|
776
797
|
if (byteLength(JSON.stringify(state)) > limits.maxStateBytes) throw new RealtimeError("too_large", "the presence state is too large");
|
|
777
798
|
const member = (await adapter.presenceList(channel)).find((item) => item.id === connectionId);
|
|
778
799
|
if (member === void 0 || member.u !== identity.user) throw new RealtimeError("forbidden", "that connection is not yours");
|
|
800
|
+
const local = conns.get(connectionId)?.subs.get(channel);
|
|
801
|
+
const version = Math.max(member.v, local?.version ?? 0) + 1;
|
|
802
|
+
if (local?.joined === true) {
|
|
803
|
+
local.version = version;
|
|
804
|
+
local.state = state;
|
|
805
|
+
}
|
|
779
806
|
await adapter.presenceSet(channel, {
|
|
780
807
|
id: member.id,
|
|
781
808
|
u: member.u,
|
|
782
809
|
s: state,
|
|
783
|
-
v:
|
|
810
|
+
v: version
|
|
784
811
|
}, limits.presenceTtlMs);
|
|
785
812
|
},
|
|
786
813
|
revoke(target) {
|
|
@@ -821,7 +848,6 @@ function createHubCore(options = {}) {
|
|
|
821
848
|
};
|
|
822
849
|
return core;
|
|
823
850
|
}
|
|
824
|
-
//#endregion
|
|
825
851
|
export { createHubCore };
|
|
826
852
|
|
|
827
853
|
//# sourceMappingURL=hub.js.map
|