@fishenv/http 0.0.2 → 0.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 (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 +33 -0
  45. package/dist/ws/channels/handler.js +486 -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 +172 -0
  55. package/dist/ws/hub.js +489 -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
@@ -1,7 +1,8 @@
1
1
  import type { AnyContractBuilder, ContractShape } from "../contract/index.ts";
2
2
  import { type CorsOptions } from "./cors.ts";
3
3
  import { type RouteBuilder, type RouterRef } from "./route-builder.ts";
4
- import type { BasePathParams, DIContainer, ErrorHandlerFn, InputOptions, MiddlewareFn, NotFoundHandler, RouteDefinition } from "./types.ts";
4
+ import type { BasePathParams, BuildEnv, DIContainer, ErrorHandlerFn, InputOptions, MiddlewareFn, NotFoundHandler, RouteDefinition } from "./types.ts";
5
+ import { type WsServerOptions } from "./upgrade.ts";
5
6
  type AnyRouter = Router<any, any>;
6
7
  /**
7
8
  * A router mounted via `.add()`, together with the prefix and middleware of
@@ -86,12 +87,20 @@ export declare class Router<Ctx extends Record<string, unknown> = Record<never,
86
87
  notFound(handler: NotFoundHandler): this;
87
88
  /** @internal Set by serve(). For testing, call before build(). */
88
89
  _setContainer(container: DIContainer): void;
90
+ /** @internal Set by serve() before build(); forwarded to route `onBuild` hooks. */
91
+ _setEnv(env: Partial<Omit<BuildEnv, "cors">>): void;
89
92
  build(): void;
90
93
  /**
91
94
  * Entry point for request handling — available after build().
92
95
  * Use directly for testing without binding a port.
93
96
  */
94
97
  readonly fetch: (req: Request) => Promise<Response>;
98
+ /**
99
+ * @internal Used by serve(): HEAD responses keep their body so Bun can
100
+ * derive `Content-Length` from it before dropping it (it does not drain
101
+ * streams to do so).
102
+ */
103
+ readonly _serverFetch: (req: Request) => Promise<Response>;
95
104
  }
96
105
  /**
97
106
  * Create a router. Pass a context type to declare what a parent must provide
@@ -127,6 +136,8 @@ export interface ServeOptions {
127
136
  * (disabled) so long-lived SSE streams are not cut between keepalives.
128
137
  */
129
138
  idleTimeout?: number;
