@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.
Files changed (179) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +49 -0
  3. package/dist/app/bridge.d.ts +12 -0
  4. package/dist/app/bridge.d.ts.map +1 -0
  5. package/dist/app/bridge.js +16 -0
  6. package/dist/app/bridge.js.map +1 -0
  7. package/dist/app/index.d.ts +23 -0
  8. package/dist/app/index.d.ts.map +1 -0
  9. package/dist/app/index.js +47 -0
  10. package/dist/app/index.js.map +1 -0
  11. package/dist/bench/index.d.ts +68 -0
  12. package/dist/bench/index.d.ts.map +1 -0
  13. package/dist/bench/index.js +250 -0
  14. package/dist/bench/index.js.map +1 -0
  15. package/dist/compression/index.d.ts +13 -0
  16. package/dist/compression/index.d.ts.map +1 -0
  17. package/dist/compression/index.js +211 -0
  18. package/dist/compression/index.js.map +1 -0
  19. package/dist/context/index.d.ts +74 -0
  20. package/dist/context/index.d.ts.map +1 -0
  21. package/dist/context/index.js +118 -0
  22. package/dist/context/index.js.map +1 -0
  23. package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
  24. package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
  25. package/dist/contract/__fixtures__/http-client.generated.js +86 -0
  26. package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
  27. package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
  28. package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
  29. package/dist/contract/__fixtures__/http-convergence.js +425 -0
  30. package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
  31. package/dist/contract/compiler/client.d.ts +12 -0
  32. package/dist/contract/compiler/client.d.ts.map +1 -0
  33. package/dist/contract/compiler/client.js +1293 -0
  34. package/dist/contract/compiler/client.js.map +1 -0
  35. package/dist/contract/compiler/index.d.ts +18 -0
  36. package/dist/contract/compiler/index.d.ts.map +1 -0
  37. package/dist/contract/compiler/index.js +1058 -0
  38. package/dist/contract/compiler/index.js.map +1 -0
  39. package/dist/contract/index.d.ts +237 -0
  40. package/dist/contract/index.d.ts.map +1 -0
  41. package/dist/contract/index.js +22 -0
  42. package/dist/contract/index.js.map +1 -0
  43. package/dist/csrf/index.d.ts +15 -0
  44. package/dist/csrf/index.d.ts.map +1 -0
  45. package/dist/csrf/index.js +218 -0
  46. package/dist/csrf/index.js.map +1 -0
  47. package/dist/data/index.d.ts +35 -0
  48. package/dist/data/index.d.ts.map +1 -0
  49. package/dist/data/index.js +58 -0
  50. package/dist/data/index.js.map +1 -0
  51. package/dist/devtools/index.d.ts +74 -0
  52. package/dist/devtools/index.d.ts.map +1 -0
  53. package/dist/devtools/index.js +552 -0
  54. package/dist/devtools/index.js.map +1 -0
  55. package/dist/dto-pipes/index.d.ts +45 -0
  56. package/dist/dto-pipes/index.d.ts.map +1 -0
  57. package/dist/dto-pipes/index.js +78 -0
  58. package/dist/dto-pipes/index.js.map +1 -0
  59. package/dist/gateways/index.d.ts +31 -0
  60. package/dist/gateways/index.d.ts.map +1 -0
  61. package/dist/gateways/index.js +138 -0
  62. package/dist/gateways/index.js.map +1 -0
  63. package/dist/health/index.d.ts +20 -0
  64. package/dist/health/index.d.ts.map +1 -0
  65. package/dist/health/index.js +148 -0
  66. package/dist/health/index.js.map +1 -0
  67. package/dist/http-token.d.ts +2 -0
  68. package/dist/http-token.d.ts.map +1 -0
  69. package/dist/http-token.js +21 -0
  70. package/dist/http-token.js.map +1 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +40 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/integrations/index.d.ts +11 -0
  76. package/dist/integrations/index.d.ts.map +1 -0
  77. package/dist/integrations/index.js +39 -0
  78. package/dist/integrations/index.js.map +1 -0
  79. package/dist/middleware/errors.d.ts +14 -0
  80. package/dist/middleware/errors.d.ts.map +1 -0
  81. package/dist/middleware/errors.js +18 -0
  82. package/dist/middleware/errors.js.map +1 -0
  83. package/dist/middleware/index.d.ts +46 -0
  84. package/dist/middleware/index.d.ts.map +1 -0
  85. package/dist/middleware/index.js +64 -0
  86. package/dist/middleware/index.js.map +1 -0
  87. package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
  88. package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
  89. package/dist/modules/__fixtures__/large-graph.js +483 -0
  90. package/dist/modules/__fixtures__/large-graph.js.map +1 -0
  91. package/dist/openapi/__fixtures__/entities.d.ts +8 -0
  92. package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
  93. package/dist/openapi/__fixtures__/entities.js +4 -0
  94. package/dist/openapi/__fixtures__/entities.js.map +1 -0
  95. package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
  96. package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
  97. package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
  98. package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
  99. package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
  100. package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
  101. package/dist/openapi/__fixtures__/route-schemas.js +17 -0
  102. package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
  103. package/dist/openapi/index.d.ts +61 -0
  104. package/dist/openapi/index.d.ts.map +1 -0
  105. package/dist/openapi/index.js +314 -0
  106. package/dist/openapi/index.js.map +1 -0
  107. package/dist/pipeline/guards.d.ts +9 -0
  108. package/dist/pipeline/guards.d.ts.map +1 -0
  109. package/dist/pipeline/guards.js +11 -0
  110. package/dist/pipeline/guards.js.map +1 -0
  111. package/dist/pipeline/index.d.ts +141 -0
  112. package/dist/pipeline/index.d.ts.map +1 -0
  113. package/dist/pipeline/index.js +1624 -0
  114. package/dist/pipeline/index.js.map +1 -0
  115. package/dist/routing/index.d.ts +40 -0
  116. package/dist/routing/index.d.ts.map +1 -0
  117. package/dist/routing/index.js +162 -0
  118. package/dist/routing/index.js.map +1 -0
  119. package/dist/static/index.d.ts +14 -0
  120. package/dist/static/index.d.ts.map +1 -0
  121. package/dist/static/index.js +348 -0
  122. package/dist/static/index.js.map +1 -0
  123. package/dist/testing/index.d.ts +20 -0
  124. package/dist/testing/index.d.ts.map +1 -0
  125. package/dist/testing/index.js +32 -0
  126. package/dist/testing/index.js.map +1 -0
  127. package/dist/traced-router.d.ts +5 -0
  128. package/dist/traced-router.d.ts.map +1 -0
  129. package/dist/traced-router.js +6 -0
  130. package/dist/traced-router.js.map +1 -0
  131. package/dist/upload/index.d.ts +28 -0
  132. package/dist/upload/index.d.ts.map +1 -0
  133. package/dist/upload/index.js +410 -0
  134. package/dist/upload/index.js.map +1 -0
  135. package/dist/versioning/index.d.ts +35 -0
  136. package/dist/versioning/index.d.ts.map +1 -0
  137. package/dist/versioning/index.js +66 -0
  138. package/dist/versioning/index.js.map +1 -0
  139. package/dist/versioning/runtime.d.ts +5 -0
  140. package/dist/versioning/runtime.d.ts.map +1 -0
  141. package/dist/versioning/runtime.js +34 -0
  142. package/dist/versioning/runtime.js.map +1 -0
  143. package/package.json +143 -0
  144. package/src/app/bridge.ts +39 -0
  145. package/src/app/index.ts +76 -0
  146. package/src/bench/index.ts +329 -0
  147. package/src/compression/index.ts +255 -0
  148. package/src/context/index.ts +181 -0
  149. package/src/contract/__fixtures__/http-client.generated.ts +73 -0
  150. package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
  151. package/src/contract/__fixtures__/http-convergence.ts +564 -0
  152. package/src/contract/compiler/client.ts +1518 -0
  153. package/src/contract/compiler/index.ts +1457 -0
  154. package/src/contract/index.ts +266 -0
  155. package/src/csrf/index.ts +264 -0
  156. package/src/data/index.ts +65 -0
  157. package/src/devtools/index.ts +727 -0
  158. package/src/dto-pipes/index.ts +93 -0
  159. package/src/gateways/index.ts +180 -0
  160. package/src/health/index.ts +198 -0
  161. package/src/http-token.ts +22 -0
  162. package/src/index.ts +167 -0
  163. package/src/integrations/index.ts +50 -0
  164. package/src/middleware/errors.ts +17 -0
  165. package/src/middleware/index.ts +108 -0
  166. package/src/modules/__fixtures__/large-graph.ts +540 -0
  167. package/src/openapi/__fixtures__/entities.ts +19 -0
  168. package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
  169. package/src/openapi/__fixtures__/route-schemas.ts +27 -0
  170. package/src/openapi/index.ts +483 -0
  171. package/src/pipeline/guards.ts +23 -0
  172. package/src/pipeline/index.ts +2041 -0
  173. package/src/routing/index.ts +201 -0
  174. package/src/static/index.ts +443 -0
  175. package/src/testing/index.ts +58 -0
  176. package/src/traced-router.ts +8 -0
  177. package/src/upload/index.ts +489 -0
  178. package/src/versioning/index.ts +106 -0
  179. package/src/versioning/runtime.ts +38 -0
