@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,82 @@
|
|
|
1
|
+
import type { AnySchema, BasePathParams, HttpMethod, InferOutput, InputKind, InputOptions, MergeParam, RouteMeta } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Plain-data snapshot of a contract endpoint.
|
|
4
|
+
*
|
|
5
|
+
* This is the runtime shape consumers (the API's `router.contract()`, an
|
|
6
|
+
* OpenAPI generator, a typed client, ...) read from. It intentionally has no
|
|
7
|
+
* generic parameters — the compile-time shape lives on `ContractBuilder`
|
|
8
|
+
* itself and is recovered via `ContractShape<C>`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ContractDefinition {
|
|
11
|
+
method: HttpMethod;
|
|
12
|
+
path: string;
|
|
13
|
+
paramSchemas: Map<string, AnySchema>;
|
|
14
|
+
inputKind: InputKind;
|
|
15
|
+
inputOptions: InputOptions<InputKind>;
|
|
16
|
+
outputSchema?: AnySchema;
|
|
17
|
+
errorTypes?: (new (...args: unknown[]) => Error)[];
|
|
18
|
+
meta?: RouteMeta;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fluent, immutable contract endpoint builder.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors `RouteBuilder` from `@fishenv/http` (method/path/param/input/output)
|
|
24
|
+
* minus everything server-only (middleware, handlers, DI). A finished
|
|
25
|
+
* `ContractBuilder` is portable data: it can be handed to
|
|
26
|
+
* `router.contract(endpoint)` on the API side, or read by an OpenAPI
|
|
27
|
+
* generator / typed client on the consumer side.
|
|
28
|
+
*/
|
|
29
|
+
export declare class ContractBuilder<M extends HttpMethod, P extends string, K extends InputKind = "none", O extends InputOptions<K> = InputOptions<K>, Params extends Record<string, unknown> = Record<string, unknown>, Output = undefined> {
|
|
30
|
+
#private;
|
|
31
|
+
readonly method: M;
|
|
32
|
+
readonly path: P;
|
|
33
|
+
constructor(method: M, path: P, paramSchemas?: Map<string, AnySchema>, inputKind?: InputKind, inputOptions?: InputOptions<InputKind>, outputSchema?: AnySchema, errorTypes?: (new (...args: unknown[]) => Error)[], meta?: RouteMeta);
|
|
34
|
+
/** Set contract metadata (title, tags, etc.) — returns new builder */
|
|
35
|
+
meta(data: RouteMeta): ContractBuilder<M, P, K, O, Params, Output>;
|
|
36
|
+
/** Narrow/coerce a path param with a Valibot schema — returns new builder */
|
|
37
|
+
param<Name extends string & keyof Params, S extends AnySchema>(name: Name, schema: S): ContractBuilder<M, P, K, O, MergeParam<Params, Name, S>, Output>;
|
|
38
|
+
/** Set input kind and options — returns new builder */
|
|
39
|
+
input<NK extends InputKind, NO extends InputOptions<NK>>(kind: NK, options?: NO): ContractBuilder<M, P, NK, NO extends InputOptions<NK> ? NO : InputOptions<NK>, Params, Output>;
|
|
40
|
+
/** Set output schema + optional error types — returns new builder */
|
|
41
|
+
output<S extends AnySchema>(schema: S, errors?: (new (...args: unknown[]) => Error)[]): ContractBuilder<M, P, K, O, Params, InferOutput<S>>;
|
|
42
|
+
/**
|
|
43
|
+
* Runtime snapshot of this endpoint. Used by consumers such as
|
|
44
|
+
* `router.contract()`, OpenAPI generation, or a typed client — not meant
|
|
45
|
+
* to be read directly by application code.
|
|
46
|
+
*/
|
|
47
|
+
get _definition(): ContractDefinition;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Recovers the compile-time shape (input/output/params) of a `ContractBuilder`.
|
|
51
|
+
* Used by consumers to derive their own generics from a contract endpoint.
|
|
52
|
+
*/
|
|
53
|
+
export type ContractShape<C> = C extends ContractBuilder<infer M, infer P, infer K, infer O, infer Params, infer Output> ? {
|
|
54
|
+
method: M;
|
|
55
|
+
path: P;
|
|
56
|
+
inputKind: K;
|
|
57
|
+
inputOptions: O;
|
|
58
|
+
params: Params;
|
|
59
|
+
output: Output;
|
|
60
|
+
} : never;
|
|
61
|
+
/** Loosest possible `ContractBuilder` — use as a generic constraint. */
|
|
62
|
+
export type AnyContractBuilder = ContractBuilder<any, any, any, any, any, any>;
|
|
63
|
+
/**
|
|
64
|
+
* Entry point for defining contract endpoints, e.g.:
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* export const contract = {
|
|
68
|
+
* users: {
|
|
69
|
+
* create: c.post("/users").input("json", { body: CreateUserSchema }).output(UserSchema),
|
|
70
|
+
* get: c.get("/users/:id").output(UserSchema),
|
|
71
|
+
* },
|
|
72
|
+
* };
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare const c: {
|
|
76
|
+
get: <P extends string>(path: P) => ContractBuilder<"GET", P, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
77
|
+
post: <P extends string>(path: P) => ContractBuilder<"POST", P, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
78
|
+
put: <P extends string>(path: P) => ContractBuilder<"PUT", P, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
79
|
+
patch: <P extends string>(path: P) => ContractBuilder<"PATCH", P, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
80
|
+
delete: <P extends string>(path: P) => ContractBuilder<"DELETE", P, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
81
|
+
options: <P extends string>(path: P) => ContractBuilder<"OPTIONS", P, "none", InputOptions<"none">, BasePathParams<P>, undefined>;
|
|
82
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fluent, immutable contract endpoint builder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `RouteBuilder` from `@fishenv/http` (method/path/param/input/output)
|
|
5
|
+
* minus everything server-only (middleware, handlers, DI). A finished
|
|
6
|
+
* `ContractBuilder` is portable data: it can be handed to
|
|
7
|
+
* `router.contract(endpoint)` on the API side, or read by an OpenAPI
|
|
8
|
+
* generator / typed client on the consumer side.
|
|
9
|
+
*/
|
|
10
|
+
export class ContractBuilder {
|
|
11
|
+
method;
|
|
12
|
+
path;
|
|
13
|
+
#paramSchemas;
|
|
14
|
+
#inputKind;
|
|
15
|
+
#inputOptions;
|
|
16
|
+
#outputSchema;
|
|
17
|
+
#errorTypes;
|
|
18
|
+
#meta;
|
|
19
|
+
constructor(method, path, paramSchemas = new Map(), inputKind = "none", inputOptions = {}, outputSchema, errorTypes, meta) {
|
|
20
|
+
this.method = method;
|
|
21
|
+
this.path = path;
|
|
22
|
+
this.#paramSchemas = paramSchemas;
|
|
23
|
+
this.#inputKind = inputKind;
|
|
24
|
+
this.#inputOptions = inputOptions;
|
|
25
|
+
this.#outputSchema = outputSchema;
|
|
26
|
+
this.#errorTypes = errorTypes;
|
|
27
|
+
this.#meta = meta;
|
|
28
|
+
}
|
|
29
|
+
/** Set contract metadata (title, tags, etc.) — returns new builder */
|
|
30
|
+
meta(data) {
|
|
31
|
+
return new ContractBuilder(this.method, this.path, new Map(this.#paramSchemas), this.#inputKind, this.#inputOptions, this.#outputSchema, this.#errorTypes ? [...this.#errorTypes] : undefined, { ...this.#meta, ...data });
|
|
32
|
+
}
|
|
33
|
+
/** Narrow/coerce a path param with a Valibot schema — returns new builder */
|
|
34
|
+
param(name, schema) {
|
|
35
|
+
const newSchemas = new Map(this.#paramSchemas);
|
|
36
|
+
newSchemas.set(name, schema);
|
|
37
|
+
return new ContractBuilder(this.method, this.path, newSchemas, this.#inputKind, this.#inputOptions, this.#outputSchema, this.#errorTypes ? [...this.#errorTypes] : undefined, this.#meta ? { ...this.#meta } : undefined);
|
|
38
|
+
}
|
|
39
|
+
/** Set input kind and options — returns new builder */
|
|
40
|
+
input(kind, options) {
|
|
41
|
+
return new ContractBuilder(this.method, this.path, new Map(this.#paramSchemas), kind, (options ?? {}), this.#outputSchema, this.#errorTypes ? [...this.#errorTypes] : undefined, this.#meta ? { ...this.#meta } : undefined);
|
|
42
|
+
}
|
|
43
|
+
/** Set output schema + optional error types — returns new builder */
|
|
44
|
+
output(schema, errors) {
|
|
45
|
+
return new ContractBuilder(this.method, this.path, new Map(this.#paramSchemas), this.#inputKind, this.#inputOptions, schema, errors, this.#meta ? { ...this.#meta } : undefined);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Runtime snapshot of this endpoint. Used by consumers such as
|
|
49
|
+
* `router.contract()`, OpenAPI generation, or a typed client — not meant
|
|
50
|
+
* to be read directly by application code.
|
|
51
|
+
*/
|
|
52
|
+
get _definition() {
|
|
53
|
+
return {
|
|
54
|
+
method: this.method,
|
|
55
|
+
path: this.path,
|
|
56
|
+
paramSchemas: this.#paramSchemas,
|
|
57
|
+
inputKind: this.#inputKind,
|
|
58
|
+
inputOptions: this.#inputOptions,
|
|
59
|
+
outputSchema: this.#outputSchema,
|
|
60
|
+
errorTypes: this.#errorTypes,
|
|
61
|
+
meta: this.#meta,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function endpoint(method) {
|
|
66
|
+
return (path) => new ContractBuilder(method, path);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Entry point for defining contract endpoints, e.g.:
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* export const contract = {
|
|
73
|
+
* users: {
|
|
74
|
+
* create: c.post("/users").input("json", { body: CreateUserSchema }).output(UserSchema),
|
|
75
|
+
* get: c.get("/users/:id").output(UserSchema),
|
|
76
|
+
* },
|
|
77
|
+
* };
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export const c = {
|
|
81
|
+
get: endpoint("GET"),
|
|
82
|
+
post: endpoint("POST"),
|
|
83
|
+
put: endpoint("PUT"),
|
|
84
|
+
patch: endpoint("PATCH"),
|
|
85
|
+
delete: endpoint("DELETE"),
|
|
86
|
+
options: endpoint("OPTIONS"),
|
|
87
|
+
};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { type AnyContractBuilder, ContractBuilder, type ContractDefinition, type ContractShape, c, } from "./builder.ts";
|
|
2
|
+
export type { AnySchema, BasePathParams, ExtractParams, HttpMethod, InferBody, InferCookies, InferHeaders, InferOutput, InferQuery, InputKind, InputOptions, MergeParam, RouteMeta, ValibotSchema, } from "./types.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ContractBuilder, c, } from "./builder.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type * as v from "valibot";
|
|
2
|
+
/**
|
|
3
|
+
* `@fishenv/http-contract` — the "shape" types shared between an API and its
|
|
4
|
+
* consumers (the API itself, an OpenAPI generator, a typed client, ...).
|
|
5
|
+
*
|
|
6
|
+
* This module has zero server-runtime dependencies (no DI, no middleware, no
|
|
7
|
+
* `Bun.serve`) — it only describes *what* an endpoint looks like: method,
|
|
8
|
+
* path, input, output. `@fishenv/http` (the `core` package) depends on this
|
|
9
|
+
* package to build its route registration API; this package must never
|
|
10
|
+
* depend back on `core`.
|
|
11
|
+
*/
|
|
12
|
+
export type ValibotSchema<O = unknown> = v.BaseSchema<unknown, O, v.BaseIssue<unknown>>;
|
|
13
|
+
export type AnySchema = ValibotSchema<unknown>;
|
|
14
|
+
export type InferOutput<S extends AnySchema> = v.InferOutput<S>;
|
|
15
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD";
|
|
16
|
+
export type InputKind = "json" | "multipart" | "urlencoded" | "blob" | "text" | "none";
|
|
17
|
+
export interface InputOptions<K extends InputKind> {
|
|
18
|
+
body?: K extends "blob" | "text" ? never : AnySchema;
|
|
19
|
+
headers?: AnySchema;
|
|
20
|
+
query?: AnySchema;
|
|
21
|
+
cookies?: AnySchema;
|
|
22
|
+
maxSize?: K extends "blob" | "multipart" ? number : never;
|
|
23
|
+
}
|
|
24
|
+
export type InferBody<K extends InputKind, O extends InputOptions<K>> = K extends "blob" ? Blob : K extends "text" ? string : K extends "none" ? undefined : O["body"] extends AnySchema ? InferOutput<O["body"]> : K extends "multipart" | "urlencoded" ? FormData : unknown;
|
|
25
|
+
export type InferHeaders<O extends InputOptions<InputKind>> = O["headers"] extends AnySchema ? InferOutput<O["headers"]> : Record<string, string>;
|
|
26
|
+
export type InferQuery<O extends InputOptions<InputKind>> = O["query"] extends AnySchema ? InferOutput<O["query"]> : Record<string, string | string[]>;
|
|
27
|
+
export type InferCookies<O extends InputOptions<InputKind>> = O["cookies"] extends AnySchema ? InferOutput<O["cookies"]> : Record<string, string>;
|
|
28
|
+
export type ExtractParams<P extends string> = P extends `${string}:${infer Param}/${infer Rest}` ? (Param extends `${infer Name}?` ? Name : Param) | ExtractParams<`/${Rest}`> : P extends `${string}:${infer Param}` ? Param extends `${infer Name}?` ? Name : Param : never;
|
|
29
|
+
export type BasePathParams<P extends string> = {
|
|
30
|
+
[K in ExtractParams<P>]: string;
|
|
31
|
+
};
|
|
32
|
+
export type MergeParam<Base extends Record<string, unknown>, Name extends string, Schema extends AnySchema> = Omit<Base, Name> & Record<Name, InferOutput<Schema>>;
|
|
33
|
+
export interface RouteMeta {
|
|
34
|
+
title?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
tags?: string[];
|
|
37
|
+
deprecated?: boolean;
|
|
38
|
+
operationId?: string;
|
|
39
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DIContainer, ErrorHandlerFn, InterceptorPair, MiddlewareFn, RouteDefinition } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Run the middleware pipeline, accumulating context via object spread.
|
|
4
|
+
* Each middleware receives the accumulated ctx and returns new fields to merge.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runMiddlewarePipeline(req: Request, middlewares: MiddlewareFn<Record<string, unknown>, Record<string, unknown>>[], container: DIContainer): Promise<Record<string, unknown>>;
|
|
7
|
+
/**
|
|
8
|
+
* Run before interceptors, the handler, then after interceptors (in reverse).
|
|
9
|
+
* Before and after share a mutable `state` object created per-request.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runInterceptors(interceptors: InterceptorPair<Record<string, unknown>>[], req: Request, ctx: Record<string, unknown>, handlerFn: () => Promise<Response>): Promise<Response>;
|
|
12
|
+
/**
|
|
13
|
+
* Coerce a handler's return value into a Response.
|
|
14
|
+
* - If already a Response, pass through.
|
|
15
|
+
* - If output schema is defined, serialize as JSON.
|
|
16
|
+
* - Otherwise throw — handler contract violation.
|
|
17
|
+
*/
|
|
18
|
+
export declare function coerceToResponse(raw: unknown, def: RouteDefinition): Response;
|
|
19
|
+
/**
|
|
20
|
+
* Full request dispatch: middleware → input parsing → interceptors + handler → error chain.
|
|
21
|
+
*/
|
|
22
|
+
export declare function dispatch(req: Request, definition: RouteDefinition, params: Record<string, string>, container: DIContainer, errorHandlers: ErrorHandlerFn[]): Promise<Response>;
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
// 1. Build context via middleware chain
|
|
54
|
+
const ctx = await runMiddlewarePipeline(req, definition.middlewares, container);
|
|
55
|
+
// 2. Parse & validate input
|
|
56
|
+
const input = await parseInput(req, definition, params);
|
|
57
|
+
// 3. Run interceptors + handler
|
|
58
|
+
const response = await runInterceptors(definition.interceptors, req, ctx, async () => {
|
|
59
|
+
const raw = await definition.handler({
|
|
60
|
+
req,
|
|
61
|
+
ctx,
|
|
62
|
+
container,
|
|
63
|
+
path: input.path,
|
|
64
|
+
body: input.body,
|
|
65
|
+
headers: input.headers,
|
|
66
|
+
query: input.query,
|
|
67
|
+
cookies: input.cookies,
|
|
68
|
+
});
|
|
69
|
+
return coerceToResponse(raw, definition);
|
|
70
|
+
});
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
for (const handler of errorHandlers) {
|
|
75
|
+
const result = await handler(err, { req });
|
|
76
|
+
if (result != null)
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
// Built-in fallback always returns
|
|
80
|
+
return defaultErrorHandler(err, { req });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -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,52 @@
|
|
|
1
|
+
import type * as v from "valibot";
|
|
2
|
+
import type { HttpMethod } from "./types.ts";
|
|
3
|
+
export declare class HttpError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(status: number, message: string, cause?: unknown);
|
|
6
|
+
toResponse(development?: boolean): Response;
|
|
7
|
+
}
|
|
8
|
+
export declare class BadRequestError extends HttpError {
|
|
9
|
+
constructor(message?: string, cause?: unknown);
|
|
10
|
+
}
|
|
11
|
+
export declare class UnauthorizedError extends HttpError {
|
|
12
|
+
constructor(message?: string);
|
|
13
|
+
}
|
|
14
|
+
export declare class ForbiddenError extends HttpError {
|
|
15
|
+
constructor(message?: string);
|
|
16
|
+
}
|
|
17
|
+
export declare class NotFoundError extends HttpError {
|
|
18
|
+
constructor(message?: string);
|
|
19
|
+
}
|
|
20
|
+
export declare class MethodNotAllowedError extends HttpError {
|
|
21
|
+
constructor(message?: string);
|
|
22
|
+
}
|
|
23
|
+
export declare class ConflictError extends HttpError {
|
|
24
|
+
constructor(message?: string, cause?: unknown);
|
|
25
|
+
}
|
|
26
|
+
export declare class UnprocessableEntityError extends HttpError {
|
|
27
|
+
constructor(message?: string, cause?: unknown);
|
|
28
|
+
}
|
|
29
|
+
export declare class TooManyRequestsError extends HttpError {
|
|
30
|
+
constructor(message?: string);
|
|
31
|
+
}
|
|
32
|
+
export declare class InternalServerError extends HttpError {
|
|
33
|
+
constructor(message?: string, cause?: unknown);
|
|
34
|
+
}
|
|
35
|
+
export type ValidationField = "body" | "headers" | "query" | "cookies" | "path";
|
|
36
|
+
export declare class ValidationError extends HttpError {
|
|
37
|
+
readonly field: ValidationField;
|
|
38
|
+
readonly issues: v.BaseIssue<unknown>[];
|
|
39
|
+
constructor(field: ValidationField, issues: v.BaseIssue<unknown>[]);
|
|
40
|
+
toResponse(): Response;
|
|
41
|
+
}
|
|
42
|
+
export interface ErrorHandlerOpts {
|
|
43
|
+
development?: boolean;
|
|
44
|
+
logger?: {
|
|
45
|
+
error: (...args: unknown[]) => void;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export declare function defaultErrorHandler(err: unknown, _args: {
|
|
49
|
+
req: Request;
|
|
50
|
+
}, opts?: ErrorHandlerOpts): Response;
|
|
51
|
+
export declare function defaultNotFoundHandler(req: Request): Response;
|
|
52
|
+
export declare function defaultMethodNotAllowedHandler(_req: Request, allowed: HttpMethod[]): Response;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(status, message, cause) {
|
|
4
|
+
super(message, { cause });
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = "HttpError";
|
|
7
|
+
}
|
|
8
|
+
toResponse(development = false) {
|
|
9
|
+
const body = { error: this.message };
|
|
10
|
+
if (development && this.stack)
|
|
11
|
+
body.stack = this.stack;
|
|
12
|
+
return Response.json(body, { status: this.status });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class BadRequestError extends HttpError {
|
|
16
|
+
constructor(message = "Bad Request", cause) {
|
|
17
|
+
super(400, message, cause);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class UnauthorizedError extends HttpError {
|
|
21
|
+
constructor(message = "Unauthorized") {
|
|
22
|
+
super(401, message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class ForbiddenError extends HttpError {
|
|
26
|
+
constructor(message = "Forbidden") {
|
|
27
|
+
super(403, message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class NotFoundError extends HttpError {
|
|
31
|
+
constructor(message = "Not Found") {
|
|
32
|
+
super(404, message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class MethodNotAllowedError extends HttpError {
|
|
36
|
+
constructor(message = "Method Not Allowed") {
|
|
37
|
+
super(405, message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class ConflictError extends HttpError {
|
|
41
|
+
constructor(message = "Conflict", cause) {
|
|
42
|
+
super(409, message, cause);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export class UnprocessableEntityError extends HttpError {
|
|
46
|
+
constructor(message = "Unprocessable Entity", cause) {
|
|
47
|
+
super(422, message, cause);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class TooManyRequestsError extends HttpError {
|
|
51
|
+
constructor(message = "Too Many Requests") {
|
|
52
|
+
super(429, message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class InternalServerError extends HttpError {
|
|
56
|
+
constructor(message = "Internal Server Error", cause) {
|
|
57
|
+
super(500, message, cause);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class ValidationError extends HttpError {
|
|
61
|
+
field;
|
|
62
|
+
issues;
|
|
63
|
+
constructor(field, issues) {
|
|
64
|
+
super(400, "Validation Error");
|
|
65
|
+
this.name = "ValidationError";
|
|
66
|
+
this.field = field;
|
|
67
|
+
this.issues = issues;
|
|
68
|
+
}
|
|
69
|
+
toResponse() {
|
|
70
|
+
return Response.json({
|
|
71
|
+
error: "ValidationError",
|
|
72
|
+
field: this.field,
|
|
73
|
+
issues: this.issues.map((i) => ({
|
|
74
|
+
message: i.message,
|
|
75
|
+
path: i.path?.map((p) => p.key) ?? [],
|
|
76
|
+
})),
|
|
77
|
+
}, { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function defaultErrorHandler(err, _args, opts = {}) {
|
|
81
|
+
if (err instanceof HttpError) {
|
|
82
|
+
return err.toResponse(opts.development);
|
|
83
|
+
}
|
|
84
|
+
const logger = opts.logger ?? console;
|
|
85
|
+
logger.error("[fishenv.http] Unhandled error:", err);
|
|
86
|
+
const body = { error: "Internal Server Error" };
|
|
87
|
+
if (opts.development && err instanceof Error)
|
|
88
|
+
body.stack = err.stack;
|
|
89
|
+
return Response.json(body, { status: 500 });
|
|
90
|
+
}
|
|
91
|
+
export function defaultNotFoundHandler(req) {
|
|
92
|
+
return Response.json({ error: "Not Found", path: new URL(req.url).pathname }, { status: 404 });
|
|
93
|
+
}
|
|
94
|
+
export function defaultMethodNotAllowedHandler(_req, allowed) {
|
|
95
|
+
return new Response(null, {
|
|
96
|
+
status: 405,
|
|
97
|
+
headers: { Allow: allowed.join(", ") },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 { ValidationField } from "./error.ts";
|
|
5
|
+
export { BadRequestError, ConflictError, defaultErrorHandler, defaultMethodNotAllowedHandler, defaultNotFoundHandler, 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 { AfterInterceptorFn, AnySchema, BasePathParams, BeforeInterceptorFn, DIContainer, ErrorHandlerFn, ExtractParams, HandlerArgs, HandlerFn, HttpMethod, InferBody, InferCookies, InferHeaders, InferOutput, InferQuery, InputKind, InputOptions, InterceptorPair, InterceptorState, MergeCtx, MergeParam, MiddlewareFn, NotFoundHandler, ResolvedInput, RouteDefinition, RouteMeta, ValibotSchema, } from "./types.ts";
|
|
15
|
+
export { buildRouteRegex, extractParamNames, mergePaths } from "./utils/url.ts";
|
|
16
|
+
export { validateField, validatePathParams } from "./validation.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
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, 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
|
+
// URL utilities
|
|
17
|
+
export { buildRouteRegex, extractParamNames, mergePaths } from "./utils/url.js";
|
|
18
|
+
// Validation
|
|
19
|
+
export { validateField, validatePathParams } from "./validation.js";
|