@bleedingdev/modern-js-renderer-core 0.0.0-bootstrap → 3.9.3-ultramodern.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 (147) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/abort.js +65 -0
  3. package/dist/cjs/adapter.js +62 -0
  4. package/dist/cjs/data/body.js +78 -0
  5. package/dist/cjs/data/client.js +409 -0
  6. package/dist/cjs/data/codec.js +168 -0
  7. package/dist/cjs/data/index.js +125 -0
  8. package/dist/cjs/data/private.js +77 -0
  9. package/dist/cjs/data/routes.js +149 -0
  10. package/dist/cjs/data/server.js +791 -0
  11. package/dist/cjs/data/static.js +110 -0
  12. package/dist/cjs/data/tree.js +231 -0
  13. package/dist/cjs/data/types.js +56 -0
  14. package/dist/cjs/document.js +184 -0
  15. package/dist/cjs/entry-client.js +142 -0
  16. package/dist/cjs/entry-server.js +253 -0
  17. package/dist/cjs/federation.js +80 -0
  18. package/dist/cjs/identity.js +120 -0
  19. package/dist/cjs/index.js +46 -0
  20. package/dist/cjs/localization.js +48 -0
  21. package/dist/cjs/router.js +293 -0
  22. package/dist/cjs/server/assets.js +56 -0
  23. package/dist/cjs/server/dispatch.js +351 -0
  24. package/dist/cjs/server/index.js +57 -0
  25. package/dist/cjs/server/types.js +18 -0
  26. package/dist/cjs/server/worker.js +73 -0
  27. package/dist/cjs/session/cache.js +172 -0
  28. package/dist/cjs/session/index.js +63 -0
  29. package/dist/cjs/session/request.js +269 -0
  30. package/dist/cjs/session/types.js +18 -0
  31. package/dist/esm/abort.mjs +27 -0
  32. package/dist/esm/adapter.mjs +21 -0
  33. package/dist/esm/data/body.mjs +40 -0
  34. package/dist/esm/data/client.mjs +368 -0
  35. package/dist/esm/data/codec.mjs +112 -0
  36. package/dist/esm/data/index.mjs +7 -0
  37. package/dist/esm/data/private.mjs +39 -0
  38. package/dist/esm/data/routes.mjs +108 -0
  39. package/dist/esm/data/server.mjs +723 -0
  40. package/dist/esm/data/static.mjs +51 -0
  41. package/dist/esm/data/tree.mjs +193 -0
  42. package/dist/esm/data/types.mjs +6 -0
  43. package/dist/esm/document.mjs +131 -0
  44. package/dist/esm/entry-client.mjs +104 -0
  45. package/dist/esm/entry-server.mjs +215 -0
  46. package/dist/esm/federation.mjs +36 -0
  47. package/dist/esm/identity.mjs +63 -0
  48. package/dist/esm/index.mjs +1 -0
  49. package/dist/esm/localization.mjs +10 -0
  50. package/dist/esm/router.mjs +225 -0
  51. package/dist/esm/server/assets.mjs +15 -0
  52. package/dist/esm/server/dispatch.mjs +307 -0
  53. package/dist/esm/server/index.mjs +3 -0
  54. package/dist/esm/server/types.mjs +0 -0
  55. package/dist/esm/server/worker.mjs +35 -0
  56. package/dist/esm/session/cache.mjs +116 -0
  57. package/dist/esm/session/index.mjs +3 -0
  58. package/dist/esm/session/request.mjs +231 -0
  59. package/dist/esm/session/types.mjs +0 -0
  60. package/dist/esm-node/abort.mjs +28 -0
  61. package/dist/esm-node/adapter.mjs +22 -0
  62. package/dist/esm-node/data/body.mjs +41 -0
  63. package/dist/esm-node/data/client.mjs +369 -0
  64. package/dist/esm-node/data/codec.mjs +113 -0
  65. package/dist/esm-node/data/index.mjs +8 -0
  66. package/dist/esm-node/data/private.mjs +40 -0
  67. package/dist/esm-node/data/routes.mjs +109 -0
  68. package/dist/esm-node/data/server.mjs +724 -0
  69. package/dist/esm-node/data/static.mjs +52 -0
  70. package/dist/esm-node/data/tree.mjs +194 -0
  71. package/dist/esm-node/data/types.mjs +7 -0
  72. package/dist/esm-node/document.mjs +132 -0
  73. package/dist/esm-node/entry-client.mjs +105 -0
  74. package/dist/esm-node/entry-server.mjs +216 -0
  75. package/dist/esm-node/federation.mjs +37 -0
  76. package/dist/esm-node/identity.mjs +64 -0
  77. package/dist/esm-node/index.mjs +2 -0
  78. package/dist/esm-node/localization.mjs +11 -0
  79. package/dist/esm-node/router.mjs +226 -0
  80. package/dist/esm-node/server/assets.mjs +16 -0
  81. package/dist/esm-node/server/dispatch.mjs +308 -0
  82. package/dist/esm-node/server/index.mjs +4 -0
  83. package/dist/esm-node/server/types.mjs +1 -0
  84. package/dist/esm-node/server/worker.mjs +36 -0
  85. package/dist/esm-node/session/cache.mjs +117 -0
  86. package/dist/esm-node/session/index.mjs +4 -0
  87. package/dist/esm-node/session/request.mjs +232 -0
  88. package/dist/esm-node/session/types.mjs +1 -0
  89. package/dist/types/abort.d.ts +6 -0
  90. package/dist/types/adapter.d.ts +195 -0
  91. package/dist/types/data/body.d.ts +2 -0
  92. package/dist/types/data/client.d.ts +17 -0
  93. package/dist/types/data/codec.d.ts +14 -0
  94. package/dist/types/data/index.d.ts +8 -0
  95. package/dist/types/data/private.d.ts +6 -0
  96. package/dist/types/data/routes.d.ts +28 -0
  97. package/dist/types/data/server.d.ts +59 -0
  98. package/dist/types/data/static.d.ts +31 -0
  99. package/dist/types/data/tree.d.ts +3 -0
  100. package/dist/types/data/types.d.ts +104 -0
  101. package/dist/types/document.d.ts +76 -0
  102. package/dist/types/entry-client.d.ts +53 -0
  103. package/dist/types/entry-server.d.ts +63 -0
  104. package/dist/types/federation.d.ts +56 -0
  105. package/dist/types/identity.d.ts +27 -0
  106. package/dist/types/index.d.ts +1 -0
  107. package/dist/types/localization.d.ts +58 -0
  108. package/dist/types/router.d.ts +201 -0
  109. package/dist/types/server/assets.d.ts +14 -0
  110. package/dist/types/server/dispatch.d.ts +11 -0
  111. package/dist/types/server/index.d.ts +4 -0
  112. package/dist/types/server/types.d.ts +64 -0
  113. package/dist/types/server/worker.d.ts +25 -0
  114. package/dist/types/session/cache.d.ts +25 -0
  115. package/dist/types/session/index.d.ts +4 -0
  116. package/dist/types/session/request.d.ts +8 -0
  117. package/dist/types/session/types.d.ts +48 -0
  118. package/package.json +159 -4
  119. package/src/abort.ts +40 -0
  120. package/src/adapter.ts +255 -0
  121. package/src/data/body.ts +43 -0
  122. package/src/data/client.ts +501 -0
  123. package/src/data/codec.ts +247 -0
  124. package/src/data/index.ts +8 -0
  125. package/src/data/private.ts +68 -0
  126. package/src/data/routes.ts +201 -0
  127. package/src/data/server.ts +977 -0
  128. package/src/data/static.ts +105 -0
  129. package/src/data/tree.ts +244 -0
  130. package/src/data/types.ts +98 -0
  131. package/src/document.ts +338 -0
  132. package/src/entry-client.ts +236 -0
  133. package/src/entry-server.ts +393 -0
  134. package/src/federation.ts +111 -0
  135. package/src/identity.ts +128 -0
  136. package/src/index.ts +7 -0
  137. package/src/localization.ts +77 -0
  138. package/src/router.ts +475 -0
  139. package/src/server/assets.ts +60 -0
  140. package/src/server/dispatch.ts +523 -0
  141. package/src/server/index.ts +16 -0
  142. package/src/server/types.ts +94 -0
  143. package/src/server/worker.ts +94 -0
  144. package/src/session/cache.ts +177 -0
  145. package/src/session/index.ts +11 -0
  146. package/src/session/request.ts +324 -0
  147. package/src/session/types.ts +60 -0
