@fishenv/http 0.0.0-stage → 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.
- package/dist/contract/builder.d.ts +87 -0
- package/dist/contract/builder.js +89 -0
- package/dist/contract/index.d.ts +4 -0
- package/dist/contract/index.js +2 -0
- package/dist/contract/types.d.ts +47 -0
- package/dist/contract/types.js +0 -0
- package/dist/contract/ws.d.ts +178 -0
- package/dist/contract/ws.js +164 -0
- package/dist/core/compose.d.ts +22 -0
- package/dist/core/compose.js +87 -0
- package/dist/core/cors.d.ts +38 -0
- package/dist/core/cors.js +96 -0
- package/dist/core/error.d.ts +73 -0
- package/dist/core/error.js +142 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +23 -0
- package/dist/core/matcher.d.ts +17 -0
- package/dist/core/matcher.js +110 -0
- package/dist/core/request.d.ts +28 -0
- package/dist/core/request.js +106 -0
- package/dist/core/route-builder.d.ts +58 -0
- package/dist/core/route-builder.js +122 -0
- package/dist/core/router.d.ts +162 -0
- package/dist/core/router.js +327 -0
- package/dist/core/transport.d.ts +39 -0
- package/dist/core/transport.js +68 -0
- package/dist/core/types.d.ts +86 -0
- package/dist/core/types.js +0 -0
- package/dist/core/upgrade.d.ts +85 -0
- package/dist/core/upgrade.js +110 -0
- package/dist/core/utils/headers.d.ts +1 -0
- package/dist/core/utils/headers.js +2 -0
- package/dist/core/utils/url.d.ts +27 -0
- package/dist/core/utils/url.js +130 -0
- package/dist/core/validation.d.ts +13 -0
- package/dist/core/validation.js +24 -0
- package/dist/sse/connection.d.ts +30 -0
- package/dist/sse/connection.js +104 -0
- package/dist/sse/encode.d.ts +10 -0
- package/dist/sse/encode.js +27 -0
- package/dist/sse/hub.d.ts +33 -0
- package/dist/sse/hub.js +115 -0
- package/dist/sse/index.d.ts +38 -0
- package/dist/sse/index.js +40 -0
- package/dist/sse/response.d.ts +10 -0
- package/dist/sse/response.js +50 -0
- package/dist/sse/route-builder.d.ts +44 -0
- package/dist/sse/route-builder.js +65 -0
- package/dist/sse/router.d.ts +11 -0
- package/dist/sse/router.js +16 -0
- package/dist/sse/types.d.ts +43 -0
- package/dist/sse/types.js +4 -0
- 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 +33 -0
- package/dist/ws/channels/handler.js +486 -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 +172 -0
- package/dist/ws/hub.js +489 -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 +54 -4
- package/README.md +0 -3
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { dispatch } from "./compose.js";
|
|
2
|
+
import { buildCors } from "./cors.js";
|
|
3
|
+
import { defaultMethodNotAllowedHandler, defaultNotFoundHandler, } from "./error.js";
|
|
4
|
+
import { Matcher } from "./matcher.js";
|
|
5
|
+
import { createRouteBuilder, createRouteBuilderFromContract, } from "./route-builder.js";
|
|
6
|
+
import { completeUpgrade, createWebSocketRuntime, } from "./upgrade.js";
|
|
7
|
+
const stubContainer = {
|
|
8
|
+
get() {
|
|
9
|
+
throw new Error("No DI container configured. Pass one via serve() options.");
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Router — immutable middleware chain, mutable route registration.
|
|
14
|
+
*
|
|
15
|
+
* `.use()` returns a new Router with the extended Ctx type but the same
|
|
16
|
+
* backing store — routes registered on either instance are collected together.
|
|
17
|
+
* Route methods (.get, .post, etc.) return a RouteBuilder chain.
|
|
18
|
+
* Call `.build()` before `.fetch` or `serve()`.
|
|
19
|
+
*
|
|
20
|
+
* `Requires` is the context this router expects a parent to provide when it
|
|
21
|
+
* is mounted via `.add()`. Create one with `r<AppCtx>()`; `.add()` only
|
|
22
|
+
* accepts it on a parent whose `Ctx` satisfies `AppCtx`, and `serve()` only
|
|
23
|
+
* accepts routers with no outstanding requirements.
|
|
24
|
+
*/
|
|
25
|
+
export class Router {
|
|
26
|
+
#prefix;
|
|
27
|
+
#middlewares;
|
|
28
|
+
#store;
|
|
29
|
+
#errorHandler;
|
|
30
|
+
#notFoundHandler;
|
|
31
|
+
#cors = null;
|
|
32
|
+
#corsOptions = null;
|
|
33
|
+
#env = {
|
|
34
|
+
development: false,
|
|
35
|
+
logger: console,
|
|
36
|
+
websocket: {},
|
|
37
|
+
};
|
|
38
|
+
#built = false;
|
|
39
|
+
#matcher = null;
|
|
40
|
+
#container = stubContainer;
|
|
41
|
+
constructor(opts, middlewares, errorHandler, notFoundHandler, store) {
|
|
42
|
+
this.#prefix = opts?.prefix ?? "";
|
|
43
|
+
this.#middlewares = middlewares ?? [];
|
|
44
|
+
this.#errorHandler = errorHandler;
|
|
45
|
+
this.#notFoundHandler = notFoundHandler;
|
|
46
|
+
this.#store = store ?? { routes: [], children: [], locked: false };
|
|
47
|
+
}
|
|
48
|
+
// ── RouterRef implementation ─────────────────────────────────────────
|
|
49
|
+
_registerRoute(def) {
|
|
50
|
+
if (this.#store.locked) {
|
|
51
|
+
throw new Error("Cannot add routes after build()");
|
|
52
|
+
}
|
|
53
|
+
this.#store.routes.push(def);
|
|
54
|
+
}
|
|
55
|
+
_getMiddlewares() {
|
|
56
|
+
return [...this.#middlewares];
|
|
57
|
+
}
|
|
58
|
+
_getPrefix() {
|
|
59
|
+
return this.#prefix;
|
|
60
|
+
}
|
|
61
|
+
// ── Middleware ────────────────────────────────────────────────────────
|
|
62
|
+
use(mw) {
|
|
63
|
+
return new Router({ prefix: this.#prefix }, [
|
|
64
|
+
...this.#middlewares,
|
|
65
|
+
mw,
|
|
66
|
+
], this.#errorHandler, this.#notFoundHandler, this.#store);
|
|
67
|
+
}
|
|
68
|
+
// ── Sub-router ───────────────────────────────────────────────────────
|
|
69
|
+
/** Create a child router and mount it on this one — shorthand for `r<Ctx>(opts)` + `.add()`. */
|
|
70
|
+
extend(opts) {
|
|
71
|
+
const child = new Router(opts);
|
|
72
|
+
this.add(child);
|
|
73
|
+
return child;
|
|
74
|
+
}
|
|
75
|
+
add(...args) {
|
|
76
|
+
if (this.#store.locked) {
|
|
77
|
+
throw new Error("Cannot add routers after build()");
|
|
78
|
+
}
|
|
79
|
+
const extra = typeof args[0] === "string" ? args.shift() : "";
|
|
80
|
+
for (const child of args) {
|
|
81
|
+
this.#store.children.push({
|
|
82
|
+
router: child,
|
|
83
|
+
prefix: this.#prefix + extra,
|
|
84
|
+
middlewares: [...this.#middlewares],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
// ── Route methods ────────────────────────────────────────────────────
|
|
90
|
+
get(path) {
|
|
91
|
+
return createRouteBuilder(this, "GET", path);
|
|
92
|
+
}
|
|
93
|
+
post(path) {
|
|
94
|
+
return createRouteBuilder(this, "POST", path);
|
|
95
|
+
}
|
|
96
|
+
put(path) {
|
|
97
|
+
return createRouteBuilder(this, "PUT", path);
|
|
98
|
+
}
|
|
99
|
+
patch(path) {
|
|
100
|
+
return createRouteBuilder(this, "PATCH", path);
|
|
101
|
+
}
|
|
102
|
+
delete(path) {
|
|
103
|
+
return createRouteBuilder(this, "DELETE", path);
|
|
104
|
+
}
|
|
105
|
+
options(path) {
|
|
106
|
+
return createRouteBuilder(this, "OPTIONS", path);
|
|
107
|
+
}
|
|
108
|
+
// ── Contract-driven route ────────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* Register a route from a `@fishenv/http-contract` endpoint definition, e.g.:
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* router.contract(contract.users.create).handle(({ body }) => { ... });
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* `.handle()` gets exactly the same type safety (body/query/params/output)
|
|
117
|
+
* as chaining `.input().output().param()` manually, because the contract
|
|
118
|
+
* endpoint and the route builder share the same shape types.
|
|
119
|
+
*/
|
|
120
|
+
contract(endpoint) {
|
|
121
|
+
return createRouteBuilderFromContract(this, endpoint);
|
|
122
|
+
}
|
|
123
|
+
// ── Error / not-found ────────────────────────────────────────────────
|
|
124
|
+
// ── CORS ─────────────────────────────────────────────────────────────
|
|
125
|
+
cors(options) {
|
|
126
|
+
this.#cors = buildCors(options);
|
|
127
|
+
this.#corsOptions = options;
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
// ── Error / not-found ────────────────────────────────────────────────
|
|
131
|
+
onError(handler) {
|
|
132
|
+
this.#errorHandler = handler;
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
notFound(handler) {
|
|
136
|
+
this.#notFoundHandler = handler;
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
// ── Container ────────────────────────────────────────────────────────
|
|
140
|
+
/** @internal Set by serve(). For testing, call before build(). */
|
|
141
|
+
_setContainer(container) {
|
|
142
|
+
this.#container = container;
|
|
143
|
+
}
|
|
144
|
+
/** @internal Set by serve() before build(); forwarded to route `onBuild` hooks. */
|
|
145
|
+
_setEnv(env) {
|
|
146
|
+
this.#env = { ...this.#env, ...env };
|
|
147
|
+
}
|
|
148
|
+
// ── Build ────────────────────────────────────────────────────────────
|
|
149
|
+
build() {
|
|
150
|
+
if (this.#built)
|
|
151
|
+
return; // idempotent
|
|
152
|
+
const matcher = new Matcher();
|
|
153
|
+
const allRoutes = this.#collectRoutes();
|
|
154
|
+
const env = { ...this.#env, cors: this.#corsOptions };
|
|
155
|
+
for (const route of allRoutes) {
|
|
156
|
+
route.onBuild?.(env);
|
|
157
|
+
matcher.add(route.method, route.path, route);
|
|
158
|
+
}
|
|
159
|
+
matcher.compile();
|
|
160
|
+
this.#matcher = matcher;
|
|
161
|
+
this.#built = true;
|
|
162
|
+
this.#store.locked = true;
|
|
163
|
+
}
|
|
164
|
+
// ── Fetch ────────────────────────────────────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* Entry point for request handling — available after build().
|
|
167
|
+
* Use directly for testing without binding a port.
|
|
168
|
+
*/
|
|
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) {
|
|
177
|
+
if (!this.#built || !this.#matcher) {
|
|
178
|
+
throw new Error("Router not built. Call .build() first.");
|
|
179
|
+
}
|
|
180
|
+
const url = new URL(req.url);
|
|
181
|
+
const method = req.method.toUpperCase();
|
|
182
|
+
const isHead = method === "HEAD";
|
|
183
|
+
// CORS preflight — short-circuit before route matching
|
|
184
|
+
if (method === "OPTIONS" && this.#cors) {
|
|
185
|
+
return this.#cors.preflight(req);
|
|
186
|
+
}
|
|
187
|
+
// Try exact method match, then HEAD→GET fallback
|
|
188
|
+
let matched = this.#matcher.match(method, url.pathname);
|
|
189
|
+
if (isHead && (!matched || matched.allowedMethods)) {
|
|
190
|
+
const getMatch = this.#matcher.match("GET", url.pathname);
|
|
191
|
+
if (getMatch && !getMatch.allowedMethods) {
|
|
192
|
+
matched = getMatch;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!matched) {
|
|
196
|
+
const notFound = this.#notFoundHandler ?? defaultNotFoundHandler;
|
|
197
|
+
return notFound(req);
|
|
198
|
+
}
|
|
199
|
+
// 405 — path matched but method didn't
|
|
200
|
+
if (matched.allowedMethods) {
|
|
201
|
+
const allowed = isHead && !matched.allowedMethods.includes("HEAD")
|
|
202
|
+
? [...matched.allowedMethods, "HEAD"]
|
|
203
|
+
: matched.allowedMethods;
|
|
204
|
+
return defaultMethodNotAllowedHandler(req, allowed);
|
|
205
|
+
}
|
|
206
|
+
// Build error handler chain: route catch → router onError → default
|
|
207
|
+
const errorHandlers = [];
|
|
208
|
+
if (matched.definition.errorHandler) {
|
|
209
|
+
errorHandlers.push(matched.definition.errorHandler);
|
|
210
|
+
}
|
|
211
|
+
if (matched.definition.routerErrorHandlers) {
|
|
212
|
+
errorHandlers.push(...matched.definition.routerErrorHandlers);
|
|
213
|
+
}
|
|
214
|
+
if (this.#errorHandler) {
|
|
215
|
+
errorHandlers.push(this.#errorHandler);
|
|
216
|
+
}
|
|
217
|
+
let response = await dispatch(req, matched.definition, matched.params, this.#container, errorHandlers);
|
|
218
|
+
// Inject CORS headers on every response
|
|
219
|
+
if (this.#cors) {
|
|
220
|
+
response = this.#cors.applyTo(req, response);
|
|
221
|
+
}
|
|
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(() => { });
|
|
225
|
+
return new Response(null, {
|
|
226
|
+
status: response.status,
|
|
227
|
+
headers: response.headers,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return response;
|
|
231
|
+
}
|
|
232
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
233
|
+
/**
|
|
234
|
+
* Flatten this router's routes plus all mounted children. Child routes are
|
|
235
|
+
* copied (a child may be mounted more than once) with the mount prefix and
|
|
236
|
+
* middleware prepended and the child's onError appended to the chain.
|
|
237
|
+
* Locks every visited store.
|
|
238
|
+
*/
|
|
239
|
+
#collectRoutes(visiting = new Set()) {
|
|
240
|
+
if (visiting.has(this.#store)) {
|
|
241
|
+
throw new Error("Router mount cycle detected");
|
|
242
|
+
}
|
|
243
|
+
visiting.add(this.#store);
|
|
244
|
+
this.#store.locked = true;
|
|
245
|
+
const routes = [...this.#store.routes];
|
|
246
|
+
for (const { router: child, prefix, middlewares } of this.#store.children) {
|
|
247
|
+
if (child.#cors || child.#notFoundHandler) {
|
|
248
|
+
throw new Error("cors() and notFound() are only supported on the root router");
|
|
249
|
+
}
|
|
250
|
+
for (const def of child.#collectRoutes(visiting)) {
|
|
251
|
+
routes.push({
|
|
252
|
+
...def,
|
|
253
|
+
path: prefix + def.path,
|
|
254
|
+
middlewares: [...middlewares, ...def.middlewares],
|
|
255
|
+
routerErrorHandlers: child.#errorHandler
|
|
256
|
+
? [...(def.routerErrorHandlers ?? []), child.#errorHandler]
|
|
257
|
+
: def.routerErrorHandlers,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
visiting.delete(this.#store);
|
|
262
|
+
return routes;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ── Factories ────────────────────────────────────────────────────────────
|
|
266
|
+
/**
|
|
267
|
+
* Create a router. Pass a context type to declare what a parent must provide
|
|
268
|
+
* when this router is mounted with `.add()`:
|
|
269
|
+
*
|
|
270
|
+
* ```ts
|
|
271
|
+
* // context.ts
|
|
272
|
+
* export const base = r().use(auth);
|
|
273
|
+
* export type AppCtx = CtxOf<typeof base>;
|
|
274
|
+
*
|
|
275
|
+
* // users.ts — type-only import, no runtime cycle
|
|
276
|
+
* export const users = r<AppCtx>({ prefix: "/users" });
|
|
277
|
+
*
|
|
278
|
+
* // main.ts
|
|
279
|
+
* serve(base.add(users));
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
export function r(opts) {
|
|
283
|
+
return new Router(opts);
|
|
284
|
+
}
|
|
285
|
+
export const router = r;
|
|
286
|
+
export function serve(
|
|
287
|
+
// biome-ignore lint/suspicious/noExplicitAny: accepts routers of any context
|
|
288
|
+
app, options) {
|
|
289
|
+
if (options?.container) {
|
|
290
|
+
app._setContainer(options.container);
|
|
291
|
+
}
|
|
292
|
+
app._setEnv({
|
|
293
|
+
development: options?.development ?? false,
|
|
294
|
+
logger: options?.logger ?? console,
|
|
295
|
+
websocket: options?.websocket ?? {},
|
|
296
|
+
});
|
|
297
|
+
app.build(); // idempotent
|
|
298
|
+
const sockets = createWebSocketRuntime(options?.websocket, options?.logger);
|
|
299
|
+
const server = Bun.serve({
|
|
300
|
+
port: options?.port ?? 8000,
|
|
301
|
+
hostname: options?.hostname ?? "0.0.0.0",
|
|
302
|
+
development: options?.development ?? false,
|
|
303
|
+
idleTimeout: options?.idleTimeout ?? 0,
|
|
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
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const handle = {
|
|
315
|
+
hostname: server.hostname ?? options?.hostname ?? "0.0.0.0",
|
|
316
|
+
port: server.port ?? options?.port ?? 8000,
|
|
317
|
+
stop: (force) => {
|
|
318
|
+
sockets.closeAll();
|
|
319
|
+
return server.stop(force);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
options?.signal?.addEventListener("abort", () => void handle.stop(), {
|
|
323
|
+
once: true,
|
|
324
|
+
});
|
|
325
|
+
options?.onListen?.({ hostname: handle.hostname, port: handle.port });
|
|
326
|
+
return handle;
|
|
327
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "shape" types (schemas, input/output, params, HTTP method, route meta)
|
|
3
|
+
* live in `@fishenv/http-contract` so that a `ContractBuilder` and a `RouteBuilder`
|
|
4
|
+
* are built from exactly the same type definitions — no structural-typing
|
|
5
|
+
* gap between `router.contract(endpoint).handle(...)` and
|
|
6
|
+
* `router.post(path).input(...).output(...).handle(...)`.
|
|
7
|
+
*/
|
|
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, 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";
|
|
13
|
+
export interface ResolvedInput<K extends InputKind, O extends InputOptions<K>> {
|
|
14
|
+
kind: K;
|
|
15
|
+
options: O;
|
|
16
|
+
}
|
|
17
|
+
export type MiddlewareFn<In extends object, Out extends object> = (ctx: In & {
|
|
18
|
+
req: Request;
|
|
19
|
+
}) => Promise<Out> | Out;
|
|
20
|
+
export type MergeCtx<A extends Record<string, unknown>, B extends Record<string, unknown>> = A & B;
|
|
21
|
+
export type InterceptorState = Record<string, unknown>;
|
|
22
|
+
export type BeforeInterceptorFn<Ctx extends Record<string, unknown>> = (args: {
|
|
23
|
+
req: Request;
|
|
24
|
+
ctx: Ctx;
|
|
25
|
+
state: InterceptorState;
|
|
26
|
+
}) => Promise<void> | void;
|
|
27
|
+
export type AfterInterceptorFn<Ctx extends Record<string, unknown>> = (args: {
|
|
28
|
+
response: Response;
|
|
29
|
+
ctx: Ctx;
|
|
30
|
+
state: InterceptorState;
|
|
31
|
+
}) => Promise<Response> | Response;
|
|
32
|
+
export interface InterceptorPair<Ctx extends Record<string, unknown>> {
|
|
33
|
+
before?: BeforeInterceptorFn<Ctx>;
|
|
34
|
+
after?: AfterInterceptorFn<Ctx>;
|
|
35
|
+
}
|
|
36
|
+
export interface HandlerArgs<Ctx extends Record<string, unknown>, K extends InputKind, O extends InputOptions<K>, Params extends Record<string, unknown>> {
|
|
37
|
+
req: Request;
|
|
38
|
+
path: Params;
|
|
39
|
+
ctx: Ctx;
|
|
40
|
+
body: InferBody<K, O>;
|
|
41
|
+
headers: InferHeaders<O>;
|
|
42
|
+
query: InferQuery<O>;
|
|
43
|
+
cookies: InferCookies<O>;
|
|
44
|
+
container: DIContainer;
|
|
45
|
+
}
|
|
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>;
|
|
47
|
+
export type ErrorHandlerFn = (err: unknown, args: {
|
|
48
|
+
req: Request;
|
|
49
|
+
}) => Response | Promise<Response> | null | undefined | void;
|
|
50
|
+
export interface RouteDefinition {
|
|
51
|
+
method: HttpMethod;
|
|
52
|
+
path: string;
|
|
53
|
+
middlewares: MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[];
|
|
54
|
+
paramSchemas: Map<string, AnySchema>;
|
|
55
|
+
inputKind: InputKind;
|
|
56
|
+
inputOptions: InputOptions<InputKind>;
|
|
57
|
+
outputSchema?: AnySchema;
|
|
58
|
+
errorTypes?: ErrorTypes;
|
|
59
|
+
meta?: RouteMeta;
|
|
60
|
+
interceptors: InterceptorPair<Record<string, unknown>>[];
|
|
61
|
+
handler: HandlerFn<Record<string, unknown>, InputKind, InputOptions<InputKind>, Record<string, unknown>, unknown>;
|
|
62
|
+
errorHandler?: ErrorHandlerFn;
|
|
63
|
+
/** onError handlers of mounted routers, innermost first. Set at build(). */
|
|
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;
|
|
72
|
+
kind: "http" | "ws" | "sse";
|
|
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
|
+
}
|
|
83
|
+
export interface DIContainer {
|
|
84
|
+
get<T>(token: abstract new (...args: any[]) => T): T;
|
|
85
|
+
}
|
|
86
|
+
export type NotFoundHandler = (req: Request) => Response | Promise<Response>;
|
|
File without changes
|
|
@@ -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;
|