139
+ /** Server-wide WebSocket settings (secure defaults; see {@link WsServerOptions}). */
140
+ websocket?: WsServerOptions;
130
141
  onListen?: (addr: {
131
142
  hostname: string;
132
143
  port: number;
@@ -136,7 +147,10 @@ export interface ServeOptions {
136
147
  export interface HttpServer {
137
148
  readonly hostname: string;
138
149
  readonly port: number;
139
- /** Stop accepting connections; `force` also closes in-flight ones. */
150
+ /**
151
+ * Stop accepting connections; `force` also closes in-flight ones. Open
152
+ * WebSockets are always closed with `1001 Going Away` first.
153
+ */
140
154
  stop(force?: boolean): Promise<void>;
141
155
  }
142
156
  export interface Logger {
@@ -3,6 +3,7 @@ import { buildCors } from "./cors.js";
3
3
  import { defaultMethodNotAllowedHandler, defaultNotFoundHandler, } from "./error.js";
4
4
  import { Matcher } from "./matcher.js";
5
5
  import { createRouteBuilder, createRouteBuilderFromContract, } from "./route-builder.js";
6
+ import { completeUpgrade, createWebSocketRuntime, } from "./upgrade.js";
6
7
  const stubContainer = {
7
8
  get() {
8
9
  throw new Error("No DI container configured. Pass one via serve() options.");
@@ -28,6 +29,12 @@ export class Router {
28
29
  #errorHandler;
29
30
  #notFoundHandler;
30
31
  #cors = null;
32
+ #corsOptions = null;
33
+ #env = {
34
+ development: false,
35
+ logger: console,
36
+ websocket: {},
37
+ };
31
38
  #built = false;
32
39
  #matcher = null;
33
40
  #container = stubContainer;
@@ -117,6 +124,7 @@ export class Router {
117
124
  // ── CORS ─────────────────────────────────────────────────────────────
118
125
  cors(options) {
119
126
  this.#cors = buildCors(options);
127
+ this.#corsOptions = options;
120
128
  return this;
121
129
  }
122
130
  // ── Error / not-found ────────────────────────────────────────────────
@@ -133,13 +141,19 @@ export class Router {
133
141
  _setContainer(container) {
134
142
  this.#container = container;
135
143
  }
144
+ /** @internal Set by serve() before build(); forwarded to route `onBuild` hooks. */
145
+ _setEnv(env) {
146
+ this.#env = { ...this.#env, ...env };
147
+ }
136
148
  // ── Build ────────────────────────────────────────────────────────────
137
149
  build() {
138
150
  if (this.#built)
139
151
  return; // idempotent
140
152
  const matcher = new Matcher();
141
153
  const allRoutes = this.#collectRoutes();
154
+ const env = { ...this.#env, cors: this.#corsOptions };
142
155
  for (const route of allRoutes) {
156
+ route.onBuild?.(env);
143
157
  matcher.add(route.method, route.path, route);
144
158
  }
145
159
  matcher.compile();
@@ -152,7 +166,14 @@ export class Router {
152
166
  * Entry point for request handling — available after build().
153
167
  * Use directly for testing without binding a port.
154
168
  */
155
- fetch = async (req) => {
169
+ fetch = (req) => this.#handle(req, true);
170
+ /**
171
+ * @internal Used by serve(): HEAD responses keep their body so Bun can
172
+ * derive `Content-Length` from it before dropping it (it does not drain
173
+ * streams to do so).
174
+ */
175
+ _serverFetch = (req) => this.#handle(req, false);
176
+ async #handle(req, stripHeadBody) {
156
177
  if (!this.#built || !this.#matcher) {
157
178
  throw new Error("Router not built. Call .build() first.");
158
179
  }
@@ -198,15 +219,16 @@ export class Router {
198
219
  if (this.#cors) {
199
220
  response = this.#cors.applyTo(req, response);
200
221
  }
201
- // HEAD: strip body
202
- if (isHead) {
222
+ // HEAD: strip body. Cancel it so a stream (e.g. SSE) is not left open.
223
+ if (isHead && stripHeadBody) {
224
+ response.body?.cancel().catch(() => { });
203
225
  return new Response(null, {
204
226
  status: response.status,
205
227
  headers: response.headers,
206
228
  });
207
229
  }
208
230
  return response;
209
- };
231
+ }
210
232
  // ── Internal ─────────────────────────────────────────────────────────
211
233
  /**
212
234
  * Flatten this router's routes plus all mounted children. Child routes are
@@ -267,18 +289,35 @@ app, options) {
267
289
  if (options?.container) {
268
290
  app._setContainer(options.container);
269
291
  }
292
+ app._setEnv({
293
+ development: options?.development ?? false,
294
+ logger: options?.logger ?? console,
295
+ websocket: options?.websocket ?? {},
296
+ });
270
297
  app.build(); // idempotent
298
+ const sockets = createWebSocketRuntime(options?.websocket, options?.logger);
271
299
  const server = Bun.serve({
272
300
  port: options?.port ?? 8000,
273
301
  hostname: options?.hostname ?? "0.0.0.0",
274
302
  development: options?.development ?? false,
275
303
  idleTimeout: options?.idleTimeout ?? 0,
276
- fetch: (req) => app.fetch(req),
304
+ fetch: async (req, server) => completeUpgrade(req, await app._serverFetch(req), server),
305
+ websocket: sockets.websocket,
306
+ // Errors outside the router's error chain (e.g. a `Bun.file` body that
307
+ // fails to open while the response is sent) would otherwise reach Bun's
308
+ // default error page, which exposes messages and paths in development.
309
+ error: (err) => {
310
+ options?.logger?.error("Unhandled error while serving request", err);
311
+ return new Response(null, { status: 500 });
312
+ },
277
313
  });
278
314
  const handle = {
279
315
  hostname: server.hostname ?? options?.hostname ?? "0.0.0.0",
280
316
  port: server.port ?? options?.port ?? 8000,
281
- stop: (force) => server.stop(force),
317
+ stop: (force) => {
318
+ sockets.closeAll();
319
+ return server.stop(force);
320
+ },
282
321
  };
283
322
  options?.signal?.addEventListener("abort", () => void handle.stop(), {
284
323
  once: true,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cross-process fan-out seam shared by `@fishenv/http/sse` and
3
+ * `@fishenv/http/ws`. One adapter (Redis pub/sub, NATS, Postgres LISTEN…)
4
+ * serves both.
5
+ */
6
+ import type { Logger } from "./router.ts";
7
+ /** Every message carries the node that published it, for echo suppression. */
8
+ export interface TransportEnvelope {
9
+ origin: string;
10
+ }
11
+ /** Messages must be JSON-serializable. */
12
+ export interface Transport<M extends TransportEnvelope = TransportEnvelope> {
13
+ publish(message: M): void | Promise<void>;
14
+ subscribe(onMessage: (message: M) => void): () => void;
15
+ }
16
+ /** `Omit` that preserves discriminated unions. */
17
+ export type WithoutOrigin<M> = M extends unknown ? Omit<M, "origin"> : never;
18
+ export interface TransportBridge<M extends TransportEnvelope> {
19
+ /** Random per-process id stamped into `origin`. */
20
+ readonly nodeId: string;
21
+ /** Stamp `origin` and publish; failures are logged, never thrown. */
22
+ publish(message: WithoutOrigin<M>): void;
23
+ /** Detach from the transport. Idempotent. */
24
+ close(): void;
25
+ }
26
+ /**
27
+ * Wrap a (possibly absent) transport with the bookkeeping every hub needs:
28
+ * a node id, dropping our own echoes, and logging publish failures so a
29
+ * flaky broker can never break a local send.
30
+ */
31
+ export declare function createTransportBridge<M extends TransportEnvelope>(transport: Transport<M> | undefined, onRemote: (message: M) => void, opts?: {
32
+ label?: string;
33
+ logger?: Pick<Logger, "error">;
34
+ }): TransportBridge<M>;
35
+ /**
36
+ * In-process transport: every subscriber sees every message. Useful for
37
+ * tests and for wiring several hubs together in one process.
38
+ */
39
+ export declare function createMemoryTransport<M extends TransportEnvelope = TransportEnvelope>(): Transport<M>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Cross-process fan-out seam shared by `@fishenv/http/sse` and
3
+ * `@fishenv/http/ws`. One adapter (Redis pub/sub, NATS, Postgres LISTEN…)
4
+ * serves both.
5
+ */
6
+ /**
7
+ * Wrap a (possibly absent) transport with the bookkeeping every hub needs:
8
+ * a node id, dropping our own echoes, and logging publish failures so a
9
+ * flaky broker can never break a local send.
10
+ */
11
+ export function createTransportBridge(transport, onRemote, opts = {}) {
12
+ const nodeId = crypto.randomUUID();
13
+ const label = opts.label ?? "[fishenv.http]";
14
+ const logger = opts.logger ?? console;
15
+ const fail = (err) => logger.error(`${label} transport publish failed:`, err);
16
+ let unsubscribe = transport?.subscribe((message) => {
17
+ if (message.origin === nodeId)
18
+ return;
19
+ try {
20
+ onRemote(message);
21
+ }
22
+ catch (err) {
23
+ logger.error(`${label} transport message handler failed:`, err);
24
+ }
25
+ });
26
+ return {
27
+ nodeId,
28
+ publish(message) {
29
+ if (transport == null)
30
+ return;
31
+ try {
32
+ const result = transport.publish({
33
+ ...message,
34
+ origin: nodeId,
35
+ });
36
+ if (result instanceof Promise)
37
+ result.catch(fail);
38
+ }
39
+ catch (err) {
40
+ fail(err);
41
+ }
42
+ },
43
+ close() {
44
+ unsubscribe?.();
45
+ unsubscribe = undefined;
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * In-process transport: every subscriber sees every message. Useful for
51
+ * tests and for wiring several hubs together in one process.
52
+ */
53
+ export function createMemoryTransport() {
54
+ const subscribers = new Set();
55
+ return {
56
+ publish(message) {
57
+ // Clone through JSON to catch non-serializable payloads early, like a
58
+ // real broker would.
59
+ const copy = JSON.parse(JSON.stringify(message));
60
+ for (const fn of [...subscribers])
61
+ fn(copy);
62
+ },
63
+ subscribe(fn) {
64
+ subscribers.add(fn);
65
+ return () => subscribers.delete(fn);
66
+ },
67
+ };
68
+ }
@@ -6,7 +6,10 @@
6
6
  * `router.post(path).input(...).output(...).handle(...)`.
7
7
  */
8
8
  export type { AnySchema, BasePathParams, ExtractParams, HttpMethod, InferBody, InferCookies, InferHeaders, InferOutput, InferQuery, InputKind, InputOptions, MergeParam, RouteMeta, ValibotSchema, } from "../contract/index.ts";
9
- import type { AnySchema, HttpMethod, InferBody, InferCookies, InferHeaders, InferQuery, InputKind, InputOptions, RouteMeta } from "../contract/index.ts";
9
+ import type { AnySchema, ErrorTypes, HttpMethod, InferBody, InferCookies, InferHeaders, InferQuery, InputKind, InputOptions, RouteMeta } from "../contract/index.ts";
10
+ import type { CorsOptions } from "./cors.ts";
11
+ import type { Logger } from "./router.ts";
12
+ import type { WsServerOptions } from "./upgrade.ts";
10
13
  export interface ResolvedInput<K extends InputKind, O extends InputOptions<K>> {
11
14
  kind: K;
12
15
  options: O;
@@ -40,7 +43,7 @@ export interface HandlerArgs<Ctx extends Record<string, unknown>, K extends Inpu
40
43
  cookies: InferCookies<O>;
41
44
  container: DIContainer;
42
45
  }
43
- export type HandlerFn<Ctx extends Record<string, unknown>, K extends InputKind, O extends InputOptions<K>, Params extends Record<string, unknown>, Output> = (args: HandlerArgs<Ctx, K, O, Params>) => Output extends unknown ? Response | Promise<Response> : Response | Output | Promise<Response | Output>;
46
+ export type HandlerFn<Ctx extends Record<string, unknown>, K extends InputKind, O extends InputOptions<K>, Params extends Record<string, unknown>, Output> = (args: HandlerArgs<Ctx, K, O, Params>) => [Output] extends [undefined] ? Response | Promise<Response> : Response | Output | Promise<Response | Output>;
44
47
  export type ErrorHandlerFn = (err: unknown, args: {
45
48
  req: Request;
46
49
  }) => Response | Promise<Response> | null | undefined | void;
@@ -52,15 +55,31 @@ export interface RouteDefinition {
52
55
  inputKind: InputKind;
53
56
  inputOptions: InputOptions<InputKind>;
54
57
  outputSchema?: AnySchema;
55
- errorTypes?: (new (...args: unknown[]) => Error)[];
58
+ errorTypes?: ErrorTypes;
56
59
  meta?: RouteMeta;
57
60
  interceptors: InterceptorPair<Record<string, unknown>>[];
58
61
  handler: HandlerFn<Record<string, unknown>, InputKind, InputOptions<InputKind>, Record<string, unknown>, unknown>;
59
62
  errorHandler?: ErrorHandlerFn;
60
63
  /** onError handlers of mounted routers, innermost first. Set at build(). */
61
64
  routerErrorHandlers?: ErrorHandlerFn[];
65
+ /**
66
+ * Run before *any* middleware (router-level included); throw to reject.
67
+ * Errors go through the normal error chain.
68
+ */
69
+ guards?: RouteGuardFn[];
70
+ /** Called once per route at `build()` with root-level configuration. */
71
+ onBuild?: (env: BuildEnv) => void;
62
72
  kind: "http" | "ws" | "sse";
63
73
  }
74
+ export type RouteGuardFn = (req: Request) => void | Promise<void>;
75
+ /** Root-level configuration handed to {@link RouteDefinition.onBuild}. */
76
+ export interface BuildEnv {
77
+ /** The root router's `cors()` options, if configured. */
78
+ cors: CorsOptions | null;
79
+ development: boolean;
80
+ logger: Logger;
81
+ websocket: WsServerOptions;
82
+ }
64
83
  export interface DIContainer {
65
84
  get<T>(token: abstract new (...args: any[]) => T): T;
66
85
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Generic WebSocket upgrade plumbing for Bun — deliberately protocol-agnostic
3
+ * so core never depends on `@fishenv/http/ws`.
4
+ *
5
+ * Bun upgrades from inside `fetch(req, server)`, but route handlers only see
6
+ * `req` and must return a `Response`. A handler therefore *requests* an
7
+ * upgrade (keyed by the `Request`, which survives CORS/HEAD response cloning)
8
+ * and returns a placeholder; `serve()` — or {@link completeUpgrade} for a
9
+ * hand-rolled `Bun.serve` — performs the actual upgrade.
10
+ */
11
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
12
+ import type { Logger } from "./router.ts";
13
+ export type UpgradedSocket = ServerWebSocket<SocketData>;
14
+ /** Per-socket lifecycle callbacks, dispatched through `ws.data`. */
15
+ export interface SocketHandlers {
16
+ open?(ws: UpgradedSocket): void | Promise<void>;
17
+ message?(ws: UpgradedSocket, message: string | Buffer): void | Promise<void>;
18
+ close?(ws: UpgradedSocket, code: number, reason: string): void | Promise<void>;
19
+ drain?(ws: UpgradedSocket): void;
20
+ }
21
+ export interface SocketData {
22
+ readonly handlers: SocketHandlers;
23
+ /** The server that owns this socket — needed for native `publish()`. */
24
+ readonly server: Server<SocketData>;
25
+ /** Owned by whoever requested the upgrade. */
26
+ state: unknown;
27
+ }
28
+ export interface UpgradeIntent {
29
+ handlers: SocketHandlers;
30
+ state?: unknown;
31
+ /** Extra handshake response headers, e.g. `Sec-WebSocket-Protocol`. */
32
+ headers?: HeadersInit;
33
+ }
34
+ /**
35
+ * Ask the server to upgrade `req` once the response pipeline finishes.
36
+ *
37
+ * The returned `426` is only ever seen when no server performs the upgrade —
38
+ * e.g. when calling `router.fetch()` directly in a unit test.
39
+ */
40
+ export declare function requestUpgrade(req: Request, intent: UpgradeIntent): Response;
41
+ /** Remove and return the pending upgrade for `req`, if any. */
42
+ export declare function takeUpgrade(req: Request): UpgradeIntent | undefined;
43
+ /**
44
+ * Finish a request inside Bun's `fetch(req, server)`: performs a pending
45
+ * upgrade (returning `undefined`, as Bun requires) or passes `res` through.
46
+ */
47
+ export declare function completeUpgrade(req: Request, res: Response, server: Server<SocketData>): Response | undefined;
48
+ /**
49
+ * Server-global socket settings. Bun's own defaults are permissive (16 MB
50
+ * frames and send buffers, slow consumers never dropped); these are not.
51
+ */
52
+ export interface WsServerOptions {
53
+ /** Max inbound message size in bytes. Default 64 KiB. */
54
+ maxPayloadLength?: number;
55
+ /** Max bytes buffered per socket before backpressure. Default 1 MiB. */
56
+ backpressureLimit?: number;
57
+ /** Close sockets that exceed `backpressureLimit`. Default `true`. */
58
+ closeOnBackpressureLimit?: boolean;
59
+ /** Seconds without traffic before a socket is closed. Default 120. */
60
+ idleTimeout?: number;
61
+ /** Send protocol pings so idle-but-alive sockets stay open. Default `true`. */
62
+ sendPings?: boolean;
63
+ /** `permessage-deflate`. Default `false` (decompression-bomb surface). */
64
+ perMessageDeflate?: boolean;
65
+ }
66
+ export declare const DEFAULT_MAX_PAYLOAD_LENGTH: number;
67
+ export declare const DEFAULT_BACKPRESSURE_LIMIT: number;
68
+ /** `1001 Going Away` — sent to every socket on graceful shutdown. */
69
+ export declare const CLOSE_GOING_AWAY = 1001;
70
+ /** `1011 Internal Error`. */
71
+ export declare const CLOSE_INTERNAL_ERROR = 1011;
72
+ export interface WebSocketRuntime {
73
+ /** Pass as `websocket` to `Bun.serve`. */
74
+ readonly websocket: WebSocketHandler<SocketData>;
75
+ /** Number of sockets currently open on this server. */
76
+ readonly size: number;
77
+ /** Close every open socket, e.g. before `server.stop()`. */
78
+ closeAll(code?: number, reason?: string): void;
79
+ }
80
+ /**
81
+ * Build the single Bun `websocket` handler that dispatches every event to the
82
+ * per-socket {@link SocketHandlers}. Also a last-resort guard: a handler that
83
+ * throws or rejects is logged and its socket closed with `1011`.
84
+ */
85
+ export declare function createWebSocketRuntime(options?: WsServerOptions, logger?: Pick<Logger, "error">): WebSocketRuntime;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Generic WebSocket upgrade plumbing for Bun — deliberately protocol-agnostic
3
+ * so core never depends on `@fishenv/http/ws`.
4
+ *
5
+ * Bun upgrades from inside `fetch(req, server)`, but route handlers only see
6
+ * `req` and must return a `Response`. A handler therefore *requests* an
7
+ * upgrade (keyed by the `Request`, which survives CORS/HEAD response cloning)
8
+ * and returns a placeholder; `serve()` — or {@link completeUpgrade} for a
9
+ * hand-rolled `Bun.serve` — performs the actual upgrade.
10
+ */
11
+ const pending = new WeakMap();
12
+ /**
13
+ * Ask the server to upgrade `req` once the response pipeline finishes.
14
+ *
15
+ * The returned `426` is only ever seen when no server performs the upgrade —
16
+ * e.g. when calling `router.fetch()` directly in a unit test.
17
+ */
18
+ export function requestUpgrade(req, intent) {
19
+ pending.set(req, intent);
20
+ return new Response("Upgrade Required", {
21
+ status: 426,
22
+ headers: { Upgrade: "websocket", Connection: "Upgrade" },
23
+ });
24
+ }
25
+ /** Remove and return the pending upgrade for `req`, if any. */
26
+ export function takeUpgrade(req) {
27
+ const intent = pending.get(req);
28
+ if (intent != null)
29
+ pending.delete(req);
30
+ return intent;
31
+ }
32
+ /**
33
+ * Finish a request inside Bun's `fetch(req, server)`: performs a pending
34
+ * upgrade (returning `undefined`, as Bun requires) or passes `res` through.
35
+ */
36
+ export function completeUpgrade(req, res, server) {
37
+ const intent = takeUpgrade(req);
38
+ if (intent == null)
39
+ return res;
40
+ const upgraded = server.upgrade(req, {
41
+ data: { handlers: intent.handlers, server, state: intent.state },
42
+ headers: intent.headers,
43
+ });
44
+ if (upgraded)
45
+ return undefined;
46
+ return new Response("WebSocket upgrade failed", { status: 400 });
47
+ }
48
+ export const DEFAULT_MAX_PAYLOAD_LENGTH = 64 * 1024;
49
+ export const DEFAULT_BACKPRESSURE_LIMIT = 1024 * 1024;
50
+ /** `1001 Going Away` — sent to every socket on graceful shutdown. */
51
+ export const CLOSE_GOING_AWAY = 1001;
52
+ /** `1011 Internal Error`. */
53
+ export const CLOSE_INTERNAL_ERROR = 1011;
54
+ /**
55
+ * Build the single Bun `websocket` handler that dispatches every event to the
56
+ * per-socket {@link SocketHandlers}. Also a last-resort guard: a handler that
57
+ * throws or rejects is logged and its socket closed with `1011`.
58
+ */
59
+ export function createWebSocketRuntime(options = {}, logger) {
60
+ const open = new Set();
61
+ const log = logger ?? console;
62
+ const guard = (ws, event, run) => {
63
+ const fail = (err) => {
64
+ log.error(`[fishenv.http] WebSocket ${event} handler failed:`, err);
65
+ if (event !== "close")
66
+ ws.close(CLOSE_INTERNAL_ERROR, "Internal Error");
67
+ };
68
+ try {
69
+ const result = run();
70
+ if (result instanceof Promise)
71
+ result.catch(fail);
72
+ }
73
+ catch (err) {
74
+ fail(err);
75
+ }
76
+ };
77
+ const websocket = {
78
+ open(ws) {
79
+ open.add(ws);
80
+ guard(ws, "open", () => ws.data.handlers.open?.(ws));
81
+ },
82
+ message(ws, message) {
83
+ guard(ws, "message", () => ws.data.handlers.message?.(ws, message));
84
+ },
85
+ close(ws, code, reason) {
86
+ open.delete(ws);
87
+ guard(ws, "close", () => ws.data.handlers.close?.(ws, code, reason));
88
+ },
89
+ drain(ws) {
90
+ guard(ws, "drain", () => ws.data.handlers.drain?.(ws));
91
+ },
92
+ maxPayloadLength: options.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH,
93
+ backpressureLimit: options.backpressureLimit ?? DEFAULT_BACKPRESSURE_LIMIT,
94
+ closeOnBackpressureLimit: options.closeOnBackpressureLimit ?? true,
95
+ idleTimeout: options.idleTimeout ?? 120,
96
+ sendPings: options.sendPings ?? true,
97
+ perMessageDeflate: options.perMessageDeflate ?? false,
98
+ publishToSelf: false,
99
+ };
100
+ return {
101
+ websocket,
102
+ get size() {
103
+ return open.size;
104
+ },
105
+ closeAll(code = CLOSE_GOING_AWAY, reason = "Server shutting down") {
106
+ for (const ws of [...open])
107
+ ws.close(code, reason);
108
+ },
109
+ };
110
+ }
@@ -5,7 +5,7 @@
5
5
  export declare function mergePaths(base: string, sub: string): string;
6
6
  /**
7
7
  * Extract all named param names from a pattern (runtime, not type-level).
8
- * E.g. "/users/:id/posts/:pid?" → ["id", "pid"]
8
+ * E.g. "/users/:id/posts/:pid?/:rest*" → ["id", "pid", "rest"]
9
9
  */
10
10
  export declare function extractParamNames(pattern: string): string[];
11
11
  /**
@@ -14,6 +14,9 @@ export declare function extractParamNames(pattern: string): string[];
14
14
  * Rules:
15
15
  * - `:name` → named capture `([^/]+)`
16
16
  * - `:name?` → optional named capture `(?:/([^/]+))?`
17
+ * - `:name*` → rest capture `(?:/(.*))?`, last segment only. The captured
18
+ * remainder is raw (percent-encoded) and keeps a trailing slash;
19
+ * the param is absent when nothing follows the prefix.
17
20
  * - `*` → glob within a segment (`[^/]*`)
18
21
  * - `**` → glob across path segments
19
22
  * - `{a,b}` → alternation `(?:a|b)`
@@ -9,13 +9,13 @@ export function mergePaths(base, sub) {
9
9
  }
10
10
  /**
11
11
  * Extract all named param names from a pattern (runtime, not type-level).
12
- * E.g. "/users/:id/posts/:pid?" → ["id", "pid"]
12
+ * E.g. "/users/:id/posts/:pid?/:rest*" → ["id", "pid", "rest"]
13
13
  */
14
14
  export function extractParamNames(pattern) {
15
15
  const names = [];
16
16
  for (const segment of pattern.split("/")) {
17
17
  if (segment.startsWith(":")) {
18
- names.push(segment.slice(1).replace(/\?$/, ""));
18
+ names.push(segment.slice(1).replace(/[?*]$/, ""));
19
19
  }
20
20
  }
21
21
  return names;
@@ -26,6 +26,9 @@ export function extractParamNames(pattern) {
26
26
  * Rules:
27
27
  * - `:name` → named capture `([^/]+)`
28
28
  * - `:name?` → optional named capture `(?:/([^/]+))?`
29
+ * - `:name*` → rest capture `(?:/(.*))?`, last segment only. The captured
30
+ * remainder is raw (percent-encoded) and keeps a trailing slash;
31
+ * the param is absent when nothing follows the prefix.
29
32
  * - `*` → glob within a segment (`[^/]*`)
30
33
  * - `**` → glob across path segments
31
34
  * - `{a,b}` → alternation `(?:a|b)`
@@ -34,7 +37,15 @@ export function buildRouteRegex(pattern) {
34
37
  const paramNames = [];
35
38
  const segments = pattern.split("/").filter((s) => s !== "");
36
39
  let regexStr = "";
37
- for (const seg of segments) {
40
+ for (const [i, seg] of segments.entries()) {
41
+ if (seg.startsWith(":") && seg.endsWith("*")) {
42
+ if (i !== segments.length - 1) {
43
+ throw new Error(`Rest param "${seg}" must be the last segment of "${pattern}"`);
44
+ }
45
+ paramNames.push(seg.slice(1, -1));
46
+ regexStr += `(?:/(.*))?`;
47
+ continue;
48
+ }
38
49
  if (seg.startsWith(":")) {
39
50
  const isOptional = seg.endsWith("?");
40
51
  const name = seg.slice(1).replace(/\?$/, "");
package/dist/sse/hub.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Keyed registry of live connections — the seam for targeted (non-broadcast) sends. */
2
+ import { createTransportBridge, } from "../core/transport.js";
2
3
  /**
3
4
  * Connections keyed by an application identifier (user id, tenant id, document
4
5
  * id…). One key maps to many connections.
@@ -10,20 +11,13 @@
10
11
  */
11
12
  export class SseHub {
12
13
  #byKey = new Map();
13
- #nodeId = crypto.randomUUID();
14
- #transport;
15
- #unsubscribe;
14
+ #bridge;
16
15
  constructor(options = {}) {
17
- this.#transport = options.transport;
18
- this.#unsubscribe = options.transport?.subscribe((message) => {
19
- if (message.origin === this.#nodeId)
20
- return;
21
- this.#deliver(message.key, message.data, {
22
- event: message.event,
23
- id: message.id,
24
- retry: message.retry,
25
- });
26
- });
16
+ this.#bridge = createTransportBridge(options.transport, (message) => this.#deliver(message.key, message.data, {
17
+ event: message.event,
18
+ id: message.id,
19
+ retry: message.retry,
20
+ }), { label: "[fishenv.sse]" });
27
21
  }
28
22
  /** Register a connection. Returns an unregister fn; also auto-removes on close. */
29
23
  add(key, connection) {
@@ -78,8 +72,7 @@ export class SseHub {
78
72
  connection.close();
79
73
  }
80
74
  this.#byKey.clear();
81
- this.#unsubscribe?.();
82
- this.#unsubscribe = undefined;
75
+ this.#bridge.close();
83
76
  }
84
77
  has(key) {
85
78
  return (this.#byKey.get(key)?.size ?? 0) > 0;
@@ -111,23 +104,12 @@ export class SseHub {
111
104
  return delivered;
112
105
  }
113
106
  #publish(key, data, opts) {
114
- if (this.#transport == null)
115
- return;
116
- try {
117
- const result = this.#transport.publish({
118
- key,
119
- data,
120
- event: opts?.event,
121
- id: opts?.id,
122
- retry: opts?.retry,
123
- origin: this.#nodeId,
124
- });
125
- if (result instanceof Promise) {
126
- result.catch((err) => console.error("[fishenv.sse] transport publish failed:", err));
127
- }
128
- }
129
- catch (err) {
130
- console.error("[fishenv.sse] transport publish failed:", err);
131
- }
107
+ this.#bridge.publish({
108
+ key,
109
+ data,
110
+ event: opts?.event,
111
+ id: opts?.id,
112
+ retry: opts?.retry,
113
+ });
132
114
  }
133
115
  }