@rsc-kit/core 0.17.0 → 0.18.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/dist/barrelImports.d.ts +6 -0
- package/dist/barrelImports.js +93 -0
- package/dist/barrelImports.js.map +1 -0
- package/dist/events.d.ts +57 -0
- package/dist/events.js +113 -0
- package/dist/events.js.map +1 -0
- package/dist/host.d.ts +16 -6
- package/dist/host.js +219 -124
- package/dist/host.js.map +1 -1
- package/dist/hostCalls.d.ts +27 -0
- package/dist/hostCalls.js +127 -12
- package/dist/hostCalls.js.map +1 -1
- package/dist/hostRouting.d.ts +53 -0
- package/dist/hostRouting.js +100 -0
- package/dist/hostRouting.js.map +1 -0
- package/dist/js/DefaultRouteError.d.ts +3 -0
- package/dist/js/DefaultRouteError.js +61 -0
- package/dist/js/DefaultRouteError.js.map +1 -0
- package/dist/js/LoadingBoundary.d.ts +5 -0
- package/dist/js/LoadingBoundary.js +19 -0
- package/dist/js/LoadingBoundary.js.map +1 -0
- package/dist/js/fallbackReport.js +10 -8
- package/dist/js/fallbackReport.js.map +1 -1
- package/dist/js/queryClient.d.ts +2 -1
- package/dist/js/queryClient.js +0 -12
- package/dist/js/queryClient.js.map +1 -1
- package/dist/js/router.d.ts +2 -2
- package/dist/js/router.js.map +1 -1
- package/dist/js/useEvents.d.ts +25 -0
- package/dist/js/useEvents.js +80 -0
- package/dist/js/useEvents.js.map +1 -0
- package/dist/js/usePolling.d.ts +29 -0
- package/dist/js/usePolling.js +142 -0
- package/dist/js/usePolling.js.map +1 -0
- package/dist/manifest.d.ts +18 -1
- package/dist/manifest.js.map +1 -1
- package/dist/metadataRoutes.d.ts +20 -0
- package/dist/metadataRoutes.js +35 -0
- package/dist/metadataRoutes.js.map +1 -1
- package/dist/prerender.js +2 -0
- package/dist/prerender.js.map +1 -1
- package/dist/redirect.d.ts +16 -2
- package/dist/redirect.js +10 -19
- package/dist/redirect.js.map +1 -1
- package/dist/revalidate.d.ts +6 -4
- package/dist/revalidate.js +6 -5
- package/dist/revalidate.js.map +1 -1
- package/dist/routeSchema.d.ts +49 -0
- package/dist/routeSchema.js.map +1 -1
- package/dist/routes.d.ts +16 -3
- package/dist/routes.js +5 -5
- package/dist/routes.js.map +1 -1
- package/dist/routing.d.ts +2 -2
- package/dist/routing.js +47 -25
- package/dist/routing.js.map +1 -1
- package/dist/testing.d.ts +8 -0
- package/dist/testing.js +27 -3
- package/dist/testing.js.map +1 -1
- package/dist/vite.d.ts +53 -2
- package/dist/vite.js +503 -38
- package/dist/vite.js.map +1 -1
- package/package.json +15 -3
package/dist/hostCalls.js
CHANGED
|
@@ -16,17 +16,29 @@ import { ActionValidationError } from './action.js';
|
|
|
16
16
|
import { redirect } from './redirect.js';
|
|
17
17
|
import { ServerAuthenticationError, ServerAuthorizationError } from './js/errors.js';
|
|
18
18
|
const DEFAULT_FORWARDED = ['cookie', 'authorization'];
|
|
19
|
+
/** How many calls one POST carries at most. */
|
|
20
|
+
const BATCH_LIMIT = 50;
|
|
21
|
+
// After the current I/O, before the next timer: what a render issued
|
|
22
|
+
// synchronously - and across the microtasks between one component's awaits -
|
|
23
|
+
// is in the bucket by then. setImmediate where the runtime has it, a zero
|
|
24
|
+
// timer where it does not (a Worker).
|
|
25
|
+
const nextTick = typeof setImmediate === 'function' ? (fn) => setImmediate(fn) : (fn) => setTimeout(fn, 0);
|
|
19
26
|
/**
|
|
20
27
|
* The function to hand to `installHostFn`, or to `hostCalls` on the JS host.
|
|
21
28
|
*/
|
|
22
29
|
export function httpHostCalls(options) {
|
|
23
|
-
const { endpoint, secret, forwardHeaders = DEFAULT_FORWARDED, timeoutMs = 30_000, fetch: fetchImpl, onRevalidate, } = options;
|
|
30
|
+
const { endpoint, secret, forwardHeaders = DEFAULT_FORWARDED, timeoutMs = 30_000, fetch: fetchImpl, onRevalidate, batch = true, } = options;
|
|
24
31
|
if (!secret) {
|
|
25
32
|
throw new Error('httpHostCalls requires a secret — see the note on the option.');
|
|
26
33
|
}
|
|
27
34
|
const forwarded = forwardHeaders.map((name) => name.toLowerCase());
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
// Off for good the first time the host answers a batch as something else.
|
|
36
|
+
let batching = batch;
|
|
37
|
+
// One bucket per set of forwarded headers: two visitors' renders in flight
|
|
38
|
+
// at once must not share a request, because the host reads the cookie off
|
|
39
|
+
// the request to know whose session a call runs under.
|
|
40
|
+
const buckets = new Map();
|
|
41
|
+
async function forwardedHeaders() {
|
|
30
42
|
const headers = {
|
|
31
43
|
'content-type': 'application/json',
|
|
32
44
|
'x-rsc-host-secret': secret,
|
|
@@ -54,6 +66,11 @@ export function httpHostCalls(options) {
|
|
|
54
66
|
if (!message.startsWith('No request in scope'))
|
|
55
67
|
throw error;
|
|
56
68
|
}
|
|
69
|
+
return headers;
|
|
70
|
+
}
|
|
71
|
+
/** One POST. `label` names what is being sent, for the error messages. */
|
|
72
|
+
async function post(body, headers, label) {
|
|
73
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
57
74
|
// AbortSignal.timeout is not on every runtime this engine targets, so the
|
|
58
75
|
// controller is written out rather than assumed.
|
|
59
76
|
const controller = new AbortController();
|
|
@@ -63,15 +80,15 @@ export function httpHostCalls(options) {
|
|
|
63
80
|
response = await doFetch(endpoint, {
|
|
64
81
|
method: 'POST',
|
|
65
82
|
headers,
|
|
66
|
-
body: JSON.stringify(
|
|
83
|
+
body: JSON.stringify(body),
|
|
67
84
|
signal: controller.signal,
|
|
68
85
|
});
|
|
69
86
|
}
|
|
70
87
|
catch (error) {
|
|
71
88
|
if (controller.signal.aborted) {
|
|
72
|
-
throw new Error(`Host call ${
|
|
89
|
+
throw new Error(`Host call ${label} timed out after ${timeoutMs}ms`);
|
|
73
90
|
}
|
|
74
|
-
throw new Error(`Host call ${
|
|
91
|
+
throw new Error(`Host call ${label} could not reach the host at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
92
|
}
|
|
76
93
|
finally {
|
|
77
94
|
clearTimeout(timer);
|
|
@@ -79,14 +96,105 @@ export function httpHostCalls(options) {
|
|
|
79
96
|
// Read the body before branching on status: a host that reports the error
|
|
80
97
|
// in JSON with a 500 is saying something more useful than "500", and
|
|
81
98
|
// throwing on the status alone discards it.
|
|
82
|
-
|
|
83
|
-
|
|
99
|
+
return { status: response.status, text: await response.text() };
|
|
100
|
+
}
|
|
101
|
+
function parse(text) {
|
|
84
102
|
try {
|
|
85
|
-
|
|
103
|
+
return text ? JSON.parse(text) : null;
|
|
86
104
|
}
|
|
87
105
|
catch {
|
|
88
|
-
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function single(name, args, headers) {
|
|
110
|
+
const { status, text } = await post({ function: name, args }, headers, JSON.stringify(name));
|
|
111
|
+
return { status, reply: parse(text), text };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Send a bucket. One call goes as itself, so a host that speaks only the
|
|
115
|
+
* single shape - or a request that happened to be alone - sees nothing
|
|
116
|
+
* new on the wire.
|
|
117
|
+
*/
|
|
118
|
+
async function flush(bucket) {
|
|
119
|
+
const { headers, calls } = bucket;
|
|
120
|
+
if (calls.length === 1) {
|
|
121
|
+
const [only] = calls;
|
|
122
|
+
try {
|
|
123
|
+
only.resolve(await single(only.name, only.args, headers));
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
only.reject(error);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const label = `batch of ${calls.length} (${calls.map((c) => c.name).join(', ')})`;
|
|
131
|
+
let status;
|
|
132
|
+
let text;
|
|
133
|
+
try {
|
|
134
|
+
;
|
|
135
|
+
({ status, text } = await post({ calls: calls.map((c) => ({ function: c.name, args: c.args })) }, headers, label));
|
|
89
136
|
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// The host could not be reached, or did not answer in time. Not
|
|
139
|
+
// re-sent one by one: the calls may have run, and an action run twice
|
|
140
|
+
// is worse than one reported as failed.
|
|
141
|
+
for (const call of calls)
|
|
142
|
+
call.reject(error);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const parsed = parse(text);
|
|
146
|
+
const replies = parsed?.replies;
|
|
147
|
+
if (status < 400 && Array.isArray(replies) && replies.length === calls.length) {
|
|
148
|
+
calls.forEach((call, i) => {
|
|
149
|
+
const { status: own, ...reply } = replies[i];
|
|
150
|
+
call.resolve({ status: own ?? 200, reply, text: JSON.stringify(reply) });
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Not a batch answer: a host that does not know the envelope refused it
|
|
155
|
+
// as one malformed call, before running anything. From here on, one at a
|
|
156
|
+
// time - and these, now.
|
|
157
|
+
batching = false;
|
|
158
|
+
await Promise.all(calls.map(async (call) => {
|
|
159
|
+
try {
|
|
160
|
+
call.resolve(await single(call.name, call.args, headers));
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
call.reject(error);
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
function enqueue(name, args, headers) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const key = JSON.stringify(headers);
|
|
170
|
+
let bucket = buckets.get(key);
|
|
171
|
+
if (!bucket) {
|
|
172
|
+
bucket = { headers, calls: [] };
|
|
173
|
+
buckets.set(key, bucket);
|
|
174
|
+
const mine = bucket;
|
|
175
|
+
nextTick(() => {
|
|
176
|
+
if (buckets.get(key) === mine)
|
|
177
|
+
buckets.delete(key);
|
|
178
|
+
void flush(mine);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
bucket.calls.push({ name, args, resolve, reject });
|
|
182
|
+
if (bucket.calls.length >= BATCH_LIMIT) {
|
|
183
|
+
buckets.delete(key);
|
|
184
|
+
void flush(bucket);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* What a reply means, in the caller's own async context.
|
|
190
|
+
*
|
|
191
|
+
* Deliberately not done where the response arrives: a batch is answered in
|
|
192
|
+
* a timer, outside every caller's request scope, and `redirect()` and
|
|
193
|
+
* `revalidate()` both write to that scope. Interpreting here, after the
|
|
194
|
+
* caller's await resumed, puts each reply back inside the render it belongs
|
|
195
|
+
* to.
|
|
196
|
+
*/
|
|
197
|
+
function interpret(name, { status, reply, text }) {
|
|
90
198
|
// Refusing the input is not the call failing — it is the call answering.
|
|
91
199
|
//
|
|
92
200
|
// Thrown rather than returned, so a handler stops where it is instead of
|
|
@@ -124,8 +232,8 @@ export function httpHostCalls(options) {
|
|
|
124
232
|
}
|
|
125
233
|
throw failure;
|
|
126
234
|
}
|
|
127
|
-
if (
|
|
128
|
-
throw new Error(`Host call ${JSON.stringify(name)} failed: ${
|
|
235
|
+
if (status >= 400) {
|
|
236
|
+
throw new Error(`Host call ${JSON.stringify(name)} failed: ${status}`.trim() +
|
|
129
237
|
(text ? ` — ${text.slice(0, 200)}` : ''));
|
|
130
238
|
}
|
|
131
239
|
if (reply === null) {
|
|
@@ -134,11 +242,18 @@ export function httpHostCalls(options) {
|
|
|
134
242
|
if (reply.revalidate?.length) {
|
|
135
243
|
if (onRevalidate)
|
|
136
244
|
onRevalidate(reply.revalidate);
|
|
245
|
+
// Named by the host over the wire - Rsc::revalidate('orders') in PHP -
|
|
246
|
+
// so nothing here can check it; the renderer refuses an unknown name.
|
|
137
247
|
else
|
|
138
248
|
for (const target of reply.revalidate)
|
|
139
249
|
revalidate(target);
|
|
140
250
|
}
|
|
141
251
|
return reply.result ?? null;
|
|
252
|
+
}
|
|
253
|
+
return async function hostCall(name, ...args) {
|
|
254
|
+
const headers = await forwardedHeaders();
|
|
255
|
+
const settled = batching ? await enqueue(name, args, headers) : await single(name, args, headers);
|
|
256
|
+
return interpret(name, settled);
|
|
142
257
|
};
|
|
143
258
|
}
|
|
144
259
|
//# sourceMappingURL=hostCalls.js.map
|
package/dist/hostCalls.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hostCalls.js","sourceRoot":"","sources":["../src/hostCalls.ts"],"names":[],"mappings":"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","sourcesContent":["// Host calls over HTTP, so a host in any language can answer them.\n//\n// A server component writing `await rpc('Orders.recent')` needs the host\n// mid-render. The Laravel path answers on a second unix socket with its own\n// framing (PROTOCOL.md, \"The callback channel\"), which every non-JS host has\n// to reimplement before it can render a single page that fetches anything.\n// This is the same conversation over an ordinary POST.\n//\n// The transport is the only thing that changes. The engine still sees one\n// function, the deferred-release ordering in streaming.ts still applies, and a\n// reply may still carry `revalidate` — a Go host that answers this endpoint\n// gets the same semantics the socket has, without the framing.\n\nimport { headers as incomingHeaders } from './request.js'\nimport { revalidate } from './revalidate.js'\nimport { ActionValidationError } from './action.js'\nimport { redirect } from './redirect.js'\nimport { ServerAuthenticationError, ServerAuthorizationError } from './js/errors.js'\n\nexport interface HttpHostCallsOptions {\n /**\n * Where calls are POSTed. A loopback address or a unix socket url — see the\n * note on `secret` for why this must not be a public one.\n */\n endpoint: string\n /**\n * Sent as `X-RSC-Host-Secret`, and required rather than optional.\n *\n * This endpoint runs functions by name on behalf of a render. PROTOCOL.md\n * Part 3b draws the line at client-supplied input deciding what RUNS, and an\n * unauthenticated callback endpoint is on the wrong side of it: anyone who\n * can reach it can invoke any registered host function directly, with\n * whatever arguments they like and none of the app's routing in front. A\n * default of \"off\" would be the kind that ships.\n */\n secret: string\n /**\n * Request headers copied from the render's own request onto the call.\n *\n * This is what makes a host call run as the person browsing: the backend's\n * session middleware reads its cookie and finds the same user. Nothing else\n * is forwarded, because everything else is either meaningless to the backend\n * or actively wrong — `content-length` and `content-type` describe this POST,\n * not the page request.\n */\n forwardHeaders?: string[]\n /** Defaults to 30s. A render blocked on a host that never answers is a hung request. */\n timeoutMs?: number\n /** Injectable for tests and for a runtime whose fetch is not global. */\n fetch?: typeof fetch\n /**\n * What to do with whatever a reply reported as invalidated.\n *\n * Defaults to marking it on the engine, which is the only thing that makes\n * `Revalidate(ctx, \"orders\")` on the host side mean anything: an action's\n * answer carries the re-rendered region only if the target reached the\n * revalidation scope before the action returned. Without it a host reports\n * what it dirtied, nothing listens, and the browser is told nothing — the\n * page simply shows stale data with no error anywhere.\n */\n onRevalidate?: (targets: string[]) => void\n}\n\nconst DEFAULT_FORWARDED = ['cookie', 'authorization']\n\nexport interface HostCallReply {\n result?: unknown\n error?: string\n revalidate?: string[]\n /**\n * Field name to messages, when the host refused the input.\n *\n * The same shape everything else here already uses: Laravel's own\n * `$e->errors()`, the socket protocol's `validation_errors`, and what\n * `issuesToErrors` turns a Standard Schema result into. Dot-joined for a\n * nested field, the empty string for a message about the form rather than\n * any one field.\n */\n validationErrors?: Record<string, string[]>\n /** The caller has no session. Becomes the engine's own authentication error. */\n unauthenticated?: boolean\n /** The caller has a session and still may not. */\n unauthorized?: boolean\n /**\n * Where the host says this request should go instead.\n *\n * Answered with a 200 and this field, never as a 3xx: fetch follows a\n * redirect transparently, so a real one would send the host call itself to\n * the destination and hand whatever came back to the render as the\n * function's result.\n */\n redirect?: string\n /** The status to redirect with. Defaults to 307, which preserves the method. */\n redirectStatus?: number\n /**\n * The status a refusal should be answered with.\n *\n * A middleware that aborted meant what it aborted with — throttle answers\n * 429, a signed-url check 403 — and collapsing those to 500 makes a\n * rate-limited visitor indistinguishable from a broken server.\n */\n refusalStatus?: number\n}\n\n/**\n * The function to hand to `installHostFn`, or to `hostCalls` on the JS host.\n */\nexport function httpHostCalls(\n options: HttpHostCallsOptions,\n): (name: string, ...args: unknown[]) => Promise<unknown> {\n const {\n endpoint,\n secret,\n forwardHeaders = DEFAULT_FORWARDED,\n timeoutMs = 30_000,\n fetch: fetchImpl,\n onRevalidate,\n } = options\n\n if (!secret) {\n throw new Error('httpHostCalls requires a secret — see the note on the option.')\n }\n\n const forwarded = forwardHeaders.map((name) => name.toLowerCase())\n\n return async function hostCall(name: string, ...args: unknown[]): Promise<unknown> {\n const doFetch = fetchImpl ?? globalThis.fetch\n\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n 'x-rsc-host-secret': secret,\n }\n\n // Outside a render — a prerender pass, or a host calling one directly —\n // there is no request to forward from, and that is not an error. The call\n // simply carries no session, which is exactly what a build-time render\n // should be doing.\n //\n // Only that one condition is swallowed. A blanket catch here is the wrong\n // shape: anything else going wrong while reading the request would come\n // out as a call with no session, which is not a failure the caller sees —\n // it is the visitor silently becoming anonymous, and the page rendering as\n // though they were logged out.\n try {\n const from = await incomingHeaders()\n\n\n for (const key of forwarded) {\n const value = from.get(key)\n if (value !== null) headers[key] = value\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n\n if (!message.startsWith('No request in scope')) throw error\n }\n\n // AbortSignal.timeout is not on every runtime this engine targets, so the\n // controller is written out rather than assumed.\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n let response: Response\n\n try {\n response = await doFetch(endpoint, {\n method: 'POST',\n headers,\n body: JSON.stringify({ function: name, args }),\n signal: controller.signal,\n })\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`Host call ${JSON.stringify(name)} timed out after ${timeoutMs}ms`)\n }\n\n throw new Error(\n `Host call ${JSON.stringify(name)} could not reach the host at ${endpoint}: ${\n error instanceof Error ? error.message : String(error)\n }`,\n )\n } finally {\n clearTimeout(timer)\n }\n\n // Read the body before branching on status: a host that reports the error\n // in JSON with a 500 is saying something more useful than \"500\", and\n // throwing on the status alone discards it.\n const text = await response.text()\n let reply: HostCallReply | null = null\n\n try {\n reply = text ? (JSON.parse(text) as HostCallReply) : null\n } catch {\n reply = null\n }\n\n // Refusing the input is not the call failing — it is the call answering.\n //\n // Thrown rather than returned, so a handler stops where it is instead of\n // carrying on with data the host rejected. createActionClient catches this\n // on the way out and returns { validationErrors }, which is the shape\n // useForm reads: React serialises a REJECTED server action opaquely, so a\n // validation error that stays thrown reaches the browser as \"an error\n // occurred\" with every field it named gone.\n //\n // Checked before `error`, so a host that sends both is read as a refusal\n // rather than as a failure with no fields.\n if (reply?.validationErrors) {\n throw new ActionValidationError(reply.validationErrors)\n }\n\n // Raised as the engine's own redirect, so it travels the path every other\n // redirect travels — a real 3xx above a Suspense boundary, the digest\n // below one — rather than becoming an error the page has to interpret.\n if (reply?.redirect) {\n redirect(reply.redirect as never, reply.redirectStatus ?? 307)\n }\n\n if (reply?.unauthenticated) {\n throw new ServerAuthenticationError(reply.error ?? 'Unauthenticated.')\n }\n\n if (reply?.unauthorized) {\n throw new ServerAuthorizationError(reply.error ?? 'This action is unauthorized.')\n }\n\n if (reply?.error !== undefined) {\n const failure = new Error(`Host call ${JSON.stringify(name)} failed: ${reply.error}`)\n\n // Carried on the error rather than thrown as another class: the host\n // chose a status and the only job here is not to lose it on the way to\n // whoever writes the response.\n if (reply.refusalStatus) {\n ;(failure as Error & { refusalStatus?: number }).refusalStatus = reply.refusalStatus\n }\n\n throw failure\n }\n\n if (!response.ok) {\n throw new Error(\n `Host call ${JSON.stringify(name)} failed: ${response.status} ${response.statusText}`.trim() +\n (text ? ` — ${text.slice(0, 200)}` : ''),\n )\n }\n\n if (reply === null) {\n throw new Error(\n `Host call ${JSON.stringify(name)} returned a body that is not JSON: ${text.slice(0, 200)}`,\n )\n }\n\n if (reply.revalidate?.length) {\n if (onRevalidate) onRevalidate(reply.revalidate)\n else for (const target of reply.revalidate) revalidate(target)\n }\n\n return reply.result ?? null\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hostCalls.js","sourceRoot":"","sources":["../src/hostCalls.ts"],"names":[],"mappings":"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","sourcesContent":["// Host calls over HTTP, so a host in any language can answer them.\n//\n// A server component writing `await rpc('Orders.recent')` needs the host\n// mid-render. The Laravel path answers on a second unix socket with its own\n// framing (PROTOCOL.md, \"The callback channel\"), which every non-JS host has\n// to reimplement before it can render a single page that fetches anything.\n// This is the same conversation over an ordinary POST.\n//\n// The transport is the only thing that changes. The engine still sees one\n// function, the deferred-release ordering in streaming.ts still applies, and a\n// reply may still carry `revalidate` — a Go host that answers this endpoint\n// gets the same semantics the socket has, without the framing.\n\nimport { headers as incomingHeaders } from './request.js'\nimport { revalidate } from './revalidate.js'\nimport type { RevalidateTarget } from './routes.js'\nimport { ActionValidationError } from './action.js'\nimport { redirect } from './redirect.js'\nimport { ServerAuthenticationError, ServerAuthorizationError } from './js/errors.js'\n\nexport interface HttpHostCallsOptions {\n /**\n * Where calls are POSTed. A loopback address or a unix socket url — see the\n * note on `secret` for why this must not be a public one.\n */\n endpoint: string\n /**\n * Sent as `X-RSC-Host-Secret`, and required rather than optional.\n *\n * This endpoint runs functions by name on behalf of a render. PROTOCOL.md\n * Part 3b draws the line at client-supplied input deciding what RUNS, and an\n * unauthenticated callback endpoint is on the wrong side of it: anyone who\n * can reach it can invoke any registered host function directly, with\n * whatever arguments they like and none of the app's routing in front. A\n * default of \"off\" would be the kind that ships.\n */\n secret: string\n /**\n * Request headers copied from the render's own request onto the call.\n *\n * This is what makes a host call run as the person browsing: the backend's\n * session middleware reads its cookie and finds the same user. Nothing else\n * is forwarded, because everything else is either meaningless to the backend\n * or actively wrong — `content-length` and `content-type` describe this POST,\n * not the page request.\n */\n forwardHeaders?: string[]\n /** Defaults to 30s. A render blocked on a host that never answers is a hung request. */\n timeoutMs?: number\n /** Injectable for tests and for a runtime whose fetch is not global. */\n fetch?: typeof fetch\n /**\n * What to do with whatever a reply reported as invalidated.\n *\n * Defaults to marking it on the engine, which is the only thing that makes\n * `Revalidate(ctx, \"orders\")` on the host side mean anything: an action's\n * answer carries the re-rendered region only if the target reached the\n * revalidation scope before the action returned. Without it a host reports\n * what it dirtied, nothing listens, and the browser is told nothing — the\n * page simply shows stale data with no error anywhere.\n */\n onRevalidate?: (targets: string[]) => void\n /**\n * Send calls issued in the same tick as one POST. On by default; a host\n * that does not understand the envelope is detected on the first batch and\n * sent single calls from then on. `false` never batches.\n */\n batch?: boolean\n}\n\nconst DEFAULT_FORWARDED = ['cookie', 'authorization']\n\nexport interface HostCallReply {\n result?: unknown\n error?: string\n revalidate?: string[]\n /**\n * Field name to messages, when the host refused the input.\n *\n * The same shape everything else here already uses: Laravel's own\n * `$e->errors()`, the socket protocol's `validation_errors`, and what\n * `issuesToErrors` turns a Standard Schema result into. Dot-joined for a\n * nested field, the empty string for a message about the form rather than\n * any one field.\n */\n validationErrors?: Record<string, string[]>\n /** The caller has no session. Becomes the engine's own authentication error. */\n unauthenticated?: boolean\n /** The caller has a session and still may not. */\n unauthorized?: boolean\n /**\n * Where the host says this request should go instead.\n *\n * Answered with a 200 and this field, never as a 3xx: fetch follows a\n * redirect transparently, so a real one would send the host call itself to\n * the destination and hand whatever came back to the render as the\n * function's result.\n */\n redirect?: string\n /** The status to redirect with. Defaults to 307, which preserves the method. */\n redirectStatus?: number\n /**\n * The status a refusal should be answered with.\n *\n * A middleware that aborted meant what it aborted with — throttle answers\n * 429, a signed-url check 403 — and collapsing those to 500 makes a\n * rate-limited visitor indistinguishable from a broken server.\n */\n refusalStatus?: number\n}\n\n/**\n * A batch on the wire: several calls in one POST, answered in order.\n *\n * Calls issued in the same tick of a render - sibling components each\n * awaiting rpc() - travel together, so a page's parallel reads cost the host\n * one request rather than one each. A host that has never heard of the\n * envelope answers it as a malformed single call, and the calls are sent one\n * at a time from then on; nothing is lost but the saving.\n */\nexport interface HostCallBatch {\n calls: { function: string; args: unknown[] }[]\n}\n\nexport interface HostCallBatchReply {\n /** One per call, in order. Each carries the status that call would have had. */\n replies: (HostCallReply & { status?: number })[]\n}\n\n/** How many calls one POST carries at most. */\nconst BATCH_LIMIT = 50\n\ntype Settled = { status: number; reply: HostCallReply | null; text: string }\n\ninterface Pending {\n name: string\n args: unknown[]\n resolve: (settled: Settled) => void\n reject: (error: unknown) => void\n}\n\ninterface Bucket {\n headers: Record<string, string>\n calls: Pending[]\n}\n\n// After the current I/O, before the next timer: what a render issued\n// synchronously - and across the microtasks between one component's awaits -\n// is in the bucket by then. setImmediate where the runtime has it, a zero\n// timer where it does not (a Worker).\nconst nextTick: (fn: () => void) => void =\n typeof setImmediate === 'function' ? (fn) => setImmediate(fn) : (fn) => setTimeout(fn, 0)\n\n/**\n * The function to hand to `installHostFn`, or to `hostCalls` on the JS host.\n */\nexport function httpHostCalls(\n options: HttpHostCallsOptions,\n): (name: string, ...args: unknown[]) => Promise<unknown> {\n const {\n endpoint,\n secret,\n forwardHeaders = DEFAULT_FORWARDED,\n timeoutMs = 30_000,\n fetch: fetchImpl,\n onRevalidate,\n batch = true,\n } = options\n\n if (!secret) {\n throw new Error('httpHostCalls requires a secret — see the note on the option.')\n }\n\n const forwarded = forwardHeaders.map((name) => name.toLowerCase())\n\n // Off for good the first time the host answers a batch as something else.\n let batching = batch\n\n // One bucket per set of forwarded headers: two visitors' renders in flight\n // at once must not share a request, because the host reads the cookie off\n // the request to know whose session a call runs under.\n const buckets = new Map<string, Bucket>()\n\n async function forwardedHeaders(): Promise<Record<string, string>> {\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n 'x-rsc-host-secret': secret,\n }\n\n // Outside a render — a prerender pass, or a host calling one directly —\n // there is no request to forward from, and that is not an error. The call\n // simply carries no session, which is exactly what a build-time render\n // should be doing.\n //\n // Only that one condition is swallowed. A blanket catch here is the wrong\n // shape: anything else going wrong while reading the request would come\n // out as a call with no session, which is not a failure the caller sees —\n // it is the visitor silently becoming anonymous, and the page rendering as\n // though they were logged out.\n try {\n const from = await incomingHeaders()\n\n for (const key of forwarded) {\n const value = from.get(key)\n if (value !== null) headers[key] = value\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n\n if (!message.startsWith('No request in scope')) throw error\n }\n\n return headers\n }\n\n /** One POST. `label` names what is being sent, for the error messages. */\n async function post(\n body: unknown,\n headers: Record<string, string>,\n label: string,\n ): Promise<{ status: number; text: string }> {\n const doFetch = fetchImpl ?? globalThis.fetch\n\n // AbortSignal.timeout is not on every runtime this engine targets, so the\n // controller is written out rather than assumed.\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n let response: Response\n\n try {\n response = await doFetch(endpoint, {\n method: 'POST',\n headers,\n body: JSON.stringify(body),\n signal: controller.signal,\n })\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`Host call ${label} timed out after ${timeoutMs}ms`)\n }\n\n throw new Error(\n `Host call ${label} could not reach the host at ${endpoint}: ${\n error instanceof Error ? error.message : String(error)\n }`,\n )\n } finally {\n clearTimeout(timer)\n }\n\n // Read the body before branching on status: a host that reports the error\n // in JSON with a 500 is saying something more useful than \"500\", and\n // throwing on the status alone discards it.\n return { status: response.status, text: await response.text() }\n }\n\n function parse(text: string): HostCallReply | null {\n try {\n return text ? (JSON.parse(text) as HostCallReply) : null\n } catch {\n return null\n }\n }\n\n async function single(name: string, args: unknown[], headers: Record<string, string>): Promise<Settled> {\n const { status, text } = await post({ function: name, args }, headers, JSON.stringify(name))\n\n return { status, reply: parse(text), text }\n }\n\n /**\n * Send a bucket. One call goes as itself, so a host that speaks only the\n * single shape - or a request that happened to be alone - sees nothing\n * new on the wire.\n */\n async function flush(bucket: Bucket): Promise<void> {\n const { headers, calls } = bucket\n\n if (calls.length === 1) {\n const [only] = calls\n\n try {\n only.resolve(await single(only.name, only.args, headers))\n } catch (error) {\n only.reject(error)\n }\n\n return\n }\n\n const label = `batch of ${calls.length} (${calls.map((c) => c.name).join(', ')})`\n let status: number\n let text: string\n\n try {\n ;({ status, text } = await post(\n { calls: calls.map((c) => ({ function: c.name, args: c.args })) } satisfies HostCallBatch,\n headers,\n label,\n ))\n } catch (error) {\n // The host could not be reached, or did not answer in time. Not\n // re-sent one by one: the calls may have run, and an action run twice\n // is worse than one reported as failed.\n for (const call of calls) call.reject(error)\n\n return\n }\n\n const parsed = parse(text) as Partial<HostCallBatchReply> | null\n const replies = parsed?.replies\n\n if (status < 400 && Array.isArray(replies) && replies.length === calls.length) {\n calls.forEach((call, i) => {\n const { status: own, ...reply } = replies[i]!\n\n call.resolve({ status: own ?? 200, reply, text: JSON.stringify(reply) })\n })\n\n return\n }\n\n // Not a batch answer: a host that does not know the envelope refused it\n // as one malformed call, before running anything. From here on, one at a\n // time - and these, now.\n batching = false\n\n await Promise.all(\n calls.map(async (call) => {\n try {\n call.resolve(await single(call.name, call.args, headers))\n } catch (error) {\n call.reject(error)\n }\n }),\n )\n }\n\n function enqueue(name: string, args: unknown[], headers: Record<string, string>): Promise<Settled> {\n return new Promise<Settled>((resolve, reject) => {\n const key = JSON.stringify(headers)\n let bucket = buckets.get(key)\n\n if (!bucket) {\n bucket = { headers, calls: [] }\n buckets.set(key, bucket)\n\n const mine = bucket\n\n nextTick(() => {\n if (buckets.get(key) === mine) buckets.delete(key)\n\n void flush(mine)\n })\n }\n\n bucket.calls.push({ name, args, resolve, reject })\n\n if (bucket.calls.length >= BATCH_LIMIT) {\n buckets.delete(key)\n\n void flush(bucket)\n }\n })\n }\n\n /**\n * What a reply means, in the caller's own async context.\n *\n * Deliberately not done where the response arrives: a batch is answered in\n * a timer, outside every caller's request scope, and `redirect()` and\n * `revalidate()` both write to that scope. Interpreting here, after the\n * caller's await resumed, puts each reply back inside the render it belongs\n * to.\n */\n function interpret(name: string, { status, reply, text }: Settled): unknown {\n // Refusing the input is not the call failing — it is the call answering.\n //\n // Thrown rather than returned, so a handler stops where it is instead of\n // carrying on with data the host rejected. createActionClient catches this\n // on the way out and returns { validationErrors }, which is the shape\n // useForm reads: React serialises a REJECTED server action opaquely, so a\n // validation error that stays thrown reaches the browser as \"an error\n // occurred\" with every field it named gone.\n //\n // Checked before `error`, so a host that sends both is read as a refusal\n // rather than as a failure with no fields.\n if (reply?.validationErrors) {\n throw new ActionValidationError(reply.validationErrors)\n }\n\n // Raised as the engine's own redirect, so it travels the path every other\n // redirect travels — a real 3xx above a Suspense boundary, the digest\n // below one — rather than becoming an error the page has to interpret.\n if (reply?.redirect) {\n redirect(reply.redirect as never, reply.redirectStatus ?? 307)\n }\n\n if (reply?.unauthenticated) {\n throw new ServerAuthenticationError(reply.error ?? 'Unauthenticated.')\n }\n\n if (reply?.unauthorized) {\n throw new ServerAuthorizationError(reply.error ?? 'This action is unauthorized.')\n }\n\n if (reply?.error !== undefined) {\n const failure = new Error(`Host call ${JSON.stringify(name)} failed: ${reply.error}`)\n\n // Carried on the error rather than thrown as another class: the host\n // chose a status and the only job here is not to lose it on the way to\n // whoever writes the response.\n if (reply.refusalStatus) {\n ;(failure as Error & { refusalStatus?: number }).refusalStatus = reply.refusalStatus\n }\n\n throw failure\n }\n\n if (status >= 400) {\n throw new Error(\n `Host call ${JSON.stringify(name)} failed: ${status}`.trim() +\n (text ? ` — ${text.slice(0, 200)}` : ''),\n )\n }\n\n if (reply === null) {\n throw new Error(\n `Host call ${JSON.stringify(name)} returned a body that is not JSON: ${text.slice(0, 200)}`,\n )\n }\n\n if (reply.revalidate?.length) {\n if (onRevalidate) onRevalidate(reply.revalidate)\n // Named by the host over the wire - Rsc::revalidate('orders') in PHP -\n // so nothing here can check it; the renderer refuses an unknown name.\n else for (const target of reply.revalidate) revalidate(target as RevalidateTarget)\n }\n\n return reply.result ?? null\n }\n\n return async function hostCall(name: string, ...args: unknown[]): Promise<unknown> {\n const headers = await forwardedHeaders()\n const settled = batching ? await enqueue(name, args, headers) : await single(name, args, headers)\n\n return interpret(name, settled)\n }\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host as a route segment.
|
|
3
|
+
*
|
|
4
|
+
* A request's host is matched the way Next matches one after a middleware
|
|
5
|
+
* rewrite: prepended to the path. The difference is that the build can see
|
|
6
|
+
* it. `admin.example.com/users` matches `app/admin/users/page.tsx`, the
|
|
7
|
+
* same file `example.com/admin/users` reaches by path; `acme.example.com/`
|
|
8
|
+
* matches `app/[domain]/page.tsx` with `domain = "acme"`; a custom domain,
|
|
9
|
+
* `acme.com/`, matches the same file with `domain = "acme.com"`.
|
|
10
|
+
*
|
|
11
|
+
* What is prepended:
|
|
12
|
+
* - nothing, for the site's own hosts (metadataBase, `www.` of it, and any
|
|
13
|
+
* `hosts` named in rscKit()) - the apex keeps path routing, so an app
|
|
14
|
+
* adds tenants without moving a file;
|
|
15
|
+
* - the subdomain, for a host under an own one: `admin.example.com` ->
|
|
16
|
+
* `admin`, `a.b.example.com` -> `a.b`;
|
|
17
|
+
* - the whole host otherwise: `acme.com` -> `acme.com`.
|
|
18
|
+
*
|
|
19
|
+
* Off entirely when the build knows no own host: every request is then the
|
|
20
|
+
* site's own, and nothing here changes a path.
|
|
21
|
+
*
|
|
22
|
+
* Pure, and the same function in the browser: a link to `/settings` on
|
|
23
|
+
* `acme.example.com` is matched as `/acme/settings` on both sides, so the
|
|
24
|
+
* payload the client asks for is the one the server would render.
|
|
25
|
+
*/
|
|
26
|
+
/** The host without a port, lower-cased; the form every comparison uses. */
|
|
27
|
+
export declare function bareHost(host: string | null | undefined): string;
|
|
28
|
+
/**
|
|
29
|
+
* The segment a host contributes, or null for the site's own host.
|
|
30
|
+
* `own` is the build's list, already bare and lower-case.
|
|
31
|
+
*/
|
|
32
|
+
export declare function hostSegment(host: string | null | undefined, own: readonly string[]): string | null;
|
|
33
|
+
/** The pathname to match, with the host's segment in front when it has one. */
|
|
34
|
+
export declare function hostPath(host: string | null | undefined, pathname: string, own: readonly string[]): string;
|
|
35
|
+
/**
|
|
36
|
+
* The site's own hosts, from what the build was told. `www.` of each is
|
|
37
|
+
* included, so `www.example.com` is never a tenant called `www`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ownHosts(metadataBase: string | URL | null | undefined, hosts?: readonly string[]): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Whether a host's segment is one some route can begin with: a host segment
|
|
42
|
+
* ([domain] at the top of app/), or a static directory of that name. Without
|
|
43
|
+
* one, the host is treated as the site's own - so an app that declares a
|
|
44
|
+
* metadataBase and has no tenant tree routes every host by path, and a
|
|
45
|
+
* proxy that forwards to the app by an internal name is not turned into a
|
|
46
|
+
* tenant called "internal".
|
|
47
|
+
*/
|
|
48
|
+
export declare function routableHost(segment: string, routes: readonly {
|
|
49
|
+
segments: readonly {
|
|
50
|
+
type: string;
|
|
51
|
+
value: string;
|
|
52
|
+
}[];
|
|
53
|
+
}[]): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host as a route segment.
|
|
3
|
+
*
|
|
4
|
+
* A request's host is matched the way Next matches one after a middleware
|
|
5
|
+
* rewrite: prepended to the path. The difference is that the build can see
|
|
6
|
+
* it. `admin.example.com/users` matches `app/admin/users/page.tsx`, the
|
|
7
|
+
* same file `example.com/admin/users` reaches by path; `acme.example.com/`
|
|
8
|
+
* matches `app/[domain]/page.tsx` with `domain = "acme"`; a custom domain,
|
|
9
|
+
* `acme.com/`, matches the same file with `domain = "acme.com"`.
|
|
10
|
+
*
|
|
11
|
+
* What is prepended:
|
|
12
|
+
* - nothing, for the site's own hosts (metadataBase, `www.` of it, and any
|
|
13
|
+
* `hosts` named in rscKit()) - the apex keeps path routing, so an app
|
|
14
|
+
* adds tenants without moving a file;
|
|
15
|
+
* - the subdomain, for a host under an own one: `admin.example.com` ->
|
|
16
|
+
* `admin`, `a.b.example.com` -> `a.b`;
|
|
17
|
+
* - the whole host otherwise: `acme.com` -> `acme.com`.
|
|
18
|
+
*
|
|
19
|
+
* Off entirely when the build knows no own host: every request is then the
|
|
20
|
+
* site's own, and nothing here changes a path.
|
|
21
|
+
*
|
|
22
|
+
* Pure, and the same function in the browser: a link to `/settings` on
|
|
23
|
+
* `acme.example.com` is matched as `/acme/settings` on both sides, so the
|
|
24
|
+
* payload the client asks for is the one the server would render.
|
|
25
|
+
*/
|
|
26
|
+
/** The host without a port, lower-cased; the form every comparison uses. */
|
|
27
|
+
export function bareHost(host) {
|
|
28
|
+
return (host ?? "").split(":")[0].toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The segment a host contributes, or null for the site's own host.
|
|
32
|
+
* `own` is the build's list, already bare and lower-case.
|
|
33
|
+
*/
|
|
34
|
+
export function hostSegment(host, own) {
|
|
35
|
+
const bare = bareHost(host);
|
|
36
|
+
if (!bare || own.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
if (own.includes(bare))
|
|
39
|
+
return null;
|
|
40
|
+
if (bare === "localhost" || /^[\d.]+$/.test(bare) || bare.startsWith("["))
|
|
41
|
+
return null;
|
|
42
|
+
for (const site of own) {
|
|
43
|
+
if (bare.endsWith("." + site)) {
|
|
44
|
+
const sub = bare.slice(0, -(site.length + 1));
|
|
45
|
+
return sub === "www" ? null : sub;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return bare;
|
|
49
|
+
}
|
|
50
|
+
/** The pathname to match, with the host's segment in front when it has one. */
|
|
51
|
+
export function hostPath(host, pathname, own) {
|
|
52
|
+
const segment = hostSegment(host, own);
|
|
53
|
+
if (segment === null)
|
|
54
|
+
return pathname;
|
|
55
|
+
const rest = pathname === "/" ? "" : pathname;
|
|
56
|
+
return "/" + segment + rest;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The site's own hosts, from what the build was told. `www.` of each is
|
|
60
|
+
* included, so `www.example.com` is never a tenant called `www`.
|
|
61
|
+
*/
|
|
62
|
+
export function ownHosts(metadataBase, hosts = []) {
|
|
63
|
+
const out = new Set();
|
|
64
|
+
if (metadataBase) {
|
|
65
|
+
try {
|
|
66
|
+
out.add(bareHost(new URL(String(metadataBase)).host));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Not a url the build could read; the option is checked elsewhere.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const host of hosts)
|
|
73
|
+
out.add(bareHost(host));
|
|
74
|
+
for (const host of [...out]) {
|
|
75
|
+
if (!host.startsWith("www."))
|
|
76
|
+
out.add("www." + host);
|
|
77
|
+
}
|
|
78
|
+
return [...out].filter(Boolean).sort();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether a host's segment is one some route can begin with: a host segment
|
|
82
|
+
* ([domain] at the top of app/), or a static directory of that name. Without
|
|
83
|
+
* one, the host is treated as the site's own - so an app that declares a
|
|
84
|
+
* metadataBase and has no tenant tree routes every host by path, and a
|
|
85
|
+
* proxy that forwards to the app by an internal name is not turned into a
|
|
86
|
+
* tenant called "internal".
|
|
87
|
+
*/
|
|
88
|
+
export function routableHost(segment, routes) {
|
|
89
|
+
for (const route of routes) {
|
|
90
|
+
const first = route.segments[0];
|
|
91
|
+
if (!first)
|
|
92
|
+
continue;
|
|
93
|
+
if (first.type === "host")
|
|
94
|
+
return true;
|
|
95
|
+
if (first.type === "static" && first.value === segment)
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=hostRouting.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hostRouting.js","sourceRoot":"","sources":["../src/hostRouting.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A host as a route segment.\n *\n * A request's host is matched the way Next matches one after a middleware\n * rewrite: prepended to the path. The difference is that the build can see\n * it. `admin.example.com/users` matches `app/admin/users/page.tsx`, the\n * same file `example.com/admin/users` reaches by path; `acme.example.com/`\n * matches `app/[domain]/page.tsx` with `domain = \"acme\"`; a custom domain,\n * `acme.com/`, matches the same file with `domain = \"acme.com\"`.\n *\n * What is prepended:\n * - nothing, for the site's own hosts (metadataBase, `www.` of it, and any\n * `hosts` named in rscKit()) - the apex keeps path routing, so an app\n * adds tenants without moving a file;\n * - the subdomain, for a host under an own one: `admin.example.com` ->\n * `admin`, `a.b.example.com` -> `a.b`;\n * - the whole host otherwise: `acme.com` -> `acme.com`.\n *\n * Off entirely when the build knows no own host: every request is then the\n * site's own, and nothing here changes a path.\n *\n * Pure, and the same function in the browser: a link to `/settings` on\n * `acme.example.com` is matched as `/acme/settings` on both sides, so the\n * payload the client asks for is the one the server would render.\n */\n\n/** The host without a port, lower-cased; the form every comparison uses. */\nexport function bareHost(host: string | null | undefined): string {\n return (host ?? \"\").split(\":\")[0].toLowerCase();\n}\n\n/**\n * The segment a host contributes, or null for the site's own host.\n * `own` is the build's list, already bare and lower-case.\n */\nexport function hostSegment(\n host: string | null | undefined,\n own: readonly string[],\n): string | null {\n const bare = bareHost(host);\n\n if (!bare || own.length === 0) return null;\n if (own.includes(bare)) return null;\n if (bare === \"localhost\" || /^[\\d.]+$/.test(bare) || bare.startsWith(\"[\"))\n return null;\n\n for (const site of own) {\n if (bare.endsWith(\".\" + site)) {\n const sub = bare.slice(0, -(site.length + 1));\n\n return sub === \"www\" ? null : sub;\n }\n }\n\n return bare;\n}\n\n/** The pathname to match, with the host's segment in front when it has one. */\nexport function hostPath(\n host: string | null | undefined,\n pathname: string,\n own: readonly string[],\n): string {\n const segment = hostSegment(host, own);\n\n if (segment === null) return pathname;\n\n const rest = pathname === \"/\" ? \"\" : pathname;\n\n return \"/\" + segment + rest;\n}\n\n/**\n * The site's own hosts, from what the build was told. `www.` of each is\n * included, so `www.example.com` is never a tenant called `www`.\n */\nexport function ownHosts(\n metadataBase: string | URL | null | undefined,\n hosts: readonly string[] = [],\n): string[] {\n const out = new Set<string>();\n\n if (metadataBase) {\n try {\n out.add(bareHost(new URL(String(metadataBase)).host));\n } catch {\n // Not a url the build could read; the option is checked elsewhere.\n }\n }\n\n for (const host of hosts) out.add(bareHost(host));\n\n for (const host of [...out]) {\n if (!host.startsWith(\"www.\")) out.add(\"www.\" + host);\n }\n\n return [...out].filter(Boolean).sort();\n}\n\n/**\n * Whether a host's segment is one some route can begin with: a host segment\n * ([domain] at the top of app/), or a static directory of that name. Without\n * one, the host is treated as the site's own - so an app that declares a\n * metadataBase and has no tenant tree routes every host by path, and a\n * proxy that forwards to the app by an internal name is not turned into a\n * tenant called \"internal\".\n */\nexport function routableHost(\n segment: string,\n routes: readonly { segments: readonly { type: string; value: string }[] }[],\n): boolean {\n for (const route of routes) {\n const first = route.segments[0];\n\n if (!first) continue;\n if (first.type === \"host\") return true;\n if (first.type === \"static\" && first.value === segment) return true;\n }\n\n return false;\n}\n"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// What a page that throws shows when no error.tsx covers it.
|
|
3
|
+
//
|
|
4
|
+
// Without this, nothing did: the Flight row failed, the browser rethrew on
|
|
5
|
+
// hydration with no boundary to catch it, and React unmounted the document -
|
|
6
|
+
// a black page with the cause nowhere near it. Next shows its overlay in
|
|
7
|
+
// development and a plain "something went wrong" in production; this is
|
|
8
|
+
// that, as a page, and the layouts above it stay on screen.
|
|
9
|
+
//
|
|
10
|
+
// In development the message and stack, because the developer is the one
|
|
11
|
+
// looking. In production React has already replaced the message with a
|
|
12
|
+
// digest, and the digest is what this shows: the line to search the server
|
|
13
|
+
// log for.
|
|
14
|
+
import { createElement } from "react";
|
|
15
|
+
const DEV = typeof import.meta !== "undefined" &&
|
|
16
|
+
import.meta.env?.DEV === true;
|
|
17
|
+
const mono = "ui-monospace,SFMono-Regular,Menlo,monospace";
|
|
18
|
+
const box = {
|
|
19
|
+
maxWidth: "60rem",
|
|
20
|
+
margin: "3rem auto",
|
|
21
|
+
padding: "1.5rem",
|
|
22
|
+
font: "14px/1.5 " + mono,
|
|
23
|
+
color: "#fecaca",
|
|
24
|
+
background: "#1f0b0b",
|
|
25
|
+
border: "1px solid #7f1d1d",
|
|
26
|
+
borderRadius: 8,
|
|
27
|
+
};
|
|
28
|
+
const heading = {
|
|
29
|
+
margin: "0 0 .75rem",
|
|
30
|
+
fontSize: 16,
|
|
31
|
+
fontWeight: 600,
|
|
32
|
+
color: "#fff",
|
|
33
|
+
};
|
|
34
|
+
const pre = {
|
|
35
|
+
whiteSpace: "pre-wrap",
|
|
36
|
+
wordBreak: "break-word",
|
|
37
|
+
margin: ".75rem 0 0",
|
|
38
|
+
color: "#fecaca",
|
|
39
|
+
};
|
|
40
|
+
const hint = { margin: ".75rem 0 0", color: "#fca5a5" };
|
|
41
|
+
const button = {
|
|
42
|
+
marginTop: "1rem",
|
|
43
|
+
padding: ".4rem .8rem",
|
|
44
|
+
border: "1px solid #fca5a5",
|
|
45
|
+
borderRadius: 6,
|
|
46
|
+
background: "transparent",
|
|
47
|
+
color: "#fff",
|
|
48
|
+
cursor: "pointer",
|
|
49
|
+
font: "inherit",
|
|
50
|
+
};
|
|
51
|
+
export function DefaultRouteError({ error, reset, }) {
|
|
52
|
+
const name = error.name && error.name !== "Error" ? error.name + ": " : "";
|
|
53
|
+
return createElement("main", { role: "alert", style: box, "data-rsc-kit-error": "" }, createElement("h1", { style: heading }, DEV ? "This page threw while rendering" : "Something went wrong"), DEV
|
|
54
|
+
? createElement("pre", { style: pre }, error.stack || name + error.message)
|
|
55
|
+
: createElement("p", { style: { margin: 0 } }, "The error was logged on the server" +
|
|
56
|
+
(error.digest ? " as " + error.digest : "") +
|
|
57
|
+
"."), DEV
|
|
58
|
+
? createElement("p", { style: hint }, "Add an error.tsx beside the page to show something of your own here.")
|
|
59
|
+
: null, createElement("button", { type: "button", style: button, onClick: reset }, "Try again"));
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=DefaultRouteError.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DefaultRouteError.js","sourceRoot":"","sources":["../../src/js/DefaultRouteError.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,6DAA6D;AAC7D,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,wEAAwE;AACxE,4DAA4D;AAC5D,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,2EAA2E;AAC3E,WAAW;AAEX,OAAO,EAAE,aAAa,EAAkB,MAAM,OAAO,CAAC;AAGtD,MAAM,GAAG,GACP,OAAO,OAAO,IAAI,KAAK,WAAW;IACjC,OAAO,IAAoC,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC;AAEjE,MAAM,IAAI,GAAG,6CAA6C,CAAC;AAC3D,MAAM,GAAG,GAAG;IACV,QAAQ,EAAE,OAAO;IACjB,MAAM,EAAE,WAAW;IACnB,OAAO,EAAE,QAAQ;IACjB,IAAI,EAAE,WAAW,GAAG,IAAI;IACxB,KAAK,EAAE,SAAS;IAChB,UAAU,EAAE,SAAS;IACrB,MAAM,EAAE,mBAAmB;IAC3B,YAAY,EAAE,CAAC;CAChB,CAAC;AACF,MAAM,OAAO,GAAG;IACd,MAAM,EAAE,YAAY;IACpB,QAAQ,EAAE,EAAE;IACZ,UAAU,EAAE,GAAG;IACf,KAAK,EAAE,MAAM;CACd,CAAC;AACF,MAAM,GAAG,GAAG;IACV,UAAU,EAAE,UAAmB;IAC/B,SAAS,EAAE,YAAqB;IAChC,MAAM,EAAE,YAAY;IACpB,KAAK,EAAE,SAAS;CACjB,CAAC;AACF,MAAM,IAAI,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;AACxD,MAAM,MAAM,GAAG;IACb,SAAS,EAAE,MAAM;IACjB,OAAO,EAAE,aAAa;IACtB,MAAM,EAAE,mBAAmB;IAC3B,YAAY,EAAE,CAAC;IACf,UAAU,EAAE,aAAa;IACzB,KAAK,EAAE,MAAM;IACb,MAAM,EAAE,SAAS;IACjB,IAAI,EAAE,SAAS;CAChB,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,EAChC,KAAK,EACL,KAAK,GACW;IAChB,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;IAE3E,OAAO,aAAa,CAClB,MAAM,EACN,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,oBAAoB,EAAE,EAAE,EAAE,EACvD,aAAa,CACX,IAAI,EACJ,EAAE,KAAK,EAAE,OAAO,EAAE,EAClB,GAAG,CAAC,CAAC,CAAC,iCAAiC,CAAC,CAAC,CAAC,sBAAsB,CACjE,EACD,GAAG;QACD,CAAC,CAAC,aAAa,CACX,KAAK,EACL,EAAE,KAAK,EAAE,GAAG,EAAE,EACd,KAAK,CAAC,KAAK,IAAI,IAAI,GAAG,KAAK,CAAC,OAAO,CACpC;QACH,CAAC,CAAC,aAAa,CACX,GAAG,EACH,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,EACxB,oCAAoC;YAClC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3C,GAAG,CACN,EACL,GAAG;QACD,CAAC,CAAC,aAAa,CACX,GAAG,EACH,EAAE,KAAK,EAAE,IAAI,EAAE,EACf,sEAAsE,CACvE;QACH,CAAC,CAAC,IAAI,EACR,aAAa,CACX,QAAQ,EACR,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,EACjD,WAAW,CACZ,CACF,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n// What a page that throws shows when no error.tsx covers it.\n//\n// Without this, nothing did: the Flight row failed, the browser rethrew on\n// hydration with no boundary to catch it, and React unmounted the document -\n// a black page with the cause nowhere near it. Next shows its overlay in\n// development and a plain \"something went wrong\" in production; this is\n// that, as a page, and the layouts above it stay on screen.\n//\n// In development the message and stack, because the developer is the one\n// looking. In production React has already replaced the message with a\n// digest, and the digest is what this shows: the line to search the server\n// log for.\n\nimport { createElement, type ReactNode } from \"react\";\nimport type { RouteErrorProps } from \"./RouteErrorBoundary\";\n\nconst DEV =\n typeof import.meta !== \"undefined\" &&\n (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true;\n\nconst mono = \"ui-monospace,SFMono-Regular,Menlo,monospace\";\nconst box = {\n maxWidth: \"60rem\",\n margin: \"3rem auto\",\n padding: \"1.5rem\",\n font: \"14px/1.5 \" + mono,\n color: \"#fecaca\",\n background: \"#1f0b0b\",\n border: \"1px solid #7f1d1d\",\n borderRadius: 8,\n};\nconst heading = {\n margin: \"0 0 .75rem\",\n fontSize: 16,\n fontWeight: 600,\n color: \"#fff\",\n};\nconst pre = {\n whiteSpace: \"pre-wrap\" as const,\n wordBreak: \"break-word\" as const,\n margin: \".75rem 0 0\",\n color: \"#fecaca\",\n};\nconst hint = { margin: \".75rem 0 0\", color: \"#fca5a5\" };\nconst button = {\n marginTop: \"1rem\",\n padding: \".4rem .8rem\",\n border: \"1px solid #fca5a5\",\n borderRadius: 6,\n background: \"transparent\",\n color: \"#fff\",\n cursor: \"pointer\",\n font: \"inherit\",\n};\n\nexport function DefaultRouteError({\n error,\n reset,\n}: RouteErrorProps): ReactNode {\n const name = error.name && error.name !== \"Error\" ? error.name + \": \" : \"\";\n\n return createElement(\n \"main\",\n { role: \"alert\", style: box, \"data-rsc-kit-error\": \"\" },\n createElement(\n \"h1\",\n { style: heading },\n DEV ? \"This page threw while rendering\" : \"Something went wrong\",\n ),\n DEV\n ? createElement(\n \"pre\",\n { style: pre },\n error.stack || name + error.message,\n )\n : createElement(\n \"p\",\n { style: { margin: 0 } },\n \"The error was logged on the server\" +\n (error.digest ? \" as \" + error.digest : \"\") +\n \".\",\n ),\n DEV\n ? createElement(\n \"p\",\n { style: hint },\n \"Add an error.tsx beside the page to show something of your own here.\",\n )\n : null,\n createElement(\n \"button\",\n { type: \"button\", style: button, onClick: reset },\n \"Try again\",\n ),\n );\n}\n"]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The engine's own Suspense: the one a loading.tsx becomes.
|
|
4
|
+
//
|
|
5
|
+
// A component, and a named one, for the report and nothing else. When a read
|
|
6
|
+
// is caught at a boundary during a server render, the component stack says
|
|
7
|
+
// whose boundary it was - and the stack is the only thing that does. It
|
|
8
|
+
// used to be inferred from what stood two frames outside the nearest
|
|
9
|
+
// Suspense, which held for a first render and broke on a PPR resume: React
|
|
10
|
+
// leaves server components out of that stack, so a <Suspense> the developer
|
|
11
|
+
// wrote as the first thing in a page sat directly under the engine's
|
|
12
|
+
// segment boundary and was reported as a loading.tsx catching the whole
|
|
13
|
+
// segment - a warning nobody could make go away, on every request. Now the
|
|
14
|
+
// engine's boundary is the one rendered by LoadingBoundary, and only that.
|
|
15
|
+
import { Suspense } from "react";
|
|
16
|
+
export function LoadingBoundary({ fallback, children, }) {
|
|
17
|
+
return _jsx(Suspense, { fallback: fallback, children: children });
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=LoadingBoundary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LoadingBoundary.js","sourceRoot":"","sources":["../../src/js/LoadingBoundary.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,4DAA4D;AAC5D,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,wEAAwE;AACxE,qEAAqE;AACrE,2EAA2E;AAC3E,4EAA4E;AAC5E,qEAAqE;AACrE,wEAAwE;AACxE,2EAA2E;AAC3E,2EAA2E;AAC3E,OAAO,EAAE,QAAQ,EAAkB,MAAM,OAAO,CAAC;AAEjD,MAAM,UAAU,eAAe,CAAC,EAC9B,QAAQ,EACR,QAAQ,GAIT;IACC,OAAO,KAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,YAAG,QAAQ,GAAY,CAAC;AAC7D,CAAC","sourcesContent":["\"use client\";\n\n// The engine's own Suspense: the one a loading.tsx becomes.\n//\n// A component, and a named one, for the report and nothing else. When a read\n// is caught at a boundary during a server render, the component stack says\n// whose boundary it was - and the stack is the only thing that does. It\n// used to be inferred from what stood two frames outside the nearest\n// Suspense, which held for a first render and broke on a PPR resume: React\n// leaves server components out of that stack, so a <Suspense> the developer\n// wrote as the first thing in a page sat directly under the engine's\n// segment boundary and was reported as a loading.tsx catching the whole\n// segment - a warning nobody could make go away, on every request. Now the\n// engine's boundary is the one rendered by LoadingBoundary, and only that.\nimport { Suspense, type ReactNode } from \"react\";\n\nexport function LoadingBoundary({\n fallback,\n children,\n}: {\n fallback: ReactNode;\n children: ReactNode;\n}) {\n return <Suspense fallback={fallback}>{children}</Suspense>;\n}\n"]}
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
* arrives, and a boundary closer to the read would keep the rest painted.
|
|
10
10
|
*
|
|
11
11
|
* The component stack says which: frames run from the component outward,
|
|
12
|
-
* and the engine
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and
|
|
12
|
+
* and the engine renders the boundary a loading.tsx becomes through a
|
|
13
|
+
* component named for it, so the frame just outside the nearest Suspense
|
|
14
|
+
* is LoadingBoundary exactly when the boundary is the engine's. Only that
|
|
15
|
+
* frame, and only that name: the check used to accept SegmentBoundary two
|
|
16
|
+
* frames out, which held for a first render and misfired on a PPR resume,
|
|
17
|
+
* where React leaves server components out of the stack and a developer's
|
|
18
|
+
* own <Suspense> at the top of a page sat directly under the segment
|
|
19
|
+
* boundary - a warning on every request that nothing could make go away.
|
|
16
20
|
*/
|
|
17
|
-
const ENGINE_BOUNDARY = /^\s*at
|
|
21
|
+
const ENGINE_BOUNDARY = /^\s*at LoadingBoundary\b/;
|
|
18
22
|
export function caughtByLoading(componentStack) {
|
|
19
23
|
const frames = (componentStack ?? "")
|
|
20
24
|
.split("\n")
|
|
@@ -22,9 +26,7 @@ export function caughtByLoading(componentStack) {
|
|
|
22
26
|
const at = frames.findIndex((line) => /^\s*at Suspense\b/.test(line));
|
|
23
27
|
if (at === -1)
|
|
24
28
|
return false;
|
|
25
|
-
return frames
|
|
26
|
-
.slice(at + 1, at + 3)
|
|
27
|
-
.some((line) => ENGINE_BOUNDARY.test(line));
|
|
29
|
+
return ENGINE_BOUNDARY.test(frames[at + 1] ?? "");
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* Whether a render error is the consumer cancelling, not the app failing.
|