@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,87 @@
|
|
|
1
|
+
import { defaultErrorHandler } from "./error.js";
|
|
2
|
+
import { parseInput } from "./request.js";
|
|
3
|
+
/**
|
|
4
|
+
* Run the middleware pipeline, accumulating context via object spread.
|
|
5
|
+
* Each middleware receives the accumulated ctx and returns new fields to merge.
|
|
6
|
+
*/
|
|
7
|
+
export async function runMiddlewarePipeline(req, middlewares, container) {
|
|
8
|
+
let ctx = { req, container };
|
|
9
|
+
for (const mw of middlewares) {
|
|
10
|
+
const additions = await mw({ ...ctx, req });
|
|
11
|
+
ctx = { ...ctx, ...additions };
|
|
12
|
+
}
|
|
13
|
+
return ctx;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run before interceptors, the handler, then after interceptors (in reverse).
|
|
17
|
+
* Before and after share a mutable `state` object created per-request.
|
|
18
|
+
*/
|
|
19
|
+
export async function runInterceptors(interceptors, req, ctx, handlerFn) {
|
|
20
|
+
const state = {};
|
|
21
|
+
// Run all before interceptors
|
|
22
|
+
for (const { before } of interceptors) {
|
|
23
|
+
if (before)
|
|
24
|
+
await before({ req, ctx, state });
|
|
25
|
+
}
|
|
26
|
+
// Run handler
|
|
27
|
+
let response = await handlerFn();
|
|
28
|
+
// Run all after interceptors in reverse order
|
|
29
|
+
for (const { after } of [...interceptors].reverse()) {
|
|
30
|
+
if (after)
|
|
31
|
+
response = await after({ response, ctx, state });
|
|
32
|
+
}
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Coerce a handler's return value into a Response.
|
|
37
|
+
* - If already a Response, pass through.
|
|
38
|
+
* - If output schema is defined, serialize as JSON.
|
|
39
|
+
* - Otherwise throw — handler contract violation.
|
|
40
|
+
*/
|
|
41
|
+
export function coerceToResponse(raw, def) {
|
|
42
|
+
if (raw instanceof Response)
|
|
43
|
+
return raw;
|
|
44
|
+
if (def.outputSchema != null)
|
|
45
|
+
return Response.json(raw);
|
|
46
|
+
throw new TypeError("Handler must return a Response when no output schema is defined");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Full request dispatch: middleware → input parsing → interceptors + handler → error chain.
|
|
50
|
+
*/
|
|
51
|
+
export async function dispatch(req, definition, params, container, errorHandlers) {
|
|
52
|
+
try {
|
|
53
|
+
// 0. Guards run before any middleware
|
|
54
|
+
if (definition.guards) {
|
|
55
|
+
for (const guard of definition.guards)
|
|
56
|
+
await guard(req);
|
|
57
|
+
}
|
|
58
|
+
// 1. Build context via middleware chain
|
|
59
|
+
const ctx = await runMiddlewarePipeline(req, definition.middlewares, container);
|
|
60
|
+
// 2. Parse & validate input
|
|
61
|
+
const input = await parseInput(req, definition, params);
|
|
62
|
+
// 3. Run interceptors + handler
|
|
63
|
+
const response = await runInterceptors(definition.interceptors, req, ctx, async () => {
|
|
64
|
+
const raw = await definition.handler({
|
|
65
|
+
req,
|
|
66
|
+
ctx,
|
|
67
|
+
container,
|
|
68
|
+
path: input.path,
|
|
69
|
+
body: input.body,
|
|
70
|
+
headers: input.headers,
|
|
71
|
+
query: input.query,
|
|
72
|
+
cookies: input.cookies,
|
|
73
|
+
});
|
|
74
|
+
return coerceToResponse(raw, definition);
|
|
75
|
+
});
|
|
76
|
+
return response;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
for (const handler of errorHandlers) {
|
|
80
|
+
const result = await handler(err, { req });
|
|
81
|
+
if (result != null)
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
// Built-in fallback always returns
|
|
85
|
+
return defaultErrorHandler(err, { req });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface CorsOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Allowed origin(s).
|
|
4
|
+
* - `'*'` — allow all origins (when credentials is false)
|
|
5
|
+
* - `string` — allow a single explicit origin
|
|
6
|
+
* - `string[]` — allow any origin in the list
|
|
7
|
+
* - `(origin) =>` — dynamic resolver; return the origin to allow or null/undefined to deny
|
|
8
|
+
*/
|
|
9
|
+
origin: string | string[] | ((origin: string) => string | null | undefined);
|
|
10
|
+
/** HTTP methods to allow. Default: GET HEAD PUT POST DELETE PATCH */
|
|
11
|
+
allowMethods?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Headers to allow. Default: [] — when empty the middleware echoes the
|
|
14
|
+
* request's Access-Control-Request-Headers value (wildcard-like behaviour).
|
|
15
|
+
*/
|
|
16
|
+
allowHeaders?: string[];
|
|
17
|
+
/** Headers to expose to the browser. Default: [] */
|
|
18
|
+
exposeHeaders?: string[];
|
|
19
|
+
/** Whether to include credentials. Default: false */
|
|
20
|
+
credentials?: boolean;
|
|
21
|
+
/** Max-age for preflight cache in seconds. Default: not set */
|
|
22
|
+
maxAge?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface CompiledCors {
|
|
25
|
+
/**
|
|
26
|
+
* Build and return a 204 preflight response.
|
|
27
|
+
* Always returns a Response — callers decide whether to use it based on the
|
|
28
|
+
* request method being OPTIONS.
|
|
29
|
+
*/
|
|
30
|
+
preflight(req: Request): Response;
|
|
31
|
+
/**
|
|
32
|
+
* Clone the response (if needed) and inject the CORS response-phase headers:
|
|
33
|
+
* Access-Control-Allow-Origin, Access-Control-Allow-Credentials,
|
|
34
|
+
* Access-Control-Expose-Headers, Vary.
|
|
35
|
+
*/
|
|
36
|
+
applyTo(req: Request, res: Response): Response;
|
|
37
|
+
}
|
|
38
|
+
export declare function buildCors(options: CorsOptions): CompiledCors;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// CORS types
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// buildCors
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const DEFAULT_ALLOW_METHODS = ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"];
|
|
8
|
+
export function buildCors(options) {
|
|
9
|
+
const { origin, allowMethods = DEFAULT_ALLOW_METHODS, allowHeaders = [], exposeHeaders = [], credentials = false, maxAge, } = options;
|
|
10
|
+
// Build a synchronous origin resolver -----------------------------------
|
|
11
|
+
function resolveOrigin(requestOrigin) {
|
|
12
|
+
if (typeof origin === "function") {
|
|
13
|
+
return origin(requestOrigin) ?? null;
|
|
14
|
+
}
|
|
15
|
+
if (origin === "*") {
|
|
16
|
+
// The spec forbids Access-Control-Allow-Origin: * together with
|
|
17
|
+
// Access-Control-Allow-Credentials: true — reflect the request origin.
|
|
18
|
+
return credentials ? requestOrigin || null : "*";
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(origin)) {
|
|
21
|
+
return origin.includes(requestOrigin) ? requestOrigin : null;
|
|
22
|
+
}
|
|
23
|
+
// Single explicit origin string
|
|
24
|
+
return origin === requestOrigin ? requestOrigin : null;
|
|
25
|
+
}
|
|
26
|
+
// Whether to add Vary: Origin -------------------------------------------
|
|
27
|
+
function needsVary() {
|
|
28
|
+
// When the effective origin is always literal '*' (no credentials, no
|
|
29
|
+
// dynamic/list/single origin), Vary is unnecessary.
|
|
30
|
+
return credentials || origin !== "*";
|
|
31
|
+
}
|
|
32
|
+
// ── preflight -----------------------------------------------------------
|
|
33
|
+
function preflight(req) {
|
|
34
|
+
const requestOrigin = req.headers.get("origin") ?? "";
|
|
35
|
+
const allowed = resolveOrigin(requestOrigin);
|
|
36
|
+
const headers = new Headers();
|
|
37
|
+
if (allowed) {
|
|
38
|
+
headers.set("Access-Control-Allow-Origin", allowed);
|
|
39
|
+
}
|
|
40
|
+
if (credentials) {
|
|
41
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
42
|
+
}
|
|
43
|
+
if (needsVary()) {
|
|
44
|
+
headers.set("Vary", "Origin");
|
|
45
|
+
}
|
|
46
|
+
if (maxAge != null) {
|
|
47
|
+
headers.set("Access-Control-Max-Age", String(maxAge));
|
|
48
|
+
}
|
|
49
|
+
if (allowMethods.length) {
|
|
50
|
+
headers.set("Access-Control-Allow-Methods", allowMethods.join(", "));
|
|
51
|
+
}
|
|
52
|
+
// Allowed headers: use configured list or echo the request header
|
|
53
|
+
let effectiveAllowHeaders = allowHeaders;
|
|
54
|
+
if (!effectiveAllowHeaders.length) {
|
|
55
|
+
const requested = req.headers.get("access-control-request-headers");
|
|
56
|
+
if (requested) {
|
|
57
|
+
effectiveAllowHeaders = requested.split(/\s*,\s*/);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (effectiveAllowHeaders.length) {
|
|
61
|
+
headers.set("Access-Control-Allow-Headers", effectiveAllowHeaders.join(", "));
|
|
62
|
+
// Vary on this header when it was echoed from the request
|
|
63
|
+
if (!allowHeaders.length) {
|
|
64
|
+
const vary = headers.get("Vary");
|
|
65
|
+
headers.set("Vary", vary
|
|
66
|
+
? `${vary}, Access-Control-Request-Headers`
|
|
67
|
+
: "Access-Control-Request-Headers");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return new Response(null, { status: 204, headers });
|
|
71
|
+
}
|
|
72
|
+
// ── applyTo -------------------------------------------------------------
|
|
73
|
+
function applyTo(req, res) {
|
|
74
|
+
const requestOrigin = req.headers.get("origin") ?? "";
|
|
75
|
+
const allowed = resolveOrigin(requestOrigin);
|
|
76
|
+
// Nothing to add if origin is not allowed
|
|
77
|
+
if (!allowed)
|
|
78
|
+
return res;
|
|
79
|
+
// Clone response so we can safely mutate headers
|
|
80
|
+
const next = new Response(res.body, res);
|
|
81
|
+
next.headers.set("Access-Control-Allow-Origin", allowed);
|
|
82
|
+
if (credentials) {
|
|
83
|
+
next.headers.set("Access-Control-Allow-Credentials", "true");
|
|
84
|
+
}
|
|
85
|
+
if (exposeHeaders.length) {
|
|
86
|
+
next.headers.set("Access-Control-Expose-Headers", exposeHeaders.join(", "));
|
|
87
|
+
}
|
|
88
|
+
if (needsVary()) {
|
|
89
|
+
// Append rather than set to preserve any existing Vary value
|
|
90
|
+
const existing = next.headers.get("Vary");
|
|
91
|
+
next.headers.set("Vary", existing ? `${existing}, Origin` : "Origin");
|
|
92
|
+
}
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
return { preflight, applyTo };
|
|
96
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type * as v from "valibot";
|
|
2
|
+
import type { HttpMethod } from "./types.ts";
|
|
3
|
+
/** Stable machine-readable code for a status, e.g. `403` → `"forbidden"`. */
|
|
4
|
+
export declare function errorCodeForStatus(status: number): string;
|
|
5
|
+
/**
|
|
6
|
+
* Transport-neutral error payload. HTTP responses and WebSocket replies are
|
|
7
|
+
* both rendered from this, so they share one disclosure policy.
|
|
8
|
+
*/
|
|
9
|
+
export interface HttpErrorJSON {
|
|
10
|
+
status: number;
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
details?: Record<string, unknown>;
|
|
14
|
+
stack?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class HttpError extends Error {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
constructor(status: number, message: string, cause?: unknown);
|
|
19
|
+
/** Machine-readable code. Override in subclasses for custom errors. */
|
|
20
|
+
get code(): string;
|
|
21
|
+
/** Extra public fields (e.g. validation issues). Never include secrets. */
|
|
22
|
+
protected details(): Record<string, unknown> | undefined;
|
|
23
|
+
/** The public payload; the stack is only included in development. */
|
|
24
|
+
toJSON(development?: boolean): HttpErrorJSON;
|
|
25
|
+
toResponse(development?: boolean): Response;
|
|
26
|
+
}
|
|
27
|
+
export declare class BadRequestError extends HttpError {
|
|
28
|
+
constructor(message?: string, cause?: unknown);
|
|
29
|
+
}
|
|
30
|
+
export declare class UnauthorizedError extends HttpError {
|
|
31
|
+
constructor(message?: string);
|
|
32
|
+
}
|
|
33
|
+
export declare class ForbiddenError extends HttpError {
|
|
34
|
+
constructor(message?: string);
|
|
35
|
+
}
|
|
36
|
+
export declare class NotFoundError extends HttpError {
|
|
37
|
+
constructor(message?: string);
|
|
38
|
+
}
|
|
39
|
+
export declare class MethodNotAllowedError extends HttpError {
|
|
40
|
+
constructor(message?: string);
|
|
41
|
+
}
|
|
42
|
+
export declare class ConflictError extends HttpError {
|
|
43
|
+
constructor(message?: string, cause?: unknown);
|
|
44
|
+
}
|
|
45
|
+
export declare class UnprocessableEntityError extends HttpError {
|
|
46
|
+
constructor(message?: string, cause?: unknown);
|
|
47
|
+
}
|
|
48
|
+
export declare class TooManyRequestsError extends HttpError {
|
|
49
|
+
constructor(message?: string);
|
|
50
|
+
}
|
|
51
|
+
export declare class InternalServerError extends HttpError {
|
|
52
|
+
constructor(message?: string, cause?: unknown);
|
|
53
|
+
}
|
|
54
|
+
export type ValidationField = "body" | "headers" | "query" | "cookies" | "path";
|
|
55
|
+
export declare class ValidationError extends HttpError {
|
|
56
|
+
readonly field: ValidationField;
|
|
57
|
+
readonly issues: v.BaseIssue<unknown>[];
|
|
58
|
+
constructor(field: ValidationField, issues: v.BaseIssue<unknown>[]);
|
|
59
|
+
get code(): string;
|
|
60
|
+
protected details(): Record<string, unknown>;
|
|
61
|
+
toResponse(): Response;
|
|
62
|
+
}
|
|
63
|
+
export interface ErrorHandlerOpts {
|
|
64
|
+
development?: boolean;
|
|
65
|
+
logger?: {
|
|
66
|
+
error: (...args: unknown[]) => void;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export declare function defaultErrorHandler(err: unknown, _args: {
|
|
70
|
+
req: Request;
|
|
71
|
+
}, opts?: ErrorHandlerOpts): Response;
|
|
72
|
+
export declare function defaultNotFoundHandler(req: Request): Response;
|
|
73
|
+
export declare function defaultMethodNotAllowedHandler(_req: Request, allowed: HttpMethod[]): Response;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const CODES = {
|
|
2
|
+
400: "bad_request",
|
|
3
|
+
401: "unauthorized",
|
|
4
|
+
403: "forbidden",
|
|
5
|
+
404: "not_found",
|
|
6
|
+
405: "method_not_allowed",
|
|
7
|
+
409: "conflict",
|
|
8
|
+
422: "unprocessable_entity",
|
|
9
|
+
429: "too_many_requests",
|
|
10
|
+
500: "internal",
|
|
11
|
+
};
|
|
12
|
+
/** Stable machine-readable code for a status, e.g. `403` → `"forbidden"`. */
|
|
13
|
+
export function errorCodeForStatus(status) {
|
|
14
|
+
return CODES[status] ?? `http_${status}`;
|
|
15
|
+
}
|
|
16
|
+
export class HttpError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
constructor(status, message, cause) {
|
|
19
|
+
super(message, { cause });
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.name = "HttpError";
|
|
22
|
+
}
|
|
23
|
+
/** Machine-readable code. Override in subclasses for custom errors. */
|
|
24
|
+
get code() {
|
|
25
|
+
return errorCodeForStatus(this.status);
|
|
26
|
+
}
|
|
27
|
+
/** Extra public fields (e.g. validation issues). Never include secrets. */
|
|
28
|
+
details() {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The public payload; the stack is only included in development. */
|
|
32
|
+
toJSON(development = false) {
|
|
33
|
+
const json = {
|
|
34
|
+
status: this.status,
|
|
35
|
+
code: this.code,
|
|
36
|
+
message: this.message,
|
|
37
|
+
};
|
|
38
|
+
const details = this.details();
|
|
39
|
+
if (details != null)
|
|
40
|
+
json.details = details;
|
|
41
|
+
if (development && this.stack)
|
|
42
|
+
json.stack = this.stack;
|
|
43
|
+
return json;
|
|
44
|
+
}
|
|
45
|
+
toResponse(development = false) {
|
|
46
|
+
const json = this.toJSON(development);
|
|
47
|
+
const body = { error: json.message };
|
|
48
|
+
if (json.stack)
|
|
49
|
+
body.stack = json.stack;
|
|
50
|
+
return Response.json(body, { status: this.status });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class BadRequestError extends HttpError {
|
|
54
|
+
constructor(message = "Bad Request", cause) {
|
|
55
|
+
super(400, message, cause);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class UnauthorizedError extends HttpError {
|
|
59
|
+
constructor(message = "Unauthorized") {
|
|
60
|
+
super(401, message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export class ForbiddenError extends HttpError {
|
|
64
|
+
constructor(message = "Forbidden") {
|
|
65
|
+
super(403, message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class NotFoundError extends HttpError {
|
|
69
|
+
constructor(message = "Not Found") {
|
|
70
|
+
super(404, message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export class MethodNotAllowedError extends HttpError {
|
|
74
|
+
constructor(message = "Method Not Allowed") {
|
|
75
|
+
super(405, message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class ConflictError extends HttpError {
|
|
79
|
+
constructor(message = "Conflict", cause) {
|
|
80
|
+
super(409, message, cause);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class UnprocessableEntityError extends HttpError {
|
|
84
|
+
constructor(message = "Unprocessable Entity", cause) {
|
|
85
|
+
super(422, message, cause);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export class TooManyRequestsError extends HttpError {
|
|
89
|
+
constructor(message = "Too Many Requests") {
|
|
90
|
+
super(429, message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export class InternalServerError extends HttpError {
|
|
94
|
+
constructor(message = "Internal Server Error", cause) {
|
|
95
|
+
super(500, message, cause);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export class ValidationError extends HttpError {
|
|
99
|
+
field;
|
|
100
|
+
issues;
|
|
101
|
+
constructor(field, issues) {
|
|
102
|
+
super(400, "Validation Error");
|
|
103
|
+
this.name = "ValidationError";
|
|
104
|
+
this.field = field;
|
|
105
|
+
this.issues = issues;
|
|
106
|
+
}
|
|
107
|
+
get code() {
|
|
108
|
+
return "validation_error";
|
|
109
|
+
}
|
|
110
|
+
details() {
|
|
111
|
+
return {
|
|
112
|
+
field: this.field,
|
|
113
|
+
issues: this.issues.map((i) => ({
|
|
114
|
+
message: i.message,
|
|
115
|
+
path: i.path?.map((p) => p.key) ?? [],
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
toResponse() {
|
|
120
|
+
return Response.json({ error: "ValidationError", ...this.details() }, { status: 400 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function defaultErrorHandler(err, _args, opts = {}) {
|
|
124
|
+
if (err instanceof HttpError) {
|
|
125
|
+
return err.toResponse(opts.development);
|
|
126
|
+
}
|
|
127
|
+
const logger = opts.logger ?? console;
|
|
128
|
+
logger.error("[fishenv.http] Unhandled error:", err);
|
|
129
|
+
const body = { error: "Internal Server Error" };
|
|
130
|
+
if (opts.development && err instanceof Error)
|
|
131
|
+
body.stack = err.stack;
|
|
132
|
+
return Response.json(body, { status: 500 });
|
|
133
|
+
}
|
|
134
|
+
export function defaultNotFoundHandler(req) {
|
|
135
|
+
return Response.json({ error: "Not Found", path: new URL(req.url).pathname }, { status: 404 });
|
|
136
|
+
}
|
|
137
|
+
export function defaultMethodNotAllowedHandler(_req, allowed) {
|
|
138
|
+
return new Response(null, {
|
|
139
|
+
status: 405,
|
|
140
|
+
headers: { Allow: allowed.join(", ") },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { coerceToResponse, dispatch, runInterceptors, runMiddlewarePipeline, } from "./compose.ts";
|
|
2
|
+
export type { CompiledCors, CorsOptions } from "./cors.ts";
|
|
3
|
+
export { buildCors } from "./cors.ts";
|
|
4
|
+
export type { HttpErrorJSON, ValidationField } from "./error.ts";
|
|
5
|
+
export { BadRequestError, ConflictError, defaultErrorHandler, defaultMethodNotAllowedHandler, defaultNotFoundHandler, errorCodeForStatus, ForbiddenError, HttpError, InternalServerError, MethodNotAllowedError, NotFoundError, TooManyRequestsError, UnauthorizedError, UnprocessableEntityError, ValidationError, } from "./error.ts";
|
|
6
|
+
export type { MatchedRoute, RouterMatcher } from "./matcher.ts";
|
|
7
|
+
export { Matcher } from "./matcher.ts";
|
|
8
|
+
export type { ParsedInput } from "./request.ts";
|
|
9
|
+
export { extractHeaders, extractQuery, parseBody, parseCookies, parseInput, } from "./request.ts";
|
|
10
|
+
export type { RouterRef } from "./route-builder.ts";
|
|
11
|
+
export { createRouteBuilder, createRouteBuilderFromContract, FinishedRoute, RouteBuilder, } from "./route-builder.ts";
|
|
12
|
+
export type { CtxOf, HttpServer, Logger, ServeOptions } from "./router.ts";
|
|
13
|
+
export { Router, r, router, serve } from "./router.ts";
|
|
14
|
+
export type { Transport, TransportBridge, TransportEnvelope, WithoutOrigin, } from "./transport.ts";
|
|
15
|
+
export { createMemoryTransport, createTransportBridge } from "./transport.ts";
|
|
16
|
+
export type { AfterInterceptorFn, AnySchema, BasePathParams, BeforeInterceptorFn, BuildEnv, DIContainer, ErrorHandlerFn, ExtractParams, HandlerArgs, HandlerFn, HttpMethod, InferBody, InferCookies, InferHeaders, InferOutput, InferQuery, InputKind, InputOptions, InterceptorPair, InterceptorState, MergeCtx, MergeParam, MiddlewareFn, NotFoundHandler, ResolvedInput, RouteDefinition, RouteGuardFn, RouteMeta, ValibotSchema, } from "./types.ts";
|
|
17
|
+
export type { SocketData, SocketHandlers, UpgradedSocket, UpgradeIntent, WebSocketRuntime, WsServerOptions, } from "./upgrade.ts";
|
|
18
|
+
export { CLOSE_GOING_AWAY, CLOSE_INTERNAL_ERROR, completeUpgrade, createWebSocketRuntime, DEFAULT_BACKPRESSURE_LIMIT, DEFAULT_MAX_PAYLOAD_LENGTH, requestUpgrade, takeUpgrade, } from "./upgrade.ts";
|
|
19
|
+
export { buildRouteRegex, extractParamNames, mergePaths } from "./utils/url.ts";
|
|
20
|
+
export { validateField, validatePathParams } from "./validation.ts";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
// Compose (pipeline)
|
|
3
|
+
export { coerceToResponse, dispatch, runInterceptors, runMiddlewarePipeline, } from "./compose.js";
|
|
4
|
+
// CORS
|
|
5
|
+
export { buildCors } from "./cors.js";
|
|
6
|
+
// Errors
|
|
7
|
+
export { BadRequestError, ConflictError, defaultErrorHandler, defaultMethodNotAllowedHandler, defaultNotFoundHandler, errorCodeForStatus, ForbiddenError, HttpError, InternalServerError, MethodNotAllowedError, NotFoundError, TooManyRequestsError, UnauthorizedError, UnprocessableEntityError, ValidationError, } from "./error.js";
|
|
8
|
+
// Matcher
|
|
9
|
+
export { Matcher } from "./matcher.js";
|
|
10
|
+
// Request parsing
|
|
11
|
+
export { extractHeaders, extractQuery, parseBody, parseCookies, parseInput, } from "./request.js";
|
|
12
|
+
// Route Builder
|
|
13
|
+
export { createRouteBuilder, createRouteBuilderFromContract, FinishedRoute, RouteBuilder, } from "./route-builder.js";
|
|
14
|
+
// Router
|
|
15
|
+
export { Router, r, router, serve } from "./router.js";
|
|
16
|
+
// Transport (shared by SSE and WS hubs)
|
|
17
|
+
export { createMemoryTransport, createTransportBridge } from "./transport.js";
|
|
18
|
+
// WebSocket upgrade plumbing
|
|
19
|
+
export { CLOSE_GOING_AWAY, CLOSE_INTERNAL_ERROR, completeUpgrade, createWebSocketRuntime, DEFAULT_BACKPRESSURE_LIMIT, DEFAULT_MAX_PAYLOAD_LENGTH, requestUpgrade, takeUpgrade, } from "./upgrade.js";
|
|
20
|
+
// URL utilities
|
|
21
|
+
export { buildRouteRegex, extractParamNames, mergePaths } from "./utils/url.js";
|
|
22
|
+
// Validation
|
|
23
|
+
export { validateField, validatePathParams } from "./validation.js";
|
|
@@ -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,110 @@
|
|
|
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 === "**" || (seg.startsWith(":") && seg.endsWith("*"))) {
|
|
8
|
+
return score + 0;
|
|
9
|
+
}
|
|
10
|
+
if (seg.includes("*") || seg.includes("{"))
|
|
11
|
+
return score + 1;
|
|
12
|
+
if (seg.startsWith(":"))
|
|
13
|
+
return score + 2;
|
|
14
|
+
return score + 3;
|
|
15
|
+
}, 0);
|
|
16
|
+
}
|
|
17
|
+
function hasPatternSyntax(path) {
|
|
18
|
+
return path.includes(":") || path.includes("*") || path.includes("{");
|
|
19
|
+
}
|
|
20
|
+
export class Matcher {
|
|
21
|
+
#rawRoutes = [];
|
|
22
|
+
#compiled = false;
|
|
23
|
+
// Per method: static map + dynamic list
|
|
24
|
+
#staticRoutes = new Map();
|
|
25
|
+
#dynamicRoutes = new Map();
|
|
26
|
+
add(method, path, def) {
|
|
27
|
+
if (this.#compiled) {
|
|
28
|
+
throw new Error("Cannot add routes after compile()");
|
|
29
|
+
}
|
|
30
|
+
this.#rawRoutes.push({ method: method.toUpperCase(), path, def });
|
|
31
|
+
}
|
|
32
|
+
compile() {
|
|
33
|
+
for (const { method, path, def } of this.#rawRoutes) {
|
|
34
|
+
if (!hasPatternSyntax(path)) {
|
|
35
|
+
// Static path
|
|
36
|
+
if (!this.#staticRoutes.has(method)) {
|
|
37
|
+
this.#staticRoutes.set(method, new Map());
|
|
38
|
+
}
|
|
39
|
+
this.#staticRoutes.get(method).set(path, def);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Dynamic path
|
|
43
|
+
const { regex, paramNames } = buildRouteRegex(path);
|
|
44
|
+
const compiled = { regex, paramNames, definition: def };
|
|
45
|
+
if (!this.#dynamicRoutes.has(method)) {
|
|
46
|
+
this.#dynamicRoutes.set(method, []);
|
|
47
|
+
}
|
|
48
|
+
this.#dynamicRoutes.get(method).push(compiled);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Sort dynamic routes: higher specificity first
|
|
52
|
+
for (const list of this.#dynamicRoutes.values()) {
|
|
53
|
+
list.sort((a, b) => specificity(b.definition.path) - specificity(a.definition.path));
|
|
54
|
+
}
|
|
55
|
+
this.#compiled = true;
|
|
56
|
+
}
|
|
57
|
+
match(method, path) {
|
|
58
|
+
method = method.toUpperCase();
|
|
59
|
+
// Check static routes (exact method, then ALL)
|
|
60
|
+
const def = this.#staticRoutes.get(method)?.get(path) ??
|
|
61
|
+
this.#staticRoutes.get("ALL")?.get(path);
|
|
62
|
+
if (def)
|
|
63
|
+
return { definition: def, params: {} };
|
|
64
|
+
// Check dynamic routes (exact method, then ALL)
|
|
65
|
+
const dynamicForMethod = this.#dynamicRoutes.get(method) ?? [];
|
|
66
|
+
const dynamicForAll = this.#dynamicRoutes.get("ALL") ?? [];
|
|
67
|
+
for (const route of [...dynamicForMethod, ...dynamicForAll]) {
|
|
68
|
+
const m = route.regex.exec(path);
|
|
69
|
+
if (m) {
|
|
70
|
+
const params = {};
|
|
71
|
+
route.paramNames.forEach((name, i) => {
|
|
72
|
+
if (m[i + 1] !== undefined) {
|
|
73
|
+
params[name] = m[i + 1];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return { definition: route.definition, params };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 405: check if ANY method matches this path
|
|
80
|
+
const allowed = this.#findAllowedMethods(path);
|
|
81
|
+
if (allowed.length > 0) {
|
|
82
|
+
return {
|
|
83
|
+
definition: undefined,
|
|
84
|
+
params: {},
|
|
85
|
+
allowedMethods: allowed,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
#findAllowedMethods(path) {
|
|
91
|
+
const methods = new Set();
|
|
92
|
+
for (const [method, map] of this.#staticRoutes) {
|
|
93
|
+
if (method === "ALL")
|
|
94
|
+
continue;
|
|
95
|
+
if (map.has(path))
|
|
96
|
+
methods.add(method);
|
|
97
|
+
}
|
|
98
|
+
for (const [method, routes] of this.#dynamicRoutes) {
|
|
99
|
+
if (method === "ALL")
|
|
100
|
+
continue;
|
|
101
|
+
for (const route of routes) {
|
|
102
|
+
if (route.regex.test(path)) {
|
|
103
|
+
methods.add(method);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...methods];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -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[]>;
|