@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,17 @@
|
|
|
1
|
+
import type { HttpMethod, RouteDefinition } from "./types.ts";
|
|
2
|
+
export interface MatchedRoute {
|
|
3
|
+
definition: RouteDefinition;
|
|
4
|
+
params: Record<string, string>;
|
|
5
|
+
allowedMethods?: HttpMethod[];
|
|
6
|
+
}
|
|
7
|
+
export interface RouterMatcher {
|
|
8
|
+
add(method: HttpMethod | "ALL", path: string, def: RouteDefinition): void;
|
|
9
|
+
match(method: string, path: string): MatchedRoute | null;
|
|
10
|
+
compile(): void;
|
|
11
|
+
}
|
|
12
|
+
export declare class Matcher implements RouterMatcher {
|
|
13
|
+
#private;
|
|
14
|
+
add(method: HttpMethod | "ALL", path: string, def: RouteDefinition): void;
|
|
15
|
+
compile(): void;
|
|
16
|
+
match(method: string, path: string): MatchedRoute | null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { buildRouteRegex } from "./utils/url.js";
|
|
2
|
+
/** Score: more static segments = higher, broad globs = lower */
|
|
3
|
+
function specificity(path) {
|
|
4
|
+
return path.split("/").reduce((score, seg) => {
|
|
5
|
+
if (!seg)
|
|
6
|
+
return score;
|
|
7
|
+
if (seg === "**")
|
|
8
|
+
return score + 0;
|
|
9
|
+
if (seg.includes("*") || seg.includes("{"))
|
|
10
|
+
return score + 1;
|
|
11
|
+
if (seg.startsWith(":"))
|
|
12
|
+
return score + 2;
|
|
13
|
+
return score + 3;
|
|
14
|
+
}, 0);
|
|
15
|
+
}
|
|
16
|
+
function hasPatternSyntax(path) {
|
|
17
|
+
return path.includes(":") || path.includes("*") || path.includes("{");
|
|
18
|
+
}
|
|
19
|
+
export class Matcher {
|
|
20
|
+
#rawRoutes = [];
|
|
21
|
+
#compiled = false;
|
|
22
|
+
// Per method: static map + dynamic list
|
|
23
|
+
#staticRoutes = new Map();
|
|
24
|
+
#dynamicRoutes = new Map();
|
|
25
|
+
add(method, path, def) {
|
|
26
|
+
if (this.#compiled) {
|
|
27
|
+
throw new Error("Cannot add routes after compile()");
|
|
28
|
+
}
|
|
29
|
+
this.#rawRoutes.push({ method: method.toUpperCase(), path, def });
|
|
30
|
+
}
|
|
31
|
+
compile() {
|
|
32
|
+
for (const { method, path, def } of this.#rawRoutes) {
|
|
33
|
+
if (!hasPatternSyntax(path)) {
|
|
34
|
+
// Static path
|
|
35
|
+
if (!this.#staticRoutes.has(method)) {
|
|
36
|
+
this.#staticRoutes.set(method, new Map());
|
|
37
|
+
}
|
|
38
|
+
this.#staticRoutes.get(method).set(path, def);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Dynamic path
|
|
42
|
+
const { regex, paramNames } = buildRouteRegex(path);
|
|
43
|
+
const compiled = { regex, paramNames, definition: def };
|
|
44
|
+
if (!this.#dynamicRoutes.has(method)) {
|
|
45
|
+
this.#dynamicRoutes.set(method, []);
|
|
46
|
+
}
|
|
47
|
+
this.#dynamicRoutes.get(method).push(compiled);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Sort dynamic routes: higher specificity first
|
|
51
|
+
for (const list of this.#dynamicRoutes.values()) {
|
|
52
|
+
list.sort((a, b) => specificity(b.definition.path) - specificity(a.definition.path));
|
|
53
|
+
}
|
|
54
|
+
this.#compiled = true;
|
|
55
|
+
}
|
|
56
|
+
match(method, path) {
|
|
57
|
+
method = method.toUpperCase();
|
|
58
|
+
// Check static routes (exact method, then ALL)
|
|
59
|
+
const def = this.#staticRoutes.get(method)?.get(path) ??
|
|
60
|
+
this.#staticRoutes.get("ALL")?.get(path);
|
|
61
|
+
if (def)
|
|
62
|
+
return { definition: def, params: {} };
|
|
63
|
+
// Check dynamic routes (exact method, then ALL)
|
|
64
|
+
const dynamicForMethod = this.#dynamicRoutes.get(method) ?? [];
|
|
65
|
+
const dynamicForAll = this.#dynamicRoutes.get("ALL") ?? [];
|
|
66
|
+
for (const route of [...dynamicForMethod, ...dynamicForAll]) {
|
|
67
|
+
const m = route.regex.exec(path);
|
|
68
|
+
if (m) {
|
|
69
|
+
const params = {};
|
|
70
|
+
route.paramNames.forEach((name, i) => {
|
|
71
|
+
if (m[i + 1] !== undefined) {
|
|
72
|
+
params[name] = m[i + 1];
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return { definition: route.definition, params };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 405: check if ANY method matches this path
|
|
79
|
+
const allowed = this.#findAllowedMethods(path);
|
|
80
|
+
if (allowed.length > 0) {
|
|
81
|
+
return {
|
|
82
|
+
definition: undefined,
|
|
83
|
+
params: {},
|
|
84
|
+
allowedMethods: allowed,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
#findAllowedMethods(path) {
|
|
90
|
+
const methods = new Set();
|
|
91
|
+
for (const [method, map] of this.#staticRoutes) {
|
|
92
|
+
if (method === "ALL")
|
|
93
|
+
continue;
|
|
94
|
+
if (map.has(path))
|
|
95
|
+
methods.add(method);
|
|
96
|
+
}
|
|
97
|
+
for (const [method, routes] of this.#dynamicRoutes) {
|
|
98
|
+
if (method === "ALL")
|
|
99
|
+
continue;
|
|
100
|
+
for (const route of routes) {
|
|
101
|
+
if (route.regex.test(path)) {
|
|
102
|
+
methods.add(method);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...methods];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InputKind, InputOptions, RouteDefinition } from "./types.ts";
|
|
2
|
+
export interface ParsedInput {
|
|
3
|
+
body: unknown;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
query: Record<string, string | string[]>;
|
|
6
|
+
cookies: Record<string, string>;
|
|
7
|
+
path: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse and validate all input from a request according to the route definition.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseInput(req: Request, def: RouteDefinition, rawParams: Record<string, string>): Promise<ParsedInput>;
|
|
13
|
+
/**
|
|
14
|
+
* Parse the request body based on InputKind.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseBody(req: Request, kind: InputKind, opts: InputOptions<InputKind>): Promise<unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Extract all headers as a lowercased-key record.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractHeaders(req: Request): Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* Parse cookies from the Cookie header.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseCookies(req: Request): Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Extract query parameters. Duplicate keys become arrays.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractQuery(req: Request): Record<string, string | string[]>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { BadRequestError, HttpError } from "./error.js";
|
|
2
|
+
import { validateField, validatePathParams } from "./validation.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parse and validate all input from a request according to the route definition.
|
|
5
|
+
*/
|
|
6
|
+
export async function parseInput(req, def, rawParams) {
|
|
7
|
+
const cookies = parseCookies(req);
|
|
8
|
+
const headers = extractHeaders(req);
|
|
9
|
+
const query = extractQuery(req);
|
|
10
|
+
const body = await parseBody(req, def.inputKind, def.inputOptions);
|
|
11
|
+
const path = validatePathParams(rawParams, def.paramSchemas);
|
|
12
|
+
// Validate each field if a schema is provided
|
|
13
|
+
const opts = def.inputOptions;
|
|
14
|
+
return {
|
|
15
|
+
body: opts.body ? validateField(opts.body, body, "body") : body,
|
|
16
|
+
headers: (opts.headers
|
|
17
|
+
? validateField(opts.headers, headers, "headers")
|
|
18
|
+
: headers),
|
|
19
|
+
query: (opts.query
|
|
20
|
+
? validateField(opts.query, query, "query")
|
|
21
|
+
: query),
|
|
22
|
+
cookies: (opts.cookies
|
|
23
|
+
? validateField(opts.cookies, cookies, "cookies")
|
|
24
|
+
: cookies),
|
|
25
|
+
path,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse the request body based on InputKind.
|
|
30
|
+
*/
|
|
31
|
+
export async function parseBody(req, kind, opts) {
|
|
32
|
+
switch (kind) {
|
|
33
|
+
case "json":
|
|
34
|
+
try {
|
|
35
|
+
return await req.json();
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw new BadRequestError("Invalid JSON", e);
|
|
39
|
+
}
|
|
40
|
+
case "multipart":
|
|
41
|
+
case "urlencoded":
|
|
42
|
+
enforceMaxSize(req, opts.maxSize);
|
|
43
|
+
return req.formData();
|
|
44
|
+
case "blob":
|
|
45
|
+
enforceMaxSize(req, opts.maxSize);
|
|
46
|
+
return req.blob();
|
|
47
|
+
case "text":
|
|
48
|
+
return req.text();
|
|
49
|
+
case "none":
|
|
50
|
+
default:
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Check Content-Length against maxSize before reading the body.
|
|
56
|
+
*/
|
|
57
|
+
function enforceMaxSize(req, maxSize) {
|
|
58
|
+
if (!maxSize)
|
|
59
|
+
return;
|
|
60
|
+
const contentLength = req.headers.get("content-length");
|
|
61
|
+
if (contentLength && parseInt(contentLength) > maxSize) {
|
|
62
|
+
throw new HttpError(413, "Payload Too Large");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Extract all headers as a lowercased-key record.
|
|
67
|
+
*/
|
|
68
|
+
export function extractHeaders(req) {
|
|
69
|
+
const headers = {};
|
|
70
|
+
req.headers.forEach((value, key) => {
|
|
71
|
+
headers[key] = value; // Headers API already lowercases keys
|
|
72
|
+
});
|
|
73
|
+
return headers;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse cookies from the Cookie header.
|
|
77
|
+
*/
|
|
78
|
+
export function parseCookies(req) {
|
|
79
|
+
const header = req.headers.get("cookie") ?? "";
|
|
80
|
+
if (!header)
|
|
81
|
+
return {};
|
|
82
|
+
return Object.fromEntries(header
|
|
83
|
+
.split(";")
|
|
84
|
+
.map((pair) => pair.trim().split("="))
|
|
85
|
+
.filter(([k]) => k?.length > 0)
|
|
86
|
+
.map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("="))]));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Extract query parameters. Duplicate keys become arrays.
|
|
90
|
+
*/
|
|
91
|
+
export function extractQuery(req) {
|
|
92
|
+
const params = {};
|
|
93
|
+
new URL(req.url).searchParams.forEach((value, key) => {
|
|
94
|
+
const existing = params[key];
|
|
95
|
+
if (existing === undefined) {
|
|
96
|
+
params[key] = value;
|
|
97
|
+
}
|
|
98
|
+
else if (Array.isArray(existing)) {
|
|
99
|
+
existing.push(value);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
params[key] = [existing, value];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return params;
|
|
106
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AnyContractBuilder, ContractShape } from "../contract/index.ts";
|
|
2
|
+
import type { AfterInterceptorFn, AnySchema, BasePathParams, BeforeInterceptorFn, ErrorHandlerFn, HandlerArgs, HttpMethod, InferOutput, InputKind, InputOptions, InterceptorPair, MergeParam, MiddlewareFn, RouteDefinition, RouteMeta } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Internal interface — the Router class (step 06) will implement this.
|
|
5
|
+
* Allows RouteBuilder to register completed routes without a circular dependency.
|
|
6
|
+
*/
|
|
7
|
+
export interface RouterRef {
|
|
8
|
+
_registerRoute(def: RouteDefinition): void;
|
|
9
|
+
_getMiddlewares(): MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[];
|
|
10
|
+
_getPrefix(): string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A completed route — returned by `RouteBuilder.handle()`.
|
|
14
|
+
* Exposes the route definition for testing/introspection and allows `.catch()`.
|
|
15
|
+
*/
|
|
16
|
+
export declare class FinishedRoute {
|
|
17
|
+
readonly definition: RouteDefinition;
|
|
18
|
+
constructor(definition: RouteDefinition);
|
|
19
|
+
catch(fn: ErrorHandlerFn): this;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fluent route builder chain.
|
|
23
|
+
*
|
|
24
|
+
* Each method that changes generic type params returns a new instance.
|
|
25
|
+
* Methods that only add runtime data (.meta(), .intercept()) mutate and return `this`.
|
|
26
|
+
*/
|
|
27
|
+
export declare class RouteBuilder<RouterCtx extends Record<string, unknown>, RouteCtx extends Record<string, unknown>, K extends InputKind = "none", O extends InputOptions<K> = InputOptions<K>, Params extends Record<string, unknown> = Record<string, unknown>, Output = undefined> {
|
|
28
|
+
#private;
|
|
29
|
+
constructor(router: RouterRef, method: HttpMethod, path: string, middlewares?: MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[], paramSchemas?: Map<string, AnySchema>, inputKind?: InputKind, inputOptions?: InputOptions<InputKind>, outputSchema?: AnySchema, errorTypes?: (new (...args: unknown[]) => Error)[], meta?: RouteMeta, interceptors?: InterceptorPair<Record<string, unknown>>[]);
|
|
30
|
+
/** Set route metadata (title, tags, etc.) — mutates, returns this */
|
|
31
|
+
meta(data: RouteMeta): this;
|
|
32
|
+
/** Narrow/coerce a path param with a Valibot schema — returns new builder */
|
|
33
|
+
param<Name extends string & keyof Params, S extends AnySchema>(name: Name, schema: S): RouteBuilder<RouterCtx, RouteCtx, K, O, MergeParam<Params, Name, S>, Output>;
|
|
34
|
+
/** Add route-level middleware — returns new builder with extended context */
|
|
35
|
+
with<NewCtx extends Record<string, unknown>>(mw: MiddlewareFn<RouterCtx & RouteCtx, NewCtx>): RouteBuilder<RouterCtx, RouteCtx & NewCtx, K, O, Params, Output>;
|
|
36
|
+
/** Set input kind and options — returns new builder */
|
|
37
|
+
input<NK extends InputKind, NO extends InputOptions<NK>>(kind: NK, options?: NO): RouteBuilder<RouterCtx, RouteCtx, NK, NO extends InputOptions<NK> ? NO : InputOptions<NK>, Params, Output>;
|
|
38
|
+
/** Set output schema + optional error types — returns new builder */
|
|
39
|
+
output<S extends AnySchema>(schema: S, errors?: (new (...args: unknown[]) => Error)[]): RouteBuilder<RouterCtx, RouteCtx, K, O, Params, InferOutput<S>>;
|
|
40
|
+
/** Add a before/after interceptor pair — mutates, returns this */
|
|
41
|
+
intercept(before?: BeforeInterceptorFn<RouterCtx & RouteCtx>, after?: AfterInterceptorFn<RouterCtx & RouteCtx>): this;
|
|
42
|
+
/** Terminal — registers the route on the router and returns a FinishedRoute */
|
|
43
|
+
handle(fn: (args: HandlerArgs<RouterCtx & RouteCtx, K, O, Params>) => Output extends undefined ? Response | Promise<Response> : Response | Output | Promise<Response | Output>): FinishedRoute;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Factory — creates a RouteBuilder bound to a router reference.
|
|
47
|
+
* Used internally by the Router class method helpers (.get(), .post(), etc.)
|
|
48
|
+
*/
|
|
49
|
+
export declare function createRouteBuilder<RouterCtx extends Record<string, unknown>, P extends string>(router: RouterRef, method: HttpMethod, path: P): RouteBuilder<RouterCtx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Factory — creates a `RouteBuilder` pre-populated from a `@fishenv/http-contract`
|
|
52
|
+
* endpoint (method, path, params, input, output). Used by `Router.contract()`.
|
|
53
|
+
*
|
|
54
|
+
* The returned builder carries exactly the generic shape (`K`, `O`, `Params`,
|
|
55
|
+
* `Output`) declared on the contract endpoint, so `.handle()` gets the same
|
|
56
|
+
* type safety as manually chaining `.input().output().param()`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createRouteBuilderFromContract<RouterCtx extends Record<string, unknown>, C extends AnyContractBuilder>(router: RouterRef, endpoint: C): RouteBuilder<RouterCtx, Record<never, never>, ContractShape<C>["inputKind"], ContractShape<C>["inputOptions"], ContractShape<C>["params"], ContractShape<C>["output"]>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A completed route — returned by `RouteBuilder.handle()`.
|
|
3
|
+
* Exposes the route definition for testing/introspection and allows `.catch()`.
|
|
4
|
+
*/
|
|
5
|
+
export class FinishedRoute {
|
|
6
|
+
definition;
|
|
7
|
+
constructor(definition) {
|
|
8
|
+
this.definition = definition;
|
|
9
|
+
}
|
|
10
|
+
catch(fn) {
|
|
11
|
+
this.definition.errorHandler = fn;
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Fluent route builder chain.
|
|
17
|
+
*
|
|
18
|
+
* Each method that changes generic type params returns a new instance.
|
|
19
|
+
* Methods that only add runtime data (.meta(), .intercept()) mutate and return `this`.
|
|
20
|
+
*/
|
|
21
|
+
export class RouteBuilder {
|
|
22
|
+
#router;
|
|
23
|
+
#method;
|
|
24
|
+
#path;
|
|
25
|
+
#middlewares;
|
|
26
|
+
#paramSchemas;
|
|
27
|
+
#inputKind;
|
|
28
|
+
#inputOptions;
|
|
29
|
+
#outputSchema;
|
|
30
|
+
#errorTypes;
|
|
31
|
+
#meta;
|
|
32
|
+
#interceptors;
|
|
33
|
+
constructor(router, method, path, middlewares = [], paramSchemas = new Map(), inputKind = "none", inputOptions = {}, outputSchema, errorTypes, meta, interceptors = []) {
|
|
34
|
+
this.#router = router;
|
|
35
|
+
this.#method = method;
|
|
36
|
+
this.#path = path;
|
|
37
|
+
this.#middlewares = middlewares;
|
|
38
|
+
this.#paramSchemas = paramSchemas;
|
|
39
|
+
this.#inputKind = inputKind;
|
|
40
|
+
this.#inputOptions = inputOptions;
|
|
41
|
+
this.#outputSchema = outputSchema;
|
|
42
|
+
this.#errorTypes = errorTypes;
|
|
43
|
+
this.#meta = meta;
|
|
44
|
+
this.#interceptors = interceptors;
|
|
45
|
+
}
|
|
46
|
+
/** Set route metadata (title, tags, etc.) — mutates, returns this */
|
|
47
|
+
meta(data) {
|
|
48
|
+
this.#meta = { ...this.#meta, ...data };
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
/** Narrow/coerce a path param with a Valibot schema — returns new builder */
|
|
52
|
+
param(name, schema) {
|
|
53
|
+
const newSchemas = new Map(this.#paramSchemas);
|
|
54
|
+
newSchemas.set(name, schema);
|
|
55
|
+
return new RouteBuilder(this.#router, this.#method, this.#path, [...this.#middlewares], newSchemas, this.#inputKind, this.#inputOptions, this.#outputSchema, this.#errorTypes ? [...this.#errorTypes] : undefined, this.#meta ? { ...this.#meta } : undefined, [...this.#interceptors]);
|
|
56
|
+
}
|
|
57
|
+
/** Add route-level middleware — returns new builder with extended context */
|
|
58
|
+
with(mw) {
|
|
59
|
+
return new RouteBuilder(this.#router, this.#method, this.#path, [
|
|
60
|
+
...this.#middlewares,
|
|
61
|
+
mw,
|
|
62
|
+
], new Map(this.#paramSchemas), this.#inputKind, this.#inputOptions, this.#outputSchema, this.#errorTypes ? [...this.#errorTypes] : undefined, this.#meta ? { ...this.#meta } : undefined, [...this.#interceptors]);
|
|
63
|
+
}
|
|
64
|
+
/** Set input kind and options — returns new builder */
|
|
65
|
+
input(kind, options) {
|
|
66
|
+
return new RouteBuilder(this.#router, this.#method, this.#path, [...this.#middlewares], new Map(this.#paramSchemas), kind, (options ?? {}), this.#outputSchema, this.#errorTypes ? [...this.#errorTypes] : undefined, this.#meta ? { ...this.#meta } : undefined, [...this.#interceptors]);
|
|
67
|
+
}
|
|
68
|
+
/** Set output schema + optional error types — returns new builder */
|
|
69
|
+
output(schema, errors) {
|
|
70
|
+
return new RouteBuilder(this.#router, this.#method, this.#path, [...this.#middlewares], new Map(this.#paramSchemas), this.#inputKind, this.#inputOptions, schema, errors, this.#meta ? { ...this.#meta } : undefined, [...this.#interceptors]);
|
|
71
|
+
}
|
|
72
|
+
/** Add a before/after interceptor pair — mutates, returns this */
|
|
73
|
+
intercept(before, after) {
|
|
74
|
+
this.#interceptors.push({
|
|
75
|
+
before: before,
|
|
76
|
+
after: after,
|
|
77
|
+
});
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
/** Terminal — registers the route on the router and returns a FinishedRoute */
|
|
81
|
+
handle(fn) {
|
|
82
|
+
const fullPath = this.#router._getPrefix()
|
|
83
|
+
? `${this.#router._getPrefix()}${this.#path}`
|
|
84
|
+
: this.#path;
|
|
85
|
+
const routerMiddlewares = this.#router._getMiddlewares();
|
|
86
|
+
const definition = {
|
|
87
|
+
method: this.#method,
|
|
88
|
+
path: fullPath,
|
|
89
|
+
middlewares: [...routerMiddlewares, ...this.#middlewares],
|
|
90
|
+
paramSchemas: this.#paramSchemas,
|
|
91
|
+
inputKind: this.#inputKind,
|
|
92
|
+
inputOptions: this.#inputOptions,
|
|
93
|
+
outputSchema: this.#outputSchema,
|
|
94
|
+
errorTypes: this.#errorTypes,
|
|
95
|
+
meta: this.#meta,
|
|
96
|
+
interceptors: this.#interceptors,
|
|
97
|
+
handler: fn,
|
|
98
|
+
kind: "http",
|
|
99
|
+
};
|
|
100
|
+
this.#router._registerRoute(definition);
|
|
101
|
+
return new FinishedRoute(definition);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Factory — creates a RouteBuilder bound to a router reference.
|
|
106
|
+
* Used internally by the Router class method helpers (.get(), .post(), etc.)
|
|
107
|
+
*/
|
|
108
|
+
export function createRouteBuilder(router, method, path) {
|
|
109
|
+
return new RouteBuilder(router, method, path);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Factory — creates a `RouteBuilder` pre-populated from a `@fishenv/http-contract`
|
|
113
|
+
* endpoint (method, path, params, input, output). Used by `Router.contract()`.
|
|
114
|
+
*
|
|
115
|
+
* The returned builder carries exactly the generic shape (`K`, `O`, `Params`,
|
|
116
|
+
* `Output`) declared on the contract endpoint, so `.handle()` gets the same
|
|
117
|
+
* type safety as manually chaining `.input().output().param()`.
|
|
118
|
+
*/
|
|
119
|
+
export function createRouteBuilderFromContract(router, endpoint) {
|
|
120
|
+
const def = endpoint._definition;
|
|
121
|
+
return new RouteBuilder(router, def.method, def.path, [], new Map(def.paramSchemas), def.inputKind, def.inputOptions, def.outputSchema, def.errorTypes ? [...def.errorTypes] : undefined, def.meta ? { ...def.meta } : undefined, []);
|
|
122
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { AnyContractBuilder, ContractShape } from "../contract/index.ts";
|
|
2
|
+
import { type CorsOptions } from "./cors.ts";
|
|
3
|
+
import { type RouteBuilder, type RouterRef } from "./route-builder.ts";
|
|
4
|
+
import type { BasePathParams, DIContainer, ErrorHandlerFn, InputOptions, MiddlewareFn, NotFoundHandler, RouteDefinition } from "./types.ts";
|
|
5
|
+
type AnyRouter = Router<any, any>;
|
|
6
|
+
/**
|
|
7
|
+
* A router mounted via `.add()`, together with the prefix and middleware of
|
|
8
|
+
* the parent variant it was mounted on. Applied to the child's routes at build().
|
|
9
|
+
*/
|
|
10
|
+
interface MountedRouter {
|
|
11
|
+
router: AnyRouter;
|
|
12
|
+
prefix: string;
|
|
13
|
+
middlewares: MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Shared mutable state between a Router and all its .use() variants.
|
|
17
|
+
* Routes registered on any variant end up in the same store.
|
|
18
|
+
*/
|
|
19
|
+
interface RouterStore {
|
|
20
|
+
routes: RouteDefinition[];
|
|
21
|
+
children: MountedRouter[];
|
|
22
|
+
locked: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Router — immutable middleware chain, mutable route registration.
|
|
26
|
+
*
|
|
27
|
+
* `.use()` returns a new Router with the extended Ctx type but the same
|
|
28
|
+
* backing store — routes registered on either instance are collected together.
|
|
29
|
+
* Route methods (.get, .post, etc.) return a RouteBuilder chain.
|
|
30
|
+
* Call `.build()` before `.fetch` or `serve()`.
|
|
31
|
+
*
|
|
32
|
+
* `Requires` is the context this router expects a parent to provide when it
|
|
33
|
+
* is mounted via `.add()`. Create one with `r<AppCtx>()`; `.add()` only
|
|
34
|
+
* accepts it on a parent whose `Ctx` satisfies `AppCtx`, and `serve()` only
|
|
35
|
+
* accepts routers with no outstanding requirements.
|
|
36
|
+
*/
|
|
37
|
+
export declare class Router<Ctx extends Record<string, unknown> = Record<never, never>, Requires extends Record<string, unknown> = Record<never, never>> implements RouterRef {
|
|
38
|
+
#private;
|
|
39
|
+
/** @internal Phantom — makes `Requires` contravariant for `.add()` checks. */
|
|
40
|
+
readonly _requires?: (ctx: Requires) => void;
|
|
41
|
+
constructor(opts?: {
|
|
42
|
+
prefix?: string;
|
|
43
|
+
}, middlewares?: MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[], errorHandler?: ErrorHandlerFn, notFoundHandler?: NotFoundHandler, store?: RouterStore);
|
|
44
|
+
_registerRoute(def: RouteDefinition): void;
|
|
45
|
+
_getMiddlewares(): MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[];
|
|
46
|
+
_getPrefix(): string;
|
|
47
|
+
use<NewCtx extends Record<string, unknown>>(mw: MiddlewareFn<Ctx, NewCtx>): Router<Ctx & NewCtx, Requires>;
|
|
48
|
+
/** Create a child router and mount it on this one — shorthand for `r<Ctx>(opts)` + `.add()`. */
|
|
49
|
+
extend(opts?: {
|
|
50
|
+
prefix?: string;
|
|
51
|
+
}): Router<Ctx, Ctx>;
|
|
52
|
+
/**
|
|
53
|
+
* Mount routers defined elsewhere, optionally under an extra prefix:
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* app.add(users, posts);
|
|
57
|
+
* app.add("/v1", users);
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* Each child's routes get this router's prefix and middleware prepended at
|
|
61
|
+
* build(). A child created with `r<AppCtx>()` only type-checks here if this
|
|
62
|
+
* router's context provides `AppCtx`.
|
|
63
|
+
*/
|
|
64
|
+
add(...children: Router<any, Ctx>[]): this;
|
|
65
|
+
add(prefix: string, ...children: Router<any, Ctx>[]): this;
|
|
66
|
+
get<P extends string>(path: P): RouteBuilder<Ctx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
67
|
+
post<P extends string>(path: P): RouteBuilder<Ctx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
68
|
+
put<P extends string>(path: P): RouteBuilder<Ctx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
69
|
+
patch<P extends string>(path: P): RouteBuilder<Ctx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
70
|
+
delete<P extends string>(path: P): RouteBuilder<Ctx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
71
|
+
options<P extends string>(path: P): RouteBuilder<Ctx, Record<never, never>, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Register a route from a `@fishenv/http-contract` endpoint definition, e.g.:
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* router.contract(contract.users.create).handle(({ body }) => { ... });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* `.handle()` gets exactly the same type safety (body/query/params/output)
|
|
80
|
+
* as chaining `.input().output().param()` manually, because the contract
|
|
81
|
+
* endpoint and the route builder share the same shape types.
|
|
82
|
+
*/
|
|
83
|
+
contract<C extends AnyContractBuilder>(endpoint: C): RouteBuilder<Ctx, Record<never, never>, ContractShape<C>["inputKind"], ContractShape<C>["inputOptions"], ContractShape<C>["params"], ContractShape<C>["output"]>;
|
|
84
|
+
cors(options: CorsOptions): this;
|
|
85
|
+
onError(handler: ErrorHandlerFn): this;
|
|
86
|
+
notFound(handler: NotFoundHandler): this;
|
|
87
|
+
/** @internal Set by serve(). For testing, call before build(). */
|
|
88
|
+
_setContainer(container: DIContainer): void;
|
|
89
|
+
build(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Entry point for request handling — available after build().
|
|
92
|
+
* Use directly for testing without binding a port.
|
|
93
|
+
*/
|
|
94
|
+
readonly fetch: (req: Request) => Promise<Response>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Create a router. Pass a context type to declare what a parent must provide
|
|
98
|
+
* when this router is mounted with `.add()`:
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* // context.ts
|
|
102
|
+
* export const base = r().use(auth);
|
|
103
|
+
* export type AppCtx = CtxOf<typeof base>;
|
|
104
|
+
*
|
|
105
|
+
* // users.ts — type-only import, no runtime cycle
|
|
106
|
+
* export const users = r<AppCtx>({ prefix: "/users" });
|
|
107
|
+
*
|
|
108
|
+
* // main.ts
|
|
109
|
+
* serve(base.add(users));
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export declare function r<Req extends Record<string, unknown> = Record<never, never>>(opts?: {
|
|
113
|
+
prefix?: string;
|
|
114
|
+
}): Router<Req, Req>;
|
|
115
|
+
export declare const router: typeof r;
|
|
116
|
+
/** The context type a router's handlers receive. */
|
|
117
|
+
export type CtxOf<R> = R extends Router<infer C, infer _> ? C : never;
|
|
118
|
+
export interface ServeOptions {
|
|
119
|
+
port?: number;
|
|
120
|
+
hostname?: string;
|
|
121
|
+
container?: DIContainer;
|
|
122
|
+
development?: boolean;
|
|
123
|
+
logger?: Logger;
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
/**
|
|
126
|
+
* Seconds a connection may sit idle before Bun closes it. Defaults to `0`
|
|
127
|
+
* (disabled) so long-lived SSE streams are not cut between keepalives.
|
|
128
|
+
*/
|
|
129
|
+
idleTimeout?: number;
|
|
130
|
+
onListen?: (addr: {
|
|
131
|
+
hostname: string;
|
|
132
|
+
port: number;
|
|
133
|
+
}) => void;
|
|
134
|
+
}
|
|
135
|
+
/** A running server started by {@link serve}. */
|
|
136
|
+
export interface HttpServer {
|
|
137
|
+
readonly hostname: string;
|
|
138
|
+
readonly port: number;
|
|
139
|
+
/** Stop accepting connections; `force` also closes in-flight ones. */
|
|
140
|
+
stop(force?: boolean): Promise<void>;
|
|
141
|
+
}
|
|
142
|
+
export interface Logger {
|
|
143
|
+
error(message: string, ...args: unknown[]): void;
|
|
144
|
+
warn(message: string, ...args: unknown[]): void;
|
|
145
|
+
info(message: string, ...args: unknown[]): void;
|
|
146
|
+
}
|
|
147
|
+
export declare function serve(app: Router<any, Record<never, never>>, options?: ServeOptions): HttpServer;
|
|
148
|
+
export {};
|