@@ -0,0 +1,977 @@
1
+ import { untilAborted } from '../abort';
2
+ import { identityCacheKey, type RendererIdentity } from '../identity';
3
+ import {
4
+ responseHeaders as collectDataHeaders,
5
+ parseCacheControl,
6
+ } from '../session/cache';
7
+ import type { DocumentCachePolicy, ResponsePolicy } from '../session/types';
8
+ import { readBoundedDataText } from './body';
9
+ import {
10
+ assertPublicData,
11
+ DataProtocolError,
12
+ serializePublicData,
13
+ } from './codec';
14
+ import { createRequestDataPolicy, type RequestDataPolicy } from './private';
15
+ import {
16
+ DATA_CONTENT_TYPE,
17
+ DATA_PROTOCOL_VERSION,
18
+ DATA_STREAM_CONTENT_TYPE,
19
+ type DataHandler,
20
+ type DataHandlerInput,
21
+ type DataOperation,
22
+ type DataOutcome,
23
+ type DataResponseMetadata,
24
+ type DataStreamFrame,
25
+ type DataWireEnvelope,
26
+ LOADER_ID_PARAM,
27
+ type PublicDataError,
28
+ type PublicDataOutcome,
29
+ type SelectedDataRoute,
30
+ } from './types';
31
+
32
+ export { collectDataHeaders };
33
+
34
+ const DEFERRED = Symbol('ultramodern.data.deferred');
35
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
36
+ const BODYLESS_STATUSES = new Set([204, 205, 304]);
37
+ const REPRESENTATION_HEADERS = new Set([
38
+ 'content-type',
39
+ 'content-length',
40
+ 'content-encoding',
41
+ 'content-digest',
42
+ 'repr-digest',
43
+ 'digest',
44
+ 'transfer-encoding',
45
+ 'content-range',
46
+ 'accept-ranges',
47
+ 'content-disposition',
48
+ 'content-language',
49
+ 'content-location',
50
+ 'etag',
51
+ 'last-modified',
52
+ 'location',
53
+ ]);
54
+ /**
55
+ * Combine header fields from two sources for one document: list fields
56
+ * (cookies, `Vary`, `Link`, `Server-Timing`, CSP) keep both sources' values,
57
+ * while a singleton field from `primary` replaces the `secondary` one.
58
+ */
59
+ export function mergeHeaderFields(
60
+ primary: readonly (readonly [string, string])[],
61
+ secondary: readonly (readonly [string, string])[],
62
+ ): [string, string][] {
63
+ const primaryNames = new Set(primary.map(([name]) => name.toLowerCase()));
64
+ return [
65
+ ...primary.map(([name, value]) => [name, value] as [string, string]),
66
+ ...secondary
67
+ .filter(
68
+ ([name]) =>
69
+ ACCUMULATED_HEADERS.has(name.toLowerCase()) ||
70
+ !primaryNames.has(name.toLowerCase()),
71
+ )
72
+ .map(([name, value]) => [name, value] as [string, string]),
73
+ ];
74
+ }
75
+
76
+ /** RFC 9110 connection-specific fields; they never describe a new response. */
77
+ const HOP_BY_HOP_HEADERS = new Set([
78
+ 'connection',
79
+ 'keep-alive',
80
+ 'proxy-connection',
81
+ 'te',
82
+ 'trailer',
83
+ 'upgrade',
84
+ ]);
85
+
86
+ /**
87
+ * Loader fields a generated envelope or document must not inherit: its
88
+ * representation metadata, hop-by-hop fields and any field `Connection` names.
89
+ */
90
+ function projectedHeaderFilter(
91
+ headers: Iterable<readonly [string, string]>,
92
+ ): (name: string) => boolean {
93
+ const stripped = new Set([...REPRESENTATION_HEADERS, ...HOP_BY_HOP_HEADERS]);
94
+ for (const [name, value] of headers)
95
+ if (name.toLowerCase() === 'connection')
96
+ for (const field of value.split(','))
97
+ if (field.trim()) stripped.add(field.trim().toLowerCase());
98
+ return name => !stripped.has(name.toLowerCase());
99
+ }
100
+
101
+ const outcomePolicies = new WeakMap<DataOutcome, RequestDataPolicy>();
102
+
103
+ /**
104
+ * Opaque result of {@link deferData}. Exported so loaders returning it keep a
105
+ * nameable type under declaration emit (composite/declaration projects).
106
+ */
107
+ export interface DeferredDataResult {
108
+ [DEFERRED]: true;
109
+ critical: Record<string, unknown>;
110
+ deferred: Record<string, Promise<unknown>>;
111
+ init?: ResponseInit;
112
+ }
113
+
114
+ export function deferData(
115
+ critical: Record<string, unknown>,
116
+ deferred: Record<string, Promise<unknown>>,
117
+ init?: ResponseInit,
118
+ ): DeferredDataResult {
119
+ for (const record of [critical, deferred]) {
120
+ if (
121
+ record === null ||
122
+ typeof record !== 'object' ||
123
+ (Object.getPrototypeOf(record) !== Object.prototype &&
124
+ Object.getPrototypeOf(record) !== null)
125
+ ) {
126
+ throw new DataProtocolError(
127
+ 'Deferred data requires plain critical and deferred records',
128
+ );
129
+ }
130
+ // The wire format keeps only enumerable string keys, so anything else
131
+ // would silently drop a value the loader returned.
132
+ for (const key of Reflect.ownKeys(record)) {
133
+ const descriptor = Object.getOwnPropertyDescriptor(record, key)!;
134
+ if (descriptor.get || descriptor.set)
135
+ throw new DataProtocolError('Deferred data must not contain accessors');
136
+ if (typeof key === 'symbol' || !descriptor.enumerable)
137
+ throw new DataProtocolError(
138
+ 'Deferred data keys must be enumerable strings',
139
+ );
140
+ }
141
+ }
142
+ assertPublicData(critical);
143
+ for (const [key, promise] of Object.entries(deferred)) {
144
+ if (Object.hasOwn(critical, key) || !(promise instanceof Promise)) {
145
+ throw new DataProtocolError(
146
+ 'Deferred data needs unique keys and Promise values',
147
+ );
148
+ }
149
+ // Rejections are serialized when consumed, including an already rejected promise.
150
+ void promise.catch(() => undefined);
151
+ }
152
+ return {
153
+ [DEFERRED]: true,
154
+ critical,
155
+ deferred: Object.fromEntries(Object.entries(deferred)),
156
+ ...(init === undefined ? {} : { init }),
157
+ };
158
+ }
159
+
160
+ function cacheLifetime(cacheControl: string): number | undefined {
161
+ const directives = parseCacheControl(cacheControl);
162
+ if (!directives) return undefined;
163
+ const names = new Set<string>();
164
+ const ages: number[] = [];
165
+ for (const { name, value: raw } of directives) {
166
+ if (name !== 'max-age' && name !== 's-maxage') continue;
167
+ if (raw === undefined) return undefined;
168
+ if (names.has(name) || !/^\d+$/.test(raw)) return undefined;
169
+ const age = Number(raw);
170
+ if (!Number.isSafeInteger(age)) return undefined;
171
+ names.add(name);
172
+ ages.push(age);
173
+ }
174
+ // Shared-cache freshness cannot establish the browser max-age the merged
175
+ // document emits; it can only shorten one.
176
+ if (!names.has('max-age')) return undefined;
177
+ return ages.length > 0 ? Math.min(...ages) : undefined;
178
+ }
179
+
180
+ const ACCUMULATED_HEADERS = new Set([
181
+ 'set-cookie',
182
+ 'vary',
183
+ 'link',
184
+ 'server-timing',
185
+ 'content-security-policy',
186
+ 'content-security-policy-report-only',
187
+ ]);
188
+
189
+ /** The native router supplies status after resolving its own route outcomes. */
190
+ export function mergeDataResponseMetadata(
191
+ outcomes: readonly DataOutcome[],
192
+ nativeResponse: { status: number; statusText?: string },
193
+ ): DataResponseMetadata {
194
+ const headers = new Headers();
195
+ let cachePolicy: DataResponseMetadata['cachePolicy'] = 'public';
196
+ for (const outcome of outcomes) {
197
+ for (const [name, value] of outcome.response.headers) {
198
+ // Browsers enforce every CSP field, so a nested loader adds to the
199
+ // layout's policies instead of replacing them.
200
+ if (ACCUMULATED_HEADERS.has(name.toLowerCase()))
201
+ headers.append(name, value);
202
+ else headers.set(name, value);
203
+ }
204
+ if (
205
+ outcome.kind !== 'success' ||
206
+ outcome.response.cachePolicy === 'no-store'
207
+ )
208
+ cachePolicy = 'no-store';
209
+ else if (
210
+ outcome.response.cachePolicy === 'private' &&
211
+ cachePolicy === 'public'
212
+ )
213
+ cachePolicy = 'private';
214
+ }
215
+ const vary = headers.get('vary');
216
+ if (vary !== null) {
217
+ const fields = new Map<string, string>();
218
+ for (const field of vary.split(',')) {
219
+ const name = field.trim();
220
+ const key = name.toLowerCase();
221
+ if (name && !fields.has(key)) fields.set(key, name);
222
+ }
223
+ headers.set('vary', [...fields.values()].join(', '));
224
+ }
225
+ if (
226
+ outcomes.length === 0 ||
227
+ headers.has('set-cookie') ||
228
+ nativeResponse.status >= 400
229
+ )
230
+ cachePolicy = 'no-store';
231
+ if (cachePolicy !== 'no-store') {
232
+ const ages = outcomes.map(outcome =>
233
+ cacheLifetime(
234
+ new Headers(outcome.response.headers).get('cache-control') ?? '',
235
+ ),
236
+ );
237
+ if (cachePolicy === 'public' && ages.some(age => age === undefined))
238
+ cachePolicy = 'no-store';
239
+ else {
240
+ // An absent or invalid private lifetime requires immediate revalidation.
241
+ const maxAge = Math.min(...ages.map(age => age ?? 0));
242
+ headers.set(
243
+ 'cache-control',
244
+ cachePolicy === 'private'
245
+ ? `private, max-age=${maxAge}, must-revalidate`
246
+ : `public, max-age=${maxAge}`,
247
+ );
248
+ }
249
+ }
250
+ if (cachePolicy === 'no-store') headers.set('cache-control', 'no-store');
251
+ return {
252
+ status: nativeResponse.status,
253
+ statusText: nativeResponse.statusText ?? '',
254
+ headers: collectDataHeaders(headers),
255
+ cachePolicy,
256
+ };
257
+ }
258
+
259
+ /** Loader representation headers cannot describe the native HTML document. */
260
+ export function dataMetadataToDocumentPolicy(
261
+ metadata: DataResponseMetadata,
262
+ ): ResponsePolicy {
263
+ const headers = new Headers();
264
+ const fields = [...metadata.headers];
265
+ const keep = projectedHeaderFilter(fields);
266
+ for (const [name, value] of fields) {
267
+ if (keep(name)) headers.append(name, value);
268
+ }
269
+ headers.set('content-type', 'text/html; charset=utf-8');
270
+ let cache: DocumentCachePolicy = { mode: 'no-store' };
271
+ const cacheControl = headers.get('cache-control') ?? '';
272
+ const directives = cacheDirectiveNames(cacheControl);
273
+ if (metadata.cachePolicy === 'private') cache = { mode: 'private' };
274
+ if (
275
+ metadata.cachePolicy === 'public' &&
276
+ metadata.status === 200 &&
277
+ !headers.has('set-cookie') &&
278
+ !directives.has('private') &&
279
+ !directives.has('no-store') &&
280
+ !directives.has('no-cache') &&
281
+ !headers
282
+ .get('vary')
283
+ ?.split(',')
284
+ .some(value => value.trim() === '*')
285
+ ) {
286
+ const maxAgeSeconds = cacheLifetime(cacheControl);
287
+ if (maxAgeSeconds !== undefined) cache = { mode: 'public', maxAgeSeconds };
288
+ }
289
+ if (headers.has('set-cookie') || metadata.status >= 400)
290
+ cache = { mode: 'no-store' };
291
+ if (cache.mode === 'no-store') headers.set('cache-control', 'no-store');
292
+ else if (cache.mode === 'private' && !directives.has('private'))
293
+ headers.set('cache-control', 'private');
294
+ return {
295
+ kind: 'document',
296
+ status: metadata.status,
297
+ headers: collectDataHeaders(headers),
298
+ cache,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * The elements of a comma-separated list field. Commas inside a quoted
304
+ * string or a `<URI-reference>` (Link) belong to the element.
305
+ */
306
+ function listFieldElements(value: string): string[] {
307
+ const elements: string[] = [];
308
+ let current = '';
309
+ let quoted = false;
310
+ let bracketed = false;
311
+ for (let index = 0; index < value.length; index++) {
312
+ const char = value[index];
313
+ if (quoted) {
314
+ current += char;
315
+ if (char === '\\' && index + 1 < value.length) current += value[++index];
316
+ else if (char === '"') quoted = false;
317
+ } else if (char === '"') {
318
+ quoted = true;
319
+ current += char;
320
+ } else if (char === '<') {
321
+ bracketed = true;
322
+ current += char;
323
+ } else if (char === '>') {
324
+ bracketed = false;
325
+ current += char;
326
+ } else if (char === ',' && !bracketed) {
327
+ if (current.trim()) elements.push(current.trim());
328
+ current = '';
329
+ } else current += char;
330
+ }
331
+ if (current.trim()) elements.push(current.trim());
332
+ return elements;
333
+ }
334
+
335
+ /** Preserve a native terminal response while adding all matched loader cookies. */
336
+ export function mergeDataResponseIntoResponse(
337
+ response: Response,
338
+ metadata: DataResponseMetadata,
339
+ ): Response {
340
+ const headers = new Headers(response.headers);
341
+ const nativeCookies = headers.getSetCookie();
342
+ const metadataHeaders = dataMetadataToDocumentPolicy(metadata).headers;
343
+ const metadataCookies: string[] = [];
344
+ for (const [name, value] of metadataHeaders) {
345
+ const headerName = name.toLowerCase();
346
+ if (headerName === 'set-cookie') metadataCookies.push(value);
347
+ // List fields keep the native values too. The native router already put
348
+ // the redirecting loader's own fields on a terminal redirect, so only
349
+ // elements the response does not carry yet are added.
350
+ else if (ACCUMULATED_HEADERS.has(headerName) && headerName !== 'vary') {
351
+ const present = new Set(listFieldElements(headers.get(name) ?? ''));
352
+ const missing = listFieldElements(value).filter(
353
+ item => !present.has(item),
354
+ );
355
+ if (missing.length) headers.append(name, missing.join(', '));
356
+ } else if (headerName === 'vary') {
357
+ const fields = new Map<string, string>();
358
+ for (const field of `${headers.get('vary') ?? ''},${value}`.split(',')) {
359
+ const item = field.trim();
360
+ if (item && !fields.has(item.toLowerCase()))
361
+ fields.set(item.toLowerCase(), item);
362
+ }
363
+ headers.set('vary', [...fields.values()].join(', '));
364
+ // The terminal response owns its singleton fields; loader metadata only
365
+ // fills fields it does not set.
366
+ } else if (!headers.has(name)) headers.set(name, value);
367
+ }
368
+ if (metadataCookies.length > 0) {
369
+ headers.delete('set-cookie');
370
+ const counts = new Map<string, number>();
371
+ for (const cookie of metadataCookies) {
372
+ headers.append('set-cookie', cookie);
373
+ counts.set(cookie, (counts.get(cookie) ?? 0) + 1);
374
+ }
375
+ for (const cookie of nativeCookies) {
376
+ const remaining = counts.get(cookie) ?? 0;
377
+ if (remaining > 0) counts.set(cookie, remaining - 1);
378
+ else headers.append('set-cookie', cookie);
379
+ }
380
+ }
381
+ // Terminal outcomes never enter the document cache or a successful stream cache.
382
+ headers.set('cache-control', 'no-store');
383
+ return new Response(response.body, {
384
+ status: response.status,
385
+ statusText: response.statusText,
386
+ headers,
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Cache-Control directive names, lowercased. Only whole tokens count, so an
392
+ * extension such as `x-public` or a quoted `"public"` value never opts in.
393
+ */
394
+ function cacheDirectiveNames(cacheControl: string): Set<string> {
395
+ const directives = parseCacheControl(cacheControl);
396
+ // A malformed field fails closed.
397
+ return directives
398
+ ? new Set(directives.map(({ name }) => name))
399
+ : new Set(['no-store']);
400
+ }
401
+
402
+ function responseMetadata(init: ResponseInit = {}): DataResponseMetadata {
403
+ const status = init.status ?? 200;
404
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
405
+ throw new DataProtocolError(
406
+ 'A data response requires a valid HTTP response status',
407
+ );
408
+ }
409
+ const headers = new Headers(init.headers);
410
+ const directives = cacheDirectiveNames(headers.get('cache-control') ?? '');
411
+ return {
412
+ status,
413
+ statusText: init.statusText ?? '',
414
+ headers: collectDataHeaders(headers),
415
+ cachePolicy:
416
+ directives.has('no-store') || directives.has('no-cache')
417
+ ? 'no-store'
418
+ : directives.has('private')
419
+ ? 'private'
420
+ : directives.has('public')
421
+ ? 'public'
422
+ : 'no-store',
423
+ };
424
+ }
425
+
426
+ const DOM_EXCEPTION_FIELDS = {
427
+ name: Object.getOwnPropertyDescriptor(DOMException.prototype, 'name')?.get,
428
+ message: Object.getOwnPropertyDescriptor(DOMException.prototype, 'message')
429
+ ?.get,
430
+ };
431
+
432
+ export function publicDataError(
433
+ error: unknown,
434
+ production = true,
435
+ ): PublicDataError {
436
+ const fallback = { name: 'Error', message: 'Unexpected Server Error' };
437
+ if (production) return fallback;
438
+ if (error instanceof Error || error instanceof DOMException) {
439
+ // Node's lazy stack is an accessor too. Only descriptor data strings are
440
+ // public; error projection must never execute application getters.
441
+ const field = (key: 'name' | 'message' | 'stack'): string | undefined => {
442
+ let object: object | null = error;
443
+ for (let depth = 0; object !== null && depth < 100; depth++) {
444
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
445
+ if (descriptor) {
446
+ if (typeof descriptor.value === 'string') return descriptor.value;
447
+ // DOMException keeps name and message behind its own intrinsic
448
+ // getters, which read internal slots and run no application code.
449
+ const intrinsic =
450
+ object === DOMException.prototype && key !== 'stack'
451
+ ? DOM_EXCEPTION_FIELDS[key]
452
+ : undefined;
453
+ if (intrinsic && descriptor.get === intrinsic) {
454
+ const value: unknown = intrinsic.call(error);
455
+ return typeof value === 'string' ? value : undefined;
456
+ }
457
+ return undefined;
458
+ }
459
+ object = Object.getPrototypeOf(object);
460
+ }
461
+ return undefined;
462
+ };
463
+ try {
464
+ const name = field('name') ?? fallback.name;
465
+ const message = field('message') ?? fallback.message;
466
+ const stack = field('stack');
467
+ return { name, message, ...(stack ? { stack } : {}) };
468
+ } catch {
469
+ return fallback;
470
+ }
471
+ }
472
+ return fallback;
473
+ }
474
+
475
+ async function responseValue(
476
+ response: Response,
477
+ signal?: AbortSignal,
478
+ ): Promise<unknown> {
479
+ if (BODYLESS_STATUSES.has(response.status) || response.body === null)
480
+ return undefined;
481
+ const text = await readBoundedDataText(response, signal);
482
+ if (
483
+ /\bapplication\/(?:[^;]+\+)?json\b/i.test(
484
+ response.headers.get('content-type') ?? '',
485
+ )
486
+ ) {
487
+ try {
488
+ return JSON.parse(text);
489
+ } catch {
490
+ throw new DataProtocolError('A data Response contains malformed JSON');
491
+ }
492
+ }
493
+ return text;
494
+ }
495
+
496
+ /** Resolve every status/redirect/header outcome before the document commits headers. */
497
+ export async function normalizeDataResult(
498
+ value: unknown,
499
+ options: {
500
+ thrown?: boolean;
501
+ production?: boolean;
502
+ signal?: AbortSignal;
503
+ } = {},
504
+ ): Promise<DataOutcome> {
505
+ const thrown = options.thrown ?? false;
506
+ const production = options.production ?? true;
507
+ if (value instanceof Response) {
508
+ const response = responseMetadata(value);
509
+ if (REDIRECT_STATUSES.has(value.status)) {
510
+ const location = value.headers.get('location');
511
+ if (!location)
512
+ throw new DataProtocolError(
513
+ 'A redirect data Response requires Location',
514
+ );
515
+ // The outcome replaces this body; release a live producer such as an
516
+ // upstream fetch instead of leaving it open, without waiting on it.
517
+ void value.body?.cancel().catch(() => undefined);
518
+ return { kind: 'redirect', location, response };
519
+ }
520
+ const data = await responseValue(value, options.signal);
521
+ assertPublicData(data);
522
+ if (value.status === 404)
523
+ return { kind: 'not-found', value: data, thrown, response };
524
+ if (thrown || value.status >= 400) {
525
+ return {
526
+ kind: 'error',
527
+ error: {
528
+ name: 'DataResponseError',
529
+ message:
530
+ value.status >= 500 && production
531
+ ? 'Unexpected Server Error'
532
+ : value.statusText || `Data request failed (${value.status})`,
533
+ },
534
+ ...(value.status >= 500 && production ? {} : { data }),
535
+ thrown,
536
+ response,
537
+ };
538
+ }
539
+ return { kind: 'success', value: data, response };
540
+ }
541
+ if (thrown) {
542
+ return {
543
+ kind: 'error',
544
+ error: publicDataError(value, production),
545
+ thrown: true,
546
+ response: responseMetadata({
547
+ status: 500,
548
+ headers: { 'cache-control': 'no-store' },
549
+ }),
550
+ };
551
+ }
552
+ if (value && typeof value === 'object' && DEFERRED in value) {
553
+ const data = value as DeferredDataResult;
554
+ const response = responseMetadata(data.init);
555
+ if (
556
+ REDIRECT_STATUSES.has(response.status) ||
557
+ response.status >= 400 ||
558
+ BODYLESS_STATUSES.has(response.status)
559
+ ) {
560
+ throw new DataProtocolError(
561
+ 'Deferred data must have a successful body-bearing response status',
562
+ );
563
+ }
564
+ return {
565
+ kind: 'deferred',
566
+ critical: data.critical,
567
+ deferred: data.deferred,
568
+ response,
569
+ };
570
+ }
571
+ assertPublicData(value);
572
+ return { kind: 'success', value, response: responseMetadata() };
573
+ }
574
+
575
+ export async function invokeRouteData<Context>(
576
+ handler: DataHandler<Context>,
577
+ input: DataHandlerInput<Context>,
578
+ options: { production?: boolean; privateValues?: readonly unknown[] } = {},
579
+ ): Promise<DataOutcome> {
580
+ input.request.signal.throwIfAborted();
581
+ const policy = createRequestDataPolicy([
582
+ input.context,
583
+ input.request,
584
+ ...(options.privateValues ?? []),
585
+ ]);
586
+ return invokeOwnedRouteData(handler, input, options, policy);
587
+ }
588
+
589
+ async function invokeOwnedRouteData<Context>(
590
+ handler: DataHandler<Context>,
591
+ input: DataHandlerInput<Context>,
592
+ options: { production?: boolean },
593
+ policy: RequestDataPolicy,
594
+ ): Promise<DataOutcome> {
595
+ input.request.signal.throwIfAborted();
596
+ let value: unknown;
597
+ let thrown = false;
598
+ try {
599
+ // A handler that ignores its request signal must not keep a superseded
600
+ // load, and its request-scoped context, alive.
601
+ value = await untilAborted(handler(input), input.request.signal);
602
+ } catch (error) {
603
+ input.request.signal.throwIfAborted();
604
+ value = error;
605
+ thrown = true;
606
+ }
607
+ if (input.request.signal.aborted) {
608
+ // A Response that arrived as the request aborted is discarded with its
609
+ // body, never left open.
610
+ if (value instanceof Response)
611
+ void value.body
612
+ ?.cancel(input.request.signal.reason)
613
+ .catch(() => undefined);
614
+ input.request.signal.throwIfAborted();
615
+ }
616
+ policy.assertRoot(value);
617
+ let outcome = await normalizeDataResult(value, {
618
+ ...options,
619
+ thrown,
620
+ signal: input.request.signal,
621
+ });
622
+ if (outcome.kind === 'deferred') {
623
+ policy.assertValue(outcome.critical);
624
+ outcome = {
625
+ ...outcome,
626
+ deferred: Object.fromEntries(
627
+ Object.entries(outcome.deferred).map(([key, promise]) => {
628
+ const checked = Promise.prototype.then.call(
629
+ promise,
630
+ (value: unknown) => {
631
+ policy.assertValue(value);
632
+ return value;
633
+ },
634
+ (error: unknown) => {
635
+ policy.assertRoot(error);
636
+ throw error;
637
+ },
638
+ ) as Promise<unknown>;
639
+ void checked.catch(() => undefined);
640
+ return [key, checked];
641
+ }),
642
+ ),
643
+ };
644
+ } else {
645
+ policy.assertValue(publicOutcome(outcome));
646
+ }
647
+ outcomePolicies.set(outcome, policy);
648
+ return outcome;
649
+ }
650
+
651
+ function publicOutcome(outcome: DataOutcome): PublicDataOutcome {
652
+ const status = outcome.response.status;
653
+ switch (outcome.kind) {
654
+ case 'success':
655
+ return { kind: 'success', value: outcome.value, status };
656
+ case 'deferred':
657
+ return { kind: 'success', value: outcome.critical, status };
658
+ case 'redirect':
659
+ return { kind: 'redirect', location: outcome.location, status };
660
+ case 'not-found':
661
+ return {
662
+ kind: 'not-found',
663
+ value: outcome.value,
664
+ thrown: outcome.thrown,
665
+ status,
666
+ };
667
+ case 'error':
668
+ return {
669
+ kind: 'error',
670
+ error: outcome.error,
671
+ ...(Object.hasOwn(outcome, 'data') ? { data: outcome.data } : {}),
672
+ thrown: outcome.thrown,
673
+ status,
674
+ };
675
+ }
676
+ }
677
+
678
+ function wireEnvelope(
679
+ outcome: DataOutcome,
680
+ identity: RendererIdentity,
681
+ routeId: string,
682
+ operation: DataOperation,
683
+ ): DataWireEnvelope {
684
+ identityCacheKey(identity);
685
+ return {
686
+ version: DATA_PROTOCOL_VERSION,
687
+ identity: {
688
+ renderer: identity.renderer,
689
+ appId: identity.appId,
690
+ entryName: identity.entryName,
691
+ protocolVersion: identity.protocolVersion,
692
+ buildId: identity.buildId,
693
+ },
694
+ routeId,
695
+ operation,
696
+ outcome: publicOutcome(outcome),
697
+ ...(outcome.kind === 'deferred'
698
+ ? { deferredKeys: Object.keys(outcome.deferred) }
699
+ : {}),
700
+ };
701
+ }
702
+
703
+ function createDeferredStream(
704
+ outcome: Extract<DataOutcome, { kind: 'deferred' }>,
705
+ envelope: DataWireEnvelope,
706
+ signal: AbortSignal | undefined,
707
+ production: boolean,
708
+ onCancel?: (reason: unknown) => void | Promise<void>,
709
+ policy?: RequestDataPolicy,
710
+ ): ReadableStream<Uint8Array> {
711
+ const encoder = new TextEncoder();
712
+ const projectError = (error: unknown): PublicDataError => {
713
+ try {
714
+ policy?.assertRoot(error);
715
+ } catch (violation) {
716
+ error = violation;
717
+ }
718
+ return publicDataError(error, production);
719
+ };
720
+ // Snapshot and validate critical data before returning a body or committing headers.
721
+ policy?.assertValue({ type: 'initial', envelope });
722
+ const initialBytes = encoder.encode(
723
+ `${serializePublicData({ type: 'initial', envelope })}\n`,
724
+ );
725
+ const transport = new TransformStream<Uint8Array, Uint8Array>();
726
+ const writer = transport.writable.getWriter();
727
+ let active = true;
728
+ let finished = false;
729
+ let cancelled = false;
730
+ // Releases deferred work that ignores cancellation, so the producer and
731
+ // the request data it holds are not retained after the client goes.
732
+ const stopped = new AbortController();
733
+ const cancelResources = (reason: unknown) => {
734
+ if (finished || cancelled) return;
735
+ cancelled = true;
736
+ stopped.abort(reason);
737
+ // The response is already interrupted. A resource callback must not crash
738
+ // its transport or prevent writer/listener cleanup.
739
+ try {
740
+ void Promise.resolve(onCancel?.(reason)).catch(() => undefined);
741
+ } catch {
742
+ /* The request owner's lifecycle records its own cleanup failures. */
743
+ }
744
+ };
745
+ const cleanup = () => {
746
+ if (!active) return;
747
+ active = false;
748
+ signal?.removeEventListener('abort', abort);
749
+ };
750
+ const abort = () => {
751
+ if (!active) return;
752
+ const reason =
753
+ signal?.reason ??
754
+ new DOMException('The data request was aborted', 'AbortError');
755
+ cancelResources(reason);
756
+ cleanup();
757
+ void writer.abort(reason).catch(() => undefined);
758
+ };
759
+ let writes = Promise.resolve();
760
+ const write = (
761
+ frame: DataStreamFrame,
762
+ encoded?: Uint8Array,
763
+ ): Promise<void> => {
764
+ const next = writes.then(async () => {
765
+ if (!active) return;
766
+ if (encoded) {
767
+ await writer.write(encoded);
768
+ return;
769
+ }
770
+ let text: string;
771
+ try {
772
+ policy?.assertValue(frame);
773
+ text = serializePublicData(frame);
774
+ } catch (error) {
775
+ if (frame.type !== 'resolve') throw error;
776
+ text = serializePublicData({
777
+ type: 'reject',
778
+ key: frame.key,
779
+ error: projectError(error),
780
+ });
781
+ }
782
+ await writer.write(encoder.encode(`${text}\n`));
783
+ });
784
+ writes = next;
785
+ return next;
786
+ };
787
+ // A single native stream consumer drives TransformStream backpressure.
788
+ void writer.closed.catch(reason => {
789
+ cancelResources(reason);
790
+ cleanup();
791
+ });
792
+ if (signal?.aborted) abort();
793
+ else signal?.addEventListener('abort', abort, { once: true });
794
+ void (async () => {
795
+ await write({ type: 'initial', envelope }, initialBytes);
796
+ await Promise.all(
797
+ Object.entries(outcome.deferred).map(async ([key, promise]) => {
798
+ let frame: DataStreamFrame;
799
+ try {
800
+ const value = await untilAborted(promise, stopped.signal);
801
+ assertPublicData(value);
802
+ frame = { type: 'resolve', key, value };
803
+ } catch (error) {
804
+ frame = {
805
+ type: 'reject',
806
+ key,
807
+ error: projectError(error),
808
+ };
809
+ }
810
+ await write(frame);
811
+ }),
812
+ );
813
+ if (!active) return;
814
+ await write({ type: 'complete' });
815
+ await writer.close();
816
+ finished = true;
817
+ cleanup();
818
+ })().catch(error => {
819
+ cancelResources(error);
820
+ cleanup();
821
+ void writer.abort(error).catch(() => undefined);
822
+ });
823
+ return transport.readable;
824
+ }
825
+
826
+ export function createDataResponse(
827
+ outcome: DataOutcome,
828
+ identity: RendererIdentity,
829
+ options: {
830
+ routeId: string;
831
+ operation: DataOperation;
832
+ signal?: AbortSignal;
833
+ production?: boolean;
834
+ onCancel?: (reason: unknown) => void | Promise<void>;
835
+ },
836
+ ): Response {
837
+ const policy = outcomePolicies.get(outcome);
838
+ policy?.assertValue(publicOutcome(outcome));
839
+ const envelope = wireEnvelope(
840
+ outcome,
841
+ identity,
842
+ options.routeId,
843
+ options.operation,
844
+ );
845
+ const headers = new Headers();
846
+ // The envelope owns new bytes. Original validators, ranges and redirects do not.
847
+ // Read the loader fields once; the private-value check counts iterations.
848
+ const fields = [...outcome.response.headers];
849
+ const keep = projectedHeaderFilter(fields);
850
+ for (const [name, value] of fields)
851
+ if (keep(name)) headers.append(name, value);
852
+ headers.set('x-modernjs-response', 'yes');
853
+ if (
854
+ !headers.has('cache-control') ||
855
+ headers.has('set-cookie') ||
856
+ outcome.kind !== 'success' ||
857
+ outcome.response.cachePolicy === 'no-store'
858
+ )
859
+ headers.set('cache-control', 'no-store');
860
+ if (outcome.kind === 'redirect')
861
+ headers.set('x-modernjs-redirect', outcome.location);
862
+ headers.set(
863
+ 'content-type',
864
+ `${outcome.kind === 'deferred' ? DATA_STREAM_CONTENT_TYPE : DATA_CONTENT_TYPE}; charset=utf-8`,
865
+ );
866
+ // Bodyless statuses need a body-bearing transport, and the envelope is never
867
+ // partial content even when the loader answered 206. The envelope keeps the
868
+ // original status.
869
+ const status =
870
+ outcome.kind === 'redirect' ||
871
+ outcome.response.status === 206 ||
872
+ BODYLESS_STATUSES.has(outcome.response.status)
873
+ ? 200
874
+ : outcome.response.status;
875
+ let body: ReadableStream<Uint8Array> | string;
876
+ if (outcome.kind === 'deferred') {
877
+ body = createDeferredStream(
878
+ outcome,
879
+ envelope,
880
+ options.signal,
881
+ options.production ?? true,
882
+ options.onCancel,
883
+ policy,
884
+ );
885
+ } else {
886
+ policy?.assertValue(envelope);
887
+ body = serializePublicData(envelope);
888
+ }
889
+ return new Response(body, { status, headers });
890
+ }
891
+
892
+ export async function handleDataRequest<Context>(options: {
893
+ request: Request;
894
+ identity: RendererIdentity;
895
+ context: Context;
896
+ selectRoute: (
897
+ request: Request,
898
+ requestedRouteId: string,
899
+ operation: DataOperation,
900
+ ) =>
901
+ | SelectedDataRoute<Context>
902
+ | undefined
903
+ | Promise<SelectedDataRoute<Context> | undefined>;
904
+ production?: boolean;
905
+ /** Additional dispatcher-owned request identities, never public payload. */
906
+ privateValues?: readonly unknown[];
907
+ }): Promise<Response | undefined> {
908
+ // Classify before cloning: cloning a non-data POST can transfer its body.
909
+ const url = new URL(options.request.url);
910
+ const routeId = url.searchParams.get(LOADER_ID_PARAM);
911
+ if (!routeId) return undefined;
912
+ identityCacheKey(options.identity);
913
+ const context = options.context;
914
+ const cancellation = new AbortController();
915
+ const request = new Request(options.request, {
916
+ signal: AbortSignal.any([options.request.signal, cancellation.signal]),
917
+ });
918
+ request.signal.throwIfAborted();
919
+ const policy = createRequestDataPolicy([
920
+ context,
921
+ options.request,
922
+ request,
923
+ ...(options.privateValues ?? []),
924
+ ]);
925
+ if (
926
+ !['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)
927
+ ) {
928
+ return new Response('Unsupported data method', {
929
+ status: 405,
930
+ headers: {
931
+ allow: 'GET, HEAD, POST, PUT, PATCH, DELETE',
932
+ 'cache-control': 'no-store',
933
+ },
934
+ });
935
+ }
936
+ const operation: DataOperation =
937
+ request.method === 'GET' || request.method === 'HEAD' ? 'loader' : 'action';
938
+ // A custom selector that ignores cancellation must not hold the request.
939
+ const selected = await untilAborted(
940
+ options.selectRoute(request, routeId, operation),
941
+ request.signal,
942
+ );
943
+ if (!selected || selected.routeId !== routeId) {
944
+ return new Response('Data route is not authorized for this URL', {
945
+ status: 403,
946
+ headers: { 'cache-control': 'no-store' },
947
+ });
948
+ }
949
+ const productionOptions =
950
+ options.production === undefined ? {} : { production: options.production };
951
+ const outcome = await invokeOwnedRouteData(
952
+ selected.handler,
953
+ {
954
+ request,
955
+ routeId,
956
+ params: selected.params,
957
+ context,
958
+ },
959
+ productionOptions,
960
+ policy,
961
+ );
962
+ const response = createDataResponse(outcome, options.identity, {
963
+ routeId,
964
+ operation,
965
+ signal: request.signal,
966
+ ...productionOptions,
967
+ onCancel: reason => cancellation.abort(reason),
968
+ });
969
+ if (request.method === 'HEAD') {
970
+ void response.body?.cancel().catch(() => undefined);
971
+ return new Response(null, {
972
+ status: response.status,
973
+ headers: response.headers,
974
+ });
975
+ }
976
+ return response;
977
+ }