@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,94 @@
1
+ import { collectDocumentAssets, type DocumentAsset } from '../document';
2
+ import { assertRendererIdentity, type RendererIdentity } from '../identity';
3
+ import { dispatchNativeRequest, rejectNativeRscRequest } from './dispatch';
4
+ import type {
5
+ NativeExecutionContext,
6
+ NativeServerConfig,
7
+ NativeServerManifest,
8
+ } from './types';
9
+
10
+ /** Build-validated per-entry inputs a worker bundle cannot read from disk. */
11
+ export interface NativeWorkerEntryResources {
12
+ readonly assets: readonly DocumentAsset[];
13
+ /** Opaque compiler-owned JSON, revalidated by the native request handler. */
14
+ readonly nativeManifest?: unknown;
15
+ readonly hydrationBuildId?: string;
16
+ readonly serverConfig: NativeServerConfig;
17
+ /** Present only when the entry admits a CSR fallback request. */
18
+ readonly csrFallbackHeader?: string;
19
+ readonly nonce?: string;
20
+ }
21
+
22
+ export interface NativeWorkerDispatchOptions<Bindings extends object> {
23
+ readonly identity: RendererIdentity;
24
+ /** The imported worker bundle: its namespace or default-exported manifest. */
25
+ readonly bundle: unknown;
26
+ readonly resources: NativeWorkerEntryResources;
27
+ /** The module worker `env`, forwarded unchanged as the platform binding. */
28
+ readonly bindings: Bindings | undefined;
29
+ readonly executionContext?: NativeExecutionContext;
30
+ }
31
+
32
+ function workerManifest<Bindings extends object>(
33
+ bundle: unknown,
34
+ identity: RendererIdentity,
35
+ ): NativeServerManifest<Bindings> {
36
+ const candidate =
37
+ bundle &&
38
+ typeof bundle === 'object' &&
39
+ !('rendererIdentity' in bundle) &&
40
+ 'default' in bundle
41
+ ? bundle.default
42
+ : bundle;
43
+ if (
44
+ !candidate ||
45
+ (typeof candidate !== 'object' && typeof candidate !== 'function') ||
46
+ !('rendererIdentity' in candidate) ||
47
+ !('nativeRequestHandler' in candidate) ||
48
+ typeof candidate.nativeRequestHandler !== 'function'
49
+ ) {
50
+ throw new Error(
51
+ 'Native worker bundle requires its renderer identity and request handler.',
52
+ );
53
+ }
54
+ assertRendererIdentity(
55
+ candidate.rendererIdentity as RendererIdentity,
56
+ identity,
57
+ );
58
+ return candidate as NativeServerManifest<Bindings>;
59
+ }
60
+
61
+ /** Worker Fetch dispatch over the same native handler the Node host loads. */
62
+ export function dispatchNativeWorkerRequest<Bindings extends object>(
63
+ request: Request,
64
+ options: NativeWorkerDispatchOptions<Bindings>,
65
+ ): Promise<Response> {
66
+ const rejection = rejectNativeRscRequest(request);
67
+ if (rejection) return Promise.resolve(rejection);
68
+ const { resources } = options;
69
+ const query = new URL(request.url).searchParams;
70
+ const forceCSR = Boolean(
71
+ resources.csrFallbackHeader &&
72
+ (query.get('csr') || request.headers.get(resources.csrFallbackHeader)),
73
+ );
74
+ const bindings = (
75
+ options.bindings !== null && typeof options.bindings === 'object'
76
+ ? options.bindings
77
+ : {}
78
+ ) as Bindings;
79
+ return dispatchNativeRequest<Bindings>(request, {
80
+ identity: options.identity,
81
+ platform: 'worker',
82
+ executionContext: options.executionContext,
83
+ hydrationBuildId: resources.hydrationBuildId,
84
+ loadManifest: () =>
85
+ workerManifest<Bindings>(options.bundle, options.identity),
86
+ context: {
87
+ assets: collectDocumentAssets(resources.assets),
88
+ nativeManifest: resources.nativeManifest,
89
+ bindings,
90
+ serverConfig: { ...resources.serverConfig, forceCSR },
91
+ nonce: resources.nonce,
92
+ },
93
+ });
94
+ }
@@ -0,0 +1,177 @@
1
+ import { identityCacheKey, type RendererIdentity } from '../identity';
2
+ import type {
3
+ DocumentCachePolicy,
4
+ ResponseHeaders,
5
+ ResponsePolicy,
6
+ } from './types';
7
+
8
+ type CacheMode = DocumentCachePolicy['mode'];
9
+
10
+ /** Compute this before looking up any application-provided request key. */
11
+ export function documentCacheKey(
12
+ identity: RendererIdentity,
13
+ requestKey: string,
14
+ ): string {
15
+ if (typeof requestKey !== 'string' || requestKey.length === 0) {
16
+ throw new TypeError('A document cache request key must be nonempty.');
17
+ }
18
+ return JSON.stringify([identityCacheKey(identity), requestKey]);
19
+ }
20
+
21
+ /** Header fields with each Set-Cookie kept as its own entry. */
22
+ export function responseHeaders(headers: Headers): [string, string][] {
23
+ const fields: [string, string][] = [];
24
+ headers.forEach((value, name) => {
25
+ if (name !== 'set-cookie') fields.push([name, value]);
26
+ });
27
+ for (const cookie of headers.getSetCookie()) {
28
+ fields.push(['set-cookie', cookie]);
29
+ }
30
+ return fields;
31
+ }
32
+
33
+ export function policyHeaders(headers: ResponseHeaders): Headers {
34
+ const result = new Headers();
35
+ for (const [name, value] of headers) result.append(name, value);
36
+ return result;
37
+ }
38
+
39
+ const NO_STORE = { mode: 'no-store' as const, ages: [] };
40
+
41
+ const CACHE_DIRECTIVE =
42
+ /\s*([!#$%&'*+.^_`|~\w-]+)\s*(?:=\s*([!#$%&'*+.^_`|~\w-]+|"(?:[^"\\]|\\.)*"))?\s*(?:,|$)/uy;
43
+
44
+ /**
45
+ * Every Cache-Control directive in order, names lowercased, or `undefined`
46
+ * when any part is malformed (an unterminated quote, trailing syntax). Quoted
47
+ * values are consumed whole, so their text is never read as a directive.
48
+ */
49
+ export function parseCacheControl(
50
+ value: string,
51
+ ): { name: string; value: string | undefined }[] | undefined {
52
+ const directives: { name: string; value: string | undefined }[] = [];
53
+ let index = 0;
54
+ while (index < value.length) {
55
+ CACHE_DIRECTIVE.lastIndex = index;
56
+ const match = CACHE_DIRECTIVE.exec(value);
57
+ if (!match || CACHE_DIRECTIVE.lastIndex === index) return undefined;
58
+ directives.push({ name: match[1].toLowerCase(), value: match[2] });
59
+ index = CACHE_DIRECTIVE.lastIndex;
60
+ }
61
+ return directives;
62
+ }
63
+
64
+ /** Read Cache-Control. Malformed or repeated lifetimes never cache. */
65
+ function cacheDirectives(value: string | null): {
66
+ mode: CacheMode;
67
+ ages: number[];
68
+ } {
69
+ let mode: CacheMode = 'public';
70
+ const ages: number[] = [];
71
+ const ageNames = new Set<string>();
72
+ const parsed = parseCacheControl(value ?? '');
73
+ if (!parsed) return NO_STORE;
74
+ for (const { name, value: raw } of parsed) {
75
+ if (name === 'no-store' || name === 'no-cache') mode = 'no-store';
76
+ else if (name === 'private' && mode !== 'no-store') mode = 'private';
77
+ if (name === 'max-age' || name === 's-maxage') {
78
+ const age = Number(raw);
79
+ if (
80
+ ageNames.has(name) ||
81
+ !/^\d+$/u.test(raw ?? '') ||
82
+ !Number.isSafeInteger(age)
83
+ ) {
84
+ return NO_STORE;
85
+ }
86
+ ageNames.add(name);
87
+ ages.push(age);
88
+ }
89
+ }
90
+ return { mode, ages };
91
+ }
92
+
93
+ /** The `max-age` and `s-maxage` lifetimes these headers declare. */
94
+ export function headerMaxAgeSeconds(headers: Headers): number[] {
95
+ return cacheDirectives(headers.get('cache-control')).ages;
96
+ }
97
+
98
+ /** The most shareable mode these headers allow; lifetimes go into `ages`. */
99
+ function headerCacheMode(headers: Headers, ages: number[]): CacheMode {
100
+ const control = cacheDirectives(headers.get('cache-control'));
101
+ ages.push(...control.ages);
102
+ if (
103
+ headers.has('set-cookie') ||
104
+ headers
105
+ .get('vary')
106
+ ?.split(',')
107
+ .some(value => value.trim() === '*')
108
+ ) {
109
+ return 'no-store';
110
+ }
111
+ return control.mode;
112
+ }
113
+
114
+ function isSingleHtmlContentType(headers: Headers): boolean {
115
+ const type = headers.get('content-type');
116
+ return (
117
+ !type?.includes(',') &&
118
+ type?.split(';')[0].trim().toLowerCase() === 'text/html'
119
+ );
120
+ }
121
+
122
+ export function permitsDocumentCache(policy: ResponsePolicy): boolean {
123
+ if (
124
+ policy.kind !== 'document' ||
125
+ policy.status !== 200 ||
126
+ policy.cache.mode !== 'public'
127
+ ) {
128
+ return false;
129
+ }
130
+ const headers = policyHeaders(policy.headers);
131
+ return (
132
+ headerCacheMode(headers, []) === 'public' &&
133
+ isSingleHtmlContentType(headers)
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Narrow a policy's cache to what every header source allows, then write the
139
+ * matching Cache-Control to `outgoing`. Only a 200 HTML document can be public;
140
+ * its lifetime is the shortest one any source declares.
141
+ */
142
+ export function restrictDocumentCache(
143
+ policy: ResponsePolicy,
144
+ sources: readonly Headers[],
145
+ outgoing: Headers,
146
+ ): DocumentCachePolicy {
147
+ const original = policyHeaders(policy.headers);
148
+ let mode: CacheMode = policy.cache.mode;
149
+ const ages =
150
+ policy.cache.mode === 'public' ? [policy.cache.maxAgeSeconds] : [];
151
+ for (const headers of [original, ...sources, outgoing]) {
152
+ const restricted = headerCacheMode(headers, ages);
153
+ if (restricted === 'no-store') mode = 'no-store';
154
+ else if (restricted === 'private' && mode === 'public') mode = 'private';
155
+ }
156
+ if (
157
+ policy.kind !== 'document' ||
158
+ policy.status !== 200 ||
159
+ !isSingleHtmlContentType(original) ||
160
+ !isSingleHtmlContentType(outgoing)
161
+ ) {
162
+ mode = 'no-store';
163
+ }
164
+ if (mode === 'public') {
165
+ const maxAgeSeconds = Math.min(...ages);
166
+ outgoing.set(
167
+ 'cache-control',
168
+ `public, max-age=${maxAgeSeconds}, s-maxage=${maxAgeSeconds}, must-revalidate`,
169
+ );
170
+ return { mode, maxAgeSeconds };
171
+ }
172
+ outgoing.set(
173
+ 'cache-control',
174
+ mode === 'private' ? 'private, max-age=0, must-revalidate' : 'no-store',
175
+ );
176
+ return { mode };
177
+ }
@@ -0,0 +1,11 @@
1
+ export { untilAborted } from '../abort';
2
+ export {
3
+ documentCacheKey,
4
+ headerMaxAgeSeconds,
5
+ permitsDocumentCache,
6
+ policyHeaders,
7
+ responseHeaders,
8
+ restrictDocumentCache,
9
+ } from './cache';
10
+ export { createRequestSession } from './request';
11
+ export type * from './types';
@@ -0,0 +1,324 @@
1
+ import type { RendererIdentity } from '../identity';
2
+ import { identityCacheKey } from '../identity';
3
+ import { permitsDocumentCache, policyHeaders } from './cache';
4
+ import type {
5
+ RequestCleanup,
6
+ RequestCompletion,
7
+ RequestPlatform,
8
+ RequestSession,
9
+ RequestState,
10
+ ResponsePolicy,
11
+ } from './types';
12
+
13
+ function freezeResponsePolicy(policy: ResponsePolicy): ResponsePolicy {
14
+ if (
15
+ !Number.isInteger(policy.status) ||
16
+ policy.status < 200 ||
17
+ policy.status > 599
18
+ ) {
19
+ throw new TypeError(
20
+ 'A renderer response status must be an integer from 200 to 599.',
21
+ );
22
+ }
23
+ if (policy.kind !== 'document' && policy.kind !== 'terminal') {
24
+ throw new TypeError(
25
+ 'A renderer response must identify its document or terminal outcome.',
26
+ );
27
+ }
28
+ const statusText = policy.statusText;
29
+ if (statusText !== undefined && typeof statusText !== 'string') {
30
+ throw new TypeError('A renderer response status text must be a string.');
31
+ }
32
+ // Fetch validates the reason text before a response can claim its stream.
33
+ new Response(null, {
34
+ status: policy.status,
35
+ ...(statusText === undefined ? {} : { statusText }),
36
+ });
37
+ if (!['no-store', 'private', 'public'].includes(policy.cache.mode)) {
38
+ throw new TypeError('A renderer response has an unknown cache policy.');
39
+ }
40
+ if (
41
+ policy.cache.mode === 'public' &&
42
+ (!Number.isInteger(policy.cache.maxAgeSeconds) ||
43
+ policy.cache.maxAgeSeconds < 0)
44
+ ) {
45
+ throw new TypeError(
46
+ 'A public document cache lifetime must be a nonnegative integer.',
47
+ );
48
+ }
49
+ const headers = policy.headers.map(([name, value]) => {
50
+ // Validate before committing. Keep separate Set-Cookie entries in the policy.
51
+ new Headers([[name, value]]);
52
+ return Object.freeze([name, value] as const);
53
+ });
54
+ return Object.freeze({
55
+ kind: policy.kind,
56
+ status: policy.status,
57
+ ...(statusText === undefined ? {} : { statusText }),
58
+ headers: Object.freeze(headers),
59
+ cache: Object.freeze({ ...policy.cache }),
60
+ });
61
+ }
62
+
63
+ /** Owns one request and the lifetime of its response body, not a server instance. */
64
+ export function createRequestSession<Bindings extends object>(input: {
65
+ request: Request;
66
+ identity: RendererIdentity;
67
+ platform: RequestPlatform<Bindings>;
68
+ }): RequestSession<Bindings> {
69
+ identityCacheKey(input.identity);
70
+ if (input.platform.kind !== 'node' && input.platform.kind !== 'worker') {
71
+ throw new TypeError(
72
+ 'A renderer request requires an explicit Node or worker binding.',
73
+ );
74
+ }
75
+ const identity = Object.freeze({ ...input.identity });
76
+ const platform = Object.freeze({ ...input.platform });
77
+ const abortController = new AbortController();
78
+ const disposers = new Set<RequestCleanup>();
79
+ let state: RequestState = 'matching';
80
+ let policy: ResponsePolicy | undefined;
81
+ let committedPolicy: ResponsePolicy | undefined;
82
+ let fallback = false;
83
+ let responseCreated = false;
84
+ let hasDocumentBody = false;
85
+ let deliveredResponse: Response | undefined;
86
+ let deliveredHeaders: Headers | undefined;
87
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
88
+ let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined;
89
+ let cancelPromise: Promise<void> | undefined;
90
+ let finishPromise: Promise<RequestCompletion> | undefined;
91
+ let resolveCompletion!: (completion: RequestCompletion) => void;
92
+ const completion = new Promise<RequestCompletion>(resolve => {
93
+ resolveCompletion = resolve;
94
+ });
95
+
96
+ const cancelSource = (reason: unknown) => {
97
+ cancelPromise ??= reader
98
+ ? reader.cancel(reason).finally(() => reader?.releaseLock())
99
+ : Promise.resolve();
100
+ return cancelPromise;
101
+ };
102
+
103
+ const finish = (
104
+ terminalState: RequestCompletion['state'],
105
+ error?: unknown,
106
+ ): Promise<RequestCompletion> => {
107
+ if (finishPromise) return finishPromise;
108
+ state = terminalState;
109
+ input.request.signal.removeEventListener('abort', onRequestAbort);
110
+ const ownedDisposers = [...disposers].reverse();
111
+ disposers.clear();
112
+ finishPromise = Promise.resolve().then(async () => {
113
+ const cleanupErrors: unknown[] = [];
114
+ let recorded = false;
115
+ // A native cancel operation can wait for its renderer owner to dispose,
116
+ // so both start together. Completion never waits on the source's cancel
117
+ // hook, which may not settle; an error it reports by then is recorded.
118
+ if (terminalState !== 'completed')
119
+ void cancelSource(error).catch(cancelError => {
120
+ if (!recorded) cleanupErrors.push(cancelError);
121
+ });
122
+ for (const dispose of ownedDisposers) {
123
+ try {
124
+ await dispose();
125
+ } catch (cleanupError) {
126
+ cleanupErrors.push(cleanupError);
127
+ }
128
+ }
129
+ // One turn lets an immediately rejected cancellation be recorded.
130
+ await Promise.resolve();
131
+ recorded = true;
132
+ if (terminalState === 'completed' && cleanupErrors.length > 0)
133
+ state = 'failed';
134
+ const result: RequestCompletion = Object.freeze({
135
+ state: state as RequestCompletion['state'],
136
+ fallback,
137
+ ...(error !== undefined ? { error } : {}),
138
+ cleanupErrors: Object.freeze(cleanupErrors),
139
+ cacheEligible:
140
+ state === 'completed' &&
141
+ hasDocumentBody &&
142
+ !fallback &&
143
+ committedPolicy !== undefined &&
144
+ permitsDocumentCache({
145
+ ...committedPolicy,
146
+ // Server middleware can append cookies to the Fetch Response before
147
+ // transport sends it. Cache admission must inspect those final headers.
148
+ headers: deliveredHeaders
149
+ ? [...deliveredHeaders.entries()]
150
+ : committedPolicy.headers,
151
+ }),
152
+ });
153
+ resolveCompletion(result);
154
+ return result;
155
+ });
156
+ if (terminalState !== 'completed') {
157
+ // Publish terminal ownership before notifying native abort listeners.
158
+ // Re-entrant failures and cancellations then share this same completion.
159
+ abortController.abort(error);
160
+ bodyController?.error(error);
161
+ }
162
+ return finishPromise;
163
+ };
164
+
165
+ const abort = (
166
+ reason: unknown = new DOMException(
167
+ 'The renderer request was aborted.',
168
+ 'AbortError',
169
+ ),
170
+ ) => {
171
+ void finish('aborted', reason);
172
+ };
173
+
174
+ function onRequestAbort() {
175
+ void abort(input.request.signal.reason);
176
+ }
177
+
178
+ const session: RequestSession<Bindings> = {
179
+ request: input.request,
180
+ identity,
181
+ platform,
182
+ signal: abortController.signal,
183
+ get state() {
184
+ return state;
185
+ },
186
+ get responsePolicy() {
187
+ return policy;
188
+ },
189
+ get committedPolicy() {
190
+ return committedPolicy;
191
+ },
192
+ completion,
193
+ resolveResponse(nextPolicy) {
194
+ if (committedPolicy || finishPromise) {
195
+ throw new Error(
196
+ 'Renderer response status and headers are already committed or terminated.',
197
+ );
198
+ }
199
+ policy = freezeResponsePolicy(nextPolicy);
200
+ if (state === 'matching') state = 'ready';
201
+ },
202
+ startRendering() {
203
+ if (state !== 'ready') {
204
+ throw new Error(
205
+ 'Resolve the blocking HTTP outcome before starting the renderer.',
206
+ );
207
+ }
208
+ state = 'rendering';
209
+ },
210
+ registerCleanup(dispose) {
211
+ if (finishPromise)
212
+ throw new Error(
213
+ 'Cannot register cleanup after a renderer request terminates.',
214
+ );
215
+ disposers.add(dispose);
216
+ return () => {
217
+ disposers.delete(dispose);
218
+ };
219
+ },
220
+ markFallback() {
221
+ if (finishPromise)
222
+ throw new Error(
223
+ 'Cannot change fallback disposition after a renderer request terminates.',
224
+ );
225
+ fallback = true;
226
+ },
227
+ respond(body) {
228
+ if (responseCreated || committedPolicy || finishPromise) {
229
+ throw new Error(
230
+ 'A renderer request has exactly one response body consumer.',
231
+ );
232
+ }
233
+ if (!policy || (state !== 'ready' && state !== 'rendering')) {
234
+ throw new Error(
235
+ 'Resolve the blocking HTTP outcome before committing a response.',
236
+ );
237
+ }
238
+ if (body !== null && [204, 205, 304].includes(policy.status)) {
239
+ throw new TypeError(
240
+ `HTTP ${policy.status} cannot contain a renderer response body.`,
241
+ );
242
+ }
243
+ reader = body?.getReader();
244
+ hasDocumentBody = body !== null;
245
+ responseCreated = true;
246
+ committedPolicy = policy;
247
+ state = 'committed';
248
+ const headers = policyHeaders(committedPolicy.headers);
249
+ let wrappedBody: ReadableStream<Uint8Array> | null = null;
250
+ if (!reader) {
251
+ void finish('completed');
252
+ } else {
253
+ const sourceReader = reader;
254
+ wrappedBody = new ReadableStream<Uint8Array>(
255
+ {
256
+ start(controller) {
257
+ bodyController = controller;
258
+ },
259
+ async pull(controller) {
260
+ try {
261
+ const next = await sourceReader.read();
262
+ if (finishPromise) return;
263
+ if (!next.done) {
264
+ controller.enqueue(next.value);
265
+ return;
266
+ }
267
+ sourceReader.releaseLock();
268
+ const result = await finish('completed');
269
+ if (result.cleanupErrors.length > 0) {
270
+ controller.error(
271
+ new AggregateError(
272
+ result.cleanupErrors,
273
+ 'Renderer request cleanup failed.',
274
+ ),
275
+ );
276
+ } else {
277
+ controller.close();
278
+ }
279
+ } catch (error) {
280
+ if (!finishPromise) {
281
+ await finish('failed', error);
282
+ controller.error(error);
283
+ }
284
+ }
285
+ },
286
+ cancel(reason) {
287
+ abort(reason);
288
+ return completion.then(() => {});
289
+ },
290
+ },
291
+ { highWaterMark: 0 },
292
+ );
293
+ }
294
+ const response = new Response(wrappedBody, {
295
+ status: committedPolicy.status,
296
+ ...(committedPolicy.statusText === undefined
297
+ ? {}
298
+ : { statusText: committedPolicy.statusText }),
299
+ headers,
300
+ });
301
+ deliveredResponse = response;
302
+ deliveredHeaders = response.headers;
303
+ return response;
304
+ },
305
+ ownsResponseBody(response) {
306
+ return (
307
+ deliveredResponse !== undefined &&
308
+ (response === deliveredResponse ||
309
+ (deliveredResponse.body !== null &&
310
+ response.body === deliveredResponse.body))
311
+ );
312
+ },
313
+ fail(error) {
314
+ void finish('failed', error);
315
+ },
316
+ abort,
317
+ };
318
+ if (input.request.signal.aborted) onRequestAbort();
319
+ else
320
+ input.request.signal.addEventListener('abort', onRequestAbort, {
321
+ once: true,
322
+ });
323
+ return session;
324
+ }
@@ -0,0 +1,60 @@
1
+ import type { RendererIdentity } from '../identity';
2
+
3
+ export type ResponseHeaders = ReadonlyArray<readonly [string, string]>;
4
+
5
+ export type DocumentCachePolicy =
6
+ | { readonly mode: 'no-store' | 'private' }
7
+ | { readonly mode: 'public'; readonly maxAgeSeconds: number };
8
+
9
+ export interface ResponsePolicy {
10
+ readonly kind: 'document' | 'terminal';
11
+ readonly status: number;
12
+ readonly statusText?: string;
13
+ readonly headers: ResponseHeaders;
14
+ readonly cache: DocumentCachePolicy;
15
+ }
16
+
17
+ export interface RequestPlatform<Bindings extends object = object> {
18
+ readonly kind: 'node' | 'worker';
19
+ readonly bindings: Bindings;
20
+ }
21
+
22
+ export type RequestState =
23
+ | 'matching'
24
+ | 'ready'
25
+ | 'rendering'
26
+ | 'committed'
27
+ | 'completed'
28
+ | 'failed'
29
+ | 'aborted';
30
+
31
+ export interface RequestCompletion {
32
+ readonly state: 'completed' | 'failed' | 'aborted';
33
+ readonly fallback: boolean;
34
+ readonly error?: unknown;
35
+ readonly cleanupErrors: readonly unknown[];
36
+ readonly cacheEligible: boolean;
37
+ }
38
+
39
+ export type RequestCleanup = () => void | Promise<void>;
40
+
41
+ export interface RequestSession<Bindings extends object = object> {
42
+ readonly request: Request;
43
+ readonly identity: Readonly<RendererIdentity>;
44
+ readonly platform: RequestPlatform<Bindings>;
45
+ readonly signal: AbortSignal;
46
+ readonly state: RequestState;
47
+ readonly responsePolicy: ResponsePolicy | undefined;
48
+ readonly committedPolicy: ResponsePolicy | undefined;
49
+ readonly completion: Promise<RequestCompletion>;
50
+ resolveResponse(policy: ResponsePolicy): void;
51
+ startRendering(): void;
52
+ registerCleanup(cleanup: RequestCleanup): () => void;
53
+ markFallback(): void;
54
+ respond(body: ReadableStream<Uint8Array> | null): Response;
55
+ /** The returned response must deliver this session's final owned stream. */
56
+ ownsResponseBody(response: Response): boolean;
57
+ /** Notify termination. Await completion separately outside owned cleanup. */
58
+ fail(error: unknown): void;
59
+ abort(reason?: unknown): void;
60
+ }