@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.
Files changed (106) hide show
  1. package/dist/contract/builder.d.ts +87 -0
  2. package/dist/contract/builder.js +89 -0
  3. package/dist/contract/index.d.ts +4 -0
  4. package/dist/contract/index.js +2 -0
  5. package/dist/contract/types.d.ts +47 -0
  6. package/dist/contract/types.js +0 -0
  7. package/dist/contract/ws.d.ts +178 -0
  8. package/dist/contract/ws.js +164 -0
  9. package/dist/core/compose.d.ts +22 -0
  10. package/dist/core/compose.js +87 -0
  11. package/dist/core/cors.d.ts +38 -0
  12. package/dist/core/cors.js +96 -0
  13. package/dist/core/error.d.ts +73 -0
  14. package/dist/core/error.js +142 -0
  15. package/dist/core/index.d.ts +20 -0
  16. package/dist/core/index.js +23 -0
  17. package/dist/core/matcher.d.ts +17 -0
  18. package/dist/core/matcher.js +110 -0
  19. package/dist/core/request.d.ts +28 -0
  20. package/dist/core/request.js +106 -0
  21. package/dist/core/route-builder.d.ts +58 -0
  22. package/dist/core/route-builder.js +122 -0
  23. package/dist/core/router.d.ts +162 -0
  24. package/dist/core/router.js +327 -0
  25. package/dist/core/transport.d.ts +39 -0
  26. package/dist/core/transport.js +68 -0
  27. package/dist/core/types.d.ts +86 -0
  28. package/dist/core/types.js +0 -0
  29. package/dist/core/upgrade.d.ts +85 -0
  30. package/dist/core/upgrade.js +110 -0
  31. package/dist/core/utils/headers.d.ts +1 -0
  32. package/dist/core/utils/headers.js +2 -0
  33. package/dist/core/utils/url.d.ts +27 -0
  34. package/dist/core/utils/url.js +130 -0
  35. package/dist/core/validation.d.ts +13 -0
  36. package/dist/core/validation.js +24 -0
  37. package/dist/sse/connection.d.ts +30 -0
  38. package/dist/sse/connection.js +104 -0
  39. package/dist/sse/encode.d.ts +10 -0
  40. package/dist/sse/encode.js +27 -0
  41. package/dist/sse/hub.d.ts +33 -0
  42. package/dist/sse/hub.js +115 -0
  43. package/dist/sse/index.d.ts +38 -0
  44. package/dist/sse/index.js +40 -0
  45. package/dist/sse/response.d.ts +10 -0
  46. package/dist/sse/response.js +50 -0
  47. package/dist/sse/route-builder.d.ts +44 -0
  48. package/dist/sse/route-builder.js +65 -0
  49. package/dist/sse/router.d.ts +11 -0
  50. package/dist/sse/router.js +16 -0
  51. package/dist/sse/types.d.ts +43 -0
  52. package/dist/sse/types.js +4 -0
  53. package/dist/static/index.d.ts +27 -0
  54. package/dist/static/index.js +25 -0
  55. package/dist/static/negotiate.d.ts +23 -0
  56. package/dist/static/negotiate.js +85 -0
  57. package/dist/static/options.d.ts +44 -0
  58. package/dist/static/options.js +190 -0
  59. package/dist/static/resolve.d.ts +56 -0
  60. package/dist/static/resolve.js +113 -0
  61. package/dist/static/response.d.ts +53 -0
  62. package/dist/static/response.js +146 -0
  63. package/dist/static/router.d.ts +59 -0
  64. package/dist/static/router.js +217 -0
  65. package/dist/static/rules.d.ts +21 -0
  66. package/dist/static/rules.js +49 -0
  67. package/dist/static/types.d.ts +76 -0
  68. package/dist/static/types.js +0 -0
  69. package/dist/ws/channels/channel.d.ts +175 -0
  70. package/dist/ws/channels/channel.js +125 -0
  71. package/dist/ws/channels/handler.d.ts +33 -0
  72. package/dist/ws/channels/handler.js +486 -0
  73. package/dist/ws/channels/protocol.d.ts +96 -0
  74. package/dist/ws/channels/protocol.js +94 -0
  75. package/dist/ws/channels/target.d.ts +36 -0
  76. package/dist/ws/channels/target.js +56 -0
  77. package/dist/ws/connection.d.ts +56 -0
  78. package/dist/ws/connection.js +172 -0
  79. package/dist/ws/helpers.d.ts +17 -0
  80. package/dist/ws/helpers.js +48 -0
  81. package/dist/ws/hub.d.ts +172 -0
  82. package/dist/ws/hub.js +489 -0
  83. package/dist/ws/index.d.ts +65 -0
  84. package/dist/ws/index.js +66 -0
  85. package/dist/ws/lifecycle.d.ts +21 -0
  86. package/dist/ws/lifecycle.js +97 -0
  87. package/dist/ws/limits.d.ts +32 -0
  88. package/dist/ws/limits.js +67 -0
  89. package/dist/ws/message.d.ts +19 -0
  90. package/dist/ws/message.js +58 -0
  91. package/dist/ws/origin.d.ts +40 -0
  92. package/dist/ws/origin.js +152 -0
  93. package/dist/ws/presence.d.ts +96 -0
  94. package/dist/ws/presence.js +420 -0
  95. package/dist/ws/route-builder.d.ts +136 -0
  96. package/dist/ws/route-builder.js +265 -0
  97. package/dist/ws/router.d.ts +21 -0
  98. package/dist/ws/router.js +36 -0
  99. package/dist/ws/tickets.d.ts +82 -0
  100. package/dist/ws/tickets.js +161 -0
  101. package/dist/ws/typed.d.ts +54 -0
  102. package/dist/ws/typed.js +104 -0
  103. package/dist/ws/types.d.ts +69 -0
  104. package/dist/ws/types.js +70 -0
  105. package/package.json +54 -4
  106. package/README.md +0 -3
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Generic WebSocket upgrade plumbing for Bun — deliberately protocol-agnostic
3
+ * so core never depends on `@fishenv/http/ws`.
4
+ *
5
+ * Bun upgrades from inside `fetch(req, server)`, but route handlers only see
6
+ * `req` and must return a `Response`. A handler therefore *requests* an
7
+ * upgrade (keyed by the `Request`, which survives CORS/HEAD response cloning)
8
+ * and returns a placeholder; `serve()` — or {@link completeUpgrade} for a
9
+ * hand-rolled `Bun.serve` — performs the actual upgrade.
10
+ */
11
+ const pending = new WeakMap();
12
+ /**
13
+ * Ask the server to upgrade `req` once the response pipeline finishes.
14
+ *
15
+ * The returned `426` is only ever seen when no server performs the upgrade —
16
+ * e.g. when calling `router.fetch()` directly in a unit test.
17
+ */
18
+ export function requestUpgrade(req, intent) {
19
+ pending.set(req, intent);
20
+ return new Response("Upgrade Required", {
21
+ status: 426,
22
+ headers: { Upgrade: "websocket", Connection: "Upgrade" },
23
+ });
24
+ }
25
+ /** Remove and return the pending upgrade for `req`, if any. */
26
+ export function takeUpgrade(req) {
27
+ const intent = pending.get(req);
28
+ if (intent != null)
29
+ pending.delete(req);
30
+ return intent;
31
+ }
32
+ /**
33
+ * Finish a request inside Bun's `fetch(req, server)`: performs a pending
34
+ * upgrade (returning `undefined`, as Bun requires) or passes `res` through.
35
+ */
36
+ export function completeUpgrade(req, res, server) {
37
+ const intent = takeUpgrade(req);
38
+ if (intent == null)
39
+ return res;
40
+ const upgraded = server.upgrade(req, {
41
+ data: { handlers: intent.handlers, server, state: intent.state },
42
+ headers: intent.headers,
43
+ });
44
+ if (upgraded)
45
+ return undefined;
46
+ return new Response("WebSocket upgrade failed", { status: 400 });
47
+ }
48
+ export const DEFAULT_MAX_PAYLOAD_LENGTH = 64 * 1024;
49
+ export const DEFAULT_BACKPRESSURE_LIMIT = 1024 * 1024;
50
+ /** `1001 Going Away` — sent to every socket on graceful shutdown. */
51
+ export const CLOSE_GOING_AWAY = 1001;
52
+ /** `1011 Internal Error`. */
53
+ export const CLOSE_INTERNAL_ERROR = 1011;
54
+ /**
55
+ * Build the single Bun `websocket` handler that dispatches every event to the
56
+ * per-socket {@link SocketHandlers}. Also a last-resort guard: a handler that
57
+ * throws or rejects is logged and its socket closed with `1011`.
58
+ */
59
+ export function createWebSocketRuntime(options = {}, logger) {
60
+ const open = new Set();
61
+ const log = logger ?? console;
62
+ const guard = (ws, event, run) => {
63
+ const fail = (err) => {
64
+ log.error(`[fishenv.http] WebSocket ${event} handler failed:`, err);
65
+ if (event !== "close")
66
+ ws.close(CLOSE_INTERNAL_ERROR, "Internal Error");
67
+ };
68
+ try {
69
+ const result = run();
70
+ if (result instanceof Promise)
71
+ result.catch(fail);
72
+ }
73
+ catch (err) {
74
+ fail(err);
75
+ }
76
+ };
77
+ const websocket = {
78
+ open(ws) {
79
+ open.add(ws);
80
+ guard(ws, "open", () => ws.data.handlers.open?.(ws));
81
+ },
82
+ message(ws, message) {
83
+ guard(ws, "message", () => ws.data.handlers.message?.(ws, message));
84
+ },
85
+ close(ws, code, reason) {
86
+ open.delete(ws);
87
+ guard(ws, "close", () => ws.data.handlers.close?.(ws, code, reason));
88
+ },
89
+ drain(ws) {
90
+ guard(ws, "drain", () => ws.data.handlers.drain?.(ws));
91
+ },
92
+ maxPayloadLength: options.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH,
93
+ backpressureLimit: options.backpressureLimit ?? DEFAULT_BACKPRESSURE_LIMIT,
94
+ closeOnBackpressureLimit: options.closeOnBackpressureLimit ?? true,
95
+ idleTimeout: options.idleTimeout ?? 120,
96
+ sendPings: options.sendPings ?? true,
97
+ perMessageDeflate: options.perMessageDeflate ?? false,
98
+ publishToSelf: false,
99
+ };
100
+ return {
101
+ websocket,
102
+ get size() {
103
+ return open.size;
104
+ },
105
+ closeAll(code = CLOSE_GOING_AWAY, reason = "Server shutting down") {
106
+ for (const ws of [...open])
107
+ ws.close(code, reason);
108
+ },
109
+ };
110
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Header normalization utilities
2
+ // TODO: implement in step 07
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Combine a base prefix and a route sub-path into one canonical path.
3
+ * Always returns a path starting with "/" and no trailing slash (except root).
4
+ */
5
+ export declare function mergePaths(base: string, sub: string): string;
6
+ /**
7
+ * Extract all named param names from a pattern (runtime, not type-level).
8
+ * E.g. "/users/:id/posts/:pid?/:rest*" → ["id", "pid", "rest"]
9
+ */
10
+ export declare function extractParamNames(pattern: string): string[];
11
+ /**
12
+ * Convert a route pattern to a regex + ordered param name list.
13
+ *
14
+ * Rules:
15
+ * - `:name` → named capture `([^/]+)`
16
+ * - `:name?` → optional named capture `(?:/([^/]+))?`
17
+ * - `:name*` → rest capture `(?:/(.*))?`, last segment only. The captured
18
+ * remainder is raw (percent-encoded) and keeps a trailing slash;
19
+ * the param is absent when nothing follows the prefix.
20
+ * - `*` → glob within a segment (`[^/]*`)
21
+ * - `**` → glob across path segments
22
+ * - `{a,b}` → alternation `(?:a|b)`
23
+ */
24
+ export declare function buildRouteRegex(pattern: string): {
25
+ regex: RegExp;
26
+ paramNames: string[];
27
+ };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Combine a base prefix and a route sub-path into one canonical path.
3
+ * Always returns a path starting with "/" and no trailing slash (except root).
4
+ */
5
+ export function mergePaths(base, sub) {
6
+ const combined = `${base.replace(/\/+$/, "")}/${sub.replace(/^\/+/, "")}`;
7
+ const normalized = `/${combined.replace(/^\/+/, "")}`.replace(/\/+$/, "") || "/";
8
+ return normalized;
9
+ }
10
+ /**
11
+ * Extract all named param names from a pattern (runtime, not type-level).
12
+ * E.g. "/users/:id/posts/:pid?/:rest*" → ["id", "pid", "rest"]
13
+ */
14
+ export function extractParamNames(pattern) {
15
+ const names = [];
16
+ for (const segment of pattern.split("/")) {
17
+ if (segment.startsWith(":")) {
18
+ names.push(segment.slice(1).replace(/[?*]$/, ""));
19
+ }
20
+ }
21
+ return names;
22
+ }
23
+ /**
24
+ * Convert a route pattern to a regex + ordered param name list.
25
+ *
26
+ * Rules:
27
+ * - `:name` → named capture `([^/]+)`
28
+ * - `:name?` → optional named capture `(?:/([^/]+))?`
29
+ * - `:name*` → rest capture `(?:/(.*))?`, last segment only. The captured
30
+ * remainder is raw (percent-encoded) and keeps a trailing slash;
31
+ * the param is absent when nothing follows the prefix.
32
+ * - `*` → glob within a segment (`[^/]*`)
33
+ * - `**` → glob across path segments
34
+ * - `{a,b}` → alternation `(?:a|b)`
35
+ */
36
+ export function buildRouteRegex(pattern) {
37
+ const paramNames = [];
38
+ const segments = pattern.split("/").filter((s) => s !== "");
39
+ let regexStr = "";
40
+ for (const [i, seg] of segments.entries()) {
41
+ if (seg.startsWith(":") && seg.endsWith("*")) {
42
+ if (i !== segments.length - 1) {
43
+ throw new Error(`Rest param "${seg}" must be the last segment of "${pattern}"`);
44
+ }
45
+ paramNames.push(seg.slice(1, -1));
46
+ regexStr += `(?:/(.*))?`;
47
+ continue;
48
+ }
49
+ if (seg.startsWith(":")) {
50
+ const isOptional = seg.endsWith("?");
51
+ const name = seg.slice(1).replace(/\?$/, "");
52
+ paramNames.push(name);
53
+ if (isOptional) {
54
+ regexStr += `(?:/([^/]+))?`;
55
+ }
56
+ else {
57
+ regexStr += `/([^/]+)`;
58
+ }
59
+ continue;
60
+ }
61
+ if (seg === "**") {
62
+ regexStr += `(?:/.*)?`;
63
+ continue;
64
+ }
65
+ regexStr += `/${globSegmentToRegex(seg)}`;
66
+ }
67
+ // Empty pattern → root
68
+ if (regexStr === "")
69
+ regexStr = "/";
70
+ return {
71
+ regex: new RegExp(`^${regexStr}$`),
72
+ paramNames,
73
+ };
74
+ }
75
+ function globSegmentToRegex(segment) {
76
+ let out = "";
77
+ let i = 0;
78
+ while (i < segment.length) {
79
+ const ch = segment[i];
80
+ if (ch === "*") {
81
+ out += "[^/]*";
82
+ i++;
83
+ continue;
84
+ }
85
+ if (ch === "{") {
86
+ const end = findClosingBrace(segment, i);
87
+ if (end === -1) {
88
+ out += "\\{";
89
+ i++;
90
+ continue;
91
+ }
92
+ const body = segment.slice(i + 1, end);
93
+ const parts = splitBraceParts(body).map((part) => globLiteralToRegex(part));
94
+ out += parts.length > 0 ? `(?:${parts.join("|")})` : "\\{\\}";
95
+ i = end + 1;
96
+ continue;
97
+ }
98
+ out += escapeRegex(ch);
99
+ i++;
100
+ }
101
+ return out;
102
+ }
103
+ function globLiteralToRegex(part) {
104
+ let out = "";
105
+ for (const ch of part) {
106
+ if (ch === "*") {
107
+ out += "[^/]*";
108
+ }
109
+ else {
110
+ out += escapeRegex(ch);
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ function findClosingBrace(str, start) {
116
+ for (let i = start + 1; i < str.length; i++) {
117
+ if (str[i] === "}")
118
+ return i;
119
+ }
120
+ return -1;
121
+ }
122
+ function splitBraceParts(body) {
123
+ return body
124
+ .split(",")
125
+ .map((part) => part.trim())
126
+ .filter((part) => part.length > 0);
127
+ }
128
+ function escapeRegex(str) {
129
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
130
+ }
@@ -0,0 +1,13 @@
1
+ import * as v from "valibot";
2
+ import { type ValidationField } from "./error.ts";
3
+ import type { AnySchema } from "./types.ts";
4
+ /**
5
+ * Validate a single field value against a Valibot schema.
6
+ * Throws ValidationError on failure.
7
+ */
8
+ export declare function validateField<S extends AnySchema>(schema: S, value: unknown, field: ValidationField): v.InferOutput<S>;
9
+ /**
10
+ * Validate path params: each param with a registered schema gets validated/coerced.
11
+ * Params without schemas pass through as raw strings.
12
+ */
13
+ export declare function validatePathParams(rawParams: Record<string, string>, schemas: Map<string, AnySchema>): Record<string, unknown>;
@@ -0,0 +1,24 @@
1
+ import * as v from "valibot";
2
+ import { ValidationError } from "./error.js";
3
+ /**
4
+ * Validate a single field value against a Valibot schema.
5
+ * Throws ValidationError on failure.
6
+ */
7
+ export function validateField(schema, value, field) {
8
+ const result = v.safeParse(schema, value);
9
+ if (!result.success) {
10
+ throw new ValidationError(field, result.issues);
11
+ }
12
+ return result.output;
13
+ }
14
+ /**
15
+ * Validate path params: each param with a registered schema gets validated/coerced.
16
+ * Params without schemas pass through as raw strings.
17
+ */
18
+ export function validatePathParams(rawParams, schemas) {
19
+ const validated = { ...rawParams };
20
+ for (const [name, schema] of schemas) {
21
+ validated[name] = validateField(schema, rawParams[name], "path");
22
+ }
23
+ return validated;
24
+ }
@@ -0,0 +1,30 @@
1
+ /** A single live SSE stream, usable beyond the lifetime of its route handler. */
2
+ import type { SseCleanupFn, SseConnectionOptions, SseEventOptions } from "./types.ts";
3
+ export declare const DEFAULT_HIGH_WATER_MARK: number;
4
+ /**
5
+ * Every method is a no-op once the connection is closed, so a stale reference
6
+ * held by a service or registry can never throw.
7
+ */
8
+ export declare class SseConnection {
9
+ #private;
10
+ /** Unique per connection; a user may hold several (one per tab/device). */
11
+ readonly id: string;
12
+ /** From the `Last-Event-ID` header, falling back to a `?lastEventId=` query param. */
13
+ readonly lastEventId: string | null;
14
+ readonly req: Request;
15
+ constructor(controller: ReadableStreamDefaultController<Uint8Array>, req: Request, options?: SseConnectionOptions);
16
+ get closed(): boolean;
17
+ /** Bytes queued but not yet flushed to the socket. */
18
+ get bufferedBytes(): number;
19
+ /** Returns `false` if the event was dropped because the connection is gone or saturated. */
20
+ send(data: unknown, opts?: SseEventOptions): boolean;
21
+ /** Send a comment line — invisible to `EventSource`, used as a heartbeat. */
22
+ comment(text?: string): boolean;
23
+ /** Tell the client how long to wait before reconnecting. */
24
+ retry(ms: number): boolean;
25
+ close(): void;
26
+ /** Runs immediately if the connection is already closed. */
27
+ onClose(fn: SseCleanupFn): void;
28
+ }
29
+ /** @internal */
30
+ export declare function runCleanup(fn: SseCleanupFn): void;
@@ -0,0 +1,104 @@
1
+ /** A single live SSE stream, usable beyond the lifetime of its route handler. */
2
+ import { encodeSseComment, encodeSseEvent, encodeSseRetry } from "./encode.js";
3
+ const encoder = new TextEncoder();
4
+ export const DEFAULT_HIGH_WATER_MARK = 64 * 1024;
5
+ /**
6
+ * Every method is a no-op once the connection is closed, so a stale reference
7
+ * held by a service or registry can never throw.
8
+ */
9
+ export class SseConnection {
10
+ /** Unique per connection; a user may hold several (one per tab/device). */
11
+ id = crypto.randomUUID();
12
+ /** From the `Last-Event-ID` header, falling back to a `?lastEventId=` query param. */
13
+ lastEventId;
14
+ req;
15
+ #controller;
16
+ #highWaterMark;
17
+ #overflow;
18
+ #closed = false;
19
+ #closeCallbacks = [];
20
+ constructor(controller, req, options = {}) {
21
+ this.#controller = controller;
22
+ this.req = req;
23
+ this.#highWaterMark = options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK;
24
+ this.#overflow = options.onOverflow ?? "close";
25
+ this.lastEventId =
26
+ req.headers.get("last-event-id") ??
27
+ new URL(req.url).searchParams.get("lastEventId");
28
+ }
29
+ get closed() {
30
+ return this.#closed;
31
+ }
32
+ /** Bytes queued but not yet flushed to the socket. */
33
+ get bufferedBytes() {
34
+ if (this.#closed || this.#controller == null)
35
+ return 0;
36
+ return this.#highWaterMark - (this.#controller.desiredSize ?? 0);
37
+ }
38
+ /** Returns `false` if the event was dropped because the connection is gone or saturated. */
39
+ send(data, opts) {
40
+ return this.#write(encodeSseEvent(data, opts));
41
+ }
42
+ /** Send a comment line — invisible to `EventSource`, used as a heartbeat. */
43
+ comment(text = "") {
44
+ return this.#write(encodeSseComment(text));
45
+ }
46
+ /** Tell the client how long to wait before reconnecting. */
47
+ retry(ms) {
48
+ return this.#write(encodeSseRetry(ms));
49
+ }
50
+ close() {
51
+ if (this.#closed)
52
+ return;
53
+ this.#closed = true;
54
+ const controller = this.#controller;
55
+ this.#controller = null;
56
+ try {
57
+ controller?.close();
58
+ }
59
+ catch {
60
+ // Already closed or errored by the consumer — nothing to do.
61
+ }
62
+ const callbacks = this.#closeCallbacks;
63
+ this.#closeCallbacks = [];
64
+ for (const cb of callbacks)
65
+ runCleanup(cb);
66
+ }
67
+ /** Runs immediately if the connection is already closed. */
68
+ onClose(fn) {
69
+ if (this.#closed) {
70
+ runCleanup(fn);
71
+ return;
72
+ }
73
+ this.#closeCallbacks.push(fn);
74
+ }
75
+ #write(frame) {
76
+ if (this.#closed || this.#controller == null)
77
+ return false;
78
+ if ((this.#controller.desiredSize ?? 0) <= 0) {
79
+ if (this.#overflow === "close")
80
+ this.close();
81
+ return false;
82
+ }
83
+ try {
84
+ this.#controller.enqueue(encoder.encode(frame));
85
+ return true;
86
+ }
87
+ catch {
88
+ this.close();
89
+ return false;
90
+ }
91
+ }
92
+ }
93
+ /** @internal */
94
+ export function runCleanup(fn) {
95
+ try {
96
+ const result = fn();
97
+ if (result instanceof Promise) {
98
+ result.catch((err) => console.error("[fishenv.sse] close callback rejected:", err));
99
+ }
100
+ }
101
+ catch (err) {
102
+ console.error("[fishenv.sse] close callback threw:", err);
103
+ }
104
+ }
@@ -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
+ }
@@ -0,0 +1,115 @@
1
+ /** Keyed registry of live connections — the seam for targeted (non-broadcast) sends. */
2
+ import { createTransportBridge, } from "../core/transport.js";
3
+ /**
4
+ * Connections keyed by an application identifier (user id, tenant id, document
5
+ * id…). One key maps to many connections.
6
+ *
7
+ * Subclass it to get a DI token:
8
+ * ```ts
9
+ * class UserEvents extends SseHub {}
10
+ * ```
11
+ */
12
+ export class SseHub {
13
+ #byKey = new Map();
14
+ #bridge;
15
+ constructor(options = {}) {
16
+ this.#bridge = createTransportBridge(options.transport, (message) => this.#deliver(message.key, message.data, {
17
+ event: message.event,
18
+ id: message.id,
19
+ retry: message.retry,
20
+ }), { label: "[fishenv.sse]" });
21
+ }
22
+ /** Register a connection. Returns an unregister fn; also auto-removes on close. */
23
+ add(key, connection) {
24
+ let set = this.#byKey.get(key);
25
+ if (set == null) {
26
+ set = new Set();
27
+ this.#byKey.set(key, set);
28
+ }
29
+ set.add(connection);
30
+ const remove = () => {
31
+ const current = this.#byKey.get(key);
32
+ if (current == null)
33
+ return;
34
+ current.delete(connection);
35
+ if (current.size === 0)
36
+ this.#byKey.delete(key);
37
+ };
38
+ connection.onClose(remove);
39
+ return remove;
40
+ }
41
+ /** Push to every connection registered under `key`. Returns local delivery count. */
42
+ send(key, data, opts) {
43
+ this.#publish(key, data, opts);
44
+ return this.#deliver(key, data, opts);
45
+ }
46
+ /** Push to every connection under any of `keys`. Returns local delivery count. */
47
+ sendMany(keys, data, opts) {
48
+ let delivered = 0;
49
+ for (const key of keys)
50
+ delivered += this.send(key, data, opts);
51
+ return delivered;
52
+ }
53
+ /** Push to every connection in this hub. Returns local delivery count. */
54
+ broadcast(data, opts) {
55
+ this.#publish(null, data, opts);
56
+ return this.#deliver(null, data, opts);
57
+ }
58
+ /** Close every connection under `key`. Returns how many were closed. */
59
+ close(key) {
60
+ const set = this.#byKey.get(key);
61
+ if (set == null)
62
+ return 0;
63
+ const connections = [...set];
64
+ for (const connection of connections)
65
+ connection.close();
66
+ return connections.length;
67
+ }
68
+ /** Close every connection and detach the transport subscription. */
69
+ closeAll() {
70
+ for (const set of [...this.#byKey.values()]) {
71
+ for (const connection of [...set])
72
+ connection.close();
73
+ }
74
+ this.#byKey.clear();
75
+ this.#bridge.close();
76
+ }
77
+ has(key) {
78
+ return (this.#byKey.get(key)?.size ?? 0) > 0;
79
+ }
80
+ /** Connection count for `key`, or the hub total when `key` is omitted. */
81
+ count(key) {
82
+ if (key != null)
83
+ return this.#byKey.get(key)?.size ?? 0;
84
+ let total = 0;
85
+ for (const set of this.#byKey.values())
86
+ total += set.size;
87
+ return total;
88
+ }
89
+ keys() {
90
+ return [...this.#byKey.keys()];
91
+ }
92
+ connections(key) {
93
+ return [...(this.#byKey.get(key) ?? [])];
94
+ }
95
+ #deliver(key, data, opts) {
96
+ const targets = key == null
97
+ ? [...this.#byKey.values()].flatMap((set) => [...set])
98
+ : [...(this.#byKey.get(key) ?? [])];
99
+ let delivered = 0;
100
+ for (const connection of targets) {
101
+ if (connection.send(data, opts))
102
+ delivered++;
103
+ }
104
+ return delivered;
105
+ }
106
+ #publish(key, data, opts) {
107
+ this.#bridge.publish({
108
+ key,
109
+ data,
110
+ event: opts?.event,
111
+ id: opts?.id,
112
+ retry: opts?.retry,
113
+ });
114
+ }
115
+ }
@@ -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";