@@ -0,0 +1,8 @@
1
+ import type { Observability } from '@zmdb/app/observability';
2
+
3
+ import { createRouter, type Router } from './pipeline/index.js';
4
+
5
+ /** Build the HTTP router with the app-owned observability ports. */
6
+ export function createTracedRouter(observability: Observability = {}): Router {
7
+ return createRouter(observability);
8
+ }
@@ -0,0 +1,489 @@
1
+ // @zmdb/web — bounded multipart/form-data parsing (epic #564, spec ./SPEC.md).
2
+ //
3
+ // The adapters have already bounded and materialised the request body. This
4
+ // parser scans that byte array without decoding file contents, bounds every
5
+ // header and part before allocating its representation, and never performs I/O.
6
+
7
+ import { isHttpTokenCode } from '../http-token.js';
8
+ import { BoundaryStatusError } from '../middleware/errors.js';
9
+
10
+ export interface UploadLimits {
11
+ readonly maxParts: number;
12
+ readonly maxPartBytes: number;
13
+ readonly maxTotalBytes: number;
14
+ readonly maxFieldNameBytes: number;
15
+ readonly maxFilenameBytes: number;
16
+ readonly maxPartHeaderBytes: number;
17
+ }
18
+
19
+ export const UPLOAD_DEFAULTS: UploadLimits = Object.freeze({
20
+ maxParts: 16,
21
+ maxPartBytes: 1_048_576,
22
+ maxTotalBytes: 8_388_608,
23
+ maxFieldNameBytes: 100,
24
+ maxFilenameBytes: 255,
25
+ maxPartHeaderBytes: 1024,
26
+ });
27
+
28
+ export interface UploadPart {
29
+ readonly name: string;
30
+ readonly filename: string | undefined;
31
+ readonly declaredType: string | undefined;
32
+ readonly bytes: Uint8Array<ArrayBuffer>;
33
+ }
34
+
35
+ export interface Multipart {
36
+ readonly fields: Readonly<Record<string, string>>;
37
+ readonly files: readonly UploadPart[];
38
+ }
39
+
40
+ interface ParsedPartHeaders {
41
+ readonly name: string;
42
+ readonly filename: string | undefined;
43
+ readonly declaredType: string | undefined;
44
+ }
45
+
46
+ interface ParameterizedValue {
47
+ readonly value: string;
48
+ readonly parameters: ReadonlyMap<string, string>;
49
+ }
50
+
51
+ const CR = 13;
52
+ const LF = 10;
53
+ const DASH = 45;
54
+ const SEMICOLON = 59;
55
+ const EQUALS = 61;
56
+ const DOUBLE_QUOTE = 34;
57
+ const BACKSLASH = 92;
58
+ const SPACE = 32;
59
+ const TAB = 9;
60
+
61
+ const utf8 = new TextEncoder();
62
+ const text = new TextDecoder();
63
+ const strictText = new TextDecoder('utf-8', { fatal: true });
64
+
65
+ function malformed(message: string): never {
66
+ throw new BoundaryStatusError(400, message);
67
+ }
68
+
69
+ function tooLarge(limit: keyof UploadLimits): never {
70
+ throw new BoundaryStatusError(413, `multipart body exceeds ${limit}`);
71
+ }
72
+
73
+ function resolvedLimits(overrides: Partial<UploadLimits>): UploadLimits {
74
+ const limits: UploadLimits = { ...UPLOAD_DEFAULTS, ...overrides };
75
+ for (const [name, value] of Object.entries(limits)) {
76
+ if (!Number.isSafeInteger(value) || value <= 0) {
77
+ throw new RangeError(`${name} must be a positive safe integer`);
78
+ }
79
+ }
80
+ return limits;
81
+ }
82
+
83
+ function bodyBytes(rawBody: unknown, maxTotalBytes: number): Uint8Array<ArrayBuffer> {
84
+ if (!(rawBody instanceof Uint8Array)) {
85
+ malformed('multipart body must be a Uint8Array');
86
+ }
87
+ if (rawBody.byteLength > maxTotalBytes) {
88
+ tooLarge('maxTotalBytes');
89
+ }
90
+ if (rawBody.buffer instanceof ArrayBuffer) {
91
+ return new Uint8Array(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength);
92
+ }
93
+ return new Uint8Array(rawBody);
94
+ }
95
+
96
+ function trimStart(value: string, start: number, end: number): number {
97
+ let at = start;
98
+ while (at < end) {
99
+ const code = value.charCodeAt(at);
100
+ if (code !== SPACE && code !== TAB) {
101
+ break;
102
+ }
103
+ at += 1;
104
+ }
105
+ return at;
106
+ }
107
+
108
+ function trimEnd(value: string, start: number, end: number): number {
109
+ let at = end;
110
+ while (at > start) {
111
+ const code = value.charCodeAt(at - 1);
112
+ if (code !== SPACE && code !== TAB) {
113
+ break;
114
+ }
115
+ at -= 1;
116
+ }
117
+ return at;
118
+ }
119
+
120
+ function parseParameterizedValue(source: string): ParameterizedValue {
121
+ const firstSemicolon = source.indexOf(';');
122
+ const valueEnd = firstSemicolon === -1 ? source.length : firstSemicolon;
123
+ const valueStart = trimStart(source, 0, valueEnd);
124
+ const trimmedValueEnd = trimEnd(source, valueStart, valueEnd);
125
+ if (valueStart === trimmedValueEnd) {
126
+ malformed('multipart header value is empty');
127
+ }
128
+
129
+ const parameters = new Map<string, string>();
130
+ let at = firstSemicolon === -1 ? source.length : firstSemicolon + 1;
131
+ while (at < source.length) {
132
+ at = trimStart(source, at, source.length);
133
+ if (at === source.length) {
134
+ break;
135
+ }
136
+
137
+ const nameStart = at;
138
+ while (at < source.length && isHttpTokenCode(source.charCodeAt(at))) {
139
+ at += 1;
140
+ }
141
+ if (at === nameStart) {
142
+ malformed('multipart parameter has no name');
143
+ }
144
+ const name = source.slice(nameStart, at).toLowerCase();
145
+ at = trimStart(source, at, source.length);
146
+ if (source.charCodeAt(at) !== EQUALS) {
147
+ malformed(`multipart parameter "${name}" has no value`);
148
+ }
149
+ at = trimStart(source, at + 1, source.length);
150
+
151
+ let parameter = '';
152
+ if (source.charCodeAt(at) === DOUBLE_QUOTE) {
153
+ at += 1;
154
+ let closed = false;
155
+ while (at < source.length) {
156
+ const code = source.charCodeAt(at);
157
+ if (code === DOUBLE_QUOTE) {
158
+ at += 1;
159
+ closed = true;
160
+ break;
161
+ }
162
+ if (code === BACKSLASH) {
163
+ at += 1;
164
+ if (at === source.length) {
165
+ malformed(`multipart parameter "${name}" has an incomplete escape`);
166
+ }
167
+ parameter += '\\';
168
+ parameter += source[at];
169
+ at += 1;
170
+ continue;
171
+ }
172
+ if (code < SPACE || code === 127) {
173
+ malformed(`multipart parameter "${name}" contains a control character`);
174
+ }
175
+ parameter += source[at];
176
+ at += 1;
177
+ }
178
+ if (!closed) {
179
+ malformed(`multipart parameter "${name}" has no closing quote`);
180
+ }
181
+ at = trimStart(source, at, source.length);
182
+ if (at < source.length && source.charCodeAt(at) !== SEMICOLON) {
183
+ malformed(`multipart parameter "${name}" has trailing data`);
184
+ }
185
+ } else {
186
+ const parameterStart = at;
187
+ while (at < source.length && source.charCodeAt(at) !== SEMICOLON) {
188
+ at += 1;
189
+ }
190
+ const parameterEnd = trimEnd(source, parameterStart, at);
191
+ if (parameterStart === parameterEnd) {
192
+ malformed(`multipart parameter "${name}" is empty`);
193
+ }
194
+ parameter = source.slice(parameterStart, parameterEnd);
195
+ }
196
+
197
+ if (parameters.has(name)) {
198
+ malformed(`multipart parameter "${name}" is repeated`);
199
+ }
200
+ parameters.set(name, parameter);
201
+ if (at < source.length) {
202
+ at += 1;
203
+ }
204
+ }
205
+
206
+ return { value: source.slice(valueStart, trimmedValueEnd), parameters };
207
+ }
208
+
209
+ function isBoundaryCode(code: number): boolean {
210
+ return (
211
+ (code >= 48 && code <= 57) ||
212
+ (code >= 65 && code <= 90) ||
213
+ (code >= 97 && code <= 122) ||
214
+ code === 39 ||
215
+ code === 40 ||
216
+ code === 41 ||
217
+ code === 43 ||
218
+ code === 44 ||
219
+ code === 45 ||
220
+ code === 46 ||
221
+ code === 47 ||
222
+ code === 58 ||
223
+ code === 61 ||
224
+ code === 63 ||
225
+ code === 95 ||
226
+ code === SPACE
227
+ );
228
+ }
229
+
230
+ function boundaryBytes(contentType: string): Uint8Array<ArrayBuffer> {
231
+ const parsed = parseParameterizedValue(contentType);
232
+ if (parsed.value.toLowerCase() !== 'multipart/form-data') {
233
+ malformed('content-type must be multipart/form-data');
234
+ }
235
+ const boundary = parsed.parameters.get('boundary');
236
+ if (boundary === undefined || boundary.length === 0 || boundary.length > 70) {
237
+ malformed('multipart/form-data requires a boundary between 1 and 70 bytes');
238
+ }
239
+ for (let index = 0; index < boundary.length; index += 1) {
240
+ if (!isBoundaryCode(boundary.charCodeAt(index))) {
241
+ malformed('multipart boundary contains an invalid character');
242
+ }
243
+ }
244
+ if (boundary.charCodeAt(boundary.length - 1) === SPACE) {
245
+ malformed('multipart boundary cannot end with whitespace');
246
+ }
247
+ return utf8.encode(boundary);
248
+ }
249
+
250
+ function matchesAt(body: Uint8Array<ArrayBuffer>, at: number, wanted: Uint8Array<ArrayBuffer>): boolean {
251
+ if (at + wanted.length > body.length) {
252
+ return false;
253
+ }
254
+ for (let index = 0; index < wanted.length; index += 1) {
255
+ if (body[at + index] !== wanted[index]) {
256
+ return false;
257
+ }
258
+ }
259
+ return true;
260
+ }
261
+
262
+ function openingBoundary(boundary: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
263
+ const marker = new Uint8Array(boundary.length + 2);
264
+ marker[0] = DASH;
265
+ marker[1] = DASH;
266
+ marker.set(boundary, 2);
267
+ return marker;
268
+ }
269
+
270
+ function headerEnd(body: Uint8Array<ArrayBuffer>, start: number, maxBytes: number): number {
271
+ for (let at = start; at + 3 < body.length; at += 1) {
272
+ if (at - start > maxBytes) {
273
+ tooLarge('maxPartHeaderBytes');
274
+ }
275
+ if (body[at] === CR && body[at + 1] === LF && body[at + 2] === CR && body[at + 3] === LF) {
276
+ return at;
277
+ }
278
+ }
279
+ if (body.length - start > maxBytes) {
280
+ tooLarge('maxPartHeaderBytes');
281
+ }
282
+ malformed('multipart part has no complete header block');
283
+ }
284
+
285
+ function nextBoundary(
286
+ body: Uint8Array<ArrayBuffer>,
287
+ start: number,
288
+ marker: Uint8Array<ArrayBuffer>,
289
+ maxPartBytes: number,
290
+ ): number {
291
+ for (let at = start; at + marker.length + 2 <= body.length; at += 1) {
292
+ if (at - start > maxPartBytes) {
293
+ tooLarge('maxPartBytes');
294
+ }
295
+ if (
296
+ body[at] === CR &&
297
+ body[at + 1] === LF &&
298
+ matchesAt(body, at + 2, marker) &&
299
+ ((body[at + 2 + marker.length] === DASH && body[at + 3 + marker.length] === DASH) ||
300
+ (body[at + 2 + marker.length] === CR && body[at + 3 + marker.length] === LF))
301
+ ) {
302
+ return at;
303
+ }
304
+ }
305
+ if (body.length - start > maxPartBytes) {
306
+ tooLarge('maxPartBytes');
307
+ }
308
+ malformed('multipart body has no closing boundary');
309
+ }
310
+
311
+ function decodeHeaderBlock(value: Uint8Array<ArrayBuffer>): string {
312
+ try {
313
+ return strictText.decode(value);
314
+ } catch {
315
+ malformed('multipart part headers are not valid UTF-8');
316
+ }
317
+ }
318
+
319
+ function parsePartHeaders(value: Uint8Array<ArrayBuffer>): ParsedPartHeaders {
320
+ const block = decodeHeaderBlock(value);
321
+ const lines = block.split('\r\n');
322
+ let disposition: string | undefined;
323
+ let declaredType: string | undefined;
324
+ let offset = 0;
325
+
326
+ for (const line of lines) {
327
+ const separator = line.indexOf(':');
328
+ if (separator <= 0) {
329
+ malformed('multipart part contains a malformed header');
330
+ }
331
+ const name = line.slice(0, separator);
332
+ for (let index = 0; index < name.length; index += 1) {
333
+ if (!isHttpTokenCode(name.charCodeAt(index))) {
334
+ malformed('multipart part contains an invalid header name');
335
+ }
336
+ }
337
+ const lower = name.toLowerCase();
338
+ const valueStart = trimStart(line, separator + 1, line.length);
339
+ const headerValue = line.slice(valueStart);
340
+ if (lower === 'content-disposition') {
341
+ if (disposition !== undefined) {
342
+ malformed('multipart part repeats content-disposition');
343
+ }
344
+ disposition = headerValue;
345
+ } else if (lower === 'content-type' && declaredType === undefined) {
346
+ const blockValueStart = offset + valueStart;
347
+ declaredType = block.slice(blockValueStart);
348
+ }
349
+ offset += line.length + 2;
350
+ }
351
+
352
+ if (disposition === undefined) {
353
+ malformed('multipart part has no content-disposition');
354
+ }
355
+ const parsed = parseParameterizedValue(disposition);
356
+ if (parsed.value.toLowerCase() !== 'form-data') {
357
+ malformed('multipart content-disposition must be form-data');
358
+ }
359
+ const name = parsed.parameters.get('name');
360
+ if (name === undefined || name.length === 0) {
361
+ malformed('multipart content-disposition requires a field name');
362
+ }
363
+
364
+ return {
365
+ name,
366
+ filename: parsed.parameters.get('filename'),
367
+ declaredType,
368
+ };
369
+ }
370
+
371
+ function unsafeFilename(filename: string): boolean {
372
+ return (
373
+ filename.includes('/') ||
374
+ filename.includes('\\') ||
375
+ filename.includes('\u0000') ||
376
+ filename === '.' ||
377
+ filename === '..' ||
378
+ /^[A-Za-z]:/.test(filename)
379
+ );
380
+ }
381
+
382
+ function truncateUtf8(value: string, maxBytes: number): string {
383
+ const encoded = utf8.encode(value);
384
+ if (encoded.length <= maxBytes) {
385
+ return value;
386
+ }
387
+ let end = maxBytes;
388
+ while (end > 0) {
389
+ try {
390
+ return strictText.decode(encoded.subarray(0, end));
391
+ } catch {
392
+ end -= 1;
393
+ }
394
+ }
395
+ return '';
396
+ }
397
+
398
+ function finishBoundary(body: Uint8Array<ArrayBuffer>, at: number): void {
399
+ if (at === body.length) {
400
+ return;
401
+ }
402
+ if (at + 2 === body.length && body[at] === CR && body[at + 1] === LF) {
403
+ return;
404
+ }
405
+ malformed('multipart closing boundary has trailing data');
406
+ }
407
+
408
+ /**
409
+ * Parse one bounded `multipart/form-data` body without decoding file contents.
410
+ *
411
+ * The adapter's `maxBodyBytes` is the transfer-time limit. This function checks
412
+ * `maxTotalBytes` before scanning and then stops each bounded scan at the first
413
+ * exceeded part/header limit, before decoding or returning that region.
414
+ */
415
+ export function parseMultipart(
416
+ rawBody: unknown,
417
+ contentType: string,
418
+ overrides: Partial<UploadLimits> = {},
419
+ ): Multipart {
420
+ const limits = resolvedLimits(overrides);
421
+ const body = bodyBytes(rawBody, limits.maxTotalBytes);
422
+
423
+ const boundary = boundaryBytes(contentType);
424
+ const marker = openingBoundary(boundary);
425
+ if (!matchesAt(body, 0, marker)) {
426
+ malformed('multipart body does not start with its declared boundary');
427
+ }
428
+
429
+ const fields = new Map<string, string>();
430
+ const files: UploadPart[] = [];
431
+ let cursor = marker.length;
432
+ if (body[cursor] === DASH && body[cursor + 1] === DASH) {
433
+ finishBoundary(body, cursor + 2);
434
+ return {
435
+ fields: Object.freeze(Object.fromEntries(fields)),
436
+ files: Object.freeze(files),
437
+ };
438
+ }
439
+ if (body[cursor] !== CR || body[cursor + 1] !== LF) {
440
+ malformed('multipart opening boundary is malformed');
441
+ }
442
+ cursor += 2;
443
+
444
+ let parts = 0;
445
+ for (;;) {
446
+ parts += 1;
447
+ if (parts > limits.maxParts) {
448
+ tooLarge('maxParts');
449
+ }
450
+
451
+ const headersEnd = headerEnd(body, cursor, limits.maxPartHeaderBytes);
452
+ const headers = parsePartHeaders(body.subarray(cursor, headersEnd));
453
+ if (utf8.encode(headers.name).byteLength > limits.maxFieldNameBytes) {
454
+ tooLarge('maxFieldNameBytes');
455
+ }
456
+
457
+ const partStart = headersEnd + 4;
458
+ const delimiter = nextBoundary(body, partStart, marker, limits.maxPartBytes);
459
+ const bytes = body.subarray(partStart, delimiter);
460
+ if (headers.filename === undefined) {
461
+ fields.set(headers.name, text.decode(bytes));
462
+ } else {
463
+ if (unsafeFilename(headers.filename)) {
464
+ malformed('multipart filename must be an opaque label, not a path');
465
+ }
466
+ files.push({
467
+ name: headers.name,
468
+ filename: truncateUtf8(headers.filename, limits.maxFilenameBytes),
469
+ declaredType: headers.declaredType,
470
+ bytes,
471
+ });
472
+ }
473
+
474
+ cursor = delimiter + 2 + marker.length;
475
+ if (body[cursor] === DASH && body[cursor + 1] === DASH) {
476
+ finishBoundary(body, cursor + 2);
477
+ break;
478
+ }
479
+ if (body[cursor] !== CR || body[cursor + 1] !== LF) {
480
+ malformed('multipart boundary is malformed');
481
+ }
482
+ cursor += 2;
483
+ }
484
+
485
+ return {
486
+ fields: Object.freeze(Object.fromEntries(fields)),
487
+ files: Object.freeze(files),
488
+ };
489
+ }
@@ -0,0 +1,106 @@
1
+ // @zmdb/web — API version declarations (epic #572, spec ./SPEC.md).
2
+ // Stage-3 class/method decorators write one private metadata value; the router
3
+ // reads it once at registration. No reflect-metadata and no runtime reflection.
4
+
5
+ import '@zmdb/app';
6
+
7
+ /** The one version source configured for a router. */
8
+ export type VersionStrategy =
9
+ | { readonly kind: 'path'; readonly prefix: string }
10
+ | { readonly kind: 'header'; readonly name: string; readonly default: string }
11
+ | { readonly kind: 'media-type'; readonly key: string; readonly default: string };
12
+
13
+ type ControllerClass = abstract new (...args: never[]) => unknown;
14
+ type ControllerMethod = (...args: never[]) => unknown;
15
+ type VersionDeclaration = readonly string[] | 'neutral';
16
+
17
+ /** A Stage-3 decorator that can be applied to a controller or one handler. */
18
+ interface VersionDecorator {
19
+ <T extends ControllerClass>(target: T, context: ClassDecoratorContext<T>): void;
20
+ (target: ControllerMethod, context: ClassMethodDecoratorContext): void;
21
+ }
22
+
23
+ const CONTROLLER_VERSION = Symbol('zmdb.web.controller-version');
24
+ const HANDLER_VERSIONS = Symbol('zmdb.web.handler-versions');
25
+
26
+ interface VersionMetadata {
27
+ [CONTROLLER_VERSION]?: VersionDeclaration;
28
+ [HANDLER_VERSIONS]?: Map<string, VersionDeclaration>;
29
+ }
30
+
31
+ // boundary: these private symbol slots are written only by the decorators below.
32
+ function versionView(metadata: DecoratorMetadata): VersionMetadata {
33
+ return metadata;
34
+ }
35
+
36
+ function ownHandlerVersions(metadata: DecoratorMetadata): Map<string, VersionDeclaration> | undefined {
37
+ return Object.hasOwn(metadata, HANDLER_VERSIONS) ? versionView(metadata)[HANDLER_VERSIONS] : undefined;
38
+ }
39
+
40
+ function recordVersion(
41
+ declaration: VersionDeclaration,
42
+ context: ClassDecoratorContext<ControllerClass> | ClassMethodDecoratorContext,
43
+ ): void {
44
+ if (context.kind === 'class') {
45
+ versionView(context.metadata)[CONTROLLER_VERSION] = declaration;
46
+ return;
47
+ }
48
+
49
+ const name = typeof context.name === 'string' ? context.name : context.name.toString();
50
+ const handlers = ownHandlerVersions(context.metadata);
51
+ if (handlers === undefined) {
52
+ versionView(context.metadata)[HANDLER_VERSIONS] = new Map([[name, declaration]]);
53
+ } else {
54
+ handlers.set(name, declaration);
55
+ }
56
+ }
57
+
58
+ function decoratorFor(declaration: VersionDeclaration): VersionDecorator {
59
+ function decorate<T extends ControllerClass>(_target: T, context: ClassDecoratorContext<T>): void;
60
+ function decorate(_target: ControllerMethod, context: ClassMethodDecoratorContext): void;
61
+ function decorate(
62
+ _target: ControllerClass | ControllerMethod,
63
+ context: ClassDecoratorContext<ControllerClass> | ClassMethodDecoratorContext,
64
+ ): void {
65
+ recordVersion(declaration, context);
66
+ }
67
+ return decorate;
68
+ }
69
+
70
+ /** Declare the versions a controller or handler serves. */
71
+ export function Version(...versions: readonly [string, ...string[]]): VersionDecorator {
72
+ return decoratorFor(Object.freeze([...versions]));
73
+ }
74
+
75
+ /** Declare that a controller or handler has the same behaviour in every version. */
76
+ export function VersionNeutral(): VersionDecorator {
77
+ return decoratorFor('neutral');
78
+ }
79
+
80
+ /**
81
+ * Read the declaration effective for one handler.
82
+ *
83
+ * A method declaration wins over every class declaration, including when the
84
+ * method is inherited. Otherwise the nearest class declaration wins.
85
+ */
86
+ export function versionsOf(controller: ControllerClass, handlerName: string): VersionDeclaration | undefined {
87
+ const metadata = controller[Symbol.metadata];
88
+ if (metadata === undefined || metadata === null) {
89
+ return undefined;
90
+ }
91
+
92
+ for (let record: DecoratorMetadata | null = metadata; record !== null; record = Object.getPrototypeOf(record)) {
93
+ const handlers = ownHandlerVersions(record);
94
+ if (handlers?.has(handlerName) === true) {
95
+ return handlers.get(handlerName);
96
+ }
97
+ }
98
+
99
+ for (let record: DecoratorMetadata | null = metadata; record !== null; record = Object.getPrototypeOf(record)) {
100
+ if (Object.hasOwn(record, CONTROLLER_VERSION)) {
101
+ return versionView(record)[CONTROLLER_VERSION];
102
+ }
103
+ }
104
+
105
+ return undefined;
106
+ }
@@ -0,0 +1,38 @@
1
+ // Shared startup/build-time representation helpers. These are deliberately not
2
+ // exported from the package: the public surface is VersionStrategy plus the
3
+ // decorators, while the router and document generator must still spell paths
4
+ // and media types identically.
5
+
6
+ import { isHttpTokenCode } from '../http-token.js';
7
+
8
+ function trimSlashes(value: string): string {
9
+ let start = 0;
10
+ let end = value.length;
11
+ while (start < end && value.charCodeAt(start) === 47) {
12
+ start += 1;
13
+ }
14
+ while (end > start && value.charCodeAt(end - 1) === 47) {
15
+ end -= 1;
16
+ }
17
+ return value.slice(start, end);
18
+ }
19
+
20
+ /** Expand one route path for a path-versioning strategy. */
21
+ export function pathForVersion(prefix: string, version: string, path: string): string {
22
+ const head = `/${trimSlashes(prefix)}${version}`;
23
+ return path === '/' ? head : `${head}${path.startsWith('/') ? '' : '/'}${path}`;
24
+ }
25
+
26
+ function mediaParameterValue(value: string): string {
27
+ for (let index = 0; index < value.length; index += 1) {
28
+ if (!isHttpTokenCode(value.charCodeAt(index))) {
29
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
30
+ }
31
+ }
32
+ return value;
33
+ }
34
+
35
+ /** The response media type and OpenAPI content key for one exact version. */
36
+ export function jsonMediaTypeForVersion(key: string, version: string): string {
37
+ return `application/json; ${key.toLowerCase()}=${mediaParameterValue(version)}`;
38
+ }