@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.
- package/LICENSE +21 -0
- package/dist/cjs/abort.js +65 -0
- package/dist/cjs/adapter.js +62 -0
- package/dist/cjs/data/body.js +78 -0
- package/dist/cjs/data/client.js +409 -0
- package/dist/cjs/data/codec.js +168 -0
- package/dist/cjs/data/index.js +125 -0
- package/dist/cjs/data/private.js +77 -0
- package/dist/cjs/data/routes.js +149 -0
- package/dist/cjs/data/server.js +791 -0
- package/dist/cjs/data/static.js +110 -0
- package/dist/cjs/data/tree.js +231 -0
- package/dist/cjs/data/types.js +56 -0
- package/dist/cjs/document.js +184 -0
- package/dist/cjs/entry-client.js +142 -0
- package/dist/cjs/entry-server.js +253 -0
- package/dist/cjs/federation.js +80 -0
- package/dist/cjs/identity.js +120 -0
- package/dist/cjs/index.js +46 -0
- package/dist/cjs/localization.js +48 -0
- package/dist/cjs/router.js +293 -0
- package/dist/cjs/server/assets.js +56 -0
- package/dist/cjs/server/dispatch.js +351 -0
- package/dist/cjs/server/index.js +57 -0
- package/dist/cjs/server/types.js +18 -0
- package/dist/cjs/server/worker.js +73 -0
- package/dist/cjs/session/cache.js +172 -0
- package/dist/cjs/session/index.js +63 -0
- package/dist/cjs/session/request.js +269 -0
- package/dist/cjs/session/types.js +18 -0
- package/dist/esm/abort.mjs +27 -0
- package/dist/esm/adapter.mjs +21 -0
- package/dist/esm/data/body.mjs +40 -0
- package/dist/esm/data/client.mjs +368 -0
- package/dist/esm/data/codec.mjs +112 -0
- package/dist/esm/data/index.mjs +7 -0
- package/dist/esm/data/private.mjs +39 -0
- package/dist/esm/data/routes.mjs +108 -0
- package/dist/esm/data/server.mjs +723 -0
- package/dist/esm/data/static.mjs +51 -0
- package/dist/esm/data/tree.mjs +193 -0
- package/dist/esm/data/types.mjs +6 -0
- package/dist/esm/document.mjs +131 -0
- package/dist/esm/entry-client.mjs +104 -0
- package/dist/esm/entry-server.mjs +215 -0
- package/dist/esm/federation.mjs +36 -0
- package/dist/esm/identity.mjs +63 -0
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/localization.mjs +10 -0
- package/dist/esm/router.mjs +225 -0
- package/dist/esm/server/assets.mjs +15 -0
- package/dist/esm/server/dispatch.mjs +307 -0
- package/dist/esm/server/index.mjs +3 -0
- package/dist/esm/server/types.mjs +0 -0
- package/dist/esm/server/worker.mjs +35 -0
- package/dist/esm/session/cache.mjs +116 -0
- package/dist/esm/session/index.mjs +3 -0
- package/dist/esm/session/request.mjs +231 -0
- package/dist/esm/session/types.mjs +0 -0
- package/dist/esm-node/abort.mjs +28 -0
- package/dist/esm-node/adapter.mjs +22 -0
- package/dist/esm-node/data/body.mjs +41 -0
- package/dist/esm-node/data/client.mjs +369 -0
- package/dist/esm-node/data/codec.mjs +113 -0
- package/dist/esm-node/data/index.mjs +8 -0
- package/dist/esm-node/data/private.mjs +40 -0
- package/dist/esm-node/data/routes.mjs +109 -0
- package/dist/esm-node/data/server.mjs +724 -0
- package/dist/esm-node/data/static.mjs +52 -0
- package/dist/esm-node/data/tree.mjs +194 -0
- package/dist/esm-node/data/types.mjs +7 -0
- package/dist/esm-node/document.mjs +132 -0
- package/dist/esm-node/entry-client.mjs +105 -0
- package/dist/esm-node/entry-server.mjs +216 -0
- package/dist/esm-node/federation.mjs +37 -0
- package/dist/esm-node/identity.mjs +64 -0
- package/dist/esm-node/index.mjs +2 -0
- package/dist/esm-node/localization.mjs +11 -0
- package/dist/esm-node/router.mjs +226 -0
- package/dist/esm-node/server/assets.mjs +16 -0
- package/dist/esm-node/server/dispatch.mjs +308 -0
- package/dist/esm-node/server/index.mjs +4 -0
- package/dist/esm-node/server/types.mjs +1 -0
- package/dist/esm-node/server/worker.mjs +36 -0
- package/dist/esm-node/session/cache.mjs +117 -0
- package/dist/esm-node/session/index.mjs +4 -0
- package/dist/esm-node/session/request.mjs +232 -0
- package/dist/esm-node/session/types.mjs +1 -0
- package/dist/types/abort.d.ts +6 -0
- package/dist/types/adapter.d.ts +195 -0
- package/dist/types/data/body.d.ts +2 -0
- package/dist/types/data/client.d.ts +17 -0
- package/dist/types/data/codec.d.ts +14 -0
- package/dist/types/data/index.d.ts +8 -0
- package/dist/types/data/private.d.ts +6 -0
- package/dist/types/data/routes.d.ts +28 -0
- package/dist/types/data/server.d.ts +59 -0
- package/dist/types/data/static.d.ts +31 -0
- package/dist/types/data/tree.d.ts +3 -0
- package/dist/types/data/types.d.ts +104 -0
- package/dist/types/document.d.ts +76 -0
- package/dist/types/entry-client.d.ts +53 -0
- package/dist/types/entry-server.d.ts +63 -0
- package/dist/types/federation.d.ts +56 -0
- package/dist/types/identity.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/localization.d.ts +58 -0
- package/dist/types/router.d.ts +201 -0
- package/dist/types/server/assets.d.ts +14 -0
- package/dist/types/server/dispatch.d.ts +11 -0
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/types.d.ts +64 -0
- package/dist/types/server/worker.d.ts +25 -0
- package/dist/types/session/cache.d.ts +25 -0
- package/dist/types/session/index.d.ts +4 -0
- package/dist/types/session/request.d.ts +8 -0
- package/dist/types/session/types.d.ts +48 -0
- package/package.json +159 -4
- package/src/abort.ts +40 -0
- package/src/adapter.ts +255 -0
- package/src/data/body.ts +43 -0
- package/src/data/client.ts +501 -0
- package/src/data/codec.ts +247 -0
- package/src/data/index.ts +8 -0
- package/src/data/private.ts +68 -0
- package/src/data/routes.ts +201 -0
- package/src/data/server.ts +977 -0
- package/src/data/static.ts +105 -0
- package/src/data/tree.ts +244 -0
- package/src/data/types.ts +98 -0
- package/src/document.ts +338 -0
- package/src/entry-client.ts +236 -0
- package/src/entry-server.ts +393 -0
- package/src/federation.ts +111 -0
- package/src/identity.ts +128 -0
- package/src/index.ts +7 -0
- package/src/localization.ts +77 -0
- package/src/router.ts +475 -0
- package/src/server/assets.ts +60 -0
- package/src/server/dispatch.ts +523 -0
- package/src/server/index.ts +16 -0
- package/src/server/types.ts +94 -0
- package/src/server/worker.ts +94 -0
- package/src/session/cache.ts +177 -0
- package/src/session/index.ts +11 -0
- package/src/session/request.ts +324 -0
- package/src/session/types.ts +60 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { untilAborted } from '../abort';
|
|
2
|
+
import { assertRendererIdentity, identityCacheKey } from '../identity';
|
|
3
|
+
import {
|
|
4
|
+
createRequestSession,
|
|
5
|
+
documentCacheKey,
|
|
6
|
+
headerMaxAgeSeconds,
|
|
7
|
+
permitsDocumentCache,
|
|
8
|
+
policyHeaders,
|
|
9
|
+
type RequestSession,
|
|
10
|
+
responseHeaders,
|
|
11
|
+
} from '../session';
|
|
12
|
+
import type {
|
|
13
|
+
CachedNativeDocument,
|
|
14
|
+
NativeDispatchOptions,
|
|
15
|
+
NativeRequestContext,
|
|
16
|
+
NativeServerManifest,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/** Runs before bundle loading, cache lookup, or any renderer dispatch. */
|
|
20
|
+
export function rejectNativeRscRequest(request: Request): Response | undefined {
|
|
21
|
+
if (
|
|
22
|
+
request.headers.has('x-rsc-tree') ||
|
|
23
|
+
request.headers.has('x-rsc-action')
|
|
24
|
+
) {
|
|
25
|
+
return Response.json(
|
|
26
|
+
{ code: 'unsupported-renderer-capability', capability: 'rsc' },
|
|
27
|
+
{ status: 400, headers: { 'cache-control': 'no-store' } },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function permitsCacheLookup(request: Request): boolean {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
const cacheControl = request.headers.get('cache-control') ?? '';
|
|
36
|
+
return (
|
|
37
|
+
request.method === 'GET' &&
|
|
38
|
+
!request.headers.has('authorization') &&
|
|
39
|
+
!request.headers.has('cookie') &&
|
|
40
|
+
!request.headers.has('range') &&
|
|
41
|
+
!request.headers.has('if-none-match') &&
|
|
42
|
+
!request.headers.has('if-modified-since') &&
|
|
43
|
+
!request.headers.has('if-match') &&
|
|
44
|
+
!request.headers.has('if-unmodified-since') &&
|
|
45
|
+
!/(?:^|,)\s*(?:private|no-store|no-cache)(?:\s|,|=|$)/i.test(
|
|
46
|
+
cacheControl,
|
|
47
|
+
) &&
|
|
48
|
+
!/(?:^|,)\s*max-age\s*=\s*(?:0+|"0+")\s*(?:,|$)/i.test(cacheControl) &&
|
|
49
|
+
// A cached entry cannot promise the remaining freshness min-fresh asks for.
|
|
50
|
+
!/(?:^|,)\s*min-fresh(?:\s|,|=|$)/i.test(cacheControl) &&
|
|
51
|
+
// Conflicting request lifetimes have no single limit to compare against.
|
|
52
|
+
(cacheControl.match(/(?:^|,)\s*max-age\s*=/gi)?.length ?? 0) <= 1 &&
|
|
53
|
+
!request.headers
|
|
54
|
+
.get('pragma')
|
|
55
|
+
?.split(',')
|
|
56
|
+
.some(directive => directive.trim().toLowerCase() === 'no-cache') &&
|
|
57
|
+
!url.searchParams.has('__loader') &&
|
|
58
|
+
!url.searchParams.has('__ssrDirect')
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The request's `max-age` limit in seconds, when it sets a valid one. */
|
|
63
|
+
function requestMaxAgeSeconds(request: Request): number | undefined {
|
|
64
|
+
const match = /(?:^|,)\s*max-age\s*=\s*(?:(\d+)|"(\d+)")\s*(?:,|$)/i.exec(
|
|
65
|
+
request.headers.get('cache-control') ?? '',
|
|
66
|
+
);
|
|
67
|
+
return match ? Number(match[1] ?? match[2]) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function documentAgeSeconds(document: CachedNativeDocument): number {
|
|
71
|
+
return Math.floor((Date.now() - document.storedAt) / 1000);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isReusableDocument(
|
|
75
|
+
document: CachedNativeDocument,
|
|
76
|
+
key: string,
|
|
77
|
+
maxAgeSeconds: number | undefined,
|
|
78
|
+
): boolean {
|
|
79
|
+
return (
|
|
80
|
+
document.identityKey === key &&
|
|
81
|
+
Number.isFinite(document.storedAt) &&
|
|
82
|
+
document.storedAt <= Date.now() &&
|
|
83
|
+
// RFC 9111 freshness needs the lifetime to exceed the unrounded age.
|
|
84
|
+
(maxAgeSeconds === undefined ||
|
|
85
|
+
Date.now() - document.storedAt < maxAgeSeconds * 1000) &&
|
|
86
|
+
document.expiresAt > Date.now() &&
|
|
87
|
+
document.status === 200 &&
|
|
88
|
+
document.bytes instanceof Uint8Array &&
|
|
89
|
+
permitsDocumentCache({
|
|
90
|
+
kind: 'document',
|
|
91
|
+
status: document.status,
|
|
92
|
+
headers: document.headers,
|
|
93
|
+
cache: { mode: 'public', maxAgeSeconds: 1 },
|
|
94
|
+
}) &&
|
|
95
|
+
!policyHeaders(document.headers).has('vary')
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The reason phrase a Fetch runtime assigns when a policy omits one: empty in
|
|
101
|
+
* Node, the standard phrase (for example `OK`) in workerd.
|
|
102
|
+
*/
|
|
103
|
+
function defaultStatusText(status: number): string {
|
|
104
|
+
return new Response(null, { status }).statusText;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeTerminalResponse<Bindings extends object>(
|
|
108
|
+
response: Response,
|
|
109
|
+
session: RequestSession<Bindings>,
|
|
110
|
+
): Response {
|
|
111
|
+
if (session.committedPolicy) {
|
|
112
|
+
if (response.status !== session.committedPolicy.status) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'Native handler changed HTTP status after committing its response policy.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
response.statusText !==
|
|
119
|
+
(session.committedPolicy.statusText ??
|
|
120
|
+
defaultStatusText(session.committedPolicy.status))
|
|
121
|
+
) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'Native handler changed HTTP status text after committing its response policy.',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!session.ownsResponseBody(response)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'Native handler returned a body outside its committed request session.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return response;
|
|
132
|
+
}
|
|
133
|
+
session.resolveResponse({
|
|
134
|
+
kind: 'terminal',
|
|
135
|
+
status: response.status,
|
|
136
|
+
statusText: response.statusText,
|
|
137
|
+
headers: responseHeaders(response.headers),
|
|
138
|
+
cache: { mode: 'no-store' },
|
|
139
|
+
});
|
|
140
|
+
return session.respond(response.body);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function chooseCSR<Bindings extends object>(
|
|
144
|
+
request: Request,
|
|
145
|
+
manifest: NativeServerManifest<Bindings>,
|
|
146
|
+
context: NativeRequestContext<Bindings>,
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
const url = new URL(request.url);
|
|
149
|
+
if (
|
|
150
|
+
(request.method !== 'GET' && request.method !== 'HEAD') ||
|
|
151
|
+
url.searchParams.has('__loader') ||
|
|
152
|
+
url.searchParams.has('__ssrDirect')
|
|
153
|
+
)
|
|
154
|
+
return false;
|
|
155
|
+
const config = context.serverConfig;
|
|
156
|
+
if (config?.ssr === false || config?.forceCSR) return true;
|
|
157
|
+
const selectedIds = config?.ssrByRouteIds;
|
|
158
|
+
if (!selectedIds?.length) return false;
|
|
159
|
+
if (!manifest.nativeMatchRouteIds) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'unsupported-renderer-capability: ssrByRouteIds requires native route matching.',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
// An authored matcher that ignores cancellation must not hold the request.
|
|
165
|
+
const matchedIds = await untilAborted(
|
|
166
|
+
manifest.nativeMatchRouteIds(request, context),
|
|
167
|
+
context.session.signal,
|
|
168
|
+
);
|
|
169
|
+
const leafId = matchedIds.at(-1);
|
|
170
|
+
return !leafId || !selectedIds.includes(leafId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Capture follows the client consumer; abandoned bodies cannot populate cache. */
|
|
174
|
+
function captureDocument<Bindings extends object>(
|
|
175
|
+
response: Response,
|
|
176
|
+
session: RequestSession<Bindings>,
|
|
177
|
+
options: NativeDispatchOptions<Bindings>,
|
|
178
|
+
key: string,
|
|
179
|
+
identityKey: string,
|
|
180
|
+
): Response {
|
|
181
|
+
if (!response.body || !options.cache) return response;
|
|
182
|
+
const reader = response.body.getReader();
|
|
183
|
+
const chunks: Uint8Array[] = [];
|
|
184
|
+
const maximum = options.maxCacheBytes ?? 2 * 1024 * 1024;
|
|
185
|
+
let total = 0;
|
|
186
|
+
let canCapture = Number.isSafeInteger(maximum) && maximum > 0;
|
|
187
|
+
let delivered!: Response;
|
|
188
|
+
// The renderer's own fields, before the host merges route and middleware
|
|
189
|
+
// headers into the returned response in place. A cache hit goes through
|
|
190
|
+
// the host again, so storing the merged wire fields would repeat them.
|
|
191
|
+
const nativeHeaders = responseHeaders(response.headers);
|
|
192
|
+
const body = new ReadableStream<Uint8Array>(
|
|
193
|
+
{
|
|
194
|
+
async pull(controller) {
|
|
195
|
+
try {
|
|
196
|
+
const part = await reader.read();
|
|
197
|
+
if (!part.done) {
|
|
198
|
+
if (canCapture) {
|
|
199
|
+
total += part.value.byteLength;
|
|
200
|
+
if (total <= maximum) chunks.push(part.value.slice());
|
|
201
|
+
else {
|
|
202
|
+
canCapture = false;
|
|
203
|
+
chunks.length = 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
controller.enqueue(part.value);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reader.releaseLock();
|
|
210
|
+
const completion = await session.completion;
|
|
211
|
+
const policy = session.committedPolicy;
|
|
212
|
+
if (
|
|
213
|
+
canCapture &&
|
|
214
|
+
completion.cacheEligible &&
|
|
215
|
+
policy?.cache.mode === 'public' &&
|
|
216
|
+
policy.cache.maxAgeSeconds > 0 &&
|
|
217
|
+
delivered.status === 200 &&
|
|
218
|
+
!delivered.headers.has('vary') &&
|
|
219
|
+
permitsDocumentCache({
|
|
220
|
+
...policy,
|
|
221
|
+
status: delivered.status,
|
|
222
|
+
headers: responseHeaders(delivered.headers),
|
|
223
|
+
})
|
|
224
|
+
) {
|
|
225
|
+
const maxAgeSeconds = policy.cache.maxAgeSeconds;
|
|
226
|
+
const bytes = new Uint8Array(total);
|
|
227
|
+
let offset = 0;
|
|
228
|
+
for (const chunk of chunks) {
|
|
229
|
+
bytes.set(chunk, offset);
|
|
230
|
+
offset += chunk.byteLength;
|
|
231
|
+
}
|
|
232
|
+
// Final wire headers decide admission and lifetime only.
|
|
233
|
+
const commit = async (headers: Headers | undefined) => {
|
|
234
|
+
if (
|
|
235
|
+
!headers ||
|
|
236
|
+
headers.has('vary') ||
|
|
237
|
+
!permitsDocumentCache({
|
|
238
|
+
...policy,
|
|
239
|
+
status: delivered.status,
|
|
240
|
+
headers: responseHeaders(headers),
|
|
241
|
+
})
|
|
242
|
+
)
|
|
243
|
+
return;
|
|
244
|
+
// Middleware may shorten the committed lifetime; the stored
|
|
245
|
+
// entry never outlives what the delivered headers allow.
|
|
246
|
+
const lifetimeSeconds = Math.min(
|
|
247
|
+
maxAgeSeconds,
|
|
248
|
+
...headerMaxAgeSeconds(headers),
|
|
249
|
+
);
|
|
250
|
+
// Upstream Age and an older Date already spent part of that
|
|
251
|
+
// lifetime. Backdating storedAt by the larger one keeps both the
|
|
252
|
+
// expiry and the replayed Age honest.
|
|
253
|
+
const age = headers.get('age')?.trim() ?? '0';
|
|
254
|
+
const date = Date.parse(headers.get('date') ?? '');
|
|
255
|
+
const ageSeconds = Math.max(
|
|
256
|
+
/^\d+$/u.test(age) ? Number(age) : 0,
|
|
257
|
+
Number.isFinite(date)
|
|
258
|
+
? Math.floor((Date.now() - date) / 1000)
|
|
259
|
+
: 0,
|
|
260
|
+
);
|
|
261
|
+
if (lifetimeSeconds - ageSeconds <= 0) return;
|
|
262
|
+
try {
|
|
263
|
+
const storedAt = Date.now() - ageSeconds * 1000;
|
|
264
|
+
await options.cache?.set(key, {
|
|
265
|
+
identityKey,
|
|
266
|
+
storedAt,
|
|
267
|
+
expiresAt: storedAt + lifetimeSeconds * 1000,
|
|
268
|
+
status: 200,
|
|
269
|
+
statusText: delivered.statusText,
|
|
270
|
+
headers: nativeHeaders,
|
|
271
|
+
bytes,
|
|
272
|
+
});
|
|
273
|
+
} catch (error) {
|
|
274
|
+
options.onCacheError?.(error);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
if (options.confirmDelivery) {
|
|
278
|
+
// Node finish needs body EOF first. Confirm wire headers only
|
|
279
|
+
// after middleware and the transport have completed delivery.
|
|
280
|
+
chunks.length = 0;
|
|
281
|
+
controller.close();
|
|
282
|
+
void options
|
|
283
|
+
.confirmDelivery(delivered)
|
|
284
|
+
.then(commit)
|
|
285
|
+
.catch(error => {
|
|
286
|
+
options.onCacheError?.(error);
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// The write starts before EOF, but EOF never waits for it: a
|
|
291
|
+
// stalled cache must not hold a delivered response open.
|
|
292
|
+
void commit(delivered.headers).catch(error => {
|
|
293
|
+
options.onCacheError?.(error);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
chunks.length = 0;
|
|
297
|
+
controller.close();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
chunks.length = 0;
|
|
300
|
+
session.fail(error);
|
|
301
|
+
await session.completion;
|
|
302
|
+
controller.error(error);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
async cancel(reason) {
|
|
306
|
+
chunks.length = 0;
|
|
307
|
+
// Like session completion, delivery never waits on the source's
|
|
308
|
+
// cancel hook: an uncooperative stream must not hold the wire open.
|
|
309
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
310
|
+
session.abort(reason);
|
|
311
|
+
await session.completion;
|
|
312
|
+
reader.releaseLock();
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{ highWaterMark: 0 },
|
|
316
|
+
);
|
|
317
|
+
delivered = new Response(body, {
|
|
318
|
+
status: response.status,
|
|
319
|
+
statusText: response.statusText,
|
|
320
|
+
headers: response.headers,
|
|
321
|
+
});
|
|
322
|
+
return delivered;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function openSession<Bindings extends object>(
|
|
326
|
+
request: Request,
|
|
327
|
+
options: NativeDispatchOptions<Bindings>,
|
|
328
|
+
): RequestSession<Bindings> {
|
|
329
|
+
const session = createRequestSession({
|
|
330
|
+
request,
|
|
331
|
+
identity: options.identity,
|
|
332
|
+
platform: {
|
|
333
|
+
kind: options.platform ?? 'node',
|
|
334
|
+
bindings: options.context.bindings,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
// Worker isolates may end once fetch returns; keep owned cleanup alive.
|
|
338
|
+
options.executionContext?.waitUntil(session.completion);
|
|
339
|
+
return session;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Production Fetch dispatch. React rendering and matching are never imported. */
|
|
343
|
+
export async function dispatchNativeRequest<Bindings extends object>(
|
|
344
|
+
request: Request,
|
|
345
|
+
options: NativeDispatchOptions<Bindings>,
|
|
346
|
+
): Promise<Response> {
|
|
347
|
+
const rscRejection = rejectNativeRscRequest(request);
|
|
348
|
+
if (rscRejection) return rscRejection;
|
|
349
|
+
const identityKey = identityCacheKey(options.identity);
|
|
350
|
+
if (options.identity.renderer === 'react') {
|
|
351
|
+
throw new Error('Native dispatch requires Solid or Octane.');
|
|
352
|
+
}
|
|
353
|
+
let session = openSession(request, options);
|
|
354
|
+
const { bindings: _bindings, ...renderContext } = options.context;
|
|
355
|
+
let context: NativeRequestContext<Bindings> = {
|
|
356
|
+
...renderContext,
|
|
357
|
+
session,
|
|
358
|
+
entry: session.identity,
|
|
359
|
+
};
|
|
360
|
+
let handlerStarted = false;
|
|
361
|
+
try {
|
|
362
|
+
session.signal.throwIfAborted();
|
|
363
|
+
// Every stage before the response races the session signal: work that
|
|
364
|
+
// ignores cancellation must not hold a disconnected request.
|
|
365
|
+
const manifest = await untilAborted(options.loadManifest(), session.signal);
|
|
366
|
+
assertRendererIdentity(manifest.rendererIdentity, options.identity);
|
|
367
|
+
if (typeof manifest.nativeRequestHandler !== 'function') {
|
|
368
|
+
throw new Error('Native renderer manifest has no request handler.');
|
|
369
|
+
}
|
|
370
|
+
const csr = await chooseCSR(request, manifest, context);
|
|
371
|
+
session.signal.throwIfAborted();
|
|
372
|
+
if (csr && !manifest.nativeCSRRequestHandler) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
'unsupported-renderer-capability: selected CSR requires a native document handler.',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (csr) session.markFallback();
|
|
378
|
+
if (
|
|
379
|
+
options.hydrationBuildId !== undefined &&
|
|
380
|
+
(typeof options.hydrationBuildId !== 'string' ||
|
|
381
|
+
!options.hydrationBuildId.trim())
|
|
382
|
+
)
|
|
383
|
+
throw new Error(
|
|
384
|
+
'Native hydration cache identity requires a nonempty build ID.',
|
|
385
|
+
);
|
|
386
|
+
const cacheKey = documentCacheKey(
|
|
387
|
+
options.identity,
|
|
388
|
+
JSON.stringify([
|
|
389
|
+
request.url,
|
|
390
|
+
options.hydrationBuildId ?? options.identity.buildId,
|
|
391
|
+
options.context.assets ?? [],
|
|
392
|
+
options.context.nonce ?? null,
|
|
393
|
+
]),
|
|
394
|
+
);
|
|
395
|
+
const cacheAllowed = !csr && permitsCacheLookup(request);
|
|
396
|
+
if (cacheAllowed && options.cache) {
|
|
397
|
+
try {
|
|
398
|
+
// A cache outage must not keep a cancelled request alive.
|
|
399
|
+
const cached = await untilAborted(
|
|
400
|
+
options.cache.get(cacheKey),
|
|
401
|
+
session.signal,
|
|
402
|
+
);
|
|
403
|
+
if (
|
|
404
|
+
cached &&
|
|
405
|
+
isReusableDocument(cached, identityKey, requestMaxAgeSeconds(request))
|
|
406
|
+
) {
|
|
407
|
+
// The replayed cache-control lifetime started when the document was
|
|
408
|
+
// stored. Age keeps downstream caches from extending its freshness.
|
|
409
|
+
const age = documentAgeSeconds(cached);
|
|
410
|
+
session.resolveResponse({
|
|
411
|
+
kind: 'terminal',
|
|
412
|
+
status: cached.status,
|
|
413
|
+
statusText: cached.statusText,
|
|
414
|
+
headers: [
|
|
415
|
+
...cached.headers.filter(
|
|
416
|
+
([name]) => name.toLowerCase() !== 'age',
|
|
417
|
+
),
|
|
418
|
+
['age', String(age)],
|
|
419
|
+
],
|
|
420
|
+
cache: { mode: 'no-store' },
|
|
421
|
+
});
|
|
422
|
+
return session.respond(
|
|
423
|
+
new ReadableStream({
|
|
424
|
+
start(controller) {
|
|
425
|
+
controller.enqueue(cached.bytes.slice());
|
|
426
|
+
controller.close();
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (session.signal.aborted) throw error;
|
|
433
|
+
options.onCacheError?.(error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
session.signal.throwIfAborted();
|
|
437
|
+
handlerStarted = true;
|
|
438
|
+
let response = await untilAborted(
|
|
439
|
+
(csr ? manifest.nativeCSRRequestHandler! : manifest.nativeRequestHandler)(
|
|
440
|
+
request,
|
|
441
|
+
context,
|
|
442
|
+
),
|
|
443
|
+
session.signal,
|
|
444
|
+
);
|
|
445
|
+
if (!(response instanceof Response)) {
|
|
446
|
+
throw new TypeError(
|
|
447
|
+
'Native renderer handler must return a Fetch Response.',
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
response = normalizeTerminalResponse(response, session);
|
|
451
|
+
if (request.method === 'HEAD' && response.body) {
|
|
452
|
+
// A HEAD answer never waits on, or fails with, its discarded body.
|
|
453
|
+
void response.body.cancel().catch(() => {});
|
|
454
|
+
return new Response(null, {
|
|
455
|
+
status: response.status,
|
|
456
|
+
statusText: response.statusText,
|
|
457
|
+
headers: response.headers,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return cacheAllowed
|
|
461
|
+
? captureDocument(response, session, options, cacheKey, identityKey)
|
|
462
|
+
: response;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const committed = session.committedPolicy !== undefined;
|
|
465
|
+
session.fail(error);
|
|
466
|
+
await session.completion;
|
|
467
|
+
if (
|
|
468
|
+
!handlerStarted ||
|
|
469
|
+
committed ||
|
|
470
|
+
request.signal.aborted ||
|
|
471
|
+
!options.onError
|
|
472
|
+
)
|
|
473
|
+
throw error;
|
|
474
|
+
// A pre-commit fallback owns a fresh lifetime; the failed session is disposed.
|
|
475
|
+
session = openSession(request, options);
|
|
476
|
+
session.markFallback();
|
|
477
|
+
context = { ...context, session };
|
|
478
|
+
try {
|
|
479
|
+
const response = await untilAborted(
|
|
480
|
+
options.onError(error, request, context),
|
|
481
|
+
session.signal,
|
|
482
|
+
);
|
|
483
|
+
if (!(response instanceof Response))
|
|
484
|
+
throw new TypeError('Native fallback must return a Fetch Response.');
|
|
485
|
+
const owned = normalizeTerminalResponse(response, session);
|
|
486
|
+
if (request.method === 'HEAD' && owned.body) {
|
|
487
|
+
void owned.body.cancel().catch(() => {});
|
|
488
|
+
return new Response(null, {
|
|
489
|
+
status: owned.status,
|
|
490
|
+
statusText: owned.statusText,
|
|
491
|
+
headers: owned.headers,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return owned;
|
|
495
|
+
} catch (fallbackError) {
|
|
496
|
+
session.fail(fallbackError);
|
|
497
|
+
await session.completion;
|
|
498
|
+
throw fallbackError;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Node host dispatch; the request platform binding is always `node`.
|
|
505
|
+
* The Node adapter hands over a Proxy for requests with a body, which
|
|
506
|
+
* `new Request(request)` rejects, so the body request is rebuilt first.
|
|
507
|
+
*/
|
|
508
|
+
export function dispatchNativeNodeRequest<Bindings extends object>(
|
|
509
|
+
request: Request,
|
|
510
|
+
options: NativeDispatchOptions<Bindings>,
|
|
511
|
+
): Promise<Response> {
|
|
512
|
+
const fetchRequest =
|
|
513
|
+
request.body === null
|
|
514
|
+
? request
|
|
515
|
+
: new Request(request.url, {
|
|
516
|
+
method: request.method,
|
|
517
|
+
headers: request.headers,
|
|
518
|
+
body: request.body,
|
|
519
|
+
signal: request.signal,
|
|
520
|
+
duplex: 'half',
|
|
521
|
+
} as RequestInit);
|
|
522
|
+
return dispatchNativeRequest(fetchRequest, { ...options, platform: 'node' });
|
|
523
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type NativeClientAssetManifest,
|
|
3
|
+
RENDERER_ASSET_MANIFEST_FILE,
|
|
4
|
+
validateNativeClientAssetManifest,
|
|
5
|
+
} from './assets';
|
|
6
|
+
export {
|
|
7
|
+
dispatchNativeNodeRequest,
|
|
8
|
+
dispatchNativeRequest,
|
|
9
|
+
rejectNativeRscRequest,
|
|
10
|
+
} from './dispatch';
|
|
11
|
+
export type * from './types';
|
|
12
|
+
export {
|
|
13
|
+
dispatchNativeWorkerRequest,
|
|
14
|
+
type NativeWorkerDispatchOptions,
|
|
15
|
+
type NativeWorkerEntryResources,
|
|
16
|
+
} from './worker';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { DocumentAsset } from '../document';
|
|
2
|
+
import type { RendererIdentity } from '../identity';
|
|
3
|
+
import type { RequestPlatform, RequestSession } from '../session';
|
|
4
|
+
|
|
5
|
+
/** Native route matching remains with the selected router adapter. */
|
|
6
|
+
export interface NativeServerConfig {
|
|
7
|
+
readonly ssr?: boolean | 'stream';
|
|
8
|
+
readonly forceCSR?: boolean;
|
|
9
|
+
readonly ssrByRouteIds?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NativeRequestContext<Bindings extends object = object> {
|
|
13
|
+
readonly session: RequestSession<Bindings>;
|
|
14
|
+
readonly assets?: readonly DocumentAsset[];
|
|
15
|
+
readonly nonce?: string;
|
|
16
|
+
/** Opaque compiler-owned JSON. The native adapter validates its own ABI. */
|
|
17
|
+
readonly nativeManifest?: unknown;
|
|
18
|
+
readonly serverConfig?: NativeServerConfig;
|
|
19
|
+
readonly entry: Readonly<RendererIdentity>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type NativeRequestHandler<Bindings extends object = object> = (
|
|
23
|
+
request: Request,
|
|
24
|
+
context: NativeRequestContext<Bindings>,
|
|
25
|
+
) => Response | Promise<Response>;
|
|
26
|
+
|
|
27
|
+
/** Identity is generated with the bundle, never inferred from its filename. */
|
|
28
|
+
export interface NativeServerManifest<Bindings extends object = object> {
|
|
29
|
+
readonly rendererIdentity: RendererIdentity;
|
|
30
|
+
readonly nativeRequestHandler: NativeRequestHandler<Bindings>;
|
|
31
|
+
readonly nativeCSRRequestHandler?: NativeRequestHandler<Bindings>;
|
|
32
|
+
readonly nativeMatchRouteIds?: (
|
|
33
|
+
request: Request,
|
|
34
|
+
context: NativeRequestContext<Bindings>,
|
|
35
|
+
) => readonly string[] | Promise<readonly string[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CachedNativeDocument {
|
|
39
|
+
readonly identityKey: string;
|
|
40
|
+
/** Epoch milliseconds when the document was stored; replays derive Age from it. */
|
|
41
|
+
readonly storedAt: number;
|
|
42
|
+
readonly expiresAt: number;
|
|
43
|
+
readonly status: 200;
|
|
44
|
+
readonly statusText: string;
|
|
45
|
+
readonly headers: ReadonlyArray<readonly [string, string]>;
|
|
46
|
+
readonly bytes: Uint8Array;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** All calls receive an identity-namespaced key, including custom stores. */
|
|
50
|
+
export interface NativeDocumentCache {
|
|
51
|
+
get(
|
|
52
|
+
key: string,
|
|
53
|
+
):
|
|
54
|
+
| CachedNativeDocument
|
|
55
|
+
| undefined
|
|
56
|
+
| Promise<CachedNativeDocument | undefined>;
|
|
57
|
+
set(key: string, value: CachedNativeDocument): void | Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The subset of a worker execution context the native dispatcher uses. */
|
|
61
|
+
export interface NativeExecutionContext {
|
|
62
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NativeDispatchOptions<Bindings extends object = object> {
|
|
66
|
+
readonly identity: RendererIdentity;
|
|
67
|
+
readonly loadManifest: () =>
|
|
68
|
+
| NativeServerManifest<Bindings>
|
|
69
|
+
| Promise<NativeServerManifest<Bindings>>;
|
|
70
|
+
readonly context: Omit<
|
|
71
|
+
NativeRequestContext<Bindings>,
|
|
72
|
+
'session' | 'entry'
|
|
73
|
+
> & {
|
|
74
|
+
readonly bindings: Bindings;
|
|
75
|
+
};
|
|
76
|
+
/** The request host binding kind. Defaults to `node`. */
|
|
77
|
+
readonly platform?: RequestPlatform['kind'];
|
|
78
|
+
/** Worker hosts retain each owned session until its cleanup completes. */
|
|
79
|
+
readonly executionContext?: NativeExecutionContext;
|
|
80
|
+
readonly cache?: NativeDocumentCache;
|
|
81
|
+
readonly maxCacheBytes?: number;
|
|
82
|
+
/** Native hydration ABI build, distinct from the source/profile identity. */
|
|
83
|
+
readonly hydrationBuildId?: string;
|
|
84
|
+
/** Node hosts confirm complete delivery and return the final wire headers. */
|
|
85
|
+
readonly confirmDelivery?: (
|
|
86
|
+
response: Response,
|
|
87
|
+
) => Promise<Headers | undefined>;
|
|
88
|
+
readonly onCacheError?: (error: unknown) => void;
|
|
89
|
+
readonly onError?: (
|
|
90
|
+
error: unknown,
|
|
91
|
+
request: Request,
|
|
92
|
+
context: NativeRequestContext<Bindings>,
|
|
93
|
+
) => Response | Promise<Response>;
|
|
94
|
+
}
|