@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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* An SSE route is an ordinary `GET` route whose handler returns a streaming
|
|
5
|
+
* `Response`, so middleware, path-param validation, interceptors and the error
|
|
6
|
+
* chain all work unchanged — no core dispatch branching is required.
|
|
7
|
+
*
|
|
8
|
+
* Live connections can be registered in an {@link SseHub} so that any service
|
|
9
|
+
* can push to a specific client later:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* class UserEvents extends SseHub {}
|
|
13
|
+
*
|
|
14
|
+
* api.sse("/events").keepalive(30_000).subscribe(UserEvents, ({ ctx }) => ctx.user.id);
|
|
15
|
+
*
|
|
16
|
+
* // elsewhere, e.g. inside a use case
|
|
17
|
+
* userEvents.send(userId, { type: "order.shipped" }, { event: "notification" });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Module layout:
|
|
21
|
+
* - `types.ts` — shared data types
|
|
22
|
+
* - `encode.ts` — `text/event-stream` framing
|
|
23
|
+
* - `connection.ts` — `SseConnection`, a single live stream
|
|
24
|
+
* - `response.ts` — `createSseResponse()`, request → streaming response
|
|
25
|
+
* - `hub.ts` — `SseHub`, keyed registry for targeted sends
|
|
26
|
+
* - `route-builder.ts` — `SseRouteBuilder`
|
|
27
|
+
* - `router.ts` — `withSse()` mixin
|
|
28
|
+
*/
|
|
29
|
+
// Connection
|
|
30
|
+
export { SseConnection } from "./connection.js";
|
|
31
|
+
// Framing
|
|
32
|
+
export { encodeSseComment, encodeSseEvent, encodeSseRetry } from "./encode.js";
|
|
33
|
+
// Hub
|
|
34
|
+
export { SseHub } from "./hub.js";
|
|
35
|
+
// Response
|
|
36
|
+
export { createSseResponse } from "./response.js";
|
|
37
|
+
// Route builder
|
|
38
|
+
export { SseRouteBuilder } from "./route-builder.js";
|
|
39
|
+
// Router mixin
|
|
40
|
+
export { withSse } from "./router.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Turning a request into a streaming `text/event-stream` response. */
|
|
2
|
+
import { SseConnection } from "./connection.ts";
|
|
3
|
+
import type { SseCleanupFn, SseConnectionOptions } from "./types.ts";
|
|
4
|
+
export type SseOpenFn = (sse: SseConnection) => void | SseCleanupFn | Promise<void | SseCleanupFn>;
|
|
5
|
+
/**
|
|
6
|
+
* `onOpen` runs after the response has been handed to the client, so anything
|
|
7
|
+
* it throws can no longer change the status code — it is logged and the stream
|
|
8
|
+
* is closed.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createSseResponse(req: Request, options: SseConnectionOptions, onOpen: SseOpenFn): Response;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Turning a request into a streaming `text/event-stream` response. */
|
|
2
|
+
import { DEFAULT_HIGH_WATER_MARK, SseConnection } from "./connection.js";
|
|
3
|
+
/**
|
|
4
|
+
* `onOpen` runs after the response has been handed to the client, so anything
|
|
5
|
+
* it throws can no longer change the status code — it is logged and the stream
|
|
6
|
+
* is closed.
|
|
7
|
+
*/
|
|
8
|
+
export function createSseResponse(req, options, onOpen) {
|
|
9
|
+
const highWaterMark = options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK;
|
|
10
|
+
let connection;
|
|
11
|
+
const stream = new ReadableStream({
|
|
12
|
+
start(controller) {
|
|
13
|
+
const sse = new SseConnection(controller, req, options);
|
|
14
|
+
connection = sse;
|
|
15
|
+
if (options.retryMs != null)
|
|
16
|
+
sse.retry(options.retryMs);
|
|
17
|
+
if (options.keepaliveMs != null && options.keepaliveMs > 0) {
|
|
18
|
+
const timer = setInterval(() => sse.comment("ka"), options.keepaliveMs);
|
|
19
|
+
sse.onClose(() => clearInterval(timer));
|
|
20
|
+
}
|
|
21
|
+
// Not awaited: a handler that never resolves must not block the stream.
|
|
22
|
+
void (async () => {
|
|
23
|
+
try {
|
|
24
|
+
const cleanup = await onOpen(sse);
|
|
25
|
+
if (typeof cleanup === "function")
|
|
26
|
+
sse.onClose(cleanup);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
console.error("[fishenv.sse] handler error after stream start:", err);
|
|
30
|
+
sse.close();
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
},
|
|
34
|
+
cancel() {
|
|
35
|
+
// Sole disconnect signal: `req.signal` semantics differ between
|
|
36
|
+
// runtimes (some abort it on *successful* delivery), which would tear
|
|
37
|
+
// down healthy streams.
|
|
38
|
+
connection?.close();
|
|
39
|
+
},
|
|
40
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark }));
|
|
41
|
+
return new Response(stream, {
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: {
|
|
44
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
45
|
+
// `no-transform` stops proxies (and server-side compression) from buffering.
|
|
46
|
+
"cache-control": "no-cache, no-store, no-transform",
|
|
47
|
+
"x-accel-buffering": "no",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Fluent builder for SSE routes, layered on the core `RouteBuilder`. */
|
|
2
|
+
import type { AnySchema, DIContainer, FinishedRoute, InputOptions, MergeParam, MiddlewareFn, RouteBuilder, RouteMeta } from "../core/index.ts";
|
|
3
|
+
import type { SseConnection } from "./connection.ts";
|
|
4
|
+
import type { SseHub } from "./hub.ts";
|
|
5
|
+
import type { SseCleanupFn, SseConnectionOptions, SseOverflowPolicy } from "./types.ts";
|
|
6
|
+
export interface SseHandlerArgs<Ctx extends Record<string, unknown>, Params extends Record<string, unknown>> {
|
|
7
|
+
sse: SseConnection;
|
|
8
|
+
path: Params;
|
|
9
|
+
ctx: Ctx;
|
|
10
|
+
req: Request;
|
|
11
|
+
container: DIContainer;
|
|
12
|
+
}
|
|
13
|
+
export type SseHandlerFn<Ctx extends Record<string, unknown>, Params extends Record<string, unknown>> = (args: SseHandlerArgs<Ctx, Params>) => void | SseCleanupFn | Promise<void | SseCleanupFn>;
|
|
14
|
+
/**
|
|
15
|
+
* Delegates to the core `RouteBuilder`, so `.param()` / `.with()` carry exactly
|
|
16
|
+
* the same generics as HTTP routes.
|
|
17
|
+
*/
|
|
18
|
+
export declare class SseRouteBuilder<RouterCtx extends Record<string, unknown>, RouteCtx extends Record<string, unknown>, Params extends Record<string, unknown>> {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(inner: RouteBuilder<RouterCtx, RouteCtx, "none", InputOptions<"none">, Params, undefined>, options?: SseConnectionOptions);
|
|
21
|
+
meta(data: RouteMeta): this;
|
|
22
|
+
/** Heartbeat interval. Also how the server notices a socket died. */
|
|
23
|
+
keepalive(intervalMs: number): this;
|
|
24
|
+
/** Reconnection delay hint sent to the client on connect. */
|
|
25
|
+
retry(ms: number): this;
|
|
26
|
+
backpressure(opts: {
|
|
27
|
+
highWaterMark?: number;
|
|
28
|
+
onOverflow?: SseOverflowPolicy;
|
|
29
|
+
}): this;
|
|
30
|
+
param<Name extends string & keyof Params, S extends AnySchema>(name: Name, schema: S): SseRouteBuilder<RouterCtx, RouteCtx, MergeParam<Params, Name, S>>;
|
|
31
|
+
with<NewCtx extends Record<string, unknown>>(mw: MiddlewareFn<RouterCtx & RouteCtx, NewCtx>): SseRouteBuilder<RouterCtx, RouteCtx & NewCtx, Params>;
|
|
32
|
+
/**
|
|
33
|
+
* Terminal. The returned function (if any) runs when the connection closes.
|
|
34
|
+
*
|
|
35
|
+
* `.catch()` on the result only covers failures raised *before* the stream
|
|
36
|
+
* opens (middleware, param validation) — after that the status is already sent.
|
|
37
|
+
*/
|
|
38
|
+
handle(fn: SseHandlerFn<RouterCtx & RouteCtx, Params>): FinishedRoute;
|
|
39
|
+
/**
|
|
40
|
+
* Terminal shorthand: resolve a hub from the container and register the
|
|
41
|
+
* connection under the key derived from the request.
|
|
42
|
+
*/
|
|
43
|
+
subscribe<H extends SseHub>(token: abstract new (...args: any[]) => H, key: (args: SseHandlerArgs<RouterCtx & RouteCtx, Params>) => string): FinishedRoute;
|
|
44
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Fluent builder for SSE routes, layered on the core `RouteBuilder`. */
|
|
2
|
+
import { createSseResponse } from "./response.js";
|
|
3
|
+
/**
|
|
4
|
+
* Delegates to the core `RouteBuilder`, so `.param()` / `.with()` carry exactly
|
|
5
|
+
* the same generics as HTTP routes.
|
|
6
|
+
*/
|
|
7
|
+
export class SseRouteBuilder {
|
|
8
|
+
#inner;
|
|
9
|
+
#options;
|
|
10
|
+
constructor(inner, options = {}) {
|
|
11
|
+
this.#inner = inner;
|
|
12
|
+
this.#options = options;
|
|
13
|
+
}
|
|
14
|
+
meta(data) {
|
|
15
|
+
this.#inner.meta(data);
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
/** Heartbeat interval. Also how the server notices a socket died. */
|
|
19
|
+
keepalive(intervalMs) {
|
|
20
|
+
this.#options.keepaliveMs = intervalMs;
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
/** Reconnection delay hint sent to the client on connect. */
|
|
24
|
+
retry(ms) {
|
|
25
|
+
this.#options.retryMs = ms;
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
backpressure(opts) {
|
|
29
|
+
if (opts.highWaterMark != null) {
|
|
30
|
+
this.#options.highWaterMark = opts.highWaterMark;
|
|
31
|
+
}
|
|
32
|
+
if (opts.onOverflow != null)
|
|
33
|
+
this.#options.onOverflow = opts.onOverflow;
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
param(name, schema) {
|
|
37
|
+
return new SseRouteBuilder(this.#inner.param(name, schema), {
|
|
38
|
+
...this.#options,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
with(mw) {
|
|
42
|
+
return new SseRouteBuilder(this.#inner.with(mw), { ...this.#options });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Terminal. The returned function (if any) runs when the connection closes.
|
|
46
|
+
*
|
|
47
|
+
* `.catch()` on the result only covers failures raised *before* the stream
|
|
48
|
+
* opens (middleware, param validation) — after that the status is already sent.
|
|
49
|
+
*/
|
|
50
|
+
handle(fn) {
|
|
51
|
+
const options = { ...this.#options };
|
|
52
|
+
const route = this.#inner.handle(({ req, path, ctx, container }) => createSseResponse(req, options, (sse) => fn({ sse, path, ctx, req, container })));
|
|
53
|
+
route.definition.kind = "sse";
|
|
54
|
+
return route;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Terminal shorthand: resolve a hub from the container and register the
|
|
58
|
+
* connection under the key derived from the request.
|
|
59
|
+
*/
|
|
60
|
+
subscribe(
|
|
61
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional any
|
|
62
|
+
token, key) {
|
|
63
|
+
return this.handle((args) => args.container.get(token).add(key(args), args.sse));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The `withSse()` mixin that adds `.sse()` to a core `Router`. */
|
|
2
|
+
import type { BasePathParams, Router } from "../core/index.ts";
|
|
3
|
+
import { SseRouteBuilder } from "./route-builder.ts";
|
|
4
|
+
export interface SseRouter<Ctx extends Record<string, unknown>> {
|
|
5
|
+
sse<P extends string>(path: P): SseRouteBuilder<Ctx, Record<never, never>, BasePathParams<P>>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `.use()` returns a *new* router, so apply `withSse` last:
|
|
9
|
+
* `withSse(r({ prefix: "/api" }).use(auth))`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withSse<Ctx extends Record<string, unknown>, Req extends Record<string, unknown> = Record<never, never>>(router: Router<Ctx, Req>): Router<Ctx, Req> & SseRouter<Ctx>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The `withSse()` mixin that adds `.sse()` to a core `Router`. */
|
|
2
|
+
import { createRouteBuilder } from "../core/index.js";
|
|
3
|
+
import { SseRouteBuilder } from "./route-builder.js";
|
|
4
|
+
/**
|
|
5
|
+
* `.use()` returns a *new* router, so apply `withSse` last:
|
|
6
|
+
* `withSse(r({ prefix: "/api" }).use(auth))`.
|
|
7
|
+
*/
|
|
8
|
+
export function withSse(router) {
|
|
9
|
+
const target = router;
|
|
10
|
+
Object.defineProperty(target, "sse", {
|
|
11
|
+
value: (path) => new SseRouteBuilder(createRouteBuilder(router, "GET", path), {}),
|
|
12
|
+
enumerable: false,
|
|
13
|
+
configurable: true,
|
|
14
|
+
});
|
|
15
|
+
return target;
|
|
16
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSE data types. Deliberately free of class references so every other
|
|
3
|
+
* module in this package can import from here without a cycle.
|
|
4
|
+
*/
|
|
5
|
+
import type { Transport, TransportEnvelope } from "../core/transport.ts";
|
|
6
|
+
export interface SseEventOptions {
|
|
7
|
+
/** `event:` field — the client-side listener name. Defaults to `message`. */
|
|
8
|
+
event?: string;
|
|
9
|
+
/** `id:` field — echoed back by the browser as `Last-Event-ID` on reconnect. */
|
|
10
|
+
id?: string;
|
|
11
|
+
/** `retry:` field — reconnection delay in milliseconds. */
|
|
12
|
+
retry?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Return value is ignored; a returned promise is awaited only for error logging. */
|
|
15
|
+
export type SseCleanupFn = () => unknown;
|
|
16
|
+
/** What to do when a client is too slow to drain the send buffer. */
|
|
17
|
+
export type SseOverflowPolicy = "close" | "drop";
|
|
18
|
+
export interface SseConnectionOptions {
|
|
19
|
+
/** Send a `:` comment every N ms. Also how dead sockets get detected. */
|
|
20
|
+
keepaliveMs?: number;
|
|
21
|
+
/** Initial `retry:` hint sent as soon as the stream opens. */
|
|
22
|
+
retryMs?: number;
|
|
23
|
+
/** Send-buffer size in bytes before the overflow policy kicks in. */
|
|
24
|
+
highWaterMark?: number;
|
|
25
|
+
/** Default `"close"` — a stalled client is dropped and reconnects on its own. */
|
|
26
|
+
onOverflow?: SseOverflowPolicy;
|
|
27
|
+
}
|
|
28
|
+
/** Cross-process fan-out envelope. Must be JSON-serializable. */
|
|
29
|
+
export interface SseTransportMessage extends TransportEnvelope {
|
|
30
|
+
/** `null` means broadcast to every connection. */
|
|
31
|
+
key: string | null;
|
|
32
|
+
data: unknown;
|
|
33
|
+
event?: string;
|
|
34
|
+
id?: string;
|
|
35
|
+
retry?: number;
|
|
36
|
+
/** Node that published it — used to suppress echo back to the sender. */
|
|
37
|
+
origin: string;
|
|
38
|
+
}
|
|
39
|
+
/** Seam for multi-instance deployments (Redis pub/sub, NATS, Postgres LISTEN…). */
|
|
40
|
+
export type SseTransport = Transport<SseTransportMessage>;
|
|
41
|
+
export interface SseHubOptions {
|
|
42
|
+
transport?: SseTransport;
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file serving for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* `staticFiles()` returns an ordinary router, so it is mounted, prefixed and
|
|
5
|
+
* wrapped in middleware like any other:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* app.add("/assets", staticFiles({ root: "./public" }));
|
|
9
|
+
* app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Requests are decoded and validated per segment, confined to the real root
|
|
13
|
+
* (symlinks included), dotfiles are hidden and every rejection is a plain 404.
|
|
14
|
+
*
|
|
15
|
+
* Module layout:
|
|
16
|
+
* - `types.ts` — option and rule types
|
|
17
|
+
* - `options.ts` — validation and normalization at creation (throws)
|
|
18
|
+
* - `resolve.ts` — request path → file on disk (the security core)
|
|
19
|
+
* - `rules.ts` — route-style globs for rules and `exclude`
|
|
20
|
+
* - `negotiate.ts` — Accept-Encoding, Accept and conditional requests
|
|
21
|
+
* - `response.ts` — headers and body; `serveFile()`
|
|
22
|
+
* - `router.ts` — `staticFiles()`, `spa()`, `withStatic()`
|
|
23
|
+
*/
|
|
24
|
+
export { serveFile } from "./response.ts";
|
|
25
|
+
export type { StaticRouter } from "./router.ts";
|
|
26
|
+
export { spa, staticFiles, withStatic } from "./router.ts";
|
|
27
|
+
export type { SetHeadersFn, StaticCacheOptions, StaticEncoding, StaticFileInfo, StaticOptions, StaticResponseOptions, StaticRule, } from "./types.ts";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file serving for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* `staticFiles()` returns an ordinary router, so it is mounted, prefixed and
|
|
5
|
+
* wrapped in middleware like any other:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* app.add("/assets", staticFiles({ root: "./public" }));
|
|
9
|
+
* app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Requests are decoded and validated per segment, confined to the real root
|
|
13
|
+
* (symlinks included), dotfiles are hidden and every rejection is a plain 404.
|
|
14
|
+
*
|
|
15
|
+
* Module layout:
|
|
16
|
+
* - `types.ts` — option and rule types
|
|
17
|
+
* - `options.ts` — validation and normalization at creation (throws)
|
|
18
|
+
* - `resolve.ts` — request path → file on disk (the security core)
|
|
19
|
+
* - `rules.ts` — route-style globs for rules and `exclude`
|
|
20
|
+
* - `negotiate.ts` — Accept-Encoding, Accept and conditional requests
|
|
21
|
+
* - `response.ts` — headers and body; `serveFile()`
|
|
22
|
+
* - `router.ts` — `staticFiles()`, `spa()`, `withStatic()`
|
|
23
|
+
*/
|
|
24
|
+
export { serveFile } from "./response.js";
|
|
25
|
+
export { spa, staticFiles, withStatic } from "./router.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StaticEncoding } from "./types.ts";
|
|
2
|
+
interface QItem {
|
|
3
|
+
value: string;
|
|
4
|
+
q: number;
|
|
5
|
+
}
|
|
6
|
+
/** Parse a `token;q=0.5, other` header into lowercase values with weights. */
|
|
7
|
+
export declare function parseQList(header: string): QItem[];
|
|
8
|
+
/**
|
|
9
|
+
* Pick the best available encoding for an `Accept-Encoding` header. Highest
|
|
10
|
+
* q wins; ties go to `available` order (server preference). `q=0` refuses a
|
|
11
|
+
* coding, `*` covers codings not listed. No header means identity only.
|
|
12
|
+
*/
|
|
13
|
+
export declare function chooseEncoding(header: string | null, available: StaticEncoding[]): StaticEncoding | undefined;
|
|
14
|
+
/** True when the client accepts an HTML document (used for SPA fallback). */
|
|
15
|
+
export declare function acceptsHtml(header: string | null): boolean;
|
|
16
|
+
/** Weak ETag from size + mtime (no inode), suffixed per encoding. */
|
|
17
|
+
export declare function makeEtag(size: number, mtimeMs: number, encoding?: StaticEncoding): string;
|
|
18
|
+
/**
|
|
19
|
+
* Evaluate `If-None-Match` (weak comparison, `*`, lists), falling back to
|
|
20
|
+
* `If-Modified-Since` only when `If-None-Match` is absent (RFC 9110 §13.2.2).
|
|
21
|
+
*/
|
|
22
|
+
export declare function isNotModified(req: Request, etag: string | undefined, mtimeMs: number | undefined): boolean;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Parse a `token;q=0.5, other` header into lowercase values with weights. */
|
|
2
|
+
export function parseQList(header) {
|
|
3
|
+
const items = [];
|
|
4
|
+
for (const part of header.split(",")) {
|
|
5
|
+
const [rawValue, ...params] = part.split(";");
|
|
6
|
+
const value = rawValue.trim().toLowerCase();
|
|
7
|
+
if (!value)
|
|
8
|
+
continue;
|
|
9
|
+
let q = 1;
|
|
10
|
+
for (const param of params) {
|
|
11
|
+
const [k, v] = param.split("=").map((s) => s.trim());
|
|
12
|
+
if (k.toLowerCase() === "q") {
|
|
13
|
+
const n = Number(v);
|
|
14
|
+
q = Number.isFinite(n) ? Math.min(Math.max(n, 0), 1) : 0;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
items.push({ value, q });
|
|
18
|
+
}
|
|
19
|
+
return items;
|
|
20
|
+
}
|
|
21
|
+
const CODING_NAMES = {
|
|
22
|
+
br: ["br"],
|
|
23
|
+
gzip: ["gzip", "x-gzip"],
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Pick the best available encoding for an `Accept-Encoding` header. Highest
|
|
27
|
+
* q wins; ties go to `available` order (server preference). `q=0` refuses a
|
|
28
|
+
* coding, `*` covers codings not listed. No header means identity only.
|
|
29
|
+
*/
|
|
30
|
+
export function chooseEncoding(header, available) {
|
|
31
|
+
if (!header || available.length === 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const items = parseQList(header);
|
|
34
|
+
const star = items.find((i) => i.value === "*")?.q;
|
|
35
|
+
let best;
|
|
36
|
+
let bestQ = 0;
|
|
37
|
+
for (const enc of available) {
|
|
38
|
+
const explicit = items.find((i) => CODING_NAMES[enc].includes(i.value));
|
|
39
|
+
const q = explicit?.q ?? star ?? 0;
|
|
40
|
+
if (q > bestQ) {
|
|
41
|
+
best = enc;
|
|
42
|
+
bestQ = q;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
47
|
+
/** True when the client accepts an HTML document (used for SPA fallback). */
|
|
48
|
+
export function acceptsHtml(header) {
|
|
49
|
+
if (!header)
|
|
50
|
+
return true;
|
|
51
|
+
return parseQList(header).some((i) => i.q > 0 &&
|
|
52
|
+
(i.value === "text/html" || i.value === "text/*" || i.value === "*/*"));
|
|
53
|
+
}
|
|
54
|
+
/** Weak ETag from size + mtime (no inode), suffixed per encoding. */
|
|
55
|
+
export function makeEtag(size, mtimeMs, encoding) {
|
|
56
|
+
const suffix = encoding ? `-${encoding}` : "";
|
|
57
|
+
return `W/"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}${suffix}"`;
|
|
58
|
+
}
|
|
59
|
+
function opaqueTag(tag) {
|
|
60
|
+
return tag.trim().replace(/^W\//, "");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Evaluate `If-None-Match` (weak comparison, `*`, lists), falling back to
|
|
64
|
+
* `If-Modified-Since` only when `If-None-Match` is absent (RFC 9110 §13.2.2).
|
|
65
|
+
*/
|
|
66
|
+
export function isNotModified(req, etag, mtimeMs) {
|
|
67
|
+
const inm = req.headers.get("if-none-match");
|
|
68
|
+
if (inm !== null) {
|
|
69
|
+
if (!etag)
|
|
70
|
+
return false;
|
|
71
|
+
if (inm.trim() === "*")
|
|
72
|
+
return true;
|
|
73
|
+
const ours = opaqueTag(etag);
|
|
74
|
+
return inm.split(",").some((t) => opaqueTag(t) === ours);
|
|
75
|
+
}
|
|
76
|
+
const ims = req.headers.get("if-modified-since");
|
|
77
|
+
if (ims !== null && mtimeMs !== undefined) {
|
|
78
|
+
const since = Date.parse(ims);
|
|
79
|
+
if (Number.isNaN(since))
|
|
80
|
+
return false;
|
|
81
|
+
// HTTP dates have second precision.
|
|
82
|
+
return Math.floor(mtimeMs / 1000) * 1000 <= since;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type CanonicalCheck, type SymlinkPolicy } from "./resolve.ts";
|
|
2
|
+
import { type CompiledRule } from "./rules.ts";
|
|
3
|
+
import type { SetHeadersFn, StaticEncoding, StaticOptions, StaticResponseOptions } from "./types.ts";
|
|
4
|
+
/** Normalized response options. */
|
|
5
|
+
export interface ResponseConfig {
|
|
6
|
+
precompressed: StaticEncoding[];
|
|
7
|
+
etag: boolean;
|
|
8
|
+
lastModified: boolean;
|
|
9
|
+
cacheControl: string;
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
untrusted: boolean;
|
|
12
|
+
setHeaders?: SetHeadersFn;
|
|
13
|
+
}
|
|
14
|
+
export interface StaticConfig extends ResponseConfig {
|
|
15
|
+
root: string;
|
|
16
|
+
index: string[];
|
|
17
|
+
extensions: string[];
|
|
18
|
+
dotfiles: "hide" | "allow";
|
|
19
|
+
allowDotfiles: Set<string>;
|
|
20
|
+
symlinks: SymlinkPolicy;
|
|
21
|
+
rules: CompiledRule[];
|
|
22
|
+
exclude: RegExp[];
|
|
23
|
+
fallback?: string[];
|
|
24
|
+
cache?: {
|
|
25
|
+
ttlMs: number;
|
|
26
|
+
maxEntries: number;
|
|
27
|
+
};
|
|
28
|
+
windows: boolean;
|
|
29
|
+
/** `passesFilePolicy` bound to this config, for `locate()`. */
|
|
30
|
+
allowCanonical: CanonicalCheck;
|
|
31
|
+
}
|
|
32
|
+
/** Validate and normalize the options shared with `serveFile()`. */
|
|
33
|
+
export declare function compileResponseOptions(opts: StaticResponseOptions): ResponseConfig;
|
|
34
|
+
export declare function compileServeFileOptions(opts: StaticResponseOptions): ResponseConfig;
|
|
35
|
+
/** True when `segments` may be served at all (dotfiles, deny, exclude). */
|
|
36
|
+
export declare function isServable(cfg: StaticConfig, segments: string[]): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Dotfile and deny rules. Unlike `exclude`, which carves out URL space,
|
|
39
|
+
* these protect files, so they are also applied to the canonical on-disk
|
|
40
|
+
* name of whatever a request resolves to.
|
|
41
|
+
*/
|
|
42
|
+
export declare function passesFilePolicy(cfg: StaticConfig, segments: string[]): boolean;
|
|
43
|
+
/** Validate options and resolve the root. Throws on any misconfiguration. */
|
|
44
|
+
export declare function compileStaticOptions(opts: StaticOptions): StaticConfig;
|