@fishenv/http 0.0.2 → 0.0.4

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 (78) hide show
  1. package/dist/contract/builder.d.ts +9 -4
  2. package/dist/contract/builder.js +2 -0
  3. package/dist/contract/index.d.ts +3 -1
  4. package/dist/contract/index.js +1 -0
  5. package/dist/contract/types.d.ts +9 -1
  6. package/dist/contract/ws.d.ts +178 -0
  7. package/dist/contract/ws.js +164 -0
  8. package/dist/core/compose.js +5 -0
  9. package/dist/core/error.d.ts +21 -0
  10. package/dist/core/error.js +50 -7
  11. package/dist/core/index.d.ts +7 -3
  12. package/dist/core/index.js +5 -1
  13. package/dist/core/matcher.js +2 -1
  14. package/dist/core/route-builder.d.ts +4 -4
  15. package/dist/core/router.d.ts +16 -2
  16. package/dist/core/router.js +45 -6
  17. package/dist/core/transport.d.ts +39 -0
  18. package/dist/core/transport.js +68 -0
  19. package/dist/core/types.d.ts +22 -3
  20. package/dist/core/upgrade.d.ts +85 -0
  21. package/dist/core/upgrade.js +110 -0
  22. package/dist/core/utils/url.d.ts +4 -1
  23. package/dist/core/utils/url.js +14 -3
  24. package/dist/sse/hub.js +15 -33
  25. package/dist/sse/types.d.ts +3 -5
  26. package/dist/static/index.d.ts +27 -0
  27. package/dist/static/index.js +25 -0
  28. package/dist/static/negotiate.d.ts +23 -0
  29. package/dist/static/negotiate.js +85 -0
  30. package/dist/static/options.d.ts +44 -0
  31. package/dist/static/options.js +190 -0
  32. package/dist/static/resolve.d.ts +56 -0
  33. package/dist/static/resolve.js +113 -0
  34. package/dist/static/response.d.ts +53 -0
  35. package/dist/static/response.js +146 -0
  36. package/dist/static/router.d.ts +59 -0
  37. package/dist/static/router.js +217 -0
  38. package/dist/static/rules.d.ts +21 -0
  39. package/dist/static/rules.js +49 -0
  40. package/dist/static/types.d.ts +76 -0
  41. package/dist/static/types.js +0 -0
  42. package/dist/ws/channels/channel.d.ts +175 -0
  43. package/dist/ws/channels/channel.js +125 -0
  44. package/dist/ws/channels/handler.d.ts +44 -0
  45. package/dist/ws/channels/handler.js +517 -0
  46. package/dist/ws/channels/protocol.d.ts +96 -0
  47. package/dist/ws/channels/protocol.js +94 -0
  48. package/dist/ws/channels/target.d.ts +36 -0
  49. package/dist/ws/channels/target.js +56 -0
  50. package/dist/ws/connection.d.ts +56 -0
  51. package/dist/ws/connection.js +172 -0
  52. package/dist/ws/helpers.d.ts +17 -0
  53. package/dist/ws/helpers.js +48 -0
  54. package/dist/ws/hub.d.ts +190 -0
  55. package/dist/ws/hub.js +562 -0
  56. package/dist/ws/index.d.ts +65 -0
  57. package/dist/ws/index.js +66 -0
  58. package/dist/ws/lifecycle.d.ts +21 -0
  59. package/dist/ws/lifecycle.js +97 -0
  60. package/dist/ws/limits.d.ts +32 -0
  61. package/dist/ws/limits.js +67 -0
  62. package/dist/ws/message.d.ts +19 -0
  63. package/dist/ws/message.js +58 -0
  64. package/dist/ws/origin.d.ts +40 -0
  65. package/dist/ws/origin.js +152 -0
  66. package/dist/ws/presence.d.ts +96 -0
  67. package/dist/ws/presence.js +420 -0
  68. package/dist/ws/route-builder.d.ts +136 -0
  69. package/dist/ws/route-builder.js +265 -0
  70. package/dist/ws/router.d.ts +21 -0
  71. package/dist/ws/router.js +36 -0
  72. package/dist/ws/tickets.d.ts +82 -0
  73. package/dist/ws/tickets.js +161 -0
  74. package/dist/ws/typed.d.ts +54 -0
  75. package/dist/ws/typed.js +104 -0
  76. package/dist/ws/types.d.ts +69 -0
  77. package/dist/ws/types.js +70 -0
  78. package/package.json +14 -2
