@fishenv/http 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/builder.d.ts +82 -0
- package/dist/contract/builder.js +87 -0
- package/dist/contract/index.d.ts +2 -0
- package/dist/contract/index.js +1 -0
- package/dist/contract/types.d.ts +39 -0
- package/dist/contract/types.js +0 -0
- package/dist/core/compose.d.ts +22 -0
- package/dist/core/compose.js +82 -0
- package/dist/core/cors.d.ts +38 -0
- package/dist/core/cors.js +96 -0
- package/dist/core/error.d.ts +52 -0
- package/dist/core/error.js +99 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +19 -0
- package/dist/core/matcher.d.ts +17 -0
- package/dist/core/matcher.js +109 -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 +148 -0
- package/dist/core/router.js +288 -0
- package/dist/core/types.d.ts +67 -0
- package/dist/core/types.js +0 -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 +24 -0
- package/dist/core/utils/url.js +119 -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 +133 -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 +45 -0
- package/dist/sse/types.js +4 -0
- package/package.json +42 -4
- package/README.md +0 -3
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
const stubContainer = {
|
|
7
|
+
get() {
|
|
8
|
+
throw new Error("No DI container configured. Pass one via serve() options.");
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Router — immutable middleware chain, mutable route registration.
|
|
13
|
+
*
|
|
14
|
+
* `.use()` returns a new Router with the extended Ctx type but the same
|
|
15
|
+
* backing store — routes registered on either instance are collected together.
|
|
16
|
+
* Route methods (.get, .post, etc.) return a RouteBuilder chain.
|
|
17
|
+
* Call `.build()` before `.fetch` or `serve()`.
|
|
18
|
+
*
|
|
19
|
+
* `Requires` is the context this router expects a parent to provide when it
|
|
20
|
+
* is mounted via `.add()`. Create one with `r<AppCtx>()`; `.add()` only
|
|
21
|
+
* accepts it on a parent whose `Ctx` satisfies `AppCtx`, and `serve()` only
|
|
22
|
+
* accepts routers with no outstanding requirements.
|
|
23
|
+
*/
|
|
24
|
+
export class Router {
|
|
25
|
+
#prefix;
|
|
26
|
+
#middlewares;
|
|
27
|
+
#store;
|
|
28
|
+
#errorHandler;
|
|
29
|
+
#notFoundHandler;
|
|
30
|
+
#cors = null;
|
|
31
|
+
#built = false;
|
|
32
|
+
#matcher = null;
|
|
33
|
+
#container = stubContainer;
|
|
34
|
+
constructor(opts, middlewares, errorHandler, notFoundHandler, store) {
|
|
35
|
+
this.#prefix = opts?.prefix ?? "";
|
|
36
|
+
this.#middlewares = middlewares ?? [];
|
|
37
|
+
this.#errorHandler = errorHandler;
|
|
38
|
+
this.#notFoundHandler = notFoundHandler;
|
|
39
|
+
this.#store = store ?? { routes: [], children: [], locked: false };
|
|
40
|
+
}
|
|
41
|
+
// ── RouterRef implementation ─────────────────────────────────────────
|
|
42
|
+
_registerRoute(def) {
|
|
43
|
+
if (this.#store.locked) {
|
|
44
|
+
throw new Error("Cannot add routes after build()");
|
|
45
|
+
}
|
|
46
|
+
this.#store.routes.push(def);
|
|
47
|
+
}
|
|
48
|
+
_getMiddlewares() {
|
|
49
|
+
return [...this.#middlewares];
|
|
50
|
+
}
|
|
51
|
+
_getPrefix() {
|
|
52
|
+
return this.#prefix;
|
|
53
|
+
}
|
|
54
|
+
// ── Middleware ────────────────────────────────────────────────────────
|
|
55
|
+
use(mw) {
|
|
56
|
+
return new Router({ prefix: this.#prefix }, [
|
|
57
|
+
...this.#middlewares,
|
|
58
|
+
mw,
|
|
59
|
+
], this.#errorHandler, this.#notFoundHandler, this.#store);
|
|
60
|
+
}
|
|
61
|
+
// ── Sub-router ───────────────────────────────────────────────────────
|
|
62
|
+
/** Create a child router and mount it on this one — shorthand for `r<Ctx>(opts)` + `.add()`. */
|
|
63
|
+
extend(opts) {
|
|
64
|
+
const child = new Router(opts);
|
|
65
|
+
this.add(child);
|
|
66
|
+
return child;
|
|
67
|
+
}
|
|
68
|
+
add(...args) {
|
|
69
|
+
if (this.#store.locked) {
|
|
70
|
+
throw new Error("Cannot add routers after build()");
|
|
71
|
+
}
|
|
72
|
+
const extra = typeof args[0] === "string" ? args.shift() : "";
|
|
73
|
+
for (const child of args) {
|
|
74
|
+
this.#store.children.push({
|
|
75
|
+
router: child,
|
|
76
|
+
prefix: this.#prefix + extra,
|
|
77
|
+
middlewares: [...this.#middlewares],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
// ── Route methods ────────────────────────────────────────────────────
|
|
83
|
+
get(path) {
|
|
84
|
+
return createRouteBuilder(this, "GET", path);
|
|
85
|
+
}
|
|
86
|
+
post(path) {
|
|
87
|
+
return createRouteBuilder(this, "POST", path);
|
|
88
|
+
}
|
|
89
|
+
put(path) {
|
|
90
|
+
return createRouteBuilder(this, "PUT", path);
|
|
91
|
+
}
|
|
92
|
+
patch(path) {
|
|
93
|
+
return createRouteBuilder(this, "PATCH", path);
|
|
94
|
+
}
|
|
95
|
+
delete(path) {
|
|
96
|
+
return createRouteBuilder(this, "DELETE", path);
|
|
97
|
+
}
|
|
98
|
+
options(path) {
|
|
99
|
+
return createRouteBuilder(this, "OPTIONS", path);
|
|
100
|
+
}
|
|
101
|
+
// ── Contract-driven route ────────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* Register a route from a `@fishenv/http-contract` endpoint definition, e.g.:
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* router.contract(contract.users.create).handle(({ body }) => { ... });
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* `.handle()` gets exactly the same type safety (body/query/params/output)
|
|
110
|
+
* as chaining `.input().output().param()` manually, because the contract
|
|
111
|
+
* endpoint and the route builder share the same shape types.
|
|
112
|
+
*/
|
|
113
|
+
contract(endpoint) {
|
|
114
|
+
return createRouteBuilderFromContract(this, endpoint);
|
|
115
|
+
}
|
|
116
|
+
// ── Error / not-found ────────────────────────────────────────────────
|
|
117
|
+
// ── CORS ─────────────────────────────────────────────────────────────
|
|
118
|
+
cors(options) {
|
|
119
|
+
this.#cors = buildCors(options);
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
// ── Error / not-found ────────────────────────────────────────────────
|
|
123
|
+
onError(handler) {
|
|
124
|
+
this.#errorHandler = handler;
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
notFound(handler) {
|
|
128
|
+
this.#notFoundHandler = handler;
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
// ── Container ────────────────────────────────────────────────────────
|
|
132
|
+
/** @internal Set by serve(). For testing, call before build(). */
|
|
133
|
+
_setContainer(container) {
|
|
134
|
+
this.#container = container;
|
|
135
|
+
}
|
|
136
|
+
// ── Build ────────────────────────────────────────────────────────────
|
|
137
|
+
build() {
|
|
138
|
+
if (this.#built)
|
|
139
|
+
return; // idempotent
|
|
140
|
+
const matcher = new Matcher();
|
|
141
|
+
const allRoutes = this.#collectRoutes();
|
|
142
|
+
for (const route of allRoutes) {
|
|
143
|
+
matcher.add(route.method, route.path, route);
|
|
144
|
+
}
|
|
145
|
+
matcher.compile();
|
|
146
|
+
this.#matcher = matcher;
|
|
147
|
+
this.#built = true;
|
|
148
|
+
this.#store.locked = true;
|
|
149
|
+
}
|
|
150
|
+
// ── Fetch ────────────────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Entry point for request handling — available after build().
|
|
153
|
+
* Use directly for testing without binding a port.
|
|
154
|
+
*/
|
|
155
|
+
fetch = async (req) => {
|
|
156
|
+
if (!this.#built || !this.#matcher) {
|
|
157
|
+
throw new Error("Router not built. Call .build() first.");
|
|
158
|
+
}
|
|
159
|
+
const url = new URL(req.url);
|
|
160
|
+
const method = req.method.toUpperCase();
|
|
161
|
+
const isHead = method === "HEAD";
|
|
162
|
+
// CORS preflight — short-circuit before route matching
|
|
163
|
+
if (method === "OPTIONS" && this.#cors) {
|
|
164
|
+
return this.#cors.preflight(req);
|
|
165
|
+
}
|
|
166
|
+
// Try exact method match, then HEAD→GET fallback
|
|
167
|
+
let matched = this.#matcher.match(method, url.pathname);
|
|
168
|
+
if (isHead && (!matched || matched.allowedMethods)) {
|
|
169
|
+
const getMatch = this.#matcher.match("GET", url.pathname);
|
|
170
|
+
if (getMatch && !getMatch.allowedMethods) {
|
|
171
|
+
matched = getMatch;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!matched) {
|
|
175
|
+
const notFound = this.#notFoundHandler ?? defaultNotFoundHandler;
|
|
176
|
+
return notFound(req);
|
|
177
|
+
}
|
|
178
|
+
// 405 — path matched but method didn't
|
|
179
|
+
if (matched.allowedMethods) {
|
|
180
|
+
const allowed = isHead && !matched.allowedMethods.includes("HEAD")
|
|
181
|
+
? [...matched.allowedMethods, "HEAD"]
|
|
182
|
+
: matched.allowedMethods;
|
|
183
|
+
return defaultMethodNotAllowedHandler(req, allowed);
|
|
184
|
+
}
|
|
185
|
+
// Build error handler chain: route catch → router onError → default
|
|
186
|
+
const errorHandlers = [];
|
|
187
|
+
if (matched.definition.errorHandler) {
|
|
188
|
+
errorHandlers.push(matched.definition.errorHandler);
|
|
189
|
+
}
|
|
190
|
+
if (matched.definition.routerErrorHandlers) {
|
|
191
|
+
errorHandlers.push(...matched.definition.routerErrorHandlers);
|
|
192
|
+
}
|
|
193
|
+
if (this.#errorHandler) {
|
|
194
|
+
errorHandlers.push(this.#errorHandler);
|
|
195
|
+
}
|
|
196
|
+
let response = await dispatch(req, matched.definition, matched.params, this.#container, errorHandlers);
|
|
197
|
+
// Inject CORS headers on every response
|
|
198
|
+
if (this.#cors) {
|
|
199
|
+
response = this.#cors.applyTo(req, response);
|
|
200
|
+
}
|
|
201
|
+
// HEAD: strip body
|
|
202
|
+
if (isHead) {
|
|
203
|
+
return new Response(null, {
|
|
204
|
+
status: response.status,
|
|
205
|
+
headers: response.headers,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return response;
|
|
209
|
+
};
|
|
210
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
211
|
+
/**
|
|
212
|
+
* Flatten this router's routes plus all mounted children. Child routes are
|
|
213
|
+
* copied (a child may be mounted more than once) with the mount prefix and
|
|
214
|
+
* middleware prepended and the child's onError appended to the chain.
|
|
215
|
+
* Locks every visited store.
|
|
216
|
+
*/
|
|
217
|
+
#collectRoutes(visiting = new Set()) {
|
|
218
|
+
if (visiting.has(this.#store)) {
|
|
219
|
+
throw new Error("Router mount cycle detected");
|
|
220
|
+
}
|
|
221
|
+
visiting.add(this.#store);
|
|
222
|
+
this.#store.locked = true;
|
|
223
|
+
const routes = [...this.#store.routes];
|
|
224
|
+
for (const { router: child, prefix, middlewares } of this.#store.children) {
|
|
225
|
+
if (child.#cors || child.#notFoundHandler) {
|
|
226
|
+
throw new Error("cors() and notFound() are only supported on the root router");
|
|
227
|
+
}
|
|
228
|
+
for (const def of child.#collectRoutes(visiting)) {
|
|
229
|
+
routes.push({
|
|
230
|
+
...def,
|
|
231
|
+
path: prefix + def.path,
|
|
232
|
+
middlewares: [...middlewares, ...def.middlewares],
|
|
233
|
+
routerErrorHandlers: child.#errorHandler
|
|
234
|
+
? [...(def.routerErrorHandlers ?? []), child.#errorHandler]
|
|
235
|
+
: def.routerErrorHandlers,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
visiting.delete(this.#store);
|
|
240
|
+
return routes;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ── Factories ────────────────────────────────────────────────────────────
|
|
244
|
+
/**
|
|
245
|
+
* Create a router. Pass a context type to declare what a parent must provide
|
|
246
|
+
* when this router is mounted with `.add()`:
|
|
247
|
+
*
|
|
248
|
+
* ```ts
|
|
249
|
+
* // context.ts
|
|
250
|
+
* export const base = r().use(auth);
|
|
251
|
+
* export type AppCtx = CtxOf<typeof base>;
|
|
252
|
+
*
|
|
253
|
+
* // users.ts — type-only import, no runtime cycle
|
|
254
|
+
* export const users = r<AppCtx>({ prefix: "/users" });
|
|
255
|
+
*
|
|
256
|
+
* // main.ts
|
|
257
|
+
* serve(base.add(users));
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
export function r(opts) {
|
|
261
|
+
return new Router(opts);
|
|
262
|
+
}
|
|
263
|
+
export const router = r;
|
|
264
|
+
export function serve(
|
|
265
|
+
// biome-ignore lint/suspicious/noExplicitAny: accepts routers of any context
|
|
266
|
+
app, options) {
|
|
267
|
+
if (options?.container) {
|
|
268
|
+
app._setContainer(options.container);
|
|
269
|
+
}
|
|
270
|
+
app.build(); // idempotent
|
|
271
|
+
const server = Bun.serve({
|
|
272
|
+
port: options?.port ?? 8000,
|
|
273
|
+
hostname: options?.hostname ?? "0.0.0.0",
|
|
274
|
+
development: options?.development ?? false,
|
|
275
|
+
idleTimeout: options?.idleTimeout ?? 0,
|
|
276
|
+
fetch: (req) => app.fetch(req),
|
|
277
|
+
});
|
|
278
|
+
const handle = {
|
|
279
|
+
hostname: server.hostname ?? options?.hostname ?? "0.0.0.0",
|
|
280
|
+
port: server.port ?? options?.port ?? 8000,
|
|
281
|
+
stop: (force) => server.stop(force),
|
|
282
|
+
};
|
|
283
|
+
options?.signal?.addEventListener("abort", () => void handle.stop(), {
|
|
284
|
+
once: true,
|
|
285
|
+
});
|
|
286
|
+
options?.onListen?.({ hostname: handle.hostname, port: handle.port });
|
|
287
|
+
return handle;
|
|
288
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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, HttpMethod, InferBody, InferCookies, InferHeaders, InferQuery, InputKind, InputOptions, RouteMeta } from "../contract/index.ts";
|
|
10
|
+
export interface ResolvedInput<K extends InputKind, O extends InputOptions<K>> {
|
|
11
|
+
kind: K;
|
|
12
|
+
options: O;
|
|
13
|
+
}
|
|
14
|
+
export type MiddlewareFn<In extends object, Out extends object> = (ctx: In & {
|
|
15
|
+
req: Request;
|
|
16
|
+
}) => Promise<Out> | Out;
|
|
17
|
+
export type MergeCtx<A extends Record<string, unknown>, B extends Record<string, unknown>> = A & B;
|
|
18
|
+
export type InterceptorState = Record<string, unknown>;
|
|
19
|
+
export type BeforeInterceptorFn<Ctx extends Record<string, unknown>> = (args: {
|
|
20
|
+
req: Request;
|
|
21
|
+
ctx: Ctx;
|
|
22
|
+
state: InterceptorState;
|
|
23
|
+
}) => Promise<void> | void;
|
|
24
|
+
export type AfterInterceptorFn<Ctx extends Record<string, unknown>> = (args: {
|
|
25
|
+
response: Response;
|
|
26
|
+
ctx: Ctx;
|
|
27
|
+
state: InterceptorState;
|
|
28
|
+
}) => Promise<Response> | Response;
|
|
29
|
+
export interface InterceptorPair<Ctx extends Record<string, unknown>> {
|
|
30
|
+
before?: BeforeInterceptorFn<Ctx>;
|
|
31
|
+
after?: AfterInterceptorFn<Ctx>;
|
|
32
|
+
}
|
|
33
|
+
export interface HandlerArgs<Ctx extends Record<string, unknown>, K extends InputKind, O extends InputOptions<K>, Params extends Record<string, unknown>> {
|
|
34
|
+
req: Request;
|
|
35
|
+
path: Params;
|
|
36
|
+
ctx: Ctx;
|
|
37
|
+
body: InferBody<K, O>;
|
|
38
|
+
headers: InferHeaders<O>;
|
|
39
|
+
query: InferQuery<O>;
|
|
40
|
+
cookies: InferCookies<O>;
|
|
41
|
+
container: DIContainer;
|
|
42
|
+
}
|
|
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>;
|
|
44
|
+
export type ErrorHandlerFn = (err: unknown, args: {
|
|
45
|
+
req: Request;
|
|
46
|
+
}) => Response | Promise<Response> | null | undefined | void;
|
|
47
|
+
export interface RouteDefinition {
|
|
48
|
+
method: HttpMethod;
|
|
49
|
+
path: string;
|
|
50
|
+
middlewares: MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[];
|
|
51
|
+
paramSchemas: Map<string, AnySchema>;
|
|
52
|
+
inputKind: InputKind;
|
|
53
|
+
inputOptions: InputOptions<InputKind>;
|
|
54
|
+
outputSchema?: AnySchema;
|
|
55
|
+
errorTypes?: (new (...args: unknown[]) => Error)[];
|
|
56
|
+
meta?: RouteMeta;
|
|
57
|
+
interceptors: InterceptorPair<Record<string, unknown>>[];
|
|
58
|
+
handler: HandlerFn<Record<string, unknown>, InputKind, InputOptions<InputKind>, Record<string, unknown>, unknown>;
|
|
59
|
+
errorHandler?: ErrorHandlerFn;
|
|
60
|
+
/** onError handlers of mounted routers, innermost first. Set at build(). */
|
|
61
|
+
routerErrorHandlers?: ErrorHandlerFn[];
|
|
62
|
+
kind: "http" | "ws" | "sse";
|
|
63
|
+
}
|
|
64
|
+
export interface DIContainer {
|
|
65
|
+
get<T>(token: abstract new (...args: any[]) => T): T;
|
|
66
|
+
}
|
|
67
|
+
export type NotFoundHandler = (req: Request) => Response | Promise<Response>;
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combine a base prefix and a route sub-path into one canonical path.
|
|
3
|
+
* Always returns a path starting with "/" and no trailing slash (except root).
|
|
4
|
+
*/
|
|
5
|
+
export declare function mergePaths(base: string, sub: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Extract all named param names from a pattern (runtime, not type-level).
|
|
8
|
+
* E.g. "/users/:id/posts/:pid?" → ["id", "pid"]
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractParamNames(pattern: string): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Convert a route pattern to a regex + ordered param name list.
|
|
13
|
+
*
|
|
14
|
+
* Rules:
|
|
15
|
+
* - `:name` → named capture `([^/]+)`
|
|
16
|
+
* - `:name?` → optional named capture `(?:/([^/]+))?`
|
|
17
|
+
* - `*` → glob within a segment (`[^/]*`)
|
|
18
|
+
* - `**` → glob across path segments
|
|
19
|
+
* - `{a,b}` → alternation `(?:a|b)`
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildRouteRegex(pattern: string): {
|
|
22
|
+
regex: RegExp;
|
|
23
|
+
paramNames: string[];
|
|
24
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combine a base prefix and a route sub-path into one canonical path.
|
|
3
|
+
* Always returns a path starting with "/" and no trailing slash (except root).
|
|
4
|
+
*/
|
|
5
|
+
export function mergePaths(base, sub) {
|
|
6
|
+
const combined = `${base.replace(/\/+$/, "")}/${sub.replace(/^\/+/, "")}`;
|
|
7
|
+
const normalized = `/${combined.replace(/^\/+/, "")}`.replace(/\/+$/, "") || "/";
|
|
8
|
+
return normalized;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extract all named param names from a pattern (runtime, not type-level).
|
|
12
|
+
* E.g. "/users/:id/posts/:pid?" → ["id", "pid"]
|
|
13
|
+
*/
|
|
14
|
+
export function extractParamNames(pattern) {
|
|
15
|
+
const names = [];
|
|
16
|
+
for (const segment of pattern.split("/")) {
|
|
17
|
+
if (segment.startsWith(":")) {
|
|
18
|
+
names.push(segment.slice(1).replace(/\?$/, ""));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return names;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Convert a route pattern to a regex + ordered param name list.
|
|
25
|
+
*
|
|
26
|
+
* Rules:
|
|
27
|
+
* - `:name` → named capture `([^/]+)`
|
|
28
|
+
* - `:name?` → optional named capture `(?:/([^/]+))?`
|
|
29
|
+
* - `*` → glob within a segment (`[^/]*`)
|
|
30
|
+
* - `**` → glob across path segments
|
|
31
|
+
* - `{a,b}` → alternation `(?:a|b)`
|
|
32
|
+
*/
|
|
33
|
+
export function buildRouteRegex(pattern) {
|
|
34
|
+
const paramNames = [];
|
|
35
|
+
const segments = pattern.split("/").filter((s) => s !== "");
|
|
36
|
+
let regexStr = "";
|
|
37
|
+
for (const seg of segments) {
|
|
38
|
+
if (seg.startsWith(":")) {
|
|
39
|
+
const isOptional = seg.endsWith("?");
|
|
40
|
+
const name = seg.slice(1).replace(/\?$/, "");
|
|
41
|
+
paramNames.push(name);
|
|
42
|
+
if (isOptional) {
|
|
43
|
+
regexStr += `(?:/([^/]+))?`;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
regexStr += `/([^/]+)`;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (seg === "**") {
|
|
51
|
+
regexStr += `(?:/.*)?`;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
regexStr += `/${globSegmentToRegex(seg)}`;
|
|
55
|
+
}
|
|
56
|
+
// Empty pattern → root
|
|
57
|
+
if (regexStr === "")
|
|
58
|
+
regexStr = "/";
|
|
59
|
+
return {
|
|
60
|
+
regex: new RegExp(`^${regexStr}$`),
|
|
61
|
+
paramNames,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function globSegmentToRegex(segment) {
|
|
65
|
+
let out = "";
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < segment.length) {
|
|
68
|
+
const ch = segment[i];
|
|
69
|
+
if (ch === "*") {
|
|
70
|
+
out += "[^/]*";
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (ch === "{") {
|
|
75
|
+
const end = findClosingBrace(segment, i);
|
|
76
|
+
if (end === -1) {
|
|
77
|
+
out += "\\{";
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const body = segment.slice(i + 1, end);
|
|
82
|
+
const parts = splitBraceParts(body).map((part) => globLiteralToRegex(part));
|
|
83
|
+
out += parts.length > 0 ? `(?:${parts.join("|")})` : "\\{\\}";
|
|
84
|
+
i = end + 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out += escapeRegex(ch);
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function globLiteralToRegex(part) {
|
|
93
|
+
let out = "";
|
|
94
|
+
for (const ch of part) {
|
|
95
|
+
if (ch === "*") {
|
|
96
|
+
out += "[^/]*";
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
out += escapeRegex(ch);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function findClosingBrace(str, start) {
|
|
105
|
+
for (let i = start + 1; i < str.length; i++) {
|
|
106
|
+
if (str[i] === "}")
|
|
107
|
+
return i;
|
|
108
|
+
}
|
|
109
|
+
return -1;
|
|
110
|
+
}
|
|
111
|
+
function splitBraceParts(body) {
|
|
112
|
+
return body
|
|
113
|
+
.split(",")
|
|
114
|
+
.map((part) => part.trim())
|
|
115
|
+
.filter((part) => part.length > 0);
|
|
116
|
+
}
|
|
117
|
+
function escapeRegex(str) {
|
|
118
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { type ValidationField } from "./error.ts";
|
|
3
|
+
import type { AnySchema } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Validate a single field value against a Valibot schema.
|
|
6
|
+
* Throws ValidationError on failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateField<S extends AnySchema>(schema: S, value: unknown, field: ValidationField): v.InferOutput<S>;
|
|
9
|
+
/**
|
|
10
|
+
* Validate path params: each param with a registered schema gets validated/coerced.
|
|
11
|
+
* Params without schemas pass through as raw strings.
|
|
12
|
+
*/
|
|
13
|
+
export declare function validatePathParams(rawParams: Record<string, string>, schemas: Map<string, AnySchema>): Record<string, unknown>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { ValidationError } from "./error.js";
|
|
3
|
+
/**
|
|
4
|
+
* Validate a single field value against a Valibot schema.
|
|
5
|
+
* Throws ValidationError on failure.
|
|
6
|
+
*/
|
|
7
|
+
export function validateField(schema, value, field) {
|
|
8
|
+
const result = v.safeParse(schema, value);
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
throw new ValidationError(field, result.issues);
|
|
11
|
+
}
|
|
12
|
+
return result.output;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validate path params: each param with a registered schema gets validated/coerced.
|
|
16
|
+
* Params without schemas pass through as raw strings.
|
|
17
|
+
*/
|
|
18
|
+
export function validatePathParams(rawParams, schemas) {
|
|
19
|
+
const validated = { ...rawParams };
|
|
20
|
+
for (const [name, schema] of schemas) {
|
|
21
|
+
validated[name] = validateField(schema, rawParams[name], "path");
|
|
22
|
+
}
|
|
23
|
+
return validated;
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A single live SSE stream, usable beyond the lifetime of its route handler. */
|
|
2
|
+
import type { SseCleanupFn, SseConnectionOptions, SseEventOptions } from "./types.ts";
|
|
3
|
+
export declare const DEFAULT_HIGH_WATER_MARK: number;
|
|
4
|
+
/**
|
|
5
|
+
* Every method is a no-op once the connection is closed, so a stale reference
|
|
6
|
+
* held by a service or registry can never throw.
|
|
7
|
+
*/
|
|
8
|
+
export declare class SseConnection {
|
|
9
|
+
#private;
|
|
10
|
+
/** Unique per connection; a user may hold several (one per tab/device). */
|
|
11
|
+
readonly id: string;
|
|
12
|
+
/** From the `Last-Event-ID` header, falling back to a `?lastEventId=` query param. */
|
|
13
|
+
readonly lastEventId: string | null;
|
|
14
|
+
readonly req: Request;
|
|
15
|
+
constructor(controller: ReadableStreamDefaultController<Uint8Array>, req: Request, options?: SseConnectionOptions);
|
|
16
|
+
get closed(): boolean;
|
|
17
|
+
/** Bytes queued but not yet flushed to the socket. */
|
|
18
|
+
get bufferedBytes(): number;
|
|
19
|
+
/** Returns `false` if the event was dropped because the connection is gone or saturated. */
|
|
20
|
+
send(data: unknown, opts?: SseEventOptions): boolean;
|
|
21
|
+
/** Send a comment line — invisible to `EventSource`, used as a heartbeat. */
|
|
22
|
+
comment(text?: string): boolean;
|
|
23
|
+
/** Tell the client how long to wait before reconnecting. */
|
|
24
|
+
retry(ms: number): boolean;
|
|
25
|
+
close(): void;
|
|
26
|
+
/** Runs immediately if the connection is already closed. */
|
|
27
|
+
onClose(fn: SseCleanupFn): void;
|
|
28
|
+
}
|
|
29
|
+
/** @internal */
|
|
30
|
+
export declare function runCleanup(fn: SseCleanupFn): void;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** A single live SSE stream, usable beyond the lifetime of its route handler. */
|
|
2
|
+
import { encodeSseComment, encodeSseEvent, encodeSseRetry } from "./encode.js";
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
export const DEFAULT_HIGH_WATER_MARK = 64 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Every method is a no-op once the connection is closed, so a stale reference
|
|
7
|
+
* held by a service or registry can never throw.
|
|
8
|
+
*/
|
|
9
|
+
export class SseConnection {
|
|
10
|
+
/** Unique per connection; a user may hold several (one per tab/device). */
|
|
11
|
+
id = crypto.randomUUID();
|
|
12
|
+
/** From the `Last-Event-ID` header, falling back to a `?lastEventId=` query param. */
|
|
13
|
+
lastEventId;
|
|
14
|
+
req;
|
|
15
|
+
#controller;
|
|
16
|
+
#highWaterMark;
|
|
17
|
+
#overflow;
|
|
18
|
+
#closed = false;
|
|
19
|
+
#closeCallbacks = [];
|
|
20
|
+
constructor(controller, req, options = {}) {
|
|
21
|
+
this.#controller = controller;
|
|
22
|
+
this.req = req;
|
|
23
|
+
this.#highWaterMark = options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK;
|
|
24
|
+
this.#overflow = options.onOverflow ?? "close";
|
|
25
|
+
this.lastEventId =
|
|
26
|
+
req.headers.get("last-event-id") ??
|
|
27
|
+
new URL(req.url).searchParams.get("lastEventId");
|
|
28
|
+
}
|
|
29
|
+
get closed() {
|
|
30
|
+
return this.#closed;
|
|
31
|
+
}
|
|
32
|
+
/** Bytes queued but not yet flushed to the socket. */
|
|
33
|
+
get bufferedBytes() {
|
|
34
|
+
if (this.#closed || this.#controller == null)
|
|
35
|
+
return 0;
|
|
36
|
+
return this.#highWaterMark - (this.#controller.desiredSize ?? 0);
|
|
37
|
+
}
|
|
38
|
+
/** Returns `false` if the event was dropped because the connection is gone or saturated. */
|
|
39
|
+
send(data, opts) {
|
|
40
|
+
return this.#write(encodeSseEvent(data, opts));
|
|
41
|
+
}
|
|
42
|
+
/** Send a comment line — invisible to `EventSource`, used as a heartbeat. */
|
|
43
|
+
comment(text = "") {
|
|
44
|
+
return this.#write(encodeSseComment(text));
|
|
45
|
+
}
|
|
46
|
+
/** Tell the client how long to wait before reconnecting. */
|
|
47
|
+
retry(ms) {
|
|
48
|
+
return this.#write(encodeSseRetry(ms));
|
|
49
|
+
}
|
|
50
|
+
close() {
|
|
51
|
+
if (this.#closed)
|
|
52
|
+
return;
|
|
53
|
+
this.#closed = true;
|
|
54
|
+
const controller = this.#controller;
|
|
55
|
+
this.#controller = null;
|
|
56
|
+
try {
|
|
57
|
+
controller?.close();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Already closed or errored by the consumer — nothing to do.
|
|
61
|
+
}
|
|
62
|
+
const callbacks = this.#closeCallbacks;
|
|
63
|
+
this.#closeCallbacks = [];
|
|
64
|
+
for (const cb of callbacks)
|
|
65
|
+
runCleanup(cb);
|
|
66
|
+
}
|
|
67
|
+
/** Runs immediately if the connection is already closed. */
|
|
68
|
+
onClose(fn) {
|
|
69
|
+
if (this.#closed) {
|
|
70
|
+
runCleanup(fn);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.#closeCallbacks.push(fn);
|
|
74
|
+
}
|
|
75
|
+
#write(frame) {
|
|
76
|
+
if (this.#closed || this.#controller == null)
|
|
77
|
+
return false;
|
|
78
|
+
if ((this.#controller.desiredSize ?? 0) <= 0) {
|
|
79
|
+
if (this.#overflow === "close")
|
|
80
|
+
this.close();
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
this.#controller.enqueue(encoder.encode(frame));
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
this.close();
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** @internal */
|
|
94
|
+
export function runCleanup(fn) {
|
|
95
|
+
try {
|
|
96
|
+
const result = fn();
|
|
97
|
+
if (result instanceof Promise) {
|
|
98
|
+
result.catch((err) => console.error("[fishenv.sse] close callback rejected:", err));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error("[fishenv.sse] close callback threw:", err);
|
|
103
|
+
}
|
|
104
|
+
}
|