@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.
- package/dist/contract/builder.d.ts +9 -4
- package/dist/contract/builder.js +2 -0
- package/dist/contract/index.d.ts +3 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/types.d.ts +9 -1
- package/dist/contract/ws.d.ts +178 -0
- package/dist/contract/ws.js +164 -0
- package/dist/core/compose.js +5 -0
- package/dist/core/error.d.ts +21 -0
- package/dist/core/error.js +50 -7
- package/dist/core/index.d.ts +7 -3
- package/dist/core/index.js +5 -1
- package/dist/core/matcher.js +2 -1
- package/dist/core/route-builder.d.ts +4 -4
- package/dist/core/router.d.ts +16 -2
- package/dist/core/router.js +45 -6
- package/dist/core/transport.d.ts +39 -0
- package/dist/core/transport.js +68 -0
- package/dist/core/types.d.ts +22 -3
- package/dist/core/upgrade.d.ts +85 -0
- package/dist/core/upgrade.js +110 -0
- package/dist/core/utils/url.d.ts +4 -1
- package/dist/core/utils/url.js +14 -3
- package/dist/sse/hub.js +15 -33
- package/dist/sse/types.d.ts +3 -5
- package/dist/static/index.d.ts +27 -0
- package/dist/static/index.js +25 -0
- package/dist/static/negotiate.d.ts +23 -0
- package/dist/static/negotiate.js +85 -0
- package/dist/static/options.d.ts +44 -0
- package/dist/static/options.js +190 -0
- package/dist/static/resolve.d.ts +56 -0
- package/dist/static/resolve.js +113 -0
- package/dist/static/response.d.ts +53 -0
- package/dist/static/response.js +146 -0
- package/dist/static/router.d.ts +59 -0
- package/dist/static/router.js +217 -0
- package/dist/static/rules.d.ts +21 -0
- package/dist/static/rules.js +49 -0
- package/dist/static/types.d.ts +76 -0
- package/dist/static/types.js +0 -0
- package/dist/ws/channels/channel.d.ts +175 -0
- package/dist/ws/channels/channel.js +125 -0
- package/dist/ws/channels/handler.d.ts +44 -0
- package/dist/ws/channels/handler.js +517 -0
- package/dist/ws/channels/protocol.d.ts +96 -0
- package/dist/ws/channels/protocol.js +94 -0
- package/dist/ws/channels/target.d.ts +36 -0
- package/dist/ws/channels/target.js +56 -0
- package/dist/ws/connection.d.ts +56 -0
- package/dist/ws/connection.js +172 -0
- package/dist/ws/helpers.d.ts +17 -0
- package/dist/ws/helpers.js +48 -0
- package/dist/ws/hub.d.ts +190 -0
- package/dist/ws/hub.js +562 -0
- package/dist/ws/index.d.ts +65 -0
- package/dist/ws/index.js +66 -0
- package/dist/ws/lifecycle.d.ts +21 -0
- package/dist/ws/lifecycle.js +97 -0
- package/dist/ws/limits.d.ts +32 -0
- package/dist/ws/limits.js +67 -0
- package/dist/ws/message.d.ts +19 -0
- package/dist/ws/message.js +58 -0
- package/dist/ws/origin.d.ts +40 -0
- package/dist/ws/origin.js +152 -0
- package/dist/ws/presence.d.ts +96 -0
- package/dist/ws/presence.js +420 -0
- package/dist/ws/route-builder.d.ts +136 -0
- package/dist/ws/route-builder.js +265 -0
- package/dist/ws/router.d.ts +21 -0
- package/dist/ws/router.js +36 -0
- package/dist/ws/tickets.d.ts +82 -0
- package/dist/ws/tickets.js +161 -0
- package/dist/ws/typed.d.ts +54 -0
- package/dist/ws/typed.js +104 -0
- package/dist/ws/types.d.ts +69 -0
- package/dist/ws/types.js +70 -0
- package/package.json +14 -2
package/dist/core/router.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/dist/core/router.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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) =>
|
|
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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
|
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?:
|
|
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
|
+
}
|
package/dist/core/utils/url.d.ts
CHANGED
|
@@ -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
|
|
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)`
|
package/dist/core/utils/url.js
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
14
|
-
#transport;
|
|
15
|
-
#unsubscribe;
|
|
14
|
+
#bridge;
|
|
16
15
|
constructor(options = {}) {
|
|
17
|
-
this.#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.#
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
}
|