@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,10 @@
|
|
|
1
|
+
/** SSE wire format — turning values into `text/event-stream` frames. */
|
|
2
|
+
import type { SseEventOptions } from "./types.ts";
|
|
3
|
+
/** Field values are attacker-reachable; CR/LF/NUL would forge extra frames. */
|
|
4
|
+
export declare function sanitizeField(value: string): string;
|
|
5
|
+
/** Serialize a value into a complete SSE frame (including the terminating blank line). */
|
|
6
|
+
export declare function encodeSseEvent(data: unknown, opts?: SseEventOptions): string;
|
|
7
|
+
/** A standalone `retry:` frame, sent without any accompanying data. */
|
|
8
|
+
export declare function encodeSseRetry(ms: number): string;
|
|
9
|
+
/** A comment frame — invisible to `EventSource`, used as a heartbeat. */
|
|
10
|
+
export declare function encodeSseComment(text: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** SSE wire format — turning values into `text/event-stream` frames. */
|
|
2
|
+
/** Field values are attacker-reachable; CR/LF/NUL would forge extra frames. */
|
|
3
|
+
export function sanitizeField(value) {
|
|
4
|
+
return value.replace(/[\r\n\0]/g, "");
|
|
5
|
+
}
|
|
6
|
+
/** Serialize a value into a complete SSE frame (including the terminating blank line). */
|
|
7
|
+
export function encodeSseEvent(data, opts) {
|
|
8
|
+
let frame = "";
|
|
9
|
+
if (opts?.id != null)
|
|
10
|
+
frame += `id: ${sanitizeField(opts.id)}\n`;
|
|
11
|
+
if (opts?.event != null)
|
|
12
|
+
frame += `event: ${sanitizeField(opts.event)}\n`;
|
|
13
|
+
if (opts?.retry != null)
|
|
14
|
+
frame += `retry: ${Math.trunc(opts.retry)}\n`;
|
|
15
|
+
const text = typeof data === "string" ? data : (JSON.stringify(data) ?? "");
|
|
16
|
+
for (const line of text.split(/\r\n|\r|\n/))
|
|
17
|
+
frame += `data: ${line}\n`;
|
|
18
|
+
return `${frame}\n`;
|
|
19
|
+
}
|
|
20
|
+
/** A standalone `retry:` frame, sent without any accompanying data. */
|
|
21
|
+
export function encodeSseRetry(ms) {
|
|
22
|
+
return `retry: ${Math.trunc(ms)}\n\n`;
|
|
23
|
+
}
|
|
24
|
+
/** A comment frame — invisible to `EventSource`, used as a heartbeat. */
|
|
25
|
+
export function encodeSseComment(text) {
|
|
26
|
+
return `: ${sanitizeField(text)}\n\n`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Keyed registry of live connections — the seam for targeted (non-broadcast) sends. */
|
|
2
|
+
import type { SseConnection } from "./connection.ts";
|
|
3
|
+
import type { SseEventOptions, SseHubOptions } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Connections keyed by an application identifier (user id, tenant id, document
|
|
6
|
+
* id…). One key maps to many connections.
|
|
7
|
+
*
|
|
8
|
+
* Subclass it to get a DI token:
|
|
9
|
+
* ```ts
|
|
10
|
+
* class UserEvents extends SseHub {}
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare class SseHub {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(options?: SseHubOptions);
|
|
16
|
+
/** Register a connection. Returns an unregister fn; also auto-removes on close. */
|
|
17
|
+
add(key: string, connection: SseConnection): () => void;
|
|
18
|
+
/** Push to every connection registered under `key`. Returns local delivery count. */
|
|
19
|
+
send(key: string, data: unknown, opts?: SseEventOptions): number;
|
|
20
|
+
/** Push to every connection under any of `keys`. Returns local delivery count. */
|
|
21
|
+
sendMany(keys: Iterable<string>, data: unknown, opts?: SseEventOptions): number;
|
|
22
|
+
/** Push to every connection in this hub. Returns local delivery count. */
|
|
23
|
+
broadcast(data: unknown, opts?: SseEventOptions): number;
|
|
24
|
+
/** Close every connection under `key`. Returns how many were closed. */
|
|
25
|
+
close(key: string): number;
|
|
26
|
+
/** Close every connection and detach the transport subscription. */
|
|
27
|
+
closeAll(): void;
|
|
28
|
+
has(key: string): boolean;
|
|
29
|
+
/** Connection count for `key`, or the hub total when `key` is omitted. */
|
|
30
|
+
count(key?: string): number;
|
|
31
|
+
keys(): string[];
|
|
32
|
+
connections(key: string): SseConnection[];
|
|
33
|
+
}
|
package/dist/sse/hub.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Keyed registry of live connections — the seam for targeted (non-broadcast) sends. */
|
|
2
|
+
/**
|
|
3
|
+
* Connections keyed by an application identifier (user id, tenant id, document
|
|
4
|
+
* id…). One key maps to many connections.
|
|
5
|
+
*
|
|
6
|
+
* Subclass it to get a DI token:
|
|
7
|
+
* ```ts
|
|
8
|
+
* class UserEvents extends SseHub {}
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export class SseHub {
|
|
12
|
+
#byKey = new Map();
|
|
13
|
+
#nodeId = crypto.randomUUID();
|
|
14
|
+
#transport;
|
|
15
|
+
#unsubscribe;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.#transport = options.transport;
|
|
18
|
+
this.#unsubscribe = options.transport?.subscribe((message) => {
|
|
19
|
+
if (message.origin === this.#nodeId)
|
|
20
|
+
return;
|
|
21
|
+
this.#deliver(message.key, message.data, {
|
|
22
|
+
event: message.event,
|
|
23
|
+
id: message.id,
|
|
24
|
+
retry: message.retry,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Register a connection. Returns an unregister fn; also auto-removes on close. */
|
|
29
|
+
add(key, connection) {
|
|
30
|
+
let set = this.#byKey.get(key);
|
|
31
|
+
if (set == null) {
|
|
32
|
+
set = new Set();
|
|
33
|
+
this.#byKey.set(key, set);
|
|
34
|
+
}
|
|
35
|
+
set.add(connection);
|
|
36
|
+
const remove = () => {
|
|
37
|
+
const current = this.#byKey.get(key);
|
|
38
|
+
if (current == null)
|
|
39
|
+
return;
|
|
40
|
+
current.delete(connection);
|
|
41
|
+
if (current.size === 0)
|
|
42
|
+
this.#byKey.delete(key);
|
|
43
|
+
};
|
|
44
|
+
connection.onClose(remove);
|
|
45
|
+
return remove;
|
|
46
|
+
}
|
|
47
|
+
/** Push to every connection registered under `key`. Returns local delivery count. */
|
|
48
|
+
send(key, data, opts) {
|
|
49
|
+
this.#publish(key, data, opts);
|
|
50
|
+
return this.#deliver(key, data, opts);
|
|
51
|
+
}
|
|
52
|
+
/** Push to every connection under any of `keys`. Returns local delivery count. */
|
|
53
|
+
sendMany(keys, data, opts) {
|
|
54
|
+
let delivered = 0;
|
|
55
|
+
for (const key of keys)
|
|
56
|
+
delivered += this.send(key, data, opts);
|
|
57
|
+
return delivered;
|
|
58
|
+
}
|
|
59
|
+
/** Push to every connection in this hub. Returns local delivery count. */
|
|
60
|
+
broadcast(data, opts) {
|
|
61
|
+
this.#publish(null, data, opts);
|
|
62
|
+
return this.#deliver(null, data, opts);
|
|
63
|
+
}
|
|
64
|
+
/** Close every connection under `key`. Returns how many were closed. */
|
|
65
|
+
close(key) {
|
|
66
|
+
const set = this.#byKey.get(key);
|
|
67
|
+
if (set == null)
|
|
68
|
+
return 0;
|
|
69
|
+
const connections = [...set];
|
|
70
|
+
for (const connection of connections)
|
|
71
|
+
connection.close();
|
|
72
|
+
return connections.length;
|
|
73
|
+
}
|
|
74
|
+
/** Close every connection and detach the transport subscription. */
|
|
75
|
+
closeAll() {
|
|
76
|
+
for (const set of [...this.#byKey.values()]) {
|
|
77
|
+
for (const connection of [...set])
|
|
78
|
+
connection.close();
|
|
79
|
+
}
|
|
80
|
+
this.#byKey.clear();
|
|
81
|
+
this.#unsubscribe?.();
|
|
82
|
+
this.#unsubscribe = undefined;
|
|
83
|
+
}
|
|
84
|
+
has(key) {
|
|
85
|
+
return (this.#byKey.get(key)?.size ?? 0) > 0;
|
|
86
|
+
}
|
|
87
|
+
/** Connection count for `key`, or the hub total when `key` is omitted. */
|
|
88
|
+
count(key) {
|
|
89
|
+
if (key != null)
|
|
90
|
+
return this.#byKey.get(key)?.size ?? 0;
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const set of this.#byKey.values())
|
|
93
|
+
total += set.size;
|
|
94
|
+
return total;
|
|
95
|
+
}
|
|
96
|
+
keys() {
|
|
97
|
+
return [...this.#byKey.keys()];
|
|
98
|
+
}
|
|
99
|
+
connections(key) {
|
|
100
|
+
return [...(this.#byKey.get(key) ?? [])];
|
|
101
|
+
}
|
|
102
|
+
#deliver(key, data, opts) {
|
|
103
|
+
const targets = key == null
|
|
104
|
+
? [...this.#byKey.values()].flatMap((set) => [...set])
|
|
105
|
+
: [...(this.#byKey.get(key) ?? [])];
|
|
106
|
+
let delivered = 0;
|
|
107
|
+
for (const connection of targets) {
|
|
108
|
+
if (connection.send(data, opts))
|
|
109
|
+
delivered++;
|
|
110
|
+
}
|
|
111
|
+
return delivered;
|
|
112
|
+
}
|
|
113
|
+
#publish(key, data, opts) {
|
|
114
|
+
if (this.#transport == null)
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
const result = this.#transport.publish({
|
|
118
|
+
key,
|
|
119
|
+
data,
|
|
120
|
+
event: opts?.event,
|
|
121
|
+
id: opts?.id,
|
|
122
|
+
retry: opts?.retry,
|
|
123
|
+
origin: this.#nodeId,
|
|
124
|
+
});
|
|
125
|
+
if (result instanceof Promise) {
|
|
126
|
+
result.catch((err) => console.error("[fishenv.sse] transport publish failed:", err));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error("[fishenv.sse] transport publish failed:", err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
export { SseConnection } from "./connection.ts";
|
|
30
|
+
export { encodeSseComment, encodeSseEvent, encodeSseRetry } from "./encode.ts";
|
|
31
|
+
export { SseHub } from "./hub.ts";
|
|
32
|
+
export type { SseOpenFn } from "./response.ts";
|
|
33
|
+
export { createSseResponse } from "./response.ts";
|
|
34
|
+
export type { SseHandlerArgs, SseHandlerFn } from "./route-builder.ts";
|
|
35
|
+
export { SseRouteBuilder } from "./route-builder.ts";
|
|
36
|
+
export type { SseRouter } from "./router.ts";
|
|
37
|
+
export { withSse } from "./router.ts";
|
|
38
|
+
export type { SseCleanupFn, SseConnectionOptions, SseEventOptions, SseHubOptions, SseOverflowPolicy, SseTransport, SseTransportMessage, } from "./types.ts";
|
|
@@ -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,45 @@
|
|
|
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
|
+
export interface SseEventOptions {
|
|
6
|
+
/** `event:` field — the client-side listener name. Defaults to `message`. */
|
|
7
|
+
event?: string;
|
|
8
|
+
/** `id:` field — echoed back by the browser as `Last-Event-ID` on reconnect. */
|
|
9
|
+
id?: string;
|
|
10
|
+
/** `retry:` field — reconnection delay in milliseconds. */
|
|
11
|
+
retry?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Return value is ignored; a returned promise is awaited only for error logging. */
|
|
14
|
+
export type SseCleanupFn = () => unknown;
|
|
15
|
+
/** What to do when a client is too slow to drain the send buffer. */
|
|
16
|
+
export type SseOverflowPolicy = "close" | "drop";
|
|
17
|
+
export interface SseConnectionOptions {
|
|
18
|
+
/** Send a `:` comment every N ms. Also how dead sockets get detected. */
|
|
19
|
+
keepaliveMs?: number;
|
|
20
|
+
/** Initial `retry:` hint sent as soon as the stream opens. */
|
|
21
|
+
retryMs?: number;
|
|
22
|
+
/** Send-buffer size in bytes before the overflow policy kicks in. */
|
|
23
|
+
highWaterMark?: number;
|
|
24
|
+
/** Default `"close"` — a stalled client is dropped and reconnects on its own. */
|
|
25
|
+
onOverflow?: SseOverflowPolicy;
|
|
26
|
+
}
|
|
27
|
+
/** Cross-process fan-out envelope. Must be JSON-serializable. */
|
|
28
|
+
export interface SseTransportMessage {
|
|
29
|
+
/** `null` means broadcast to every connection. */
|
|
30
|
+
key: string | null;
|
|
31
|
+
data: unknown;
|
|
32
|
+
event?: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
retry?: number;
|
|
35
|
+
/** Node that published it — used to suppress echo back to the sender. */
|
|
36
|
+
origin: string;
|
|
37
|
+
}
|
|
38
|
+
/** Seam for multi-instance deployments (Redis pub/sub, NATS, Postgres LISTEN…). */
|
|
39
|
+
export interface SseTransport {
|
|
40
|
+
publish(message: SseTransportMessage): void | Promise<void>;
|
|
41
|
+
subscribe(onMessage: (message: SseTransportMessage) => void): () => void;
|
|
42
|
+
}
|
|
43
|
+
export interface SseHubOptions {
|
|
44
|
+
transport?: SseTransport;
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fishenv/http",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Type-safe HTTP router for Bun with valibot validation, contracts and Server-Sent Events.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/core/index.d.ts",
|
|
14
|
+
"default": "./dist/core/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./sse": {
|
|
17
|
+
"types": "./dist/sse/index.d.ts",
|
|
18
|
+
"default": "./dist/sse/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./contract": {
|
|
21
|
+
"types": "./dist/contract/index.d.ts",
|
|
22
|
+
"default": "./dist/contract/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"check": "tsc --noEmit",
|
|
31
|
+
"lint": "biome check .",
|
|
32
|
+
"format": "biome format --write .",
|
|
33
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
34
|
+
"prepack": "bun run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"valibot": "^1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@biomejs/biome": "^2.5.15",
|
|
41
|
+
"@types/bun": "^1.4.3",
|
|
42
|
+
"typescript": "^7.0.2"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/README.md
DELETED