@zmdb/web 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +49 -0
- package/dist/app/bridge.d.ts +12 -0
- package/dist/app/bridge.d.ts.map +1 -0
- package/dist/app/bridge.js +16 -0
- package/dist/app/bridge.js.map +1 -0
- package/dist/app/index.d.ts +23 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +47 -0
- package/dist/app/index.js.map +1 -0
- package/dist/bench/index.d.ts +68 -0
- package/dist/bench/index.d.ts.map +1 -0
- package/dist/bench/index.js +250 -0
- package/dist/bench/index.js.map +1 -0
- package/dist/compression/index.d.ts +13 -0
- package/dist/compression/index.d.ts.map +1 -0
- package/dist/compression/index.js +211 -0
- package/dist/compression/index.js.map +1 -0
- package/dist/context/index.d.ts +74 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +118 -0
- package/dist/context/index.js.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.js +86 -0
- package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.js +425 -0
- package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
- package/dist/contract/compiler/client.d.ts +12 -0
- package/dist/contract/compiler/client.d.ts.map +1 -0
- package/dist/contract/compiler/client.js +1293 -0
- package/dist/contract/compiler/client.js.map +1 -0
- package/dist/contract/compiler/index.d.ts +18 -0
- package/dist/contract/compiler/index.d.ts.map +1 -0
- package/dist/contract/compiler/index.js +1058 -0
- package/dist/contract/compiler/index.js.map +1 -0
- package/dist/contract/index.d.ts +237 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +22 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/csrf/index.d.ts +15 -0
- package/dist/csrf/index.d.ts.map +1 -0
- package/dist/csrf/index.js +218 -0
- package/dist/csrf/index.js.map +1 -0
- package/dist/data/index.d.ts +35 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +58 -0
- package/dist/data/index.js.map +1 -0
- package/dist/devtools/index.d.ts +74 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/index.js +552 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/dto-pipes/index.d.ts +45 -0
- package/dist/dto-pipes/index.d.ts.map +1 -0
- package/dist/dto-pipes/index.js +78 -0
- package/dist/dto-pipes/index.js.map +1 -0
- package/dist/gateways/index.d.ts +31 -0
- package/dist/gateways/index.d.ts.map +1 -0
- package/dist/gateways/index.js +138 -0
- package/dist/gateways/index.js.map +1 -0
- package/dist/health/index.d.ts +20 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +148 -0
- package/dist/health/index.js.map +1 -0
- package/dist/http-token.d.ts +2 -0
- package/dist/http-token.d.ts.map +1 -0
- package/dist/http-token.js +21 -0
- package/dist/http-token.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/index.d.ts +11 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +39 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/middleware/errors.d.ts +14 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +18 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/middleware/index.d.ts +46 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +64 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
- package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
- package/dist/modules/__fixtures__/large-graph.js +483 -0
- package/dist/modules/__fixtures__/large-graph.js.map +1 -0
- package/dist/openapi/__fixtures__/entities.d.ts +8 -0
- package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/entities.js +4 -0
- package/dist/openapi/__fixtures__/entities.js.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.js +17 -0
- package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
- package/dist/openapi/index.d.ts +61 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +314 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/pipeline/guards.d.ts +9 -0
- package/dist/pipeline/guards.d.ts.map +1 -0
- package/dist/pipeline/guards.js +11 -0
- package/dist/pipeline/guards.js.map +1 -0
- package/dist/pipeline/index.d.ts +141 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +1624 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/routing/index.d.ts +40 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +162 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/static/index.d.ts +14 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +348 -0
- package/dist/static/index.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/traced-router.d.ts +5 -0
- package/dist/traced-router.d.ts.map +1 -0
- package/dist/traced-router.js +6 -0
- package/dist/traced-router.js.map +1 -0
- package/dist/upload/index.d.ts +28 -0
- package/dist/upload/index.d.ts.map +1 -0
- package/dist/upload/index.js +410 -0
- package/dist/upload/index.js.map +1 -0
- package/dist/versioning/index.d.ts +35 -0
- package/dist/versioning/index.d.ts.map +1 -0
- package/dist/versioning/index.js +66 -0
- package/dist/versioning/index.js.map +1 -0
- package/dist/versioning/runtime.d.ts +5 -0
- package/dist/versioning/runtime.d.ts.map +1 -0
- package/dist/versioning/runtime.js +34 -0
- package/dist/versioning/runtime.js.map +1 -0
- package/package.json +143 -0
- package/src/app/bridge.ts +39 -0
- package/src/app/index.ts +76 -0
- package/src/bench/index.ts +329 -0
- package/src/compression/index.ts +255 -0
- package/src/context/index.ts +181 -0
- package/src/contract/__fixtures__/http-client.generated.ts +73 -0
- package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
- package/src/contract/__fixtures__/http-convergence.ts +564 -0
- package/src/contract/compiler/client.ts +1518 -0
- package/src/contract/compiler/index.ts +1457 -0
- package/src/contract/index.ts +266 -0
- package/src/csrf/index.ts +264 -0
- package/src/data/index.ts +65 -0
- package/src/devtools/index.ts +727 -0
- package/src/dto-pipes/index.ts +93 -0
- package/src/gateways/index.ts +180 -0
- package/src/health/index.ts +198 -0
- package/src/http-token.ts +22 -0
- package/src/index.ts +167 -0
- package/src/integrations/index.ts +50 -0
- package/src/middleware/errors.ts +17 -0
- package/src/middleware/index.ts +108 -0
- package/src/modules/__fixtures__/large-graph.ts +540 -0
- package/src/openapi/__fixtures__/entities.ts +19 -0
- package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
- package/src/openapi/__fixtures__/route-schemas.ts +27 -0
- package/src/openapi/index.ts +483 -0
- package/src/pipeline/guards.ts +23 -0
- package/src/pipeline/index.ts +2041 -0
- package/src/routing/index.ts +201 -0
- package/src/static/index.ts +443 -0
- package/src/testing/index.ts +58 -0
- package/src/traced-router.ts +8 -0
- package/src/upload/index.ts +489 -0
- package/src/versioning/index.ts +106 -0
- package/src/versioning/runtime.ts +38 -0
|
@@ -0,0 +1,2041 @@
|
|
|
1
|
+
// @zmdb/web — request pipeline & runtime adapters (epic #272, spec ./SPEC.md).
|
|
2
|
+
// Dispatches matched routes through: build Ctx → run route guards → validate
|
|
3
|
+
// body → invoke handler → serialize. Thin, structurally-typed node:http + Fetch
|
|
4
|
+
// adapters (no hard deps). No reflection; no `as` on the consumer surface.
|
|
5
|
+
|
|
6
|
+
import '@zmdb/app';
|
|
7
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
8
|
+
|
|
9
|
+
import { createRequestData } from '@zmdb/app/data';
|
|
10
|
+
import type { Constructor } from '@zmdb/app/di';
|
|
11
|
+
import { fromTraceContext } from '@zmdb/app/observability';
|
|
12
|
+
import type { Observability, Span, Tracer } from '@zmdb/app/observability';
|
|
13
|
+
import { claimsValidationIssues, ValidationError, validationIssuesOf } from '@zmdb/validator';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
compilePattern,
|
|
17
|
+
countSegments,
|
|
18
|
+
matchCompiled,
|
|
19
|
+
type CompiledPattern,
|
|
20
|
+
type Ctx,
|
|
21
|
+
type QueryValues,
|
|
22
|
+
} from '../context/index.js';
|
|
23
|
+
import type { CompiledHttpContract, HttpOperationIR, SecurityRequirement } from '../contract/index.js';
|
|
24
|
+
import { BoundaryStatusError } from '../middleware/errors.js';
|
|
25
|
+
import type { Guard, SecurityAwareGuard } from '../middleware/index.js';
|
|
26
|
+
import { getRoutes, isPublic, type ResolvedRoute } from '../routing/index.js';
|
|
27
|
+
import { versionsOf, type VersionStrategy } from '../versioning/index.js';
|
|
28
|
+
import { jsonMediaTypeForVersion, pathForVersion } from '../versioning/runtime.js';
|
|
29
|
+
import { resolveGuards, type GuardRegistry } from './guards.js';
|
|
30
|
+
|
|
31
|
+
export type { Ctx } from '../context/index.js';
|
|
32
|
+
export type { SecurityRequirement } from '../contract/index.js';
|
|
33
|
+
export type { GuardRegistry } from './guards.js';
|
|
34
|
+
|
|
35
|
+
/** A minimal, framework-neutral request. */
|
|
36
|
+
export interface WebRequest {
|
|
37
|
+
readonly method: string;
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
40
|
+
readonly rawBody?: unknown;
|
|
41
|
+
readonly query?: QueryValues;
|
|
42
|
+
readonly scheme?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A minimal, framework-neutral response body. */
|
|
46
|
+
export type ResponseBody =
|
|
47
|
+
| { readonly kind: 'text'; readonly value: string }
|
|
48
|
+
| { readonly kind: 'bytes'; readonly value: Uint8Array<ArrayBuffer> }
|
|
49
|
+
| {
|
|
50
|
+
readonly kind: 'stream';
|
|
51
|
+
readonly value: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
52
|
+
readonly length: number | undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A minimal, framework-neutral response. */
|
|
56
|
+
export interface WebResponse {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
readonly body: ResponseBody;
|
|
59
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per-handler pipeline, guard and OpenAPI options. */
|
|
63
|
+
export interface RouteOptions {
|
|
64
|
+
readonly validateBody?: (raw: unknown) => unknown;
|
|
65
|
+
readonly guards?: readonly Guard[];
|
|
66
|
+
readonly security?: readonly SecurityRequirement[];
|
|
67
|
+
readonly deprecated?: true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Router-wide guard configuration shared with OpenAPI generation. */
|
|
71
|
+
export interface RouterOptions extends Observability {
|
|
72
|
+
readonly guardRegistry?: GuardRegistry;
|
|
73
|
+
readonly versioning?: VersionStrategy;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A handler takes one Ctx and returns a (possibly async) result. */
|
|
77
|
+
type Handler = (ctx: Ctx<Record<string, string>, unknown, QueryValues>) => unknown;
|
|
78
|
+
|
|
79
|
+
interface BoundRoute {
|
|
80
|
+
readonly route: ResolvedRoute;
|
|
81
|
+
/** The exact serialisable operation object when this route came from a contract. */
|
|
82
|
+
readonly operation?: HttpOperationIR;
|
|
83
|
+
readonly pattern: CompiledPattern;
|
|
84
|
+
readonly handler: Handler;
|
|
85
|
+
readonly validateBody?: (raw: unknown) => unknown;
|
|
86
|
+
readonly guards?: readonly Guard[];
|
|
87
|
+
readonly neutral?: true;
|
|
88
|
+
readonly versionJsonHeaders?: Readonly<Record<string, string>>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Routes are indexed by method, then by segment count, because a route can only
|
|
92
|
+
// match a path that agrees on both — so a request never looks at a route it
|
|
93
|
+
// could not possibly match. `handle` used to scan every registered route and
|
|
94
|
+
// re-split each one's pattern, which made matching O(routes) with a handful of
|
|
95
|
+
// allocations per candidate; with a real route table that cost grows without
|
|
96
|
+
// bound while the two keys below are known before any comparison happens.
|
|
97
|
+
//
|
|
98
|
+
// Within a bucket the registration order of `register` is preserved, so
|
|
99
|
+
// first-registered-wins is unchanged: a `/user/:id` declared before `/user/me`
|
|
100
|
+
// still shadows it, exactly as the flat scan did.
|
|
101
|
+
type MethodBuckets = Map<string, BoundRoute[][]>;
|
|
102
|
+
type VersionBuckets = Map<string, Map<string, BoundRoute[][]>>;
|
|
103
|
+
|
|
104
|
+
interface SupportedRoute {
|
|
105
|
+
readonly pattern: CompiledPattern;
|
|
106
|
+
readonly versions: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type SupportedBuckets = Map<string, SupportedRoute[][]>;
|
|
110
|
+
|
|
111
|
+
interface VersionTrieNode {
|
|
112
|
+
readonly children: Map<number, VersionTrieNode>;
|
|
113
|
+
version?: string;
|
|
114
|
+
id?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface VersionLookup {
|
|
118
|
+
readonly root: VersionTrieNode;
|
|
119
|
+
nextId: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function bucketFor<T>(buckets: Map<string, T[][]>, method: string, segmentCount: number): T[] {
|
|
123
|
+
let bySegmentCount = buckets.get(method);
|
|
124
|
+
if (bySegmentCount === undefined) {
|
|
125
|
+
bySegmentCount = [];
|
|
126
|
+
buckets.set(method, bySegmentCount);
|
|
127
|
+
}
|
|
128
|
+
let bucket = bySegmentCount[segmentCount];
|
|
129
|
+
if (bucket === undefined) {
|
|
130
|
+
bucket = [];
|
|
131
|
+
bySegmentCount[segmentCount] = bucket;
|
|
132
|
+
}
|
|
133
|
+
return bucket;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function versionBucketFor(
|
|
137
|
+
buckets: VersionBuckets,
|
|
138
|
+
neutralBuckets: MethodBuckets,
|
|
139
|
+
method: string,
|
|
140
|
+
version: string,
|
|
141
|
+
segmentCount: number,
|
|
142
|
+
): BoundRoute[] {
|
|
143
|
+
let byVersion = buckets.get(method);
|
|
144
|
+
if (byVersion === undefined) {
|
|
145
|
+
byVersion = new Map();
|
|
146
|
+
buckets.set(method, byVersion);
|
|
147
|
+
}
|
|
148
|
+
let bySegmentCount = byVersion.get(version);
|
|
149
|
+
if (bySegmentCount === undefined) {
|
|
150
|
+
bySegmentCount = [];
|
|
151
|
+
byVersion.set(version, bySegmentCount);
|
|
152
|
+
}
|
|
153
|
+
let bucket = bySegmentCount[segmentCount];
|
|
154
|
+
if (bucket === undefined) {
|
|
155
|
+
bucket = [...(neutralBuckets.get(method)?.[segmentCount] ?? [])];
|
|
156
|
+
bySegmentCount[segmentCount] = bucket;
|
|
157
|
+
}
|
|
158
|
+
return bucket;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function addSpecificVersionRoute(
|
|
162
|
+
buckets: VersionBuckets,
|
|
163
|
+
neutralBuckets: MethodBuckets,
|
|
164
|
+
method: string,
|
|
165
|
+
version: string,
|
|
166
|
+
route: BoundRoute,
|
|
167
|
+
): void {
|
|
168
|
+
const bucket = versionBucketFor(buckets, neutralBuckets, method, version, route.pattern.segmentCount);
|
|
169
|
+
const samePathNeutral = bucket.findIndex(
|
|
170
|
+
candidate => candidate.neutral === true && candidate.pattern.pattern === route.pattern.pattern,
|
|
171
|
+
);
|
|
172
|
+
if (samePathNeutral === -1) {
|
|
173
|
+
bucket.push(route);
|
|
174
|
+
} else {
|
|
175
|
+
bucket.splice(samePathNeutral, 0, route);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function addNeutralRoute(
|
|
180
|
+
buckets: VersionBuckets,
|
|
181
|
+
neutralBuckets: MethodBuckets,
|
|
182
|
+
method: string,
|
|
183
|
+
route: BoundRoute,
|
|
184
|
+
): void {
|
|
185
|
+
bucketFor(neutralBuckets, method, route.pattern.segmentCount).push(route);
|
|
186
|
+
const byVersion = buckets.get(method);
|
|
187
|
+
if (byVersion === undefined) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const bySegmentCount of byVersion.values()) {
|
|
191
|
+
let bucket = bySegmentCount[route.pattern.segmentCount];
|
|
192
|
+
if (bucket === undefined) {
|
|
193
|
+
bucket = [];
|
|
194
|
+
bySegmentCount[route.pattern.segmentCount] = bucket;
|
|
195
|
+
}
|
|
196
|
+
bucket.push(route);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function addSupportedRoute(
|
|
201
|
+
buckets: SupportedBuckets,
|
|
202
|
+
method: string,
|
|
203
|
+
pattern: CompiledPattern,
|
|
204
|
+
versions: readonly string[],
|
|
205
|
+
): void {
|
|
206
|
+
const bucket = bucketFor(buckets, method, pattern.segmentCount);
|
|
207
|
+
const existing = bucket.find(candidate => candidate.pattern.pattern === pattern.pattern);
|
|
208
|
+
if (existing === undefined) {
|
|
209
|
+
bucket.push({ pattern, versions: [...versions] });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
for (const version of versions) {
|
|
213
|
+
if (!existing.versions.includes(version)) {
|
|
214
|
+
existing.versions.push(version);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function supportedVersions(buckets: SupportedBuckets, method: string, path: string): readonly string[] | undefined {
|
|
220
|
+
for (const candidate of buckets.get(method)?.[countSegments(path)] ?? []) {
|
|
221
|
+
if (matchCompiled(candidate.pattern, path) !== undefined) {
|
|
222
|
+
return candidate.versions;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function createVersionLookup(initial: string): VersionLookup {
|
|
229
|
+
const lookup = { root: { children: new Map<number, VersionTrieNode>() }, nextId: 0 };
|
|
230
|
+
addKnownVersion(lookup, initial);
|
|
231
|
+
return lookup;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function addKnownVersion(lookup: VersionLookup, version: string): void {
|
|
235
|
+
let node = lookup.root;
|
|
236
|
+
for (let index = 0; index < version.length; index += 1) {
|
|
237
|
+
const code = version.charCodeAt(index);
|
|
238
|
+
let child = node.children.get(code);
|
|
239
|
+
if (child === undefined) {
|
|
240
|
+
child = { children: new Map() };
|
|
241
|
+
node.children.set(code, child);
|
|
242
|
+
}
|
|
243
|
+
node = child;
|
|
244
|
+
}
|
|
245
|
+
if (node.version === undefined) {
|
|
246
|
+
node.version = version;
|
|
247
|
+
node.id = lookup.nextId;
|
|
248
|
+
lookup.nextId += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const COMMA = 44;
|
|
253
|
+
const DOUBLE_QUOTE = 34;
|
|
254
|
+
const EQUALS = 61;
|
|
255
|
+
const SEMICOLON = 59;
|
|
256
|
+
const SPACE = 32;
|
|
257
|
+
const TAB = 9;
|
|
258
|
+
const UNKNOWN_MEDIA_VERSION = '\u0000';
|
|
259
|
+
|
|
260
|
+
function unquotedIndexOf(value: string, wanted: number, start: number, end: number): number {
|
|
261
|
+
let quoted = false;
|
|
262
|
+
for (let index = start; index < end; index += 1) {
|
|
263
|
+
const code = value.charCodeAt(index);
|
|
264
|
+
if (code === DOUBLE_QUOTE) {
|
|
265
|
+
quoted = !quoted;
|
|
266
|
+
} else if (!quoted && code === wanted) {
|
|
267
|
+
return index;
|
|
268
|
+
} else if (quoted && code === 92) {
|
|
269
|
+
index += 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return end;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function trimStart(value: string, start: number, end: number): number {
|
|
276
|
+
let trimmed = start;
|
|
277
|
+
while (trimmed < end) {
|
|
278
|
+
const code = value.charCodeAt(trimmed);
|
|
279
|
+
if (code !== SPACE && code !== TAB) {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
trimmed += 1;
|
|
283
|
+
}
|
|
284
|
+
return trimmed;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function trimEnd(value: string, start: number, end: number): number {
|
|
288
|
+
let trimmed = end;
|
|
289
|
+
while (trimmed > start) {
|
|
290
|
+
const code = value.charCodeAt(trimmed - 1);
|
|
291
|
+
if (code !== SPACE && code !== TAB) {
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
trimmed -= 1;
|
|
295
|
+
}
|
|
296
|
+
return trimmed;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function asciiLower(code: number): number {
|
|
300
|
+
return code >= 65 && code <= 90 ? code + 32 : code;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function equalsAsciiIgnoringCase(value: string, start: number, end: number, expected: string): boolean {
|
|
304
|
+
const first = trimStart(value, start, end);
|
|
305
|
+
const last = trimEnd(value, first, end);
|
|
306
|
+
if (last - first !== expected.length) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
310
|
+
if (asciiLower(value.charCodeAt(first + index)) !== expected.charCodeAt(index)) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function unquotedValueStart(value: string, start: number, end: number): number {
|
|
318
|
+
const first = trimStart(value, start, end);
|
|
319
|
+
const last = trimEnd(value, first, end);
|
|
320
|
+
return last - first >= 2 && value.charCodeAt(first) === DOUBLE_QUOTE && value.charCodeAt(last - 1) === DOUBLE_QUOTE
|
|
321
|
+
? first + 1
|
|
322
|
+
: first;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function unquotedValueEnd(value: string, start: number, end: number): number {
|
|
326
|
+
const first = trimStart(value, start, end);
|
|
327
|
+
const last = trimEnd(value, first, end);
|
|
328
|
+
return last - first >= 2 && value.charCodeAt(first) === DOUBLE_QUOTE && value.charCodeAt(last - 1) === DOUBLE_QUOTE
|
|
329
|
+
? last - 1
|
|
330
|
+
: last;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function knownVersion(lookup: VersionLookup, value: string, start: number, end: number): VersionTrieNode | undefined {
|
|
334
|
+
const first = unquotedValueStart(value, start, end);
|
|
335
|
+
const last = unquotedValueEnd(value, start, end);
|
|
336
|
+
let node = lookup.root;
|
|
337
|
+
for (let index = first; index < last; index += 1) {
|
|
338
|
+
const child = node.children.get(value.charCodeAt(index));
|
|
339
|
+
if (child === undefined) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
node = child;
|
|
343
|
+
}
|
|
344
|
+
return node.version === undefined ? undefined : node;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function quality(value: string, start: number, end: number): number {
|
|
348
|
+
const first = unquotedValueStart(value, start, end);
|
|
349
|
+
const last = unquotedValueEnd(value, start, end);
|
|
350
|
+
if (first === last) {
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const whole = value.charCodeAt(first);
|
|
355
|
+
if (whole !== 48 && whole !== 49) {
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
if (first + 1 === last) {
|
|
359
|
+
return whole === 49 ? 1000 : 0;
|
|
360
|
+
}
|
|
361
|
+
if (value.charCodeAt(first + 1) !== 46) {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let fraction = 0;
|
|
366
|
+
let scale = 100;
|
|
367
|
+
for (let index = first + 2; index < last; index += 1) {
|
|
368
|
+
const digit = value.charCodeAt(index) - 48;
|
|
369
|
+
if (digit < 0 || digit > 9 || scale === 0) {
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
fraction += digit * scale;
|
|
373
|
+
scale = Math.floor(scale / 10);
|
|
374
|
+
}
|
|
375
|
+
if (whole === 49) {
|
|
376
|
+
return fraction === 0 ? 1000 : 0;
|
|
377
|
+
}
|
|
378
|
+
return fraction;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function versionInMember(
|
|
382
|
+
accept: string,
|
|
383
|
+
memberStart: number,
|
|
384
|
+
memberEnd: number,
|
|
385
|
+
key: string,
|
|
386
|
+
lookup: VersionLookup,
|
|
387
|
+
): VersionTrieNode | null | undefined {
|
|
388
|
+
let parameterStart = unquotedIndexOf(accept, SEMICOLON, memberStart, memberEnd);
|
|
389
|
+
let found: VersionTrieNode | null | undefined;
|
|
390
|
+
while (parameterStart < memberEnd) {
|
|
391
|
+
const parameterEnd = unquotedIndexOf(accept, SEMICOLON, parameterStart + 1, memberEnd);
|
|
392
|
+
const separator = unquotedIndexOf(accept, EQUALS, parameterStart + 1, parameterEnd);
|
|
393
|
+
if (separator < parameterEnd && equalsAsciiIgnoringCase(accept, parameterStart + 1, separator, key)) {
|
|
394
|
+
found = knownVersion(lookup, accept, separator + 1, parameterEnd) ?? null;
|
|
395
|
+
}
|
|
396
|
+
parameterStart = parameterEnd;
|
|
397
|
+
}
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function qualityInMember(accept: string, memberStart: number, memberEnd: number): number {
|
|
402
|
+
let parameterStart = unquotedIndexOf(accept, SEMICOLON, memberStart, memberEnd);
|
|
403
|
+
let found = 1000;
|
|
404
|
+
while (parameterStart < memberEnd) {
|
|
405
|
+
const parameterEnd = unquotedIndexOf(accept, SEMICOLON, parameterStart + 1, memberEnd);
|
|
406
|
+
const separator = unquotedIndexOf(accept, EQUALS, parameterStart + 1, parameterEnd);
|
|
407
|
+
if (separator < parameterEnd && equalsAsciiIgnoringCase(accept, parameterStart + 1, separator, 'q')) {
|
|
408
|
+
found = quality(accept, separator + 1, parameterEnd);
|
|
409
|
+
}
|
|
410
|
+
parameterStart = parameterEnd;
|
|
411
|
+
}
|
|
412
|
+
return found;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isProhibited(accept: string, key: string, lookup: VersionLookup, versionId: number): boolean {
|
|
416
|
+
let memberStart = 0;
|
|
417
|
+
while (memberStart <= accept.length) {
|
|
418
|
+
const memberEnd = unquotedIndexOf(accept, COMMA, memberStart, accept.length);
|
|
419
|
+
const version = versionInMember(accept, memberStart, memberEnd, key, lookup);
|
|
420
|
+
if (version?.id === versionId && qualityInMember(accept, memberStart, memberEnd) === 0) {
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
if (memberEnd === accept.length) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
memberStart = memberEnd + 1;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function mediaTypeVersion(accept: string | undefined, key: string, fallback: string, lookup: VersionLookup): string {
|
|
432
|
+
if (accept === undefined) {
|
|
433
|
+
return fallback;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const onlyMemberEnd = unquotedIndexOf(accept, COMMA, 0, accept.length);
|
|
437
|
+
if (onlyMemberEnd === accept.length) {
|
|
438
|
+
const onlyVersion = versionInMember(accept, 0, onlyMemberEnd, key, lookup);
|
|
439
|
+
if (onlyVersion === undefined) {
|
|
440
|
+
return fallback;
|
|
441
|
+
}
|
|
442
|
+
if (onlyVersion === null || onlyVersion.version === undefined) {
|
|
443
|
+
return UNKNOWN_MEDIA_VERSION;
|
|
444
|
+
}
|
|
445
|
+
return qualityInMember(accept, 0, onlyMemberEnd) === 0 ? UNKNOWN_MEDIA_VERSION : onlyVersion.version;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let memberStart = 0;
|
|
449
|
+
let named = false;
|
|
450
|
+
let best: string | undefined;
|
|
451
|
+
let bestQuality = -1;
|
|
452
|
+
while (memberStart <= accept.length) {
|
|
453
|
+
const memberEnd = unquotedIndexOf(accept, COMMA, memberStart, accept.length);
|
|
454
|
+
const version = versionInMember(accept, memberStart, memberEnd, key, lookup);
|
|
455
|
+
if (version !== undefined) {
|
|
456
|
+
named = true;
|
|
457
|
+
const memberQuality = qualityInMember(accept, memberStart, memberEnd);
|
|
458
|
+
if (
|
|
459
|
+
version !== null &&
|
|
460
|
+
version.version !== undefined &&
|
|
461
|
+
version.id !== undefined &&
|
|
462
|
+
memberQuality > bestQuality &&
|
|
463
|
+
memberQuality > 0 &&
|
|
464
|
+
!isProhibited(accept, key, lookup, version.id)
|
|
465
|
+
) {
|
|
466
|
+
best = version.version;
|
|
467
|
+
bestQuality = memberQuality;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (memberEnd === accept.length) {
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
memberStart = memberEnd + 1;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return best ?? (named ? UNKNOWN_MEDIA_VERSION : fallback);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function mediaTypeVersionLabel(accept: string | undefined, key: string): string {
|
|
480
|
+
if (accept === undefined) {
|
|
481
|
+
return '';
|
|
482
|
+
}
|
|
483
|
+
let memberStart = 0;
|
|
484
|
+
let selectedStart = 0;
|
|
485
|
+
let selectedEnd = 0;
|
|
486
|
+
let bestQuality = -1;
|
|
487
|
+
while (memberStart <= accept.length) {
|
|
488
|
+
const memberEnd = unquotedIndexOf(accept, COMMA, memberStart, accept.length);
|
|
489
|
+
let parameterStart = unquotedIndexOf(accept, SEMICOLON, memberStart, memberEnd);
|
|
490
|
+
while (parameterStart < memberEnd) {
|
|
491
|
+
const parameterEnd = unquotedIndexOf(accept, SEMICOLON, parameterStart + 1, memberEnd);
|
|
492
|
+
const separator = unquotedIndexOf(accept, EQUALS, parameterStart + 1, parameterEnd);
|
|
493
|
+
if (separator < parameterEnd && equalsAsciiIgnoringCase(accept, parameterStart + 1, separator, key)) {
|
|
494
|
+
const memberQuality = qualityInMember(accept, memberStart, memberEnd);
|
|
495
|
+
if (memberQuality > bestQuality) {
|
|
496
|
+
selectedStart = unquotedValueStart(accept, separator + 1, parameterEnd);
|
|
497
|
+
selectedEnd = unquotedValueEnd(accept, separator + 1, parameterEnd);
|
|
498
|
+
bestQuality = memberQuality;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
parameterStart = parameterEnd;
|
|
502
|
+
}
|
|
503
|
+
if (memberEnd === accept.length) {
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
memberStart = memberEnd + 1;
|
|
507
|
+
}
|
|
508
|
+
return accept.slice(selectedStart, selectedEnd);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function requestedVersion(
|
|
512
|
+
strategy: Exclude<VersionStrategy, { readonly kind: 'path' }>,
|
|
513
|
+
headers: Readonly<Record<string, string>>,
|
|
514
|
+
headerName: string | undefined,
|
|
515
|
+
mediaTypeKey: string | undefined,
|
|
516
|
+
mediaLookup: VersionLookup | undefined,
|
|
517
|
+
): string {
|
|
518
|
+
if (strategy.kind === 'header') {
|
|
519
|
+
return headers[headerName ?? strategy.name] ?? strategy.default;
|
|
520
|
+
}
|
|
521
|
+
return mediaLookup === undefined
|
|
522
|
+
? strategy.default
|
|
523
|
+
: mediaTypeVersion(headers.accept, mediaTypeKey ?? strategy.key, strategy.default, mediaLookup);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function unsupportedVersion(
|
|
527
|
+
strategy: Exclude<VersionStrategy, { readonly kind: 'path' }>,
|
|
528
|
+
requested: string,
|
|
529
|
+
supported: readonly string[],
|
|
530
|
+
headers: Readonly<Record<string, string>>,
|
|
531
|
+
mediaTypeKey: string | undefined,
|
|
532
|
+
): WebResponse {
|
|
533
|
+
const value =
|
|
534
|
+
strategy.kind === 'media-type' && requested === UNKNOWN_MEDIA_VERSION
|
|
535
|
+
? mediaTypeVersionLabel(headers.accept, mediaTypeKey ?? strategy.key)
|
|
536
|
+
: requested;
|
|
537
|
+
return jsonResponse(strategy.kind === 'header' ? 400 : 406, {
|
|
538
|
+
error: `unsupported version "${value}"`,
|
|
539
|
+
supported,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function mediaVersionedResponse(
|
|
544
|
+
response: WebResponse,
|
|
545
|
+
versionHeaders: Readonly<Record<string, string>> | undefined,
|
|
546
|
+
): WebResponse {
|
|
547
|
+
if (versionHeaders === undefined) {
|
|
548
|
+
return response;
|
|
549
|
+
}
|
|
550
|
+
const contentType = response.headers['content-type'];
|
|
551
|
+
if (contentType === undefined || !contentType.toLowerCase().startsWith('application/json')) {
|
|
552
|
+
return response;
|
|
553
|
+
}
|
|
554
|
+
if (response.headers === versionHeaders) {
|
|
555
|
+
return response;
|
|
556
|
+
}
|
|
557
|
+
return { ...response, headers: { ...response.headers, ...versionHeaders } };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const JSON_HEADERS: Readonly<Record<string, string>> = { 'content-type': 'application/json' };
|
|
561
|
+
const TEXT_HEADERS: Readonly<Record<string, string>> = { 'content-type': 'text/plain; charset=utf-8' };
|
|
562
|
+
const NO_HEADERS: Readonly<Record<string, string>> = {};
|
|
563
|
+
const TEXT_BODY_KIND = 'text';
|
|
564
|
+
const EMPTY_TEXT: ResponseBody = Object.freeze({ kind: 'text', value: '' });
|
|
565
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
566
|
+
const STANDARD_HTTP_METHODS = new Set(['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE']);
|
|
567
|
+
|
|
568
|
+
function jsonResponse(
|
|
569
|
+
status: number,
|
|
570
|
+
value: unknown,
|
|
571
|
+
headers: Readonly<Record<string, string>> = JSON_HEADERS,
|
|
572
|
+
): WebResponse {
|
|
573
|
+
return { status, body: textBody(JSON.stringify(value) ?? ''), headers };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function textBody(value: string): ResponseBody {
|
|
577
|
+
return value.length === 0 ? EMPTY_TEXT : { kind: 'text', value };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---- Handler-controlled responses ------------------------------------------
|
|
581
|
+
//
|
|
582
|
+
// A handler normally returns a plain value and the pipeline serializes it as
|
|
583
|
+
// `200 application/json`. That covers a JSON API and nothing else: until these
|
|
584
|
+
// factories existed a handler could not choose a status, set a header, or return
|
|
585
|
+
// a body that was not JSON, so anything needing one of those had to be done in a
|
|
586
|
+
// hand-written adapter outside the framework.
|
|
587
|
+
//
|
|
588
|
+
// Detection is a marker symbol, deliberately not a structural check. Sniffing
|
|
589
|
+
// for a `status` property would be cheaper and needs no new API, but a DTO with
|
|
590
|
+
// a `status` field is an entirely ordinary thing to return — `{ status: 'draft' }`
|
|
591
|
+
// would silently stop being a body and become an HTTP status. The symbol makes
|
|
592
|
+
// "plain object → 200 JSON" provably unchanged for every existing caller.
|
|
593
|
+
//
|
|
594
|
+
// Symbol.for, not a fresh Symbol: two copies of this package in one process
|
|
595
|
+
// (a hoisting mismatch, or an app importing both `@zmdb/web` and `zmdb/web`)
|
|
596
|
+
// must still recognise each other's responses.
|
|
597
|
+
const RESPONSE_TAG = Symbol.for('zmdb.web.response');
|
|
598
|
+
|
|
599
|
+
/** Status and headers a response factory accepts. */
|
|
600
|
+
export interface ResponseOptions {
|
|
601
|
+
readonly status?: number;
|
|
602
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Options for a streamed response. */
|
|
606
|
+
export interface StreamOptions extends ResponseOptions {
|
|
607
|
+
readonly length?: number;
|
|
608
|
+
readonly onError: (error: unknown) => void;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Options for a response streamed from a known file path. */
|
|
612
|
+
export interface FileResponseOptions extends ResponseOptions {
|
|
613
|
+
readonly contentType?: string;
|
|
614
|
+
readonly onError: (error: unknown) => void;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// The tag is non-enumerable so a WebResponse remains the plain
|
|
618
|
+
// `{ status, body, headers }` record consumers expect at the top level.
|
|
619
|
+
function tagged(response: WebResponse): WebResponse {
|
|
620
|
+
Object.defineProperty(response, RESPONSE_TAG, { value: true, enumerable: false });
|
|
621
|
+
return response;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function isTaggedResponse(value: unknown): value is WebResponse {
|
|
625
|
+
return typeof value === 'object' && value !== null && RESPONSE_TAG in value;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* A JSON response with an explicit status and/or extra headers.
|
|
630
|
+
*
|
|
631
|
+
* `return json(created, { status: 201, headers: { location } })`
|
|
632
|
+
*/
|
|
633
|
+
export function json(value: unknown, options: ResponseOptions = {}): WebResponse {
|
|
634
|
+
return tagged({
|
|
635
|
+
status: options.status ?? 200,
|
|
636
|
+
body: textBody(JSON.stringify(value) ?? ''),
|
|
637
|
+
headers: options.headers === undefined ? JSON_HEADERS : { ...JSON_HEADERS, ...options.headers },
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* A `text/plain` response, returned byte-for-byte as given.
|
|
643
|
+
*
|
|
644
|
+
* `return text(ctx.params.id)`
|
|
645
|
+
*/
|
|
646
|
+
export function text(body: string, options: ResponseOptions = {}): WebResponse {
|
|
647
|
+
return tagged({
|
|
648
|
+
status: options.status ?? 200,
|
|
649
|
+
body: textBody(body),
|
|
650
|
+
headers: options.headers === undefined ? TEXT_HEADERS : { ...TEXT_HEADERS, ...options.headers },
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* A response with full control and no assumed content type — for HTML, CSV, a
|
|
656
|
+
* redirect, or a `204` with no body. Nothing is added to `headers`, so set
|
|
657
|
+
* `content-type` yourself if the body has one.
|
|
658
|
+
*
|
|
659
|
+
* `return respond({ status: 302, headers: { location: '/login' } })`
|
|
660
|
+
*/
|
|
661
|
+
export function respond(init: {
|
|
662
|
+
readonly status?: number;
|
|
663
|
+
readonly body?: string;
|
|
664
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
665
|
+
}): WebResponse {
|
|
666
|
+
return tagged({ status: init.status ?? 200, body: textBody(init.body ?? ''), headers: init.headers ?? NO_HEADERS });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** A byte response with no implicit content type. */
|
|
670
|
+
export function bytes(value: Uint8Array<ArrayBuffer>, options: ResponseOptions = {}): WebResponse {
|
|
671
|
+
return tagged({
|
|
672
|
+
status: options.status ?? 200,
|
|
673
|
+
body: { kind: 'bytes', value },
|
|
674
|
+
headers: options.headers ?? NO_HEADERS,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** A streaming response whose failures are reported exactly once. */
|
|
679
|
+
export function stream(value: ReadableStream<Uint8Array<ArrayBuffer>>, options: StreamOptions): WebResponse {
|
|
680
|
+
if (options.length !== undefined && (!Number.isSafeInteger(options.length) || options.length < 0)) {
|
|
681
|
+
throw new RangeError('length must be a non-negative safe integer');
|
|
682
|
+
}
|
|
683
|
+
return tagged({
|
|
684
|
+
status: options.status ?? 200,
|
|
685
|
+
body: {
|
|
686
|
+
kind: 'stream',
|
|
687
|
+
value: reportingStream(value, options.length, options.onError),
|
|
688
|
+
length: options.length,
|
|
689
|
+
},
|
|
690
|
+
headers: options.headers ?? NO_HEADERS,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Stream one known file path. Path confinement belongs to the static-file handler. */
|
|
695
|
+
export async function file(path: string, options: FileResponseOptions): Promise<WebResponse> {
|
|
696
|
+
let handle: FileHandle | undefined;
|
|
697
|
+
try {
|
|
698
|
+
const { open } = await import('node:fs/promises');
|
|
699
|
+
handle = await open(path, 'r');
|
|
700
|
+
const stat = await handle.stat();
|
|
701
|
+
if (!stat.isFile()) {
|
|
702
|
+
throw new Error(`not a file: ${path}`);
|
|
703
|
+
}
|
|
704
|
+
const body = fileHandleStream(handle);
|
|
705
|
+
const headers =
|
|
706
|
+
options.contentType === undefined ? options.headers : { ...options.headers, 'content-type': options.contentType };
|
|
707
|
+
return stream(body, {
|
|
708
|
+
...(options.status === undefined ? {} : { status: options.status }),
|
|
709
|
+
...(headers === undefined ? {} : { headers }),
|
|
710
|
+
length: stat.size,
|
|
711
|
+
onError: options.onError,
|
|
712
|
+
});
|
|
713
|
+
} catch (error) {
|
|
714
|
+
await handle?.close().catch(() => undefined);
|
|
715
|
+
options.onError(error);
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Decode a response body for assertions and other in-process consumers. */
|
|
721
|
+
export async function bodyText(response: WebResponse): Promise<string> {
|
|
722
|
+
switch (response.body.kind) {
|
|
723
|
+
case TEXT_BODY_KIND:
|
|
724
|
+
return response.body.value;
|
|
725
|
+
case 'bytes':
|
|
726
|
+
return new TextDecoder().decode(response.body.value);
|
|
727
|
+
case 'stream':
|
|
728
|
+
return new Response(response.body.value).text();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function reportingStream(
|
|
733
|
+
source: ReadableStream<Uint8Array<ArrayBuffer>>,
|
|
734
|
+
expectedLength: number | undefined,
|
|
735
|
+
onError: (error: unknown) => void,
|
|
736
|
+
): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
737
|
+
const reader = source.getReader();
|
|
738
|
+
let reported = false;
|
|
739
|
+
let received = 0;
|
|
740
|
+
const report = (error: unknown): void => {
|
|
741
|
+
if (!reported) {
|
|
742
|
+
reported = true;
|
|
743
|
+
onError(error);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
747
|
+
async pull(controller) {
|
|
748
|
+
try {
|
|
749
|
+
const next = await reader.read();
|
|
750
|
+
if (next.done) {
|
|
751
|
+
if (expectedLength !== undefined && received !== expectedLength) {
|
|
752
|
+
throw new Error(
|
|
753
|
+
`stream length mismatch: expected ${String(expectedLength)} bytes, received ${String(received)}`,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
controller.close();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
received += next.value.byteLength;
|
|
760
|
+
if (expectedLength !== undefined && received > expectedLength) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
`stream length mismatch: expected ${String(expectedLength)} bytes, received at least ${String(received)}`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
controller.enqueue(next.value);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
report(error);
|
|
768
|
+
void reader.cancel(error).catch(report);
|
|
769
|
+
controller.error(error);
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
async cancel(reason) {
|
|
773
|
+
try {
|
|
774
|
+
await reader.cancel(reason);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
report(error);
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function fileHandleStream(handle: FileHandle): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
784
|
+
let closed = false;
|
|
785
|
+
const close = async (): Promise<void> => {
|
|
786
|
+
if (!closed) {
|
|
787
|
+
closed = true;
|
|
788
|
+
await handle.close();
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
792
|
+
async pull(controller) {
|
|
793
|
+
try {
|
|
794
|
+
const chunk = new Uint8Array(64 * 1024);
|
|
795
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null);
|
|
796
|
+
if (bytesRead === 0) {
|
|
797
|
+
await close();
|
|
798
|
+
controller.close();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
controller.enqueue(chunk.slice(0, bytesRead));
|
|
802
|
+
} catch (error) {
|
|
803
|
+
await close().catch(() => undefined);
|
|
804
|
+
controller.error(error);
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
cancel: close,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export interface Router {
|
|
812
|
+
register(controller: object, options?: Readonly<Record<string, RouteOptions>>): void;
|
|
813
|
+
registerContract(
|
|
814
|
+
contract: CompiledHttpContract,
|
|
815
|
+
controllers: readonly object[],
|
|
816
|
+
options?: Readonly<Record<string, Readonly<Record<string, RouteOptions>>>>,
|
|
817
|
+
): void;
|
|
818
|
+
registerDeferred(controller: Constructor<object>, instance: () => Promise<object>): void;
|
|
819
|
+
handle(req: WebRequest): Promise<WebResponse>;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Create a router. Routes and their effective guards are resolved once at register time. */
|
|
823
|
+
export function createRouter(routerOptions: RouterOptions = {}): Router {
|
|
824
|
+
const buckets: MethodBuckets = new Map();
|
|
825
|
+
const versioning = routerOptions.versioning;
|
|
826
|
+
const requestVersioning = versioning?.kind === 'header' || versioning?.kind === 'media-type' ? versioning : undefined;
|
|
827
|
+
const versionBuckets: VersionBuckets = new Map();
|
|
828
|
+
const neutralBuckets: MethodBuckets = new Map();
|
|
829
|
+
const supportedBuckets: SupportedBuckets = new Map();
|
|
830
|
+
const versionedRouteKeys = new Set<string>();
|
|
831
|
+
const registeredOperationIds = new Set<string>();
|
|
832
|
+
const versionHeaderName = versioning?.kind === 'header' ? versioning.name.toLowerCase() : undefined;
|
|
833
|
+
const mediaTypeKey = versioning?.kind === 'media-type' ? versioning.key.toLowerCase() : undefined;
|
|
834
|
+
const mediaVersionLookup = versioning?.kind === 'media-type' ? createVersionLookup(versioning.default) : undefined;
|
|
835
|
+
const tracer = routerOptions.tracer;
|
|
836
|
+
const requestDuration = routerOptions.meter?.histogram('http.server.request.duration', 's');
|
|
837
|
+
// One stable branch protects the original request path. A comments-only
|
|
838
|
+
// configuration does not activate spans or metrics.
|
|
839
|
+
const observed = tracer === undefined && requestDuration === undefined ? undefined : true;
|
|
840
|
+
|
|
841
|
+
function requestVersionFor(headers: WebRequest['headers']): string | undefined {
|
|
842
|
+
return requestVersioning === undefined
|
|
843
|
+
? undefined
|
|
844
|
+
: requestedVersion(requestVersioning, headers, versionHeaderName, mediaTypeKey, mediaVersionLookup);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function routeCandidates(method: string, segmentCount: number, version: string | undefined): readonly BoundRoute[] {
|
|
848
|
+
return version === undefined
|
|
849
|
+
? (buckets.get(method)?.[segmentCount] ?? [])
|
|
850
|
+
: (versionBuckets.get(method)?.get(version)?.[segmentCount] ?? neutralBuckets.get(method)?.[segmentCount] ?? []);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function claimVersionedRoute(
|
|
854
|
+
controller: ControllerCtor,
|
|
855
|
+
route: ResolvedRoute,
|
|
856
|
+
version: string,
|
|
857
|
+
publicPath: string,
|
|
858
|
+
): void {
|
|
859
|
+
const key = `${route.method}\u0000${version}\u0000${publicPath}`;
|
|
860
|
+
if (versionedRouteKeys.has(key)) {
|
|
861
|
+
throw new Error(
|
|
862
|
+
`Version registration error at ${controller.name}.${route.handlerName}: ` +
|
|
863
|
+
`${route.method} ${publicPath} is already registered for version "${version}"`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
versionedRouteKeys.add(key);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function addPathRoute(
|
|
870
|
+
route: ResolvedRoute,
|
|
871
|
+
handler: Handler,
|
|
872
|
+
validateBody: ((raw: unknown) => unknown) | undefined,
|
|
873
|
+
guards: readonly Guard[],
|
|
874
|
+
): void {
|
|
875
|
+
const pattern = compilePattern(route.path);
|
|
876
|
+
bucketFor(buckets, route.method, pattern.segmentCount).push({
|
|
877
|
+
route,
|
|
878
|
+
pattern,
|
|
879
|
+
handler,
|
|
880
|
+
...(validateBody === undefined ? {} : { validateBody }),
|
|
881
|
+
...(guards.length === 0 ? {} : { guards }),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function addBoundRoute(
|
|
886
|
+
controller: ControllerCtor,
|
|
887
|
+
route: ResolvedRoute,
|
|
888
|
+
handler: Handler,
|
|
889
|
+
validateBody: ((raw: unknown) => unknown) | undefined,
|
|
890
|
+
guards: readonly Guard[],
|
|
891
|
+
): void {
|
|
892
|
+
const declaration = versionsOf(controller, route.handlerName);
|
|
893
|
+
|
|
894
|
+
if (versioning === undefined) {
|
|
895
|
+
if (declaration !== undefined && declaration !== 'neutral') {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`Version registration error at ${controller.name}.${route.handlerName}: ` +
|
|
898
|
+
'@Version() requires createRouter({ versioning: ... })',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
addPathRoute(route, handler, validateBody, guards);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (declaration === undefined) {
|
|
906
|
+
throw new Error(
|
|
907
|
+
`Version registration error at ${controller.name}.${route.handlerName}: ` +
|
|
908
|
+
'declare @Version(...) or @VersionNeutral()',
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (versioning.kind === 'path') {
|
|
913
|
+
if (declaration === 'neutral') {
|
|
914
|
+
addPathRoute(route, handler, validateBody, guards);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
for (const version of declaration) {
|
|
918
|
+
const publicPath = pathForVersion(versioning.prefix, version, route.path);
|
|
919
|
+
claimVersionedRoute(controller, route, version, publicPath);
|
|
920
|
+
addPathRoute({ ...route, path: publicPath }, handler, validateBody, guards);
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const pattern = compilePattern(route.path);
|
|
926
|
+
const base = {
|
|
927
|
+
route,
|
|
928
|
+
pattern,
|
|
929
|
+
handler,
|
|
930
|
+
...(validateBody === undefined ? {} : { validateBody }),
|
|
931
|
+
...(guards.length === 0 ? {} : { guards }),
|
|
932
|
+
};
|
|
933
|
+
if (declaration === 'neutral') {
|
|
934
|
+
addNeutralRoute(versionBuckets, neutralBuckets, route.method, { ...base, neutral: true });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
for (const version of declaration) {
|
|
938
|
+
claimVersionedRoute(controller, route, version, route.path);
|
|
939
|
+
if (mediaVersionLookup !== undefined) {
|
|
940
|
+
addKnownVersion(mediaVersionLookup, version);
|
|
941
|
+
}
|
|
942
|
+
addSpecificVersionRoute(versionBuckets, neutralBuckets, route.method, version, {
|
|
943
|
+
...base,
|
|
944
|
+
...(versioning.kind === 'media-type'
|
|
945
|
+
? {
|
|
946
|
+
versionJsonHeaders: Object.freeze({
|
|
947
|
+
'content-type': jsonMediaTypeForVersion(versioning.key, version),
|
|
948
|
+
}),
|
|
949
|
+
}
|
|
950
|
+
: {}),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
addSupportedRoute(supportedBuckets, route.method, pattern, declaration);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function addContractRoute(
|
|
957
|
+
controller: ControllerCtor,
|
|
958
|
+
operation: HttpOperationIR,
|
|
959
|
+
handler: Handler,
|
|
960
|
+
validateBody: ((raw: unknown) => unknown) | undefined,
|
|
961
|
+
guards: readonly Guard[],
|
|
962
|
+
): void {
|
|
963
|
+
const route = { method: operation.method, path: operation.path, handlerName: operation.handler };
|
|
964
|
+
const pattern = compilePattern(operation.path);
|
|
965
|
+
const base = {
|
|
966
|
+
route,
|
|
967
|
+
operation,
|
|
968
|
+
pattern,
|
|
969
|
+
handler,
|
|
970
|
+
...(validateBody === undefined ? {} : { validateBody }),
|
|
971
|
+
...(guards.length === 0 ? {} : { guards }),
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
if (operation.version.kind === 'none') {
|
|
975
|
+
if (versioning !== undefined) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
`Contract registration error at ${operation.operationId}: an unversioned operation ` +
|
|
978
|
+
'cannot be registered on a versioned router',
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
bucketFor(buckets, operation.method, pattern.segmentCount).push(base);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (versioning === undefined) {
|
|
986
|
+
throw new Error(
|
|
987
|
+
`Contract registration error at ${operation.operationId}: ${operation.version.kind} versioning ` +
|
|
988
|
+
'requires createRouter({ versioning: ... })',
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (operation.version.kind === 'neutral') {
|
|
993
|
+
if (versioning.kind === 'path') {
|
|
994
|
+
bucketFor(buckets, operation.method, pattern.segmentCount).push(base);
|
|
995
|
+
} else {
|
|
996
|
+
addNeutralRoute(versionBuckets, neutralBuckets, operation.method, { ...base, neutral: true });
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (operation.version.kind === 'path') {
|
|
1002
|
+
if (versioning.kind !== 'path') {
|
|
1003
|
+
throw new Error(
|
|
1004
|
+
`Contract registration error at ${operation.operationId}: contract uses path versioning, ` +
|
|
1005
|
+
`router uses ${versioning.kind}`,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
claimVersionedRoute(controller, route, operation.version.value, operation.path);
|
|
1009
|
+
bucketFor(buckets, operation.method, pattern.segmentCount).push(base);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (versioning.kind !== operation.version.kind) {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
`Contract registration error at ${operation.operationId}: contract uses ${operation.version.kind} ` +
|
|
1016
|
+
`versioning, router uses ${versioning.kind}`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
const contractName =
|
|
1020
|
+
operation.version.kind === 'header' ? operation.version.name.toLowerCase() : operation.version.key.toLowerCase();
|
|
1021
|
+
const routerName = versioning.kind === 'header' ? versioning.name.toLowerCase() : versioning.key.toLowerCase();
|
|
1022
|
+
if (contractName !== routerName || operation.version.default !== versioning.default) {
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`Contract registration error at ${operation.operationId}: router ${versioning.kind} name/default ` +
|
|
1025
|
+
'does not match the compiled operation',
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
for (const version of operation.version.values) {
|
|
1030
|
+
claimVersionedRoute(controller, route, version, operation.path);
|
|
1031
|
+
if (mediaVersionLookup !== undefined) addKnownVersion(mediaVersionLookup, version);
|
|
1032
|
+
addSpecificVersionRoute(versionBuckets, neutralBuckets, operation.method, version, {
|
|
1033
|
+
...base,
|
|
1034
|
+
...(operation.version.kind === 'media-type'
|
|
1035
|
+
? {
|
|
1036
|
+
versionJsonHeaders: Object.freeze({
|
|
1037
|
+
'content-type': jsonMediaTypeForVersion(operation.version.key, version),
|
|
1038
|
+
}),
|
|
1039
|
+
}
|
|
1040
|
+
: {}),
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
addSupportedRoute(supportedBuckets, operation.method, pattern, operation.version.values);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async function handleObserved(req: WebRequest): Promise<WebResponse> {
|
|
1047
|
+
const started = Date.now();
|
|
1048
|
+
const method = req.method.toUpperCase();
|
|
1049
|
+
const methodAttribute = STANDARD_HTTP_METHODS.has(method) ? method : '_OTHER';
|
|
1050
|
+
|
|
1051
|
+
// OpenTelemetry semantic conventions v1.30.0: start the SERVER span with the
|
|
1052
|
+
// method-only fallback name, then update it once route resolution produces the
|
|
1053
|
+
// low-cardinality route pattern.
|
|
1054
|
+
const remoteParent =
|
|
1055
|
+
tracer === undefined ? undefined : fromTraceContext(req.headers.traceparent, req.headers.tracestate);
|
|
1056
|
+
const serverSpan =
|
|
1057
|
+
tracer === undefined
|
|
1058
|
+
? undefined
|
|
1059
|
+
: remoteParent === undefined
|
|
1060
|
+
? tracer.startSpan(method, { kind: 'server' })
|
|
1061
|
+
: tracer.startSpan(method, { kind: 'server', parent: remoteParent });
|
|
1062
|
+
try {
|
|
1063
|
+
if (serverSpan !== undefined) {
|
|
1064
|
+
serverSpan.setAttribute('http.request.method', methodAttribute);
|
|
1065
|
+
serverSpan.setAttribute('url.path', req.path);
|
|
1066
|
+
serverSpan.setAttribute('url.scheme', req.scheme ?? 'http');
|
|
1067
|
+
const address = req.headers.host;
|
|
1068
|
+
if (address !== undefined) {
|
|
1069
|
+
serverSpan.setAttribute('server.address', address);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
let matched: BoundRoute | undefined;
|
|
1074
|
+
let matchedParams: Record<string, string> | undefined;
|
|
1075
|
+
let requestVersion: string | undefined;
|
|
1076
|
+
const routeSpan = childSpan(tracer, serverSpan, 'zmdb.route');
|
|
1077
|
+
try {
|
|
1078
|
+
const segmentCount = countSegments(req.path);
|
|
1079
|
+
requestVersion = requestVersionFor(req.headers);
|
|
1080
|
+
const candidates = routeCandidates(method, segmentCount, requestVersion);
|
|
1081
|
+
for (const candidate of candidates) {
|
|
1082
|
+
const params = matchCompiled(candidate.pattern, req.path);
|
|
1083
|
+
if (params !== undefined) {
|
|
1084
|
+
matched = candidate;
|
|
1085
|
+
matchedParams = params;
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
} finally {
|
|
1090
|
+
routeSpan?.end();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (serverSpan !== undefined && matched !== undefined) {
|
|
1094
|
+
const routePath = matched.operation?.path ?? matched.route.path;
|
|
1095
|
+
serverSpan.updateName(`${method} ${routePath}`);
|
|
1096
|
+
serverSpan.setAttribute('http.route', routePath);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
let response: WebResponse | undefined;
|
|
1100
|
+
let failed = false;
|
|
1101
|
+
let failure: unknown;
|
|
1102
|
+
try {
|
|
1103
|
+
if (matched === undefined || matchedParams === undefined) {
|
|
1104
|
+
if (requestVersion !== undefined && requestVersioning !== undefined) {
|
|
1105
|
+
const supported = supportedVersions(supportedBuckets, method, req.path);
|
|
1106
|
+
if (supported !== undefined) {
|
|
1107
|
+
response = unsupportedVersion(requestVersioning, requestVersion, supported, req.headers, mediaTypeKey);
|
|
1108
|
+
return response;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
response = jsonResponse(404, { error: `no route for ${method} ${req.path}` });
|
|
1112
|
+
return response;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const ctx = Object.assign(createRequestData(), {
|
|
1116
|
+
params: matchedParams,
|
|
1117
|
+
body: req.rawBody,
|
|
1118
|
+
query: req.query ?? {},
|
|
1119
|
+
headers: req.headers,
|
|
1120
|
+
method,
|
|
1121
|
+
path: req.path,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
for (const guard of matched.guards ?? []) {
|
|
1125
|
+
try {
|
|
1126
|
+
if (!(await guard.canActivate(ctx))) {
|
|
1127
|
+
response = jsonResponse(403, { error: 'forbidden' });
|
|
1128
|
+
return response;
|
|
1129
|
+
}
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
failed = true;
|
|
1132
|
+
failure = error;
|
|
1133
|
+
response = jsonResponse(500, { error: messageOf(error) });
|
|
1134
|
+
return response;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (matched.validateBody !== undefined) {
|
|
1139
|
+
const validationSpan = childSpan(tracer, serverSpan, 'zmdb.validate');
|
|
1140
|
+
try {
|
|
1141
|
+
ctx.body = matched.validateBody(req.rawBody);
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
failed = true;
|
|
1144
|
+
failure = error;
|
|
1145
|
+
recordFailure(validationSpan, error);
|
|
1146
|
+
const message = messageOf(error);
|
|
1147
|
+
const issues = validationIssuesOf(error);
|
|
1148
|
+
response = jsonResponse(400, issues ? { error: message, issues } : { error: message });
|
|
1149
|
+
return response;
|
|
1150
|
+
} finally {
|
|
1151
|
+
validationSpan?.end();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const handlerSpan = childSpan(tracer, serverSpan, 'zmdb.handler');
|
|
1156
|
+
const handlerCtx = handlerSpan === undefined ? ctx : { ...ctx, span: handlerSpan };
|
|
1157
|
+
try {
|
|
1158
|
+
const result = await matched.handler(handlerCtx);
|
|
1159
|
+
response = mediaVersionedResponse(
|
|
1160
|
+
isTaggedResponse(result) ? result : jsonResponse(200, result, matched.versionJsonHeaders ?? JSON_HEADERS),
|
|
1161
|
+
matched.versionJsonHeaders,
|
|
1162
|
+
);
|
|
1163
|
+
return response;
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
failed = true;
|
|
1166
|
+
failure = error;
|
|
1167
|
+
recordFailure(handlerSpan, error);
|
|
1168
|
+
if (error instanceof BoundaryStatusError) {
|
|
1169
|
+
response = jsonResponse(error.status, { error: error.message });
|
|
1170
|
+
return response;
|
|
1171
|
+
}
|
|
1172
|
+
if (error instanceof ValidationError || claimsValidationIssues(error)) {
|
|
1173
|
+
const message = messageOf(error);
|
|
1174
|
+
const issues = validationIssuesOf(error);
|
|
1175
|
+
response = jsonResponse(400, issues ? { error: message, issues } : { error: message });
|
|
1176
|
+
return response;
|
|
1177
|
+
}
|
|
1178
|
+
response = jsonResponse(500, { error: messageOf(error) });
|
|
1179
|
+
return response;
|
|
1180
|
+
} finally {
|
|
1181
|
+
handlerSpan?.end();
|
|
1182
|
+
}
|
|
1183
|
+
} finally {
|
|
1184
|
+
if (response !== undefined) {
|
|
1185
|
+
const serverFailed = response.status >= 500;
|
|
1186
|
+
const errorType = serverFailed ? (failed ? typeOfFailure(failure) : String(response.status)) : undefined;
|
|
1187
|
+
if (serverSpan !== undefined) {
|
|
1188
|
+
serverSpan.setAttribute('http.response.status_code', response.status);
|
|
1189
|
+
if (errorType !== undefined) {
|
|
1190
|
+
serverSpan.setAttribute('error.type', errorType);
|
|
1191
|
+
serverSpan.setStatus({ error: true });
|
|
1192
|
+
}
|
|
1193
|
+
if (serverFailed && failed) {
|
|
1194
|
+
serverSpan.recordException(errorValue(failure));
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (requestDuration !== undefined) {
|
|
1198
|
+
const attributes: Record<string, string | number | boolean> = {
|
|
1199
|
+
'http.request.method': methodAttribute,
|
|
1200
|
+
'http.response.status_code': response.status,
|
|
1201
|
+
};
|
|
1202
|
+
if (matched !== undefined) {
|
|
1203
|
+
attributes['http.route'] = matched.operation?.path ?? matched.route.path;
|
|
1204
|
+
}
|
|
1205
|
+
if (errorType !== undefined) {
|
|
1206
|
+
attributes['error.type'] = errorType;
|
|
1207
|
+
}
|
|
1208
|
+
requestDuration.record((Date.now() - started) / 1000, attributes);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
} finally {
|
|
1213
|
+
serverSpan?.end();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return {
|
|
1218
|
+
register(controller: object, options: Readonly<Record<string, RouteOptions>> = {}): void {
|
|
1219
|
+
const ctor = controllerCtor(controller);
|
|
1220
|
+
if (ctor === undefined) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
for (const route of getRoutes(ctor)) {
|
|
1224
|
+
const handler = readHandler(controller, route.handlerName);
|
|
1225
|
+
if (handler === undefined) {
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
const opts = options[route.handlerName];
|
|
1229
|
+
const routeGuards = opts?.guards ?? [];
|
|
1230
|
+
const publicRoute = isPublic(ctor, route.handlerName);
|
|
1231
|
+
if (publicRoute && (routeGuards.length > 0 || (opts?.security !== undefined && opts.security.length > 0))) {
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`Guard configuration error at ${ctor.name}.${route.handlerName}: an @Public() route cannot declare route guards or a non-empty security requirement`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
const guards = publicRoute ? [] : resolveGuards(routerOptions.guardRegistry, ctor.name, routeGuards);
|
|
1237
|
+
addBoundRoute(ctor, route, handler, opts?.validateBody, guards);
|
|
1238
|
+
}
|
|
1239
|
+
},
|
|
1240
|
+
|
|
1241
|
+
registerContract(
|
|
1242
|
+
contract: CompiledHttpContract,
|
|
1243
|
+
controllers: readonly object[],
|
|
1244
|
+
options: Readonly<Record<string, Readonly<Record<string, RouteOptions>>>> = {},
|
|
1245
|
+
): void {
|
|
1246
|
+
if (contract.ir.format !== 1) {
|
|
1247
|
+
throw new Error(`Contract registration error: unsupported HttpContractIR format ${String(contract.ir.format)}`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const instances = new Map<ControllerCtor, object>();
|
|
1251
|
+
for (const controller of controllers) {
|
|
1252
|
+
const ctor = controllerCtor(controller);
|
|
1253
|
+
if (ctor === undefined) continue;
|
|
1254
|
+
if (instances.has(ctor)) {
|
|
1255
|
+
throw new Error(`Contract registration error: more than one instance of ${ctor.name} was supplied`);
|
|
1256
|
+
}
|
|
1257
|
+
instances.set(ctor, controller);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
for (const binding of contract.operations) {
|
|
1261
|
+
const operation = binding.operation;
|
|
1262
|
+
if (!contract.ir.operations.includes(operation)) {
|
|
1263
|
+
throw new Error(
|
|
1264
|
+
`Contract registration error at ${operation.operationId}: runtime binding does not reference an operation in the IR`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
if (registeredOperationIds.has(operation.operationId)) {
|
|
1268
|
+
throw new Error(`Contract registration error at ${operation.operationId}: operation is already registered`);
|
|
1269
|
+
}
|
|
1270
|
+
const controller = instances.get(binding.controller);
|
|
1271
|
+
if (controller === undefined) {
|
|
1272
|
+
throw new Error(
|
|
1273
|
+
`Contract registration error at ${operation.operationId}: no ${binding.controller.name} instance was supplied`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
const handler = readHandler(controller, binding.handler);
|
|
1277
|
+
if (handler === undefined) {
|
|
1278
|
+
throw new Error(
|
|
1279
|
+
`Contract registration error at ${operation.operationId}: ` +
|
|
1280
|
+
`${binding.controller.name}.${binding.handler} is not callable`,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const decorated = getRoutes(binding.controller).find(
|
|
1285
|
+
route => route.handlerName === binding.handler && route.method === operation.method,
|
|
1286
|
+
);
|
|
1287
|
+
if (decorated === undefined) {
|
|
1288
|
+
throw new Error(
|
|
1289
|
+
`Contract registration error at ${operation.operationId}: decorator route is missing or uses another method`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
const expectedPath =
|
|
1293
|
+
operation.version.kind === 'path' && versioning?.kind === 'path'
|
|
1294
|
+
? pathForVersion(versioning.prefix, operation.version.value, decorated.path)
|
|
1295
|
+
: decorated.path;
|
|
1296
|
+
if (expectedPath !== operation.path) {
|
|
1297
|
+
throw new Error(
|
|
1298
|
+
`Contract registration error at ${operation.operationId}: compiled path ${operation.path} ` +
|
|
1299
|
+
`does not match decorator path ${expectedPath}`,
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const opts = options[operation.controller]?.[operation.handler];
|
|
1304
|
+
if ((opts?.deprecated === true) !== operation.deprecated) {
|
|
1305
|
+
throw new Error(
|
|
1306
|
+
`Contract registration error at ${operation.operationId}: RouteOptions.deprecated disagrees with the contract`,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const routeGuards = opts?.guards ?? [];
|
|
1311
|
+
const publicRoute = isPublic(binding.controller, binding.handler);
|
|
1312
|
+
if (publicRoute && (routeGuards.length > 0 || (opts?.security !== undefined && opts.security.length > 0))) {
|
|
1313
|
+
throw new Error(
|
|
1314
|
+
`Guard configuration error at ${operation.controller}.${operation.handler}: ` +
|
|
1315
|
+
'an @Public() route cannot declare route guards or a non-empty security requirement',
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
const guards = publicRoute ? [] : resolveGuards(routerOptions.guardRegistry, operation.controller, routeGuards);
|
|
1319
|
+
const configuredSecurity = publicRoute ? [] : (opts?.security ?? securityFromGuards(guards));
|
|
1320
|
+
if (publicRoute !== (operation.security.length === 0)) {
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`Contract registration error at ${operation.operationId}: @Public() and contract security disagree`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
if (!publicRoute && guards.length === 0) {
|
|
1326
|
+
throw new Error(
|
|
1327
|
+
`Contract registration error at ${operation.operationId}: a protected operation has no runtime guard`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
if (configuredSecurity === undefined || !sameSecurity(configuredSecurity, operation.security)) {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`Contract registration error at ${operation.operationId}: effective runtime security disagrees with the contract`,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
addContractRoute(binding.controller, operation, handler, opts?.validateBody, guards);
|
|
1336
|
+
registeredOperationIds.add(operation.operationId);
|
|
1337
|
+
}
|
|
1338
|
+
},
|
|
1339
|
+
|
|
1340
|
+
registerDeferred(controller: Constructor<object>, instance: () => Promise<object>): void {
|
|
1341
|
+
for (const route of getRoutes(controller)) {
|
|
1342
|
+
let resolved: Handler | undefined;
|
|
1343
|
+
const handler: Handler = async ctx => {
|
|
1344
|
+
if (resolved === undefined) {
|
|
1345
|
+
const built = await instance();
|
|
1346
|
+
resolved = readHandler(built, route.handlerName);
|
|
1347
|
+
if (resolved === undefined) {
|
|
1348
|
+
throw new Error(`@zmdb/web: controller has no handler named "${route.handlerName}"`);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return resolved(ctx);
|
|
1352
|
+
};
|
|
1353
|
+
const guards = isPublic(controller, route.handlerName)
|
|
1354
|
+
? []
|
|
1355
|
+
: resolveGuards(routerOptions.guardRegistry, controller.name);
|
|
1356
|
+
addBoundRoute(controller, route, handler, undefined, guards);
|
|
1357
|
+
}
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
async handle(req: WebRequest): Promise<WebResponse> {
|
|
1361
|
+
if (observed !== undefined) {
|
|
1362
|
+
return handleObserved(req);
|
|
1363
|
+
}
|
|
1364
|
+
const method = req.method.toUpperCase();
|
|
1365
|
+
const segmentCount = countSegments(req.path);
|
|
1366
|
+
const requestVersion = requestVersionFor(req.headers);
|
|
1367
|
+
const candidates = routeCandidates(method, segmentCount, requestVersion);
|
|
1368
|
+
for (const bound of candidates) {
|
|
1369
|
+
const params = matchCompiled(bound.pattern, req.path);
|
|
1370
|
+
if (params === undefined) {
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
const ctx = Object.assign(createRequestData(), {
|
|
1374
|
+
params,
|
|
1375
|
+
body: req.rawBody,
|
|
1376
|
+
query: req.query ?? {},
|
|
1377
|
+
headers: req.headers,
|
|
1378
|
+
method,
|
|
1379
|
+
path: req.path,
|
|
1380
|
+
});
|
|
1381
|
+
for (const guard of bound.guards ?? []) {
|
|
1382
|
+
try {
|
|
1383
|
+
if (!(await guard.canActivate(ctx))) {
|
|
1384
|
+
return jsonResponse(403, { error: 'forbidden' });
|
|
1385
|
+
}
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
return jsonResponse(500, { error: messageOf(error) });
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (bound.validateBody !== undefined) {
|
|
1392
|
+
try {
|
|
1393
|
+
ctx.body = bound.validateBody(req.rawBody);
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
const message = messageOf(error);
|
|
1396
|
+
const issues = validationIssuesOf(error);
|
|
1397
|
+
return jsonResponse(400, issues ? { error: message, issues } : { error: message });
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
try {
|
|
1401
|
+
const result = await bound.handler(ctx);
|
|
1402
|
+
// One symbol check on the hot path, no extra allocation: a handler that
|
|
1403
|
+
// returns a plain value takes exactly the path it took before.
|
|
1404
|
+
return mediaVersionedResponse(
|
|
1405
|
+
isTaggedResponse(result) ? result : jsonResponse(200, result, bound.versionJsonHeaders ?? JSON_HEADERS),
|
|
1406
|
+
bound.versionJsonHeaders,
|
|
1407
|
+
);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
// A framework boundary refusal keeps its selected status. A validation
|
|
1410
|
+
// error out of the handler is the request's fault and becomes 400;
|
|
1411
|
+
// anything else is 500 with its message and nothing invented.
|
|
1412
|
+
if (error instanceof BoundaryStatusError) {
|
|
1413
|
+
return jsonResponse(error.status, { error: error.message });
|
|
1414
|
+
}
|
|
1415
|
+
if (error instanceof ValidationError || claimsValidationIssues(error)) {
|
|
1416
|
+
const message = messageOf(error);
|
|
1417
|
+
const issues = validationIssuesOf(error);
|
|
1418
|
+
return jsonResponse(400, issues ? { error: message, issues } : { error: message });
|
|
1419
|
+
}
|
|
1420
|
+
return jsonResponse(500, { error: messageOf(error) });
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (requestVersion !== undefined && requestVersioning !== undefined) {
|
|
1424
|
+
const supported = supportedVersions(supportedBuckets, method, req.path);
|
|
1425
|
+
if (supported !== undefined) {
|
|
1426
|
+
return unsupportedVersion(requestVersioning, requestVersion, supported, req.headers, mediaTypeKey);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return jsonResponse(404, { error: `no route for ${method} ${req.path}` });
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// The constructor type getRoutes reads metadata from.
|
|
1435
|
+
type ControllerCtor = abstract new (...args: never[]) => unknown;
|
|
1436
|
+
|
|
1437
|
+
// boundary: an instance's `.constructor` carries the Symbol.metadata the class
|
|
1438
|
+
// decorators wrote; narrowing it to a constructor type for getRoutes is sound.
|
|
1439
|
+
function controllerCtor(controller: object): ControllerCtor | undefined {
|
|
1440
|
+
const ctor = controller.constructor;
|
|
1441
|
+
if (typeof ctor !== 'function') {
|
|
1442
|
+
return undefined;
|
|
1443
|
+
}
|
|
1444
|
+
return ctor as ControllerCtor;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function sameSecurity(left: readonly SecurityRequirement[], right: readonly SecurityRequirement[]): boolean {
|
|
1448
|
+
if (left.length !== right.length) return false;
|
|
1449
|
+
return left.every((requirement, index) => {
|
|
1450
|
+
const expected = right[index];
|
|
1451
|
+
if (expected === undefined) return false;
|
|
1452
|
+
const names = Object.keys(requirement).toSorted();
|
|
1453
|
+
const expectedNames = Object.keys(expected).toSorted();
|
|
1454
|
+
return (
|
|
1455
|
+
names.length === expectedNames.length &&
|
|
1456
|
+
names.every((name, nameIndex) => {
|
|
1457
|
+
const scopes = [...(requirement[name] ?? [])].toSorted();
|
|
1458
|
+
const expectedScopes = [...(expected[name] ?? [])].toSorted();
|
|
1459
|
+
return (
|
|
1460
|
+
name === expectedNames[nameIndex] &&
|
|
1461
|
+
scopes.length === expectedScopes.length &&
|
|
1462
|
+
scopes.every((scope, scopeIndex) => scope === expectedScopes[scopeIndex])
|
|
1463
|
+
);
|
|
1464
|
+
})
|
|
1465
|
+
);
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function isSecurityAwareGuard(guard: Guard): guard is SecurityAwareGuard {
|
|
1470
|
+
if (!('enforces' in guard)) return false;
|
|
1471
|
+
const enforcement = guard.enforces;
|
|
1472
|
+
return (
|
|
1473
|
+
typeof enforcement === 'object' &&
|
|
1474
|
+
enforcement !== null &&
|
|
1475
|
+
'scheme' in enforcement &&
|
|
1476
|
+
typeof enforcement.scheme === 'string' &&
|
|
1477
|
+
'scopes' in enforcement &&
|
|
1478
|
+
Array.isArray(enforcement.scopes) &&
|
|
1479
|
+
enforcement.scopes.every(scope => typeof scope === 'string')
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function securityFromGuards(guards: readonly Guard[]): readonly SecurityRequirement[] | undefined {
|
|
1484
|
+
const schemes = new Map<string, Set<string>>();
|
|
1485
|
+
for (const guard of guards) {
|
|
1486
|
+
if (!isSecurityAwareGuard(guard)) continue;
|
|
1487
|
+
const scopes = schemes.get(guard.enforces.scheme) ?? new Set<string>();
|
|
1488
|
+
for (const scope of guard.enforces.scopes) scopes.add(scope);
|
|
1489
|
+
schemes.set(guard.enforces.scheme, scopes);
|
|
1490
|
+
}
|
|
1491
|
+
if (schemes.size === 0) return undefined;
|
|
1492
|
+
return [
|
|
1493
|
+
Object.fromEntries(
|
|
1494
|
+
[...schemes.entries()]
|
|
1495
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
1496
|
+
.map(([name, scopes]) => [name, [...scopes].toSorted()]),
|
|
1497
|
+
),
|
|
1498
|
+
];
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// boundary: a controller's own decorated methods are the handlers; reading a
|
|
1502
|
+
// method by its recorded name off the instance and treating it as a Handler is
|
|
1503
|
+
// sound because getRoutes only yields names of methods the decorators saw. This
|
|
1504
|
+
// is the single enumerated boundary for the pipeline (ARCHITECTURE.md §2.1).
|
|
1505
|
+
function readHandler(controller: object, name: string): Handler | undefined {
|
|
1506
|
+
const value = Reflect.get(controller, name);
|
|
1507
|
+
if (typeof value !== 'function') {
|
|
1508
|
+
return undefined;
|
|
1509
|
+
}
|
|
1510
|
+
const bound = value.bind(controller);
|
|
1511
|
+
return bound as Handler;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function messageOf(error: unknown): string {
|
|
1515
|
+
return error instanceof Error ? error.message : String(error);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function childSpan(tracer: Tracer | undefined, parent: Span | undefined, name: string): Span | undefined {
|
|
1519
|
+
return tracer === undefined || parent === undefined
|
|
1520
|
+
? undefined
|
|
1521
|
+
: tracer.startSpan(name, { kind: 'internal', parent: parent.spanContext() });
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function recordFailure(span: Span | undefined, error: unknown): void {
|
|
1525
|
+
if (span === undefined) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
span.recordException(errorValue(error));
|
|
1529
|
+
span.setStatus({ error: true });
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function errorValue(error: unknown): Error {
|
|
1533
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function typeOfFailure(error: unknown): string {
|
|
1537
|
+
if (error instanceof Error) {
|
|
1538
|
+
return error.constructor.name;
|
|
1539
|
+
}
|
|
1540
|
+
if (error !== null && typeof error === 'object') {
|
|
1541
|
+
const ctor: unknown = Reflect.get(error, 'constructor');
|
|
1542
|
+
if (typeof ctor === 'function' && ctor.name.length > 0) {
|
|
1543
|
+
return ctor.name;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return typeof error;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// ---- Adapters (structurally typed; no hard node:http / Hono dependency) ----
|
|
1550
|
+
|
|
1551
|
+
// The subset of node:http we touch. `setEncoding` and `writeHead` are optional
|
|
1552
|
+
// because this adapter is structurally typed — a hand-rolled req/res that lacks
|
|
1553
|
+
// them still works, it just takes the slower path.
|
|
1554
|
+
interface NodeReqLike {
|
|
1555
|
+
readonly method?: string;
|
|
1556
|
+
readonly url?: string;
|
|
1557
|
+
readonly headers: Readonly<Record<string, string | string[] | undefined>>;
|
|
1558
|
+
readonly socket?: { readonly encrypted?: boolean };
|
|
1559
|
+
on(event: string, listener: (chunk: unknown) => void): void;
|
|
1560
|
+
setEncoding?(encoding: string): void;
|
|
1561
|
+
}
|
|
1562
|
+
interface NodeResLike {
|
|
1563
|
+
statusCode: number;
|
|
1564
|
+
setHeader(name: string, value: string): void;
|
|
1565
|
+
writeHead?(status: number, headers: Readonly<Record<string, string>>): unknown;
|
|
1566
|
+
write(chunk: Uint8Array<ArrayBuffer>): boolean;
|
|
1567
|
+
once(event: string, listener: () => void): void;
|
|
1568
|
+
destroy(error?: Error): void;
|
|
1569
|
+
end(body?: string | Uint8Array<ArrayBuffer>): void;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** Request-body limits shared by the Node and Fetch adapters. */
|
|
1573
|
+
export interface AdapterOptions {
|
|
1574
|
+
readonly maxBodyBytes: number;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Adapt a router to a node:http `(req, res)` handler.
|
|
1579
|
+
*
|
|
1580
|
+
* This is the path real consumers take, so its per-request cost is the
|
|
1581
|
+
* framework's real cost. The benchmark harness hand-writes its responses and so
|
|
1582
|
+
* never measured this; when it was measured (keep-alive on, 8 workers, c=256,
|
|
1583
|
+
* median of 5) the adapter served 294,067 req/s against the hand-written app's
|
|
1584
|
+
* 395,983 — 1.35x slower, entirely in the four things below.
|
|
1585
|
+
*/
|
|
1586
|
+
export function toNodeHandler(
|
|
1587
|
+
router: Router,
|
|
1588
|
+
options: AdapterOptions = { maxBodyBytes: DEFAULT_MAX_BODY_BYTES },
|
|
1589
|
+
): (req: NodeReqLike, res: NodeResLike) => void {
|
|
1590
|
+
validateMaxBodyBytes(options.maxBodyBytes);
|
|
1591
|
+
return function (req: NodeReqLike, res: NodeResLike): void {
|
|
1592
|
+
// A request with no body needs no 'data'/'end' listeners, no accumulator and
|
|
1593
|
+
// no extra event-loop turn — and per RFC 9112 a request with neither
|
|
1594
|
+
// content-length nor transfer-encoding HAS no body, which is the same rule
|
|
1595
|
+
// node:http itself uses to decide whether to emit 'data' at all. So for the
|
|
1596
|
+
// GET/HEAD/DELETE traffic that dominates most services this dispatches
|
|
1597
|
+
// straight away instead of registering two closures and waiting a tick.
|
|
1598
|
+
if (hasRequestBody(req)) {
|
|
1599
|
+
const announcedLength = requestContentLength(req);
|
|
1600
|
+
if (announcedLength !== undefined && announcedLength > options.maxBodyBytes) {
|
|
1601
|
+
rejectOversizedRequest(res);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
const binary = hasBinaryContentType(req.headers['content-type']);
|
|
1605
|
+
let size = 0;
|
|
1606
|
+
let exceeded = false;
|
|
1607
|
+
const byteChunks: Uint8Array<ArrayBuffer>[] = [];
|
|
1608
|
+
let raw = '';
|
|
1609
|
+
// setEncoding installs a StringDecoder, which holds partial multi-byte
|
|
1610
|
+
// sequences across reads. Decoding each chunk separately with
|
|
1611
|
+
// String(chunk) — as this used to — corrupts any character whose UTF-8
|
|
1612
|
+
// bytes straddle a chunk boundary, so a large body with non-ASCII text
|
|
1613
|
+
// would silently arrive with replacement characters in it.
|
|
1614
|
+
if (!binary) {
|
|
1615
|
+
req.setEncoding?.('utf8');
|
|
1616
|
+
}
|
|
1617
|
+
req.on('data', chunk => {
|
|
1618
|
+
if (exceeded) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
if (binary) {
|
|
1622
|
+
const chunkValue = chunkBytes(chunk);
|
|
1623
|
+
size += chunkValue.byteLength;
|
|
1624
|
+
if (size <= options.maxBodyBytes) {
|
|
1625
|
+
byteChunks.push(chunkValue);
|
|
1626
|
+
}
|
|
1627
|
+
} else {
|
|
1628
|
+
const chunkValue = String(chunk);
|
|
1629
|
+
size += new TextEncoder().encode(chunkValue).byteLength;
|
|
1630
|
+
if (size <= options.maxBodyBytes) {
|
|
1631
|
+
raw += chunkValue;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (size > options.maxBodyBytes) {
|
|
1635
|
+
exceeded = true;
|
|
1636
|
+
rejectOversizedRequest(res);
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
req.on('end', () => {
|
|
1640
|
+
if (exceeded) {
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const requestBody = binary ? joinBytes(byteChunks, size) : raw.length > 0 ? parseJson(raw) : undefined;
|
|
1644
|
+
dispatch(router, req, res, requestBody);
|
|
1645
|
+
});
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
dispatch(router, req, res, undefined);
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// content-length: 0 is explicitly "no body"; any other length, or any
|
|
1653
|
+
// transfer-encoding at all (chunked), means there is one to read.
|
|
1654
|
+
function hasRequestBody(req: NodeReqLike): boolean {
|
|
1655
|
+
const length = req.headers['content-length'];
|
|
1656
|
+
if (typeof length === 'string') {
|
|
1657
|
+
return length !== '0';
|
|
1658
|
+
}
|
|
1659
|
+
return req.headers['transfer-encoding'] !== undefined;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function dispatch(router: Router, req: NodeReqLike, res: NodeResLike, rawBody: unknown): void {
|
|
1663
|
+
// `url.split('?')` allocated an array and split the whole query string just to
|
|
1664
|
+
// throw the tail away; indexOf/slice reads the path without either.
|
|
1665
|
+
const url = req.url ?? '/';
|
|
1666
|
+
const query = url.indexOf('?');
|
|
1667
|
+
// `.then(ok, err)` rather than an `async` IIFE with `await`: the IIFE added a
|
|
1668
|
+
// second async frame and a second promise to every request on top of the one
|
|
1669
|
+
// `router.handle` already returns. The reject arm also means a throw from
|
|
1670
|
+
// outside handle's own try/catch becomes a 500 instead of an unhandled
|
|
1671
|
+
// rejection that takes the process down.
|
|
1672
|
+
void router
|
|
1673
|
+
.handle({
|
|
1674
|
+
method: req.method ?? 'GET',
|
|
1675
|
+
path: query === -1 ? url : url.slice(0, query),
|
|
1676
|
+
headers: flattenHeaders(req.headers),
|
|
1677
|
+
rawBody,
|
|
1678
|
+
scheme: req.socket?.encrypted === true ? 'https' : 'http',
|
|
1679
|
+
})
|
|
1680
|
+
.then(
|
|
1681
|
+
response => {
|
|
1682
|
+
send(res, response, req.method ?? 'GET');
|
|
1683
|
+
},
|
|
1684
|
+
(error: unknown) => {
|
|
1685
|
+
send(res, jsonResponse(500, { error: messageOf(error) }), req.method ?? 'GET');
|
|
1686
|
+
},
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function send(res: NodeResLike, response: WebResponse, method: string): void {
|
|
1691
|
+
const noBody = method.toUpperCase() === 'HEAD' || response.status === 204 || response.status === 304;
|
|
1692
|
+
if (noBody) {
|
|
1693
|
+
if (response.body.kind === 'stream') {
|
|
1694
|
+
void response.body.value.cancel('response has no body').catch(() => undefined);
|
|
1695
|
+
}
|
|
1696
|
+
writeNodeHead(res, response.status, withoutFramingHeaders(response.headers));
|
|
1697
|
+
res.end();
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
switch (response.body.kind) {
|
|
1701
|
+
case TEXT_BODY_KIND:
|
|
1702
|
+
writeNodeHead(res, response.status, withoutTransferEncoding(response.headers));
|
|
1703
|
+
res.end(response.body.value);
|
|
1704
|
+
return;
|
|
1705
|
+
case 'bytes': {
|
|
1706
|
+
const headers = withContentLength(response.headers, response.body.value.byteLength);
|
|
1707
|
+
writeNodeHead(res, response.status, headers);
|
|
1708
|
+
res.end(response.body.value);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
case 'stream':
|
|
1712
|
+
void sendNodeStream(res, response);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function writeNodeHead(res: NodeResLike, status: number, headers: Readonly<Record<string, string>>): void {
|
|
1717
|
+
// One writeHead with the whole header object beats a setHeader per entry:
|
|
1718
|
+
// setHeader was the slowest of the five header strategies measured (78,962
|
|
1719
|
+
// req/s against 91,302 for writeHead with an object literal), because each
|
|
1720
|
+
// call re-validates the name and touches the outgoing-header map. The common
|
|
1721
|
+
// case also hands writeHead the shared frozen JSON_HEADERS constant, so
|
|
1722
|
+
// nothing is iterated or allocated at all.
|
|
1723
|
+
if (res.writeHead === undefined) {
|
|
1724
|
+
res.statusCode = status;
|
|
1725
|
+
for (const key of Object.keys(headers)) {
|
|
1726
|
+
res.setHeader(key, headers[key] ?? '');
|
|
1727
|
+
}
|
|
1728
|
+
} else {
|
|
1729
|
+
res.writeHead(status, headers);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async function sendNodeStream(res: NodeResLike, response: WebResponse): Promise<void> {
|
|
1734
|
+
if (response.body.kind !== 'stream') {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
const reader = response.body.value.getReader();
|
|
1738
|
+
let headersWritten = false;
|
|
1739
|
+
let complete = false;
|
|
1740
|
+
let disconnected = false;
|
|
1741
|
+
let markDisconnected: () => void = () => undefined;
|
|
1742
|
+
const disconnectedSignal = new Promise<void>(resolve => {
|
|
1743
|
+
markDisconnected = resolve;
|
|
1744
|
+
});
|
|
1745
|
+
res.once('close', () => {
|
|
1746
|
+
if (!complete) {
|
|
1747
|
+
disconnected = true;
|
|
1748
|
+
markDisconnected();
|
|
1749
|
+
void reader.cancel('client disconnected').catch(() => undefined);
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
try {
|
|
1753
|
+
const first = await reader.read();
|
|
1754
|
+
if (disconnected) {
|
|
1755
|
+
complete = true;
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const headers =
|
|
1759
|
+
response.body.length === undefined
|
|
1760
|
+
? withoutFramingHeaders(response.headers)
|
|
1761
|
+
: withContentLength(response.headers, response.body.length);
|
|
1762
|
+
if (first.done) {
|
|
1763
|
+
writeNodeHead(res, response.status, headers);
|
|
1764
|
+
headersWritten = true;
|
|
1765
|
+
complete = true;
|
|
1766
|
+
res.end();
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
writeNodeHead(res, response.status, headers);
|
|
1770
|
+
headersWritten = true;
|
|
1771
|
+
if (!res.write(first.value)) {
|
|
1772
|
+
await Promise.race([nodeDrain(res), disconnectedSignal]);
|
|
1773
|
+
if (disconnected) {
|
|
1774
|
+
complete = true;
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
for (;;) {
|
|
1779
|
+
const next = await reader.read();
|
|
1780
|
+
if (disconnected) {
|
|
1781
|
+
complete = true;
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (next.done) {
|
|
1785
|
+
complete = true;
|
|
1786
|
+
res.end();
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (!res.write(next.value)) {
|
|
1790
|
+
await Promise.race([nodeDrain(res), disconnectedSignal]);
|
|
1791
|
+
if (disconnected) {
|
|
1792
|
+
complete = true;
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
complete = true;
|
|
1799
|
+
if (disconnected) {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (!headersWritten) {
|
|
1803
|
+
send(res, jsonResponse(500, { error: messageOf(error) }), 'GET');
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function nodeDrain(res: NodeResLike): Promise<void> {
|
|
1811
|
+
return new Promise(resolve => {
|
|
1812
|
+
res.once('drain', resolve);
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Adapt a router to a Fetch `(Request) => Promise<Response>` handler. */
|
|
1817
|
+
export function toFetchHandler(
|
|
1818
|
+
router: Router,
|
|
1819
|
+
options: AdapterOptions = { maxBodyBytes: DEFAULT_MAX_BODY_BYTES },
|
|
1820
|
+
): (request: Request) => Promise<Response> {
|
|
1821
|
+
validateMaxBodyBytes(options.maxBodyBytes);
|
|
1822
|
+
return async function (request: Request): Promise<Response> {
|
|
1823
|
+
const url = new URL(request.url);
|
|
1824
|
+
const raw =
|
|
1825
|
+
request.method === 'GET' || request.method === 'HEAD'
|
|
1826
|
+
? { ok: true as const, value: undefined }
|
|
1827
|
+
: await readFetchBody(request, options.maxBodyBytes);
|
|
1828
|
+
if (!raw.ok) {
|
|
1829
|
+
return new Response(null, { status: 413 });
|
|
1830
|
+
}
|
|
1831
|
+
const response = await router.handle({
|
|
1832
|
+
method: request.method,
|
|
1833
|
+
path: url.pathname,
|
|
1834
|
+
headers: flattenFetchHeaders(request.headers),
|
|
1835
|
+
rawBody: raw.value,
|
|
1836
|
+
scheme: url.protocol.slice(0, -1),
|
|
1837
|
+
});
|
|
1838
|
+
if (request.method === 'HEAD' || response.status === 204 || response.status === 304) {
|
|
1839
|
+
const headers = fetchHeaders(response);
|
|
1840
|
+
if (response.body.kind === 'stream') {
|
|
1841
|
+
await response.body.value.cancel('response has no body').catch(() => undefined);
|
|
1842
|
+
}
|
|
1843
|
+
headers.delete('content-length');
|
|
1844
|
+
return new Response(null, { status: response.status, headers });
|
|
1845
|
+
}
|
|
1846
|
+
if (response.body.kind === TEXT_BODY_KIND) {
|
|
1847
|
+
const headers = withoutTransferEncoding(response.headers);
|
|
1848
|
+
return new Response(fetchTextBody(response.body.value, headers), { status: response.status, headers });
|
|
1849
|
+
}
|
|
1850
|
+
return new Response(response.body.value, { status: response.status, headers: fetchHeaders(response) });
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
async function readFetchBody(
|
|
1855
|
+
request: Request,
|
|
1856
|
+
maxBodyBytes: number,
|
|
1857
|
+
): Promise<{ readonly ok: true; readonly value: unknown } | { readonly ok: false }> {
|
|
1858
|
+
const announced = request.headers.get('content-length');
|
|
1859
|
+
if (announced !== null && Number(announced) > maxBodyBytes) {
|
|
1860
|
+
await request.body?.cancel('request body exceeds maxBodyBytes');
|
|
1861
|
+
return { ok: false };
|
|
1862
|
+
}
|
|
1863
|
+
if (request.body === null) {
|
|
1864
|
+
return { ok: true, value: undefined };
|
|
1865
|
+
}
|
|
1866
|
+
const reader = request.body.getReader();
|
|
1867
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
1868
|
+
let size = 0;
|
|
1869
|
+
for (;;) {
|
|
1870
|
+
const next = await reader.read();
|
|
1871
|
+
if (next.done) {
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
size += next.value.byteLength;
|
|
1875
|
+
if (size > maxBodyBytes) {
|
|
1876
|
+
await reader.cancel('request body exceeds maxBodyBytes');
|
|
1877
|
+
return { ok: false };
|
|
1878
|
+
}
|
|
1879
|
+
chunks.push(new Uint8Array(next.value));
|
|
1880
|
+
}
|
|
1881
|
+
const joined = joinBytes(chunks, size);
|
|
1882
|
+
if (joined.byteLength === 0) {
|
|
1883
|
+
return { ok: true, value: undefined };
|
|
1884
|
+
}
|
|
1885
|
+
if (hasBinaryContentType(request.headers.get('content-type') ?? undefined)) {
|
|
1886
|
+
return { ok: true, value: joined };
|
|
1887
|
+
}
|
|
1888
|
+
return { ok: true, value: parseJson(new TextDecoder().decode(joined)) };
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function parseJson(raw: string): unknown {
|
|
1892
|
+
try {
|
|
1893
|
+
// boundary: JSON.parse yields `any`; we immediately widen to `unknown` so no
|
|
1894
|
+
// `any` escapes into the pipeline (validators/handlers narrow from there).
|
|
1895
|
+
const parsed: unknown = JSON.parse(raw);
|
|
1896
|
+
return parsed;
|
|
1897
|
+
} catch {
|
|
1898
|
+
return raw;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// Object.keys allocates one array; Object.entries — which this used to use —
|
|
1903
|
+
// allocates that array plus a two-element array per header. A request with a
|
|
1904
|
+
// dozen headers was therefore doing thirteen allocations to read six of them.
|
|
1905
|
+
function flattenHeaders(headers: Readonly<Record<string, string | string[] | undefined>>): Record<string, string> {
|
|
1906
|
+
const flat: Record<string, string> = {};
|
|
1907
|
+
for (const key of Object.keys(headers)) {
|
|
1908
|
+
const value = headers[key];
|
|
1909
|
+
if (typeof value === 'string') {
|
|
1910
|
+
flat[key] = value;
|
|
1911
|
+
} else if (Array.isArray(value)) {
|
|
1912
|
+
flat[key] = value.join(', ');
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return flat;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function flattenFetchHeaders(headers: Headers): Record<string, string> {
|
|
1919
|
+
const flat: Record<string, string> = {};
|
|
1920
|
+
headers.forEach((value, key) => {
|
|
1921
|
+
if (key === '__proto__') {
|
|
1922
|
+
Object.defineProperty(flat, key, { value, enumerable: true, configurable: true, writable: true });
|
|
1923
|
+
} else {
|
|
1924
|
+
flat[key] = value;
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
return flat;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
function validateMaxBodyBytes(value: number): void {
|
|
1931
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1932
|
+
throw new RangeError('maxBodyBytes must be a positive safe integer');
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function requestContentLength(req: NodeReqLike): number | undefined {
|
|
1937
|
+
const value = req.headers['content-length'];
|
|
1938
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
|
|
1939
|
+
return undefined;
|
|
1940
|
+
}
|
|
1941
|
+
const parsed = Number(value);
|
|
1942
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function hasBinaryContentType(value: string | readonly string[] | undefined): boolean {
|
|
1946
|
+
const contentType = Array.isArray(value) ? value[0] : value;
|
|
1947
|
+
if (contentType === undefined) {
|
|
1948
|
+
return false;
|
|
1949
|
+
}
|
|
1950
|
+
const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
|
1951
|
+
return mediaType !== 'application/json' && !mediaType.endsWith('+json') && !mediaType.startsWith('text/');
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function chunkBytes(chunk: unknown): Uint8Array<ArrayBuffer> {
|
|
1955
|
+
return chunk instanceof Uint8Array ? new Uint8Array(chunk) : new TextEncoder().encode(String(chunk));
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function joinBytes(chunks: readonly Uint8Array<ArrayBuffer>[], size: number): Uint8Array<ArrayBuffer> {
|
|
1959
|
+
if (chunks.length === 1) {
|
|
1960
|
+
return chunks[0] ?? new Uint8Array();
|
|
1961
|
+
}
|
|
1962
|
+
const joined = new Uint8Array(size);
|
|
1963
|
+
let offset = 0;
|
|
1964
|
+
for (const chunk of chunks) {
|
|
1965
|
+
joined.set(chunk, offset);
|
|
1966
|
+
offset += chunk.byteLength;
|
|
1967
|
+
}
|
|
1968
|
+
return joined;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function rejectOversizedRequest(res: NodeResLike): void {
|
|
1972
|
+
send(res, jsonResponse(413, { error: 'request body exceeds maxBodyBytes' }), 'POST');
|
|
1973
|
+
res.destroy(new Error('request body exceeds maxBodyBytes'));
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function withoutTransferEncoding(headers: Readonly<Record<string, string>>): Readonly<Record<string, string>> {
|
|
1977
|
+
return omitHeaders(headers, false);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function withoutFramingHeaders(headers: Readonly<Record<string, string>>): Readonly<Record<string, string>> {
|
|
1981
|
+
return omitHeaders(headers, true);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function omitHeaders(
|
|
1985
|
+
headers: Readonly<Record<string, string>>,
|
|
1986
|
+
removeContentLength: boolean,
|
|
1987
|
+
): Readonly<Record<string, string>> {
|
|
1988
|
+
let found = false;
|
|
1989
|
+
for (const key of Object.keys(headers)) {
|
|
1990
|
+
const lower = key.toLowerCase();
|
|
1991
|
+
if (lower === 'transfer-encoding' || (removeContentLength && lower === 'content-length')) {
|
|
1992
|
+
found = true;
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (!found) {
|
|
1997
|
+
return headers;
|
|
1998
|
+
}
|
|
1999
|
+
const clean: Record<string, string> = {};
|
|
2000
|
+
for (const key of Object.keys(headers)) {
|
|
2001
|
+
const lower = key.toLowerCase();
|
|
2002
|
+
if (lower !== 'transfer-encoding' && (!removeContentLength || lower !== 'content-length')) {
|
|
2003
|
+
clean[key] = headers[key] ?? '';
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return clean;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function withContentLength(
|
|
2010
|
+
headers: Readonly<Record<string, string>>,
|
|
2011
|
+
length: number,
|
|
2012
|
+
): Readonly<Record<string, string>> {
|
|
2013
|
+
return { ...withoutFramingHeaders(headers), 'content-length': String(length) };
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function fetchHeaders(response: WebResponse): Headers {
|
|
2017
|
+
const headers = new Headers(withoutTransferEncoding(response.headers));
|
|
2018
|
+
if (response.body.kind === 'bytes') {
|
|
2019
|
+
headers.set('content-length', String(response.body.value.byteLength));
|
|
2020
|
+
} else if (response.body.kind === 'stream') {
|
|
2021
|
+
if (response.body.length === undefined) {
|
|
2022
|
+
headers.delete('content-length');
|
|
2023
|
+
} else {
|
|
2024
|
+
headers.set('content-length', String(response.body.length));
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return headers;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function fetchTextBody(
|
|
2031
|
+
value: string,
|
|
2032
|
+
headers: Readonly<Record<string, string>>,
|
|
2033
|
+
): string | Uint8Array<ArrayBuffer> | null {
|
|
2034
|
+
if (value.length === 0) {
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
for (const key of Object.keys(headers)) {
|
|
2038
|
+
if (key.toLowerCase() === 'content-type') return value;
|
|
2039
|
+
}
|
|
2040
|
+
return new TextEncoder().encode(value);
|
|
2041
|
+
}
|