@@ -0,0 +1,265 @@
1
+ /** Fluent builder for WebSocket routes, layered on the core `RouteBuilder`. */
2
+ import { BadRequestError, DEFAULT_MAX_PAYLOAD_LENGTH, HttpError, requestUpgrade, TooManyRequestsError, } from "../core/index.js";
3
+ import { channel, } from "./channels/channel.js";
4
+ import { channels } from "./channels/handler.js";
5
+ import { isWebSocketUpgrade, negotiateProtocol, UpgradeRequiredError, } from "./helpers.js";
6
+ import { createLifecycle } from "./lifecycle.js";
7
+ import { ConnectionCounter } from "./limits.js";
8
+ import { createOriginPolicy, originSourcesFromCors, } from "./origin.js";
9
+ import { TypedWsConnection } from "./typed.js";
10
+ import { WsCloseCode, WsCloseError, } from "./types.js";
11
+ const ServiceUnavailable = () => new HttpError(503, "Too many open connections");
12
+ /**
13
+ * Delegates to the core `RouteBuilder`, so `.param()` / `.with()` carry
14
+ * exactly the same generics as HTTP and SSE routes.
15
+ *
16
+ * Handshake order: origin → upgrade headers (426) → subprotocol →
17
+ * middleware → path params → connection limits → upgrade.
18
+ */
19
+ export class WsRouteBuilder {
20
+ #inner;
21
+ #options;
22
+ constructor(inner, options = {}) {
23
+ this.#inner = inner;
24
+ this.#options = {
25
+ routerOrigins: [],
26
+ origins: [],
27
+ inheritOrigins: true,
28
+ ...options,
29
+ };
30
+ }
31
+ meta(data) {
32
+ this.#inner.meta(data);
33
+ return this;
34
+ }
35
+ /**
36
+ * Add allowed origins on top of the inherited ones (root `cors()` and
37
+ * `withWs()` options). Pass `{ inherit: false }` to replace them instead.
38
+ */
39
+ origin(...args) {
40
+ const last = args.at(-1);
41
+ let sources = args;
42
+ if (last != null && typeof last === "object" && !(last instanceof RegExp)) {
43
+ sources = args.slice(0, -1);
44
+ if (last.inherit === false) {
45
+ this.#options.inheritOrigins = false;
46
+ }
47
+ }
48
+ createOriginPolicy(sources); // validate eagerly: fail at definition time
49
+ this.#options.origins.push(...sources);
50
+ return this;
51
+ }
52
+ /** Supported subprotocols in preference order; others get `400`. */
53
+ protocols(list) {
54
+ if (list.length === 0)
55
+ throw new Error("protocols(): list is empty");
56
+ this.#options.protocols = [...list];
57
+ return this;
58
+ }
59
+ /** Per-route inbound limit (closes with `1009`). Must be ≤ the server-wide limit. */
60
+ maxMessageSize(bytes) {
61
+ if (!(bytes > 0))
62
+ throw new Error("maxMessageSize(): must be positive");
63
+ this.#options.maxMessageSize = bytes;
64
+ return this;
65
+ }
66
+ /** Inbound token bucket per connection. Opt-in. */
67
+ rateLimit(limit) {
68
+ if (!(limit.messages > 0) || !(limit.perMs > 0)) {
69
+ throw new Error("rateLimit(): messages and perMs must be positive");
70
+ }
71
+ this.#options.rateLimit = { ...limit };
72
+ return this;
73
+ }
74
+ /** Cap open sockets on this route (`503`) and/or per key (`429`). */
75
+ maxConnections(limit) {
76
+ this.#options.maxConnections = limit;
77
+ return this;
78
+ }
79
+ /** Not available on contract routes — the contract owns the params. */
80
+ param(name, schema) {
81
+ if (this.#options.contract != null) {
82
+ throw new Error(`param("${name}"): params of contract routes come from the contract`);
83
+ }
84
+ return new WsRouteBuilder(this.#inner.param(name, schema), cloneOptions(this.#options));
85
+ }
86
+ with(mw) {
87
+ return new WsRouteBuilder(this.#inner.with(mw), cloneOptions(this.#options));
88
+ }
89
+ /**
90
+ * Terminal: serve the channel protocol. Every implementation must be
91
+ * complete (all client events handled, join authorized or `.public()`),
92
+ * and on contract routes the set must match the socket contract exactly.
93
+ *
94
+ * Pass a callback to get a `channel` factory typed with this route's ctx:
95
+ * `.channels((channel) => [channel(room).join(({ ctx }) => …)])`.
96
+ */
97
+ channels(impls, options) {
98
+ const list = typeof impls === "function"
99
+ ? impls(channel.withContext())
100
+ : impls;
101
+ return this.handleWith(channels(list, options));
102
+ }
103
+ /** Terminal: plug in a reusable protocol handler (see `channels()`). */
104
+ handleWith(handler) {
105
+ if (handler.protocols != null && this.#options.protocols == null) {
106
+ this.#options.protocols = [...handler.protocols];
107
+ }
108
+ return this.#finish(handler.setup, false, handler.onBuild?.bind(handler));
109
+ }
110
+ /**
111
+ * Terminal shorthand: resolve a hub from the container and register the
112
+ * socket under the key derived from the handshake (e.g. the user id).
113
+ */
114
+ subscribe(
115
+ // biome-ignore lint/suspicious/noExplicitAny: intentional any
116
+ token, key) {
117
+ return this.handle((args) => {
118
+ const socket = args.socket;
119
+ args.container
120
+ .get(token)
121
+ .add(key(args), socket instanceof TypedWsConnection ? socket.connection : socket);
122
+ });
123
+ }
124
+ /**
125
+ * Terminal. Runs once per socket, after the upgrade. The returned function
126
+ * (if any) runs when the socket closes.
127
+ *
128
+ * `.catch()` on the result only covers failures *before* the upgrade
129
+ * (origin, middleware, params) — after that, errors close the socket.
130
+ */
131
+ handle(fn) {
132
+ if (this.#options.contract?.kind === "channels") {
133
+ throw new Error(`${this.#options.contract.path}: channel contracts are implemented with .channels([...])`);
134
+ }
135
+ return this.#finish(fn, true);
136
+ }
137
+ #finish(fn, typed, onBuild) {
138
+ const options = cloneOptions(this.#options);
139
+ const messages = typed ? options.contract?.messages : undefined;
140
+ const counter = options.maxConnections ? new ConnectionCounter() : null;
141
+ let policy;
142
+ let development = false;
143
+ let logger = console;
144
+ const route = this.#inner.handle((args) => {
145
+ const { req } = args;
146
+ const protocol = options.protocols
147
+ ? negotiateProtocol(req, options.protocols)
148
+ : null;
149
+ const baseArgs = {
150
+ path: args.path,
151
+ ctx: args.ctx,
152
+ req,
153
+ container: args.container,
154
+ };
155
+ // Pre-upgrade connection limits — cheap rejection with a real status.
156
+ const limit = options.maxConnections;
157
+ let key = null;
158
+ if (limit != null && counter != null) {
159
+ if (limit.total != null && counter.total >= limit.total) {
160
+ throw ServiceUnavailable();
161
+ }
162
+ if (limit.perKey != null) {
163
+ key = limit.perKey.key(baseArgs);
164
+ if (counter.count(key) >= limit.perKey.max) {
165
+ throw new TooManyRequestsError("Too many connections");
166
+ }
167
+ }
168
+ }
169
+ const handlers = createLifecycle(req, protocol, (socket) => fn({
170
+ socket: messages == null
171
+ ? socket
172
+ : new TypedWsConnection(socket, messages, () => development),
173
+ ...baseArgs,
174
+ }), {
175
+ maxMessageSize: options.maxMessageSize,
176
+ rateLimit: options.rateLimit,
177
+ maxQueuedMessages: options.maxQueuedMessages,
178
+ development,
179
+ logger,
180
+ onOpen: counter == null
181
+ ? undefined
182
+ : (socket) => {
183
+ // Re-check: concurrent handshakes may both have passed above.
184
+ const overTotal = limit?.total != null && counter.total >= limit.total;
185
+ const overKey = key != null &&
186
+ limit?.perKey != null &&
187
+ counter.count(key) >= limit.perKey.max;
188
+ if (overTotal || overKey) {
189
+ throw new WsCloseError(WsCloseCode.TryAgainLater, "Too many connections");
190
+ }
191
+ socket.onClose(counter.acquire(key));
192
+ },
193
+ });
194
+ return requestUpgrade(req, {
195
+ handlers,
196
+ headers: protocol ? { "Sec-WebSocket-Protocol": protocol } : undefined,
197
+ });
198
+ });
199
+ const definition = route.definition;
200
+ definition.kind = "ws";
201
+ definition.guards = [
202
+ async (req) => {
203
+ policy ??= createOriginPolicy(resolveOrigins(options, null).sources);
204
+ if (!(await policy.check(req)))
205
+ throw new ForbiddenOrigin();
206
+ if (!isWebSocketUpgrade(req))
207
+ throw new UpgradeRequiredError();
208
+ if (options.protocols != null &&
209
+ negotiateProtocol(req, options.protocols) == null) {
210
+ throw new BadRequestError("Unsupported WebSocket subprotocol");
211
+ }
212
+ },
213
+ ];
214
+ definition.onBuild = (env) => {
215
+ development = env.development;
216
+ logger = env.logger;
217
+ const { sources, wildcard } = resolveOrigins(options, env.cors);
218
+ if (wildcard) {
219
+ logger.warn(`[fishenv.ws] ${definition.path}: cors origin "*" is not applied to WebSocket routes (CSWSH); use .origin("any") to allow every origin`);
220
+ }
221
+ policy = createOriginPolicy(sources);
222
+ if (policy.allowsAny) {
223
+ logger.warn(`[fishenv.ws] ${definition.path}: origin check disabled via .origin("any")`);
224
+ }
225
+ const globalMax = env.websocket.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
226
+ if (options.maxMessageSize != null &&
227
+ options.maxMessageSize > globalMax) {
228
+ logger.warn(`[fishenv.ws] ${definition.path}: maxMessageSize ${options.maxMessageSize} exceeds the server-wide maxPayloadLength ${globalMax}; the server limit wins`);
229
+ }
230
+ onBuild?.(env, { path: definition.path, contract: options.contract });
231
+ };
232
+ return route;
233
+ }
234
+ }
235
+ /** 403 without detail on which rule failed. */
236
+ class ForbiddenOrigin extends HttpError {
237
+ constructor() {
238
+ super(403, "Origin not allowed");
239
+ }
240
+ get code() {
241
+ return "forbidden";
242
+ }
243
+ }
244
+ function resolveOrigins(options, cors) {
245
+ if (!options.inheritOrigins) {
246
+ return { sources: [...options.origins], wildcard: false };
247
+ }
248
+ const inherited = originSourcesFromCors(cors);
249
+ return {
250
+ sources: [
251
+ ...inherited.sources,
252
+ ...options.routerOrigins,
253
+ ...options.origins,
254
+ ],
255
+ wildcard: inherited.wildcard,
256
+ };
257
+ }
258
+ function cloneOptions(options) {
259
+ return {
260
+ ...options,
261
+ routerOrigins: [...options.routerOrigins],
262
+ origins: [...options.origins],
263
+ rateLimit: options.rateLimit ? { ...options.rateLimit } : undefined,
264
+ };
265
+ }
@@ -0,0 +1,21 @@
1
+ /** The `withWs()` mixin that adds `.ws()` to a core `Router`. */
2
+ import type { AnyWsContract, InferSocket } from "../contract/index.ts";
3
+ import type { BasePathParams, Router } from "../core/index.ts";
4
+ import type { WsConnection } from "./connection.ts";
5
+ import { WsRouteBuilder, type WsRouterOptions } from "./route-builder.ts";
6
+ import type { TypedWsConnection } from "./typed.ts";
7
+ /** The `socket` type handlers get for a contract. */
8
+ export type SocketOf<C extends AnyWsContract> = InferSocket<C>["kind"] extends "messages" ? TypedWsConnection<InferSocket<C>["clientMessage"], InferSocket<C>["serverMessageIn"]> : WsConnection;
9
+ export interface WsRouter<Ctx extends Record<string, unknown>> {
10
+ ws<P extends string>(path: P): WsRouteBuilder<Ctx, Record<never, never>, BasePathParams<P>>;
11
+ /** Implement a `c.ws()` contract: path, params and protocol come from it. */
12
+ ws<C extends AnyWsContract>(contract: C): WsRouteBuilder<Ctx, Record<never, never>, InferSocket<C>["params"], SocketOf<C>, C>;
13
+ }
14
+ /**
15
+ * `.use()` returns a *new* router, so apply `withWs` last:
16
+ * `withWs(r({ prefix: "/api" }).use(auth))`.
17
+ *
18
+ * `options.origins` are added to the allowed origins of every ws route
19
+ * registered through this router (on top of the root `cors()` origins).
20
+ */
21
+ export declare function withWs<Ctx extends Record<string, unknown>, Req extends Record<string, unknown> = Record<never, never>>(router: Router<Ctx, Req>, options?: WsRouterOptions): Router<Ctx, Req> & WsRouter<Ctx>;
@@ -0,0 +1,36 @@
1
+ /** The `withWs()` mixin that adds `.ws()` to a core `Router`. */
2
+ import { createRouteBuilder } from "../core/index.js";
3
+ import { WsRouteBuilder } from "./route-builder.js";
4
+ /**
5
+ * `.use()` returns a *new* router, so apply `withWs` last:
6
+ * `withWs(r({ prefix: "/api" }).use(auth))`.
7
+ *
8
+ * `options.origins` are added to the allowed origins of every ws route
9
+ * registered through this router (on top of the root `cors()` origins).
10
+ */
11
+ export function withWs(router, options = {}) {
12
+ const target = router;
13
+ const routerOrigins = [...(options.origins ?? [])];
14
+ const ws = (pathOrContract) => {
15
+ if (typeof pathOrContract === "string") {
16
+ return new WsRouteBuilder(createRouteBuilder(router, "GET", pathOrContract), { routerOrigins: [...routerOrigins] });
17
+ }
18
+ const contract = pathOrContract._definition;
19
+ // biome-ignore lint/suspicious/noExplicitAny: params are typed via the contract
20
+ let inner = createRouteBuilder(router, "GET", contract.path);
21
+ for (const [name, schema] of contract.paramSchemas) {
22
+ inner = inner.param(name, schema);
23
+ }
24
+ return new WsRouteBuilder(inner, {
25
+ routerOrigins: [...routerOrigins],
26
+ protocols: contract.protocols,
27
+ contract,
28
+ });
29
+ };
30
+ Object.defineProperty(target, "ws", {
31
+ value: ws,
32
+ enumerable: false,
33
+ configurable: true,
34
+ });
35
+ return target;
36
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Browser-safe handshake authentication.
3
+ *
4
+ * Browsers can't set `Authorization` on a WebSocket handshake. Instead of
5
+ * putting a long-lived token in the URL (where it lands in logs, proxies and
6
+ * history), an authenticated HTTP call mints a short-lived **single-use**
7
+ * ticket, and the ws route redeems it:
8
+ *
9
+ * ```ts
10
+ * class SocketTickets extends WsTickets<{ userId: string }> {}
11
+ *
12
+ * api.post("/ws-ticket").with(auth).handle(async ({ ctx, container }) =>
13
+ * Response.json({ ticket: await container.get(SocketTickets).issue({ userId: ctx.user.id }) }));
14
+ *
15
+ * api.ws("/live").with(SocketTickets.middleware()).handle(({ ctx }) => ctx.ticket.userId);
16
+ * // client: new WebSocket(`wss://host/live?ticket=${ticket}`)
17
+ * ```
18
+ */
19
+ import { type MiddlewareFn } from "../core/index.ts";
20
+ /** Storage seam — use Redis (`SET PX` + `GETDEL`) for multi-instance setups. */
21
+ export interface TicketStore {
22
+ set(id: string, value: string, ttlMs: number): void | Promise<void>;
23
+ /** Atomically read *and delete*; `null`/`undefined` if absent or expired. */
24
+ take(id: string): string | null | undefined | Promise<string | null | undefined>;
25
+ }
26
+ /** In-process store with lazy expiry and a hard size cap. */
27
+ export declare class MemoryTicketStore implements TicketStore {
28
+ #private;
29
+ constructor(opts?: {
30
+ maxSize?: number;
31
+ now?: () => number;
32
+ });
33
+ get size(): number;
34
+ set(id: string, value: string, ttlMs: number): void;
35
+ take(id: string): string | null;
36
+ }
37
+ export interface WsTicketsOptions {
38
+ /** Ticket lifetime. Default 30 s. */
39
+ ttlMs?: number;
40
+ store?: TicketStore;
41
+ /** Query parameter read by `middleware()`. Default `"ticket"`. */
42
+ param?: string;
43
+ /**
44
+ * Bind tickets to a request property (e.g. `User-Agent`) to cut replay
45
+ * value. Compared in constant time; a mismatch still consumes the ticket.
46
+ */
47
+ fingerprint?: (req: Request) => string;
48
+ }
49
+ type TicketsToken<T> = abstract new (...args: any[]) => WsTickets<T>;
50
+ /** Payloads must be JSON-serializable (stores hold strings). */
51
+ export declare class WsTickets<T = unknown> {
52
+ #private;
53
+ constructor(options?: WsTicketsOptions);
54
+ /** Mint a ticket. Pass the issuing request when using `fingerprint`. */
55
+ issue(payload: T, req?: Request): Promise<string>;
56
+ /** Consume a ticket. `null` if unknown, used, expired or mismatched. */
57
+ redeem(ticket: string | null | undefined, req?: Request): Promise<T | null>;
58
+ /** Middleware that redeems `?ticket=` and adds `ctx.ticket`; `401` otherwise. */
59
+ middleware(): MiddlewareFn<Record<string, unknown>, {
60
+ ticket: T;
61
+ }>;
62
+ /**
63
+ * Same as {@link WsTickets.middleware}, but resolves the instance from the
64
+ * DI container on each request: `.with(SocketTickets.middleware())`.
65
+ */
66
+ static middleware<T>(this: TicketsToken<T>): MiddlewareFn<Record<string, unknown>, {
67
+ ticket: T;
68
+ }>;
69
+ }
70
+ /**
71
+ * Read a bearer token from `Sec-WebSocket-Protocol: bearer, <token>` — the
72
+ * one header browsers *can* set on a handshake. Less preferred than tickets
73
+ * (the token is long-lived), but useful for native clients and dev tools.
74
+ *
75
+ * The scheme must be offered **first**: Bun echoes the first offered
76
+ * subprotocol when the route doesn't negotiate, so this guarantees the
77
+ * token is never reflected back. Pair it with `.protocols(["bearer"])`.
78
+ */
79
+ export declare function bearerFromProtocol(scheme?: string): MiddlewareFn<Record<string, unknown>, {
80
+ bearerToken: string;
81
+ }>;
82
+ export {};
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Browser-safe handshake authentication.
3
+ *
4
+ * Browsers can't set `Authorization` on a WebSocket handshake. Instead of
5
+ * putting a long-lived token in the URL (where it lands in logs, proxies and
6
+ * history), an authenticated HTTP call mints a short-lived **single-use**
7
+ * ticket, and the ws route redeems it:
8
+ *
9
+ * ```ts
10
+ * class SocketTickets extends WsTickets<{ userId: string }> {}
11
+ *
12
+ * api.post("/ws-ticket").with(auth).handle(async ({ ctx, container }) =>
13
+ * Response.json({ ticket: await container.get(SocketTickets).issue({ userId: ctx.user.id }) }));
14
+ *
15
+ * api.ws("/live").with(SocketTickets.middleware()).handle(({ ctx }) => ctx.ticket.userId);
16
+ * // client: new WebSocket(`wss://host/live?ticket=${ticket}`)
17
+ * ```
18
+ */
19
+ import { timingSafeEqual } from "node:crypto";
20
+ import { TooManyRequestsError, UnauthorizedError, } from "../core/index.js";
21
+ import { requestedProtocols } from "./helpers.js";
22
+ /** In-process store with lazy expiry and a hard size cap. */
23
+ export class MemoryTicketStore {
24
+ #entries = new Map();
25
+ #maxSize;
26
+ #now;
27
+ constructor(opts = {}) {
28
+ this.#maxSize = opts.maxSize ?? 100_000;
29
+ this.#now = opts.now ?? Date.now;
30
+ }
31
+ get size() {
32
+ return this.#entries.size;
33
+ }
34
+ set(id, value, ttlMs) {
35
+ if (this.#entries.size >= this.#maxSize)
36
+ this.#sweep();
37
+ if (this.#entries.size >= this.#maxSize) {
38
+ throw new TooManyRequestsError("Too many outstanding tickets");
39
+ }
40
+ this.#entries.set(id, { value, expires: this.#now() + ttlMs });
41
+ }
42
+ take(id) {
43
+ const entry = this.#entries.get(id);
44
+ if (entry == null)
45
+ return null;
46
+ this.#entries.delete(id);
47
+ return entry.expires > this.#now() ? entry.value : null;
48
+ }
49
+ #sweep() {
50
+ const now = this.#now();
51
+ for (const [id, entry] of this.#entries) {
52
+ if (entry.expires <= now)
53
+ this.#entries.delete(id);
54
+ }
55
+ }
56
+ }
57
+ /** 32 random bytes, base64url → 43 chars. */
58
+ const TICKET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
59
+ function newTicketId() {
60
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
61
+ return Buffer.from(bytes).toString("base64url");
62
+ }
63
+ function digest(value) {
64
+ return Buffer.from(new Bun.CryptoHasher("sha256").update(value).digest());
65
+ }
66
+ /** Payloads must be JSON-serializable (stores hold strings). */
67
+ export class WsTickets {
68
+ #ttlMs;
69
+ #store;
70
+ #param;
71
+ #fingerprint;
72
+ constructor(options = {}) {
73
+ this.#ttlMs = options.ttlMs ?? 30_000;
74
+ this.#store = options.store ?? new MemoryTicketStore();
75
+ this.#param = options.param ?? "ticket";
76
+ this.#fingerprint = options.fingerprint;
77
+ }
78
+ /** Mint a ticket. Pass the issuing request when using `fingerprint`. */
79
+ async issue(payload, req) {
80
+ const id = newTicketId();
81
+ const record = { p: payload };
82
+ if (this.#fingerprint != null) {
83
+ if (req == null) {
84
+ throw new Error("WsTickets.issue(): fingerprint requires the request");
85
+ }
86
+ record.f = digest(this.#fingerprint(req)).toString("base64");
87
+ }
88
+ await this.#store.set(id, JSON.stringify(record), this.#ttlMs);
89
+ return id;
90
+ }
91
+ /** Consume a ticket. `null` if unknown, used, expired or mismatched. */
92
+ async redeem(ticket, req) {
93
+ if (ticket == null || !TICKET_PATTERN.test(ticket))
94
+ return null;
95
+ const raw = await this.#store.take(ticket);
96
+ if (raw == null)
97
+ return null;
98
+ let record;
99
+ try {
100
+ record = JSON.parse(raw);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ if (record.f != null) {
106
+ if (this.#fingerprint == null || req == null)
107
+ return null;
108
+ const expected = Buffer.from(record.f, "base64");
109
+ const actual = digest(this.#fingerprint(req));
110
+ if (expected.byteLength !== actual.byteLength ||
111
+ !timingSafeEqual(expected, actual)) {
112
+ return null;
113
+ }
114
+ }
115
+ return record.p;
116
+ }
117
+ /** Middleware that redeems `?ticket=` and adds `ctx.ticket`; `401` otherwise. */
118
+ middleware() {
119
+ return async ({ req }) => {
120
+ const ticket = new URL(req.url).searchParams.get(this.#param);
121
+ const payload = await this.redeem(ticket, req);
122
+ if (payload == null) {
123
+ throw new UnauthorizedError("Invalid or expired ticket");
124
+ }
125
+ return { ticket: payload };
126
+ };
127
+ }
128
+ /**
129
+ * Same as {@link WsTickets.middleware}, but resolves the instance from the
130
+ * DI container on each request: `.with(SocketTickets.middleware())`.
131
+ */
132
+ static middleware() {
133
+ return (ctx) => {
134
+ const container = ctx.container;
135
+ if (container == null) {
136
+ throw new Error("WsTickets.middleware(): no DI container available");
137
+ }
138
+ // `this` is the subclass the method was called on — the DI token.
139
+ // biome-ignore lint/complexity/noThisInStatic: intentional, see above
140
+ return container.get(this).middleware()(ctx);
141
+ };
142
+ }
143
+ }
144
+ /**
145
+ * Read a bearer token from `Sec-WebSocket-Protocol: bearer, <token>` — the
146
+ * one header browsers *can* set on a handshake. Less preferred than tickets
147
+ * (the token is long-lived), but useful for native clients and dev tools.
148
+ *
149
+ * The scheme must be offered **first**: Bun echoes the first offered
150
+ * subprotocol when the route doesn't negotiate, so this guarantees the
151
+ * token is never reflected back. Pair it with `.protocols(["bearer"])`.
152
+ */
153
+ export function bearerFromProtocol(scheme = "bearer") {
154
+ return ({ req }) => {
155
+ const [first, token, ...rest] = requestedProtocols(req);
156
+ if (first !== scheme || token == null || token === "" || rest.length > 0) {
157
+ throw new UnauthorizedError("Missing bearer subprotocol");
158
+ }
159
+ return { bearerToken: token };
160
+ };
161
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A contract-typed view of a {@link WsConnection} for `c.ws().messages()`
3
+ * sockets: inbound JSON is validated against the client schema, outbound
4
+ * messages are typed (and validated in development).
5
+ */
6
+ import * as v from "valibot";
7
+ import type { AnySchema } from "../core/index.ts";
8
+ import type { WsCloseListener, WsConnection } from "./connection.ts";
9
+ import type { WsMessage } from "./message.ts";
10
+ import { type WsCleanupFn, type WsSendResult } from "./types.ts";
11
+ /** Thrown in development when the server sends something off-contract. */
12
+ export declare class ContractViolationError extends Error {
13
+ readonly issues: v.BaseIssue<unknown>[];
14
+ constructor(what: string, issues: v.BaseIssue<unknown>[]);
15
+ }
16
+ /**
17
+ * Validate an inbound payload. Failure is the *client's* fault → protocol
18
+ * error (`1008`); the reason names the first issue only in development.
19
+ */
20
+ export declare function parseInbound<S extends AnySchema>(schema: S, value: unknown, development: boolean): v.InferOutput<S>;
21
+ /**
22
+ * In development, validate an outbound payload so contract drift surfaces
23
+ * immediately. A no-op in production (types are the guarantee there).
24
+ */
25
+ export declare function checkOutbound(schema: AnySchema | undefined, value: unknown, development: boolean, what: string): void;
26
+ export declare class TypedWsConnection<In, Out> {
27
+ #private;
28
+ /** The untyped connection — pass this to hubs. */
29
+ readonly connection: WsConnection;
30
+ /** @internal Created by contract routes. */
31
+ constructor(connection: WsConnection, schemas: {
32
+ client: AnySchema;
33
+ server: AnySchema;
34
+ }, development: () => boolean);
35
+ get id(): string;
36
+ get req(): Request;
37
+ get protocol(): string | null;
38
+ get closed(): boolean;
39
+ get remoteAddress(): string;
40
+ get bufferedBytes(): number;
41
+ get data(): Record<string, unknown>;
42
+ set data(value: Record<string, unknown>);
43
+ /**
44
+ * Validated inbound messages. Non-JSON or off-contract input closes the
45
+ * socket with `1008` before `fn` runs.
46
+ */
47
+ onMessage(fn: (data: In, message: WsMessage) => unknown): () => void;
48
+ /** JSON-encode and send. Validated against the server schema in development. */
49
+ send(data: Out): WsSendResult;
50
+ close(code?: number, reason?: string): void;
51
+ expireAt(when: Date | number): void;
52
+ onClose(fn: WsCloseListener | WsCleanupFn): void;
53
+ onDrain(fn: () => void): () => void;
54
+ }