@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,217 @@
|
|
|
1
|
+
/** `staticFiles()`, `spa()` and the `withStatic()` mixin. */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BadRequestError, NotFoundError, r, } from "../core/index.js";
|
|
4
|
+
import { acceptsHtml } from "./negotiate.js";
|
|
5
|
+
import { compileStaticOptions, isServable, } from "./options.js";
|
|
6
|
+
import { BAD_ENCODING, locate, parseRequestPath } from "./resolve.js";
|
|
7
|
+
import { serveWithVariants, statFile, } from "./response.js";
|
|
8
|
+
import { applyRules } from "./rules.js";
|
|
9
|
+
/**
|
|
10
|
+
* Serve a directory as a mountable router:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* app.add("/assets", staticFiles({ root: "./public" }));
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Paths are decoded and validated per segment, kept inside the (real) root,
|
|
17
|
+
* and dotfiles are hidden by default. Every rejection is a plain 404
|
|
18
|
+
* (`NotFoundError`), except undecodable `%` sequences (`BadRequestError`).
|
|
19
|
+
* Throws at creation on any misconfiguration.
|
|
20
|
+
*/
|
|
21
|
+
export function staticFiles(opts) {
|
|
22
|
+
const cfg = compileStaticOptions(opts);
|
|
23
|
+
const cache = cfg.cache ? new LookupCache(cfg.cache) : null;
|
|
24
|
+
const router = r();
|
|
25
|
+
router.get("/:path*").handle(async ({ req, path: params }) => {
|
|
26
|
+
const raw = params.path;
|
|
27
|
+
const parsed = parseRequestPath(raw, cfg.windows);
|
|
28
|
+
if (parsed === BAD_ENCODING)
|
|
29
|
+
throw new BadRequestError();
|
|
30
|
+
if (parsed === null || !isServable(cfg, parsed.segments)) {
|
|
31
|
+
throw new NotFoundError();
|
|
32
|
+
}
|
|
33
|
+
const key = parsed.segments.join("/") + (parsed.trailingSlash ? "/" : "");
|
|
34
|
+
const found = await resolveFresh(cfg, cache, key, parsed);
|
|
35
|
+
if (found.kind === "file")
|
|
36
|
+
return serveLookup(req, cfg, found, found.stat);
|
|
37
|
+
if (found.kind === "redirect")
|
|
38
|
+
return redirectToDirectory(req);
|
|
39
|
+
if (cfg.fallback && isFallbackEligible(req, parsed.segments)) {
|
|
40
|
+
const shell = await withStat(await lookup(cfg, cfg.fallback, false));
|
|
41
|
+
if (shell?.kind === "file") {
|
|
42
|
+
return serveLookup(req, cfg, shell, shell.stat, { forceNoCache: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new NotFoundError();
|
|
46
|
+
});
|
|
47
|
+
return Object.defineProperty(router, "invalidate", {
|
|
48
|
+
value: (relPath) => cache?.invalidate(relPath),
|
|
49
|
+
enumerable: false,
|
|
50
|
+
configurable: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Single-page app: `staticFiles()` with `index.html` as both the directory
|
|
55
|
+
* index and the fallback for unknown, extension-less HTML requests.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* app.add(api).add(spa({ root: "./dist", exclude: ["api/**"] }));
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function spa(opts) {
|
|
62
|
+
return staticFiles({
|
|
63
|
+
index: ["index.html"],
|
|
64
|
+
fallback: "index.html",
|
|
65
|
+
...opts,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Mixin sugar for `router.add(prefix, staticFiles(opts))`. */
|
|
69
|
+
// biome-ignore lint/suspicious/noExplicitAny: accepts routers of any context
|
|
70
|
+
export function withStatic(router, prefix, opts) {
|
|
71
|
+
router.add(prefix, staticFiles(opts));
|
|
72
|
+
return router;
|
|
73
|
+
}
|
|
74
|
+
/** Attach a fresh stat to a file lookup; `null` if it is no longer a file. */
|
|
75
|
+
async function withStat(found) {
|
|
76
|
+
if (found.kind !== "file")
|
|
77
|
+
return found;
|
|
78
|
+
const stat = await statFile(found.abs);
|
|
79
|
+
return stat ? { ...found, stat } : null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Look up through the cache, but never build a response from a cached file
|
|
83
|
+
* without a fresh stat: a vanished or replaced entry is resolved again.
|
|
84
|
+
*/
|
|
85
|
+
async function resolveFresh(cfg, cache, key, parsed) {
|
|
86
|
+
const cached = cache?.get(key);
|
|
87
|
+
if (cached) {
|
|
88
|
+
const fresh = await withStat(cached);
|
|
89
|
+
if (fresh)
|
|
90
|
+
return fresh;
|
|
91
|
+
cache?.delete(key);
|
|
92
|
+
}
|
|
93
|
+
const found = await lookup(cfg, parsed.segments, parsed.trailingSlash);
|
|
94
|
+
const fresh = await withStat(found);
|
|
95
|
+
if (fresh) {
|
|
96
|
+
cache?.set(key, found);
|
|
97
|
+
return fresh;
|
|
98
|
+
}
|
|
99
|
+
return { kind: "missing" };
|
|
100
|
+
}
|
|
101
|
+
function serveLookup(req, cfg, found, stat, extras = {}) {
|
|
102
|
+
const rules = applyRules(cfg.rules, found.rel);
|
|
103
|
+
return serveWithVariants(req, cfg, { path: found.rel, abs: found.abs, stat, variants: found.variants }, { cacheControl: rules.cacheControl, headers: rules.headers, ...extras });
|
|
104
|
+
}
|
|
105
|
+
/** Steps 6–10: file, directory index, redirect, extension fallback. */
|
|
106
|
+
async function lookup(cfg, segments, trailingSlash) {
|
|
107
|
+
const target = await locate(cfg.root, segments, cfg.symlinks, cfg.allowCanonical);
|
|
108
|
+
if (target?.stat.isDirectory()) {
|
|
109
|
+
if (cfg.index.length === 0)
|
|
110
|
+
return { kind: "missing" };
|
|
111
|
+
if (!trailingSlash)
|
|
112
|
+
return { kind: "redirect" };
|
|
113
|
+
for (const name of cfg.index) {
|
|
114
|
+
const found = await findFile(cfg, [...segments, name]);
|
|
115
|
+
if (found)
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
return { kind: "missing" };
|
|
119
|
+
}
|
|
120
|
+
if (target && !trailingSlash)
|
|
121
|
+
return withVariants(cfg, segments, target.abs);
|
|
122
|
+
if (!trailingSlash && segments.length > 0) {
|
|
123
|
+
const last = segments[segments.length - 1];
|
|
124
|
+
for (const ext of cfg.extensions) {
|
|
125
|
+
const found = await findFile(cfg, [
|
|
126
|
+
...segments.slice(0, -1),
|
|
127
|
+
`${last}.${ext}`,
|
|
128
|
+
]);
|
|
129
|
+
if (found)
|
|
130
|
+
return found;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { kind: "missing" };
|
|
134
|
+
}
|
|
135
|
+
/** A derived candidate (index, extension) is checked like a request path. */
|
|
136
|
+
async function findFile(cfg, segments) {
|
|
137
|
+
if (!isServable(cfg, segments))
|
|
138
|
+
return null;
|
|
139
|
+
const located = await locate(cfg.root, segments, cfg.symlinks, cfg.allowCanonical);
|
|
140
|
+
if (!located?.stat.isFile())
|
|
141
|
+
return null;
|
|
142
|
+
return withVariants(cfg, segments, located.abs);
|
|
143
|
+
}
|
|
144
|
+
async function withVariants(cfg, segments, abs) {
|
|
145
|
+
const variants = {};
|
|
146
|
+
const last = segments[segments.length - 1];
|
|
147
|
+
for (const enc of cfg.precompressed) {
|
|
148
|
+
const suffix = enc === "br" ? ".br" : ".gz";
|
|
149
|
+
const sibling = await locate(cfg.root, [...segments.slice(0, -1), last + suffix], cfg.symlinks, cfg.allowCanonical);
|
|
150
|
+
if (sibling?.stat.isFile())
|
|
151
|
+
variants[enc] = sibling.abs;
|
|
152
|
+
}
|
|
153
|
+
return { kind: "file", rel: segments.join("/"), abs, variants };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Redirect `/dir` to `/dir/` with a relative `Location` built from the last
|
|
157
|
+
* URL segment. `./` keeps a segment like `https:evil.com` from reading as an
|
|
158
|
+
* absolute URL; nothing here can produce `//host`.
|
|
159
|
+
*/
|
|
160
|
+
function redirectToDirectory(req) {
|
|
161
|
+
const url = new URL(req.url);
|
|
162
|
+
const last = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
|
|
163
|
+
return new Response(null, {
|
|
164
|
+
status: 301,
|
|
165
|
+
headers: { Location: `./${last}/${url.search}` },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function isFallbackEligible(req, segments) {
|
|
169
|
+
const last = segments[segments.length - 1] ?? "";
|
|
170
|
+
if (path.extname(last) !== "")
|
|
171
|
+
return false;
|
|
172
|
+
return acceptsHtml(req.headers.get("accept"));
|
|
173
|
+
}
|
|
174
|
+
/** Bounded LRU of lookups. Misses expire sooner than hits. Internal; exported for tests. */
|
|
175
|
+
export class LookupCache {
|
|
176
|
+
#entries = new Map();
|
|
177
|
+
#ttlMs;
|
|
178
|
+
#maxEntries;
|
|
179
|
+
constructor(opts) {
|
|
180
|
+
this.#ttlMs = opts.ttlMs;
|
|
181
|
+
this.#maxEntries = opts.maxEntries;
|
|
182
|
+
}
|
|
183
|
+
get size() {
|
|
184
|
+
return this.#entries.size;
|
|
185
|
+
}
|
|
186
|
+
get(key) {
|
|
187
|
+
const entry = this.#entries.get(key);
|
|
188
|
+
if (!entry)
|
|
189
|
+
return undefined;
|
|
190
|
+
this.#entries.delete(key);
|
|
191
|
+
if (entry.expires <= Date.now())
|
|
192
|
+
return undefined;
|
|
193
|
+
this.#entries.set(key, entry); // most recently used goes last
|
|
194
|
+
return entry.lookup;
|
|
195
|
+
}
|
|
196
|
+
set(key, lookup) {
|
|
197
|
+
const ttl = lookup.kind === "missing" ? Math.min(this.#ttlMs, 1_000) : this.#ttlMs;
|
|
198
|
+
this.#entries.delete(key);
|
|
199
|
+
this.#entries.set(key, { lookup, expires: Date.now() + ttl });
|
|
200
|
+
while (this.#entries.size > this.#maxEntries) {
|
|
201
|
+
const oldest = this.#entries.keys().next().value;
|
|
202
|
+
this.#entries.delete(oldest);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
delete(key) {
|
|
206
|
+
this.#entries.delete(key);
|
|
207
|
+
}
|
|
208
|
+
invalidate(relPath) {
|
|
209
|
+
if (relPath === undefined) {
|
|
210
|
+
this.#entries.clear();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const key = relPath.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
214
|
+
this.#entries.delete(key);
|
|
215
|
+
this.#entries.delete(`${key}/`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { StaticRule } from "./types.ts";
|
|
2
|
+
export interface CompiledRule {
|
|
3
|
+
regex: RegExp;
|
|
4
|
+
rule: StaticRule;
|
|
5
|
+
}
|
|
6
|
+
export interface RuleResult {
|
|
7
|
+
deny: boolean;
|
|
8
|
+
cacheControl?: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Compile a route-style glob for matching relative file paths. Reuses the
|
|
13
|
+
* route compiler so there is one glob dialect and no user-supplied RegExp.
|
|
14
|
+
* Matching is case-insensitive so deny rules hold on case-insensitive disks.
|
|
15
|
+
*/
|
|
16
|
+
export declare function compileGlob(pattern: string): RegExp;
|
|
17
|
+
/** True when `rel` (no leading slash) matches the compiled glob. */
|
|
18
|
+
export declare function matchesGlob(regex: RegExp, rel: string): boolean;
|
|
19
|
+
export declare function compileRules(rules: StaticRule[]): CompiledRule[];
|
|
20
|
+
/** Fold every matching rule in order; later rules win per field. */
|
|
21
|
+
export declare function applyRules(rules: CompiledRule[], rel: string): RuleResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { buildRouteRegex } from "../core/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compile a route-style glob for matching relative file paths. Reuses the
|
|
4
|
+
* route compiler so there is one glob dialect and no user-supplied RegExp.
|
|
5
|
+
* Matching is case-insensitive so deny rules hold on case-insensitive disks.
|
|
6
|
+
*/
|
|
7
|
+
export function compileGlob(pattern) {
|
|
8
|
+
if (typeof pattern !== "string" || pattern.trim() === "") {
|
|
9
|
+
throw new Error("static: glob patterns must be non-empty strings");
|
|
10
|
+
}
|
|
11
|
+
const trimmed = pattern.replace(/^\/+/, "");
|
|
12
|
+
if (trimmed.split("/").some((seg) => seg.startsWith(":"))) {
|
|
13
|
+
throw new Error(`static: glob "${pattern}" has a ":param" segment; use * or ** instead`);
|
|
14
|
+
}
|
|
15
|
+
const { regex } = buildRouteRegex(`/${trimmed}`);
|
|
16
|
+
return new RegExp(regex.source, "i");
|
|
17
|
+
}
|
|
18
|
+
/** True when `rel` (no leading slash) matches the compiled glob. */
|
|
19
|
+
export function matchesGlob(regex, rel) {
|
|
20
|
+
return regex.test(`/${rel}`);
|
|
21
|
+
}
|
|
22
|
+
export function compileRules(rules) {
|
|
23
|
+
return rules.map((rule) => {
|
|
24
|
+
if (typeof rule !== "object" || rule === null) {
|
|
25
|
+
throw new Error("static: each rule must be an object");
|
|
26
|
+
}
|
|
27
|
+
for (const key of Object.keys(rule)) {
|
|
28
|
+
if (!["match", "cacheControl", "headers", "deny"].includes(key)) {
|
|
29
|
+
throw new Error(`static: unknown rule option "${key}"`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { regex: compileGlob(rule.match), rule };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Fold every matching rule in order; later rules win per field. */
|
|
36
|
+
export function applyRules(rules, rel) {
|
|
37
|
+
const result = { deny: false, headers: {} };
|
|
38
|
+
for (const { regex, rule } of rules) {
|
|
39
|
+
if (!matchesGlob(regex, rel))
|
|
40
|
+
continue;
|
|
41
|
+
if (rule.deny !== undefined)
|
|
42
|
+
result.deny = rule.deny;
|
|
43
|
+
if (rule.cacheControl !== undefined)
|
|
44
|
+
result.cacheControl = rule.cacheControl;
|
|
45
|
+
if (rule.headers)
|
|
46
|
+
Object.assign(result.headers, rule.headers);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Pre-compressed sibling encodings: `file.br` and `file.gz`. */
|
|
2
|
+
export type StaticEncoding = "br" | "gzip";
|
|
3
|
+
/** Per-path overrides, matched against the decoded relative path. */
|
|
4
|
+
export interface StaticRule {
|
|
5
|
+
/** Route-style glob (`*`, `**`, `{a,b}`), matched case-insensitively. */
|
|
6
|
+
match: string;
|
|
7
|
+
cacheControl?: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
/** Respond 404 as if the file did not exist. */
|
|
10
|
+
deny?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** What `setHeaders` learns about the file being served. */
|
|
13
|
+
export interface StaticFileInfo {
|
|
14
|
+
/** Relative `/`-joined path inside the root (absolute for `serveFile`). */
|
|
15
|
+
path: string;
|
|
16
|
+
size: number;
|
|
17
|
+
mtime: Date;
|
|
18
|
+
/** Set when a pre-compressed sibling is served. */
|
|
19
|
+
encoding?: StaticEncoding;
|
|
20
|
+
}
|
|
21
|
+
export type SetHeadersFn = (info: StaticFileInfo, headers: Headers) => void;
|
|
22
|
+
/** Response options shared by `staticFiles()` and `serveFile()`. */
|
|
23
|
+
export interface StaticResponseOptions {
|
|
24
|
+
/** Sibling encodings to look for, in server preference order. Default `[]`. */
|
|
25
|
+
precompressed?: StaticEncoding[];
|
|
26
|
+
/** Default `true`. */
|
|
27
|
+
etag?: boolean;
|
|
28
|
+
/** Default `true`. */
|
|
29
|
+
lastModified?: boolean;
|
|
30
|
+
/** Default `"no-cache"`. */
|
|
31
|
+
cacheControl?: string;
|
|
32
|
+
/** Added to every file response. */
|
|
33
|
+
headers?: Record<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* Serve files as untrusted (user uploads): CSP sandbox on every response and
|
|
36
|
+
* `Content-Disposition: attachment` for anything but passive media types.
|
|
37
|
+
*/
|
|
38
|
+
untrusted?: boolean;
|
|
39
|
+
/** Runs last; may override any header. */
|
|
40
|
+
setHeaders?: SetHeadersFn;
|
|
41
|
+
}
|
|
42
|
+
export interface StaticCacheOptions {
|
|
43
|
+
/** Default 5000. */
|
|
44
|
+
ttlMs?: number;
|
|
45
|
+
/** Default 10000. */
|
|
46
|
+
maxEntries?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface StaticOptions extends StaticResponseOptions {
|
|
49
|
+
/** Directory to serve. Resolved through symlinks once, at creation. */
|
|
50
|
+
root: string;
|
|
51
|
+
/** File names tried for directory requests. Default `["index.html"]`. */
|
|
52
|
+
index?: string[];
|
|
53
|
+
/** Extensions tried for extension-less misses, e.g. `["html"]`. Default `[]`. */
|
|
54
|
+
extensions?: string[];
|
|
55
|
+
/** `"hide"` (default) answers 404 for any path segment starting with `.`. */
|
|
56
|
+
dotfiles?: "hide" | "allow";
|
|
57
|
+
/** Dot-segments still served under `dotfiles: "hide"`, e.g. `[".well-known"]`. */
|
|
58
|
+
allowDotfiles?: string[];
|
|
59
|
+
/**
|
|
60
|
+
* `"within-root"` (default) follows links whose target stays inside the
|
|
61
|
+
* root; `"deny"` refuses any link; `"follow"` follows links anywhere.
|
|
62
|
+
* Defaults to `"deny"` when `untrusted` is set.
|
|
63
|
+
*/
|
|
64
|
+
symlinks?: "within-root" | "deny" | "follow";
|
|
65
|
+
/** Ordered per-path overrides; later rules win per field. */
|
|
66
|
+
rules?: StaticRule[];
|
|
67
|
+
/** Globs this router never serves (and never falls back for). */
|
|
68
|
+
exclude?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* File served for unknown extension-less paths when the client accepts
|
|
71
|
+
* HTML (SPA routing), e.g. `"index.html"`. Always sent `no-cache`.
|
|
72
|
+
*/
|
|
73
|
+
fallback?: string;
|
|
74
|
+
/** Cache path resolution (not contents). `true` uses the defaults. */
|
|
75
|
+
cache?: boolean | StaticCacheOptions;
|
|
76
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `channel()` — the server-side implementation of a channel, either from a
|
|
3
|
+
* shared `c.channel()` contract or defined inline.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const roomImpl = channel(contract.room)
|
|
7
|
+
* .join(async ({ ctx, path, data }) => ({ history: await load(path.roomId) }))
|
|
8
|
+
* .on("message", async ({ data, channel }) => {
|
|
9
|
+
* channel.broadcast("message", saved);
|
|
10
|
+
* return { id: saved.id };
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Builders are immutable. The type parameters track which client events have
|
|
15
|
+
* handlers and whether an authorizer exists, so `.channels([...])` rejects
|
|
16
|
+
* incomplete implementations at compile time (and again at `build()`).
|
|
17
|
+
*/
|
|
18
|
+
import type * as v from "valibot";
|
|
19
|
+
import { type AnyChannelContract, ChannelContract, type InferChannel } from "../../contract/index.ts";
|
|
20
|
+
import { type AnySchema, type DIContainer } from "../../core/index.ts";
|
|
21
|
+
import type { WsConnection } from "../connection.ts";
|
|
22
|
+
import type { WsSendResult } from "../types.ts";
|
|
23
|
+
type Shape<C> = InferChannel<C>;
|
|
24
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
25
|
+
export type ClientEventName<C> = keyof Shape<C>["clientEvents"] & string;
|
|
26
|
+
type ServerEvents<C> = Shape<C>["serverEvents"];
|
|
27
|
+
/** Declared server events, or any name when the contract declares none (inline). */
|
|
28
|
+
export type ServerEventName<C> = [keyof ServerEvents<C>] extends [never] ? string : keyof ServerEvents<C> & string;
|
|
29
|
+
export type ServerEventPayload<C, E> = E extends keyof ServerEvents<C> ? ServerEvents<C>[E]["payloadIn"] : unknown;
|
|
30
|
+
/** Why a channel was left; passed to `.leave()` handlers. */
|
|
31
|
+
export type LeaveReason = "client" | "disconnect" | "kicked" | "server";
|
|
32
|
+
type PresenceMetaIn<C> = [Shape<C>["presenceMeta"]] extends [never] ? never : Shape<C>["presenceMeta"] extends {
|
|
33
|
+
in: infer M;
|
|
34
|
+
} ? M : never;
|
|
35
|
+
/** Presence of the current channel, from a handler's point of view. */
|
|
36
|
+
export interface ChannelPresence<M> {
|
|
37
|
+
/** Change the current socket's meta (it must be tracked). */
|
|
38
|
+
update(meta: M): boolean;
|
|
39
|
+
/** Cluster-wide `{ [key]: { ref, meta }[] }`. */
|
|
40
|
+
list(): Record<string, {
|
|
41
|
+
ref: string;
|
|
42
|
+
meta: M;
|
|
43
|
+
}[]>;
|
|
44
|
+
has(key: string): boolean;
|
|
45
|
+
keys(): string[];
|
|
46
|
+
}
|
|
47
|
+
/** The `channel` argument of every handler: actions scoped to this channel. */
|
|
48
|
+
export interface ChannelContext<C> {
|
|
49
|
+
/** Only on channels whose contract declares `.presence()`. */
|
|
50
|
+
readonly presence: [PresenceMetaIn<C>] extends [never] ? undefined : ChannelPresence<PresenceMetaIn<C>>;
|
|
51
|
+
/** Concrete channel name, e.g. `rooms/42`. */
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/** Everyone in the channel on every node, including the sender. */
|
|
54
|
+
broadcast<E extends ServerEventName<C>>(event: E, payload: ServerEventPayload<C, E>): number;
|
|
55
|
+
/** Everyone except the current socket. */
|
|
56
|
+
broadcastFrom<E extends ServerEventName<C>>(event: E, payload: ServerEventPayload<C, E>): number;
|
|
57
|
+
/** Only the current socket. */
|
|
58
|
+
push<E extends ServerEventName<C>>(event: E, payload: ServerEventPayload<C, E>): WsSendResult;
|
|
59
|
+
/** Server-initiated leave for the current socket. */
|
|
60
|
+
leave(reason?: string): void;
|
|
61
|
+
}
|
|
62
|
+
export interface ChannelHandlerArgs<Ctx, C> {
|
|
63
|
+
ctx: Ctx;
|
|
64
|
+
/** Channel-name params, validated by the contract's `.param()` schemas. */
|
|
65
|
+
path: Shape<C>["params"];
|
|
66
|
+
socket: WsConnection;
|
|
67
|
+
/** The upgrade request. */
|
|
68
|
+
req: Request;
|
|
69
|
+
container: DIContainer;
|
|
70
|
+
channel: ChannelContext<C>;
|
|
71
|
+
}
|
|
72
|
+
type ReplyOf<R> = [R] extends [undefined] ? unknown : R;
|
|
73
|
+
export type JoinFn<Ctx, C> = (args: ChannelHandlerArgs<Ctx, C> & {
|
|
74
|
+
data: Shape<C>["join"]["payload"];
|
|
75
|
+
}) => MaybePromise<ReplyOf<Shape<C>["join"]["replyIn"]>>;
|
|
76
|
+
export type EventFn<Ctx, C, E extends ClientEventName<C>> = (args: ChannelHandlerArgs<Ctx, C> & {
|
|
77
|
+
data: Shape<C>["clientEvents"][E]["payload"];
|
|
78
|
+
}) => MaybePromise<ReplyOf<Shape<C>["clientEvents"][E]["replyIn"]>>;
|
|
79
|
+
export type AnyEventFn<Ctx, C> = (args: ChannelHandlerArgs<Ctx, C> & {
|
|
80
|
+
event: string;
|
|
81
|
+
data: unknown;
|
|
82
|
+
}) => unknown;
|
|
83
|
+
/**
|
|
84
|
+
* Decide how the joining socket appears in presence: return its key (e.g.
|
|
85
|
+
* the user id) and meta, or `null` to join without being tracked.
|
|
86
|
+
*/
|
|
87
|
+
export type PresenceFn<Ctx, C> = (args: ChannelHandlerArgs<Ctx, C> & {
|
|
88
|
+
data: Shape<C>["join"]["payload"];
|
|
89
|
+
}) => MaybePromise<{
|
|
90
|
+
key: string;
|
|
91
|
+
meta: PresenceMetaIn<C>;
|
|
92
|
+
} | null | undefined>;
|
|
93
|
+
export type LeaveFn<Ctx, C> = (args: Omit<ChannelHandlerArgs<Ctx, C>, "channel"> & {
|
|
94
|
+
reason: LeaveReason;
|
|
95
|
+
}) => unknown;
|
|
96
|
+
/** Runtime data of a channel implementation. */
|
|
97
|
+
export interface ChannelImplDefinition {
|
|
98
|
+
contract: AnyChannelContract;
|
|
99
|
+
pattern: RegExp;
|
|
100
|
+
paramNames: string[];
|
|
101
|
+
inline: boolean;
|
|
102
|
+
isPublic: boolean;
|
|
103
|
+
join?: (args: any) => unknown;
|
|
104
|
+
events: Map<string, (args: any) => unknown>;
|
|
105
|
+
any?: (args: any) => unknown;
|
|
106
|
+
leave?: (args: any) => unknown;
|
|
107
|
+
presence?: (args: any) => unknown;
|
|
108
|
+
}
|
|
109
|
+
type Status<Missing, Joined> = [Missing] extends [never] ? Joined extends true ? "complete" : "missing .join() or .public()" : `missing .on("${Missing & string}")`;
|
|
110
|
+
export declare class ChannelImpl<Ctx, C extends AnyChannelContract, Handled extends string = never, Joined extends boolean = false> {
|
|
111
|
+
#private;
|
|
112
|
+
/** @internal Phantom: makes `Ctx` contravariant for `.channels()` checks. */
|
|
113
|
+
readonly _ctx?: (ctx: Ctx) => void;
|
|
114
|
+
/** @internal Phantom: `"complete"` once every event is handled and joins are authorized. */
|
|
115
|
+
readonly _status?: Status<Exclude<ClientEventName<C>, Handled>, Joined>;
|
|
116
|
+
readonly contract: C;
|
|
117
|
+
/** @internal Use {@link channel}. */
|
|
118
|
+
constructor(contract: C, def?: Partial<ChannelImplDefinition>);
|
|
119
|
+
get _definition(): ChannelImplDefinition;
|
|
120
|
+
/** Re-type the handler context, e.g. `channel(room).context<AppCtx>()`. */
|
|
121
|
+
context<NewCtx>(): ChannelImpl<NewCtx, C, Handled, Joined>;
|
|
122
|
+
/** Inline channels only: narrow/coerce a channel-name param. */
|
|
123
|
+
param<Name extends string & keyof Shape<C>["params"], S extends AnySchema>(name: Name, schema: S): ChannelImpl<Ctx, ParamResult<C, Name, S>, Handled, Joined>;
|
|
124
|
+
/**
|
|
125
|
+
* The join authorizer — throw (e.g. `ForbiddenError`) to refuse. The return
|
|
126
|
+
* value is the join reply. Inline channels may pass a payload schema first.
|
|
127
|
+
*/
|
|
128
|
+
join(fn: JoinFn<Ctx, C>): ChannelImpl<Ctx, C, Handled, true>;
|
|
129
|
+
join<S extends AnySchema>(payload: S, fn: JoinFn<Ctx, JoinResult<C, S>>): ChannelImpl<Ctx, JoinResult<C, S>, Handled, true>;
|
|
130
|
+
/** Explicitly allow joining without authorization. */
|
|
131
|
+
public(): ChannelImpl<Ctx, C, Handled, true>;
|
|
132
|
+
/**
|
|
133
|
+
* Handle a client event. The return value is the reply (sent when the
|
|
134
|
+
* client passed a `ref`). Inline channels pass the payload schema second.
|
|
135
|
+
*/
|
|
136
|
+
on<E extends ClientEventName<C>>(event: E, fn: EventFn<Ctx, C, E>): ChannelImpl<Ctx, C, Handled | E, Joined>;
|
|
137
|
+
on<E extends string, S extends AnySchema>(event: E, payload: S, fn: EventFn<Ctx, EventResult<C, E, S>, E & ClientEventName<EventResult<C, E, S>>>): ChannelImpl<Ctx, EventResult<C, E, S>, Handled | E, Joined>;
|
|
138
|
+
/**
|
|
139
|
+
* Catch-all for events without a handler — unvalidated, for proxies and
|
|
140
|
+
* bridges. Undeclared events are otherwise rejected with `unknown_event`.
|
|
141
|
+
*/
|
|
142
|
+
onAny(fn: AnyEventFn<Ctx, C>): ChannelImpl<Ctx, C, Handled, Joined>;
|
|
143
|
+
/**
|
|
144
|
+
* Track joining sockets in presence (contract must declare `.presence()`).
|
|
145
|
+
* Untracked automatically on leave, kick and disconnect.
|
|
146
|
+
*/
|
|
147
|
+
presence(fn: PresenceFn<Ctx, C>): ChannelImpl<Ctx, C, Handled, Joined>;
|
|
148
|
+
/** Runs when the socket leaves for any reason (client, disconnect, kick). */
|
|
149
|
+
leave(fn: LeaveFn<Ctx, C>): ChannelImpl<Ctx, C, Handled, Joined>;
|
|
150
|
+
}
|
|
151
|
+
type ParamResult<C, Name extends string, S extends AnySchema> = C extends ChannelContract<infer N, infer P, infer J, infer CE, infer SE, infer PM> ? ChannelContract<N, Omit<P, Name> & Record<Name, v.InferOutput<S>>, J, CE, SE, PM> : never;
|
|
152
|
+
type JoinResult<C, S extends AnySchema> = C extends ChannelContract<infer N, infer P, infer _J, infer CE, infer SE, infer PM> ? ChannelContract<N, P, {
|
|
153
|
+
payload: S;
|
|
154
|
+
reply: undefined;
|
|
155
|
+
}, CE, SE, PM> : never;
|
|
156
|
+
type EventResult<C, E extends string, S extends AnySchema> = C extends ChannelContract<infer N, infer P, infer J, infer CE, infer SE, infer PM> ? ChannelContract<N, P, J, CE & Record<E, {
|
|
157
|
+
payload: S;
|
|
158
|
+
reply: undefined;
|
|
159
|
+
}>, SE, PM> : never;
|
|
160
|
+
export type AnyChannelImpl = ChannelImpl<any, AnyChannelContract, any, any>;
|
|
161
|
+
export interface ChannelFactory<Ctx> {
|
|
162
|
+
/** Implement a shared `c.channel()` contract. */
|
|
163
|
+
<C extends AnyChannelContract>(contract: C): ChannelImpl<Ctx, C>;
|
|
164
|
+
/** Define a channel inline (router path syntax, e.g. `rooms/:roomId`). */
|
|
165
|
+
<N extends string>(name: N): ChannelImpl<Ctx, ChannelContract<N>>;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create a channel implementation. Handler `ctx` is untyped by default; use
|
|
169
|
+
* `channel.withContext<AppCtx>()` (or `.context<AppCtx>()`) to type it, or
|
|
170
|
+
* the factory passed to `.channels((channel) => [...])`.
|
|
171
|
+
*/
|
|
172
|
+
export declare const channel: ChannelFactory<Record<string, unknown>> & {
|
|
173
|
+
withContext<Ctx>(): ChannelFactory<Ctx>;
|
|
174
|
+
};
|
|
175
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `channel()` — the server-side implementation of a channel, either from a
|
|
3
|
+
* shared `c.channel()` contract or defined inline.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const roomImpl = channel(contract.room)
|
|
7
|
+
* .join(async ({ ctx, path, data }) => ({ history: await load(path.roomId) }))
|
|
8
|
+
* .on("message", async ({ data, channel }) => {
|
|
9
|
+
* channel.broadcast("message", saved);
|
|
10
|
+
* return { id: saved.id };
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Builders are immutable. The type parameters track which client events have
|
|
15
|
+
* handlers and whether an authorizer exists, so `.channels([...])` rejects
|
|
16
|
+
* incomplete implementations at compile time (and again at `build()`).
|
|
17
|
+
*/
|
|
18
|
+
import { ChannelContract, } from "../../contract/index.js";
|
|
19
|
+
import { buildRouteRegex, } from "../../core/index.js";
|
|
20
|
+
export class ChannelImpl {
|
|
21
|
+
contract;
|
|
22
|
+
#def;
|
|
23
|
+
/** @internal Use {@link channel}. */
|
|
24
|
+
constructor(contract, def) {
|
|
25
|
+
this.contract = contract;
|
|
26
|
+
const { regex, paramNames } = buildRouteRegex(`/${contract.name}`);
|
|
27
|
+
this.#def = {
|
|
28
|
+
contract,
|
|
29
|
+
pattern: regex,
|
|
30
|
+
paramNames,
|
|
31
|
+
inline: false,
|
|
32
|
+
isPublic: false,
|
|
33
|
+
events: new Map(),
|
|
34
|
+
...def,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
get _definition() {
|
|
38
|
+
return this.#def;
|
|
39
|
+
}
|
|
40
|
+
#next(patch, contract) {
|
|
41
|
+
return new ChannelImpl(contract ?? this.contract, {
|
|
42
|
+
...this.#def,
|
|
43
|
+
events: new Map(this.#def.events),
|
|
44
|
+
...patch,
|
|
45
|
+
...(contract ? { contract } : {}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Re-type the handler context, e.g. `channel(room).context<AppCtx>()`. */
|
|
49
|
+
context() {
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
/** Inline channels only: narrow/coerce a channel-name param. */
|
|
53
|
+
param(name, schema) {
|
|
54
|
+
this.#assertInline("param");
|
|
55
|
+
return this.#next({}, this.contract.param(name, schema));
|
|
56
|
+
}
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
|
|
58
|
+
join(a, b) {
|
|
59
|
+
if (b === undefined)
|
|
60
|
+
return this.#next({ join: a });
|
|
61
|
+
this.#assertInline("join(schema, fn)");
|
|
62
|
+
return this.#next({ join: b }, this.contract.join(a));
|
|
63
|
+
}
|
|
64
|
+
/** Explicitly allow joining without authorization. */
|
|
65
|
+
public() {
|
|
66
|
+
return this.#next({ isPublic: true });
|
|
67
|
+
}
|
|
68
|
+
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
|
|
69
|
+
on(event, a, b) {
|
|
70
|
+
if (this.#def.events.has(event)) {
|
|
71
|
+
throw new Error(`${this.contract.name}: .on("${event}") is already defined`);
|
|
72
|
+
}
|
|
73
|
+
if (b === undefined) {
|
|
74
|
+
if (!this.contract._definition.clientEvents.has(event)) {
|
|
75
|
+
throw new Error(`${this.contract.name}: "${event}" is not a client event of the contract`);
|
|
76
|
+
}
|
|
77
|
+
const events = new Map(this.#def.events);
|
|
78
|
+
events.set(event, a);
|
|
79
|
+
return this.#next({ events });
|
|
80
|
+
}
|
|
81
|
+
this.#assertInline(`on("${event}", schema, fn)`);
|
|
82
|
+
const events = new Map(this.#def.events);
|
|
83
|
+
events.set(event, b);
|
|
84
|
+
return this.#next({ events }, this.contract.clientEvent(event, a));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Catch-all for events without a handler — unvalidated, for proxies and
|
|
88
|
+
* bridges. Undeclared events are otherwise rejected with `unknown_event`.
|
|
89
|
+
*/
|
|
90
|
+
onAny(fn) {
|
|
91
|
+
return this.#next({ any: fn });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Track joining sockets in presence (contract must declare `.presence()`).
|
|
95
|
+
* Untracked automatically on leave, kick and disconnect.
|
|
96
|
+
*/
|
|
97
|
+
presence(fn) {
|
|
98
|
+
if (this.contract._definition.presence == null) {
|
|
99
|
+
throw new Error(`${this.contract.name}: .presence() requires presence in the channel contract`);
|
|
100
|
+
}
|
|
101
|
+
return this.#next({ presence: fn });
|
|
102
|
+
}
|
|
103
|
+
/** Runs when the socket leaves for any reason (client, disconnect, kick). */
|
|
104
|
+
leave(fn) {
|
|
105
|
+
return this.#next({ leave: fn });
|
|
106
|
+
}
|
|
107
|
+
#assertInline(what) {
|
|
108
|
+
if (!this.#def.inline) {
|
|
109
|
+
throw new Error(`${this.contract.name}: ${what} is only available on inline channels — the contract defines it`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function createFactory() {
|
|
114
|
+
return ((source) => typeof source === "string"
|
|
115
|
+
? new ChannelImpl(new ChannelContract(source), { inline: true })
|
|
116
|
+
: new ChannelImpl(source));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Create a channel implementation. Handler `ctx` is untyped by default; use
|
|
120
|
+
* `channel.withContext<AppCtx>()` (or `.context<AppCtx>()`) to type it, or
|
|
121
|
+
* the factory passed to `.channels((channel) => [...])`.
|
|
122
|
+
*/
|
|
123
|
+
export const channel = Object.assign(createFactory(), {
|
|
124
|
+
withContext: () => createFactory(),
|
|
125
|
+
});
|