@rivetjs/router 0.1.0-alpha.0 → 0.1.0-alpha.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/adapters/files.d.ts +13 -0
- package/adapters/files.js +58 -6
- package/adapters/node.d.ts +22 -5
- package/adapters/node.js +113 -67
- package/adapters/static.js +1 -1
- package/adapters/target-build.d.ts +5 -2
- package/adapters/vercel.d.ts +4 -2
- package/adapters/vercel.js +1 -1
- package/build/codegen.d.ts +13 -0
- package/build/codegen.js +152 -35
- package/build/dev-errors.d.ts +6 -2
- package/build/dev-errors.js +15 -4
- package/build/lazy-rows.d.ts +19 -0
- package/build/lazy-rows.js +59 -0
- package/build/migrate.d.ts +20 -4
- package/build/migrate.js +300 -61
- package/build/reach.d.ts +60 -0
- package/build/reach.js +302 -0
- package/build/routes.d.ts +9 -4
- package/build/routes.js +30 -13
- package/build/vite.d.ts +13 -0
- package/build/vite.js +165 -7
- package/cli/rivet.js +3 -2
- package/client/dev.js +17 -0
- package/client/index.d.ts +4 -0
- package/client/index.js +34 -0
- package/client/route-context.d.ts +1 -1
- package/index.d.ts +22 -25
- package/index.js +2 -1
- package/package.json +4 -4
- package/server/app.d.ts +7 -1
- package/server/app.js +38 -5
- package/server/deferred.d.ts +12 -0
- package/server/deferred.js +60 -0
- package/server/handler.d.ts +56 -12
- package/server/handler.js +46 -8
- package/server/index.d.ts +1 -1
- package/server/module.d.ts +7 -7
- package/server/module.js +52 -34
- package/server/render.d.ts +2 -0
- package/server/render.js +3 -0
- package/server/route-cache.d.ts +74 -4
- package/server/route-cache.js +77 -6
- package/server/shell-build.d.ts +3 -0
- package/server/shell-build.js +2 -0
- package/server/shell-cache.d.ts +8 -0
- package/server/shell-cache.js +836 -0
- package/shared/server-exports.d.ts +78 -0
- package/shared/server-exports.js +77 -0
package/server/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { inlineStylesFor } from "./inline-styles.js";
|
|
|
4
4
|
import { escapeJSON } from "@rivetjs/runtime/resume-format";
|
|
5
5
|
import { createHandler, _digest, encodeData } from "./handler.js";
|
|
6
6
|
import { renderDocument } from "./render.js";
|
|
7
|
+
import { SHELL_BUILD } from "./shell-build.js";
|
|
7
8
|
// `route-cache.ts` `ROUTE_CACHE` (same registered symbol; that module loads only with rules).
|
|
8
9
|
const ROUTE_CACHE = Symbol.for("rivet.routeCache");
|
|
9
10
|
// `early-hints.ts` `EARLY_HINTS` (same registered symbol; that module loads only with `earlyHints`).
|
|
@@ -72,6 +73,7 @@ export function createApp(config) {
|
|
|
72
73
|
const document = config.document ?? entry.document;
|
|
73
74
|
if (!document)
|
|
74
75
|
throw new TypeError("createApp: no document shell (pass `document` or generate the entry with one)");
|
|
76
|
+
const moveScript = (to, nonce) => to === undefined ? "" : `<script${scriptAttrs(nonce)}>location.replace(${JSON.stringify(to).replace(/</g, "\\u003c")})</script>`;
|
|
75
77
|
const scriptAttrs = (nonce) => (nonce !== undefined ? ` nonce="${nonce.replace(/[^\w+/=-]/g, "")}"` : "");
|
|
76
78
|
// Dev pages: what the data endpoint would answer for this URL, so the client mount needs no request.
|
|
77
79
|
const devAround = (page, status, nonce, locale, mode) => {
|
|
@@ -134,6 +136,9 @@ export function createApp(config) {
|
|
|
134
136
|
const built = (i18n && i18n.manifests?.[locale ?? i18n.locales[0]]) || manifest;
|
|
135
137
|
const devMode = built || !config.devResume ? undefined : typeof config.devResume.mode === "function" ? config.devResume.mode(locale) : config.devResume.mode;
|
|
136
138
|
const pageManifest = built || config.devResume?.manifest;
|
|
139
|
+
// Dev check mode streams nothing: a shell fill's deferred data waits in place there (not an in-order read).
|
|
140
|
+
if (page.shell && devMode === "check")
|
|
141
|
+
page.shell.plain = true;
|
|
137
142
|
const dir = locals?.dir === "rtl" || locals?.dir === "ltr" ? locals.dir : undefined;
|
|
138
143
|
const exports = page.head ? (levels.get(page.head) ?? ranked([page.head], config.head)) : ranked([], config.head);
|
|
139
144
|
// Root layout component entries stay in the browser (that layout never re-renders there).
|
|
@@ -174,7 +179,9 @@ export function createApp(config) {
|
|
|
174
179
|
: {}),
|
|
175
180
|
...(pageManifest ? { manifest: pageManifest } : {}),
|
|
176
181
|
...(variant ? { variant } : {}),
|
|
177
|
-
|
|
182
|
+
// Deferred loaders: their boundaries stream out of order (shell first) even when the app set no `stream`.
|
|
183
|
+
// Shell routes (`routeRules` `shell`) always stream: the stored shell ends where the first boundary would stream.
|
|
184
|
+
...(config.stream ? { stream: config.stream } : page.deferred || page.shell ? { stream: {} } : {}),
|
|
178
185
|
...(background !== undefined || lean || ahead
|
|
179
186
|
? { preload: { ...(background !== undefined ? { background } : {}), ...(lean ? { lean } : {}), ...(ahead ? { ahead } : {}) } }
|
|
180
187
|
: {}),
|
|
@@ -188,6 +195,8 @@ export function createApp(config) {
|
|
|
188
195
|
return digest;
|
|
189
196
|
},
|
|
190
197
|
...(config.devScripts && !built ? { around: devAround(page, status, nonce, locale, devMode) } : {}),
|
|
198
|
+
// Deferred loaders' redirects (status already sent): the browser navigates at the document's end.
|
|
199
|
+
...(page.moved ? { epilogue: () => moveScript(page.moved(), nonce) } : {}),
|
|
191
200
|
...(devMode === "check" ? { discardState: true } : {}),
|
|
192
201
|
}, { status, headers });
|
|
193
202
|
return withCache
|
|
@@ -253,11 +262,15 @@ export function createApp(config) {
|
|
|
253
262
|
},
|
|
254
263
|
render: (input) => respond({
|
|
255
264
|
data: input.data,
|
|
256
|
-
|
|
265
|
+
// Shell fills: a URL whose named query reads outside the rule's key are recorded (`shell-cache.ts`).
|
|
266
|
+
url: input.shell?.url ?? input.url,
|
|
257
267
|
head: input.head,
|
|
258
268
|
...(input.action ? { action: input.action } : {}),
|
|
259
269
|
...(input.byURL ? { byURL: input.byURL } : {}),
|
|
260
270
|
...(input.deps ? { deps: input.deps } : {}),
|
|
271
|
+
...(input.deferred ? { deferred: input.deferred } : {}),
|
|
272
|
+
...(input.moved ? { moved: input.moved } : {}),
|
|
273
|
+
...(input.shell ? { shell: input.shell } : {}),
|
|
261
274
|
}, input.status, input.headers, input.nonce, input.request, input.locals, input.route.id),
|
|
262
275
|
renderError: async (input) => respond({
|
|
263
276
|
// The outermost layouts' data (error pages render inside the layouts that loaded).
|
|
@@ -270,10 +283,16 @@ export function createApp(config) {
|
|
|
270
283
|
const given = config.routeRules ?? entry.routeRules;
|
|
271
284
|
if (!given || !Object.keys(given).length)
|
|
272
285
|
return app;
|
|
273
|
-
// Dev: headers only (a stored page would hide edits).
|
|
286
|
+
// Dev: headers only (a stored page would hide edits); `shell` rules probe (track reads, log why a shell would not be stored).
|
|
274
287
|
const rules = dev
|
|
275
|
-
? Object.fromEntries(Object.entries(given).map(([pattern, rule]) => [
|
|
288
|
+
? Object.fromEntries(Object.entries(given).map(([pattern, rule]) => [
|
|
289
|
+
pattern,
|
|
290
|
+
{ ...(rule.headers ? { headers: rule.headers } : {}), ...(rule.shell !== undefined ? { shell: devShell(rule) } : {}) },
|
|
291
|
+
]))
|
|
276
292
|
: given;
|
|
293
|
+
const shelled = Object.values(rules).some((rule) => rule.shell);
|
|
294
|
+
if (shelled && !SHELL_BUILD)
|
|
295
|
+
throw new TypeError("createApp: `shell` route rules need a build whose `rivetRouter({ routeRules })` has a `shell` rule (it bundles the shell cache)");
|
|
277
296
|
// Route rules: their module loads at startup (pay-per-use); malformed rules reject there (unhandled: the process
|
|
278
297
|
// fails like a synchronous throw would). Requests before it settles wait for it.
|
|
279
298
|
const policy = {
|
|
@@ -282,11 +301,25 @@ export function createApp(config) {
|
|
|
282
301
|
hash: config.csp === undefined ? {} : typeof config.csp === "object" ? config.csp : null,
|
|
283
302
|
};
|
|
284
303
|
let cached = null;
|
|
285
|
-
|
|
304
|
+
// Shell rules: the shell cache module loads with the rules module (pay-per-use: only apps with `shell` rules).
|
|
305
|
+
const loading = Promise.all([import("./route-cache.js"), SHELL_BUILD && shelled ? SHELL_BUILD() : undefined]).then(([{ withRouteRules }, shells]) => (cached = withRouteRules(app, rules, policy, entry.base ?? "", config.routeCache, shells, {
|
|
306
|
+
dev,
|
|
307
|
+
build: manifest?.runtime ?? "",
|
|
308
|
+
})));
|
|
286
309
|
const handler = (request, context) => (cached ? cached(request, context) : loading.then((ready) => ready(request, context)));
|
|
287
310
|
// The node adapter's fast path (a stored page before a Request is built), once the module loaded.
|
|
288
311
|
Object.defineProperty(handler, ROUTE_CACHE, { get: () => (cached ? cached[ROUTE_CACHE] : undefined) });
|
|
289
312
|
return handler;
|
|
313
|
+
// Dev shell rules: keys only (the probe never stores, so no times and no fixed origin).
|
|
314
|
+
function devShell(rule) {
|
|
315
|
+
if (rule.shell === false)
|
|
316
|
+
return false;
|
|
317
|
+
const own = typeof rule.shell === "object" ? rule.shell : {};
|
|
318
|
+
const cache = typeof rule.cache === "object" ? rule.cache : {};
|
|
319
|
+
const query = own.query ?? cache.query;
|
|
320
|
+
const varies = own.varies ?? cache.varies;
|
|
321
|
+
return { maxAge: 0, ...(query !== undefined ? { query } : {}), ...(varies ? { varies } : {}) };
|
|
322
|
+
}
|
|
290
323
|
// Error pages without a settled locale: the app's resolver, else the URL's locale prefix.
|
|
291
324
|
async function errorLocals(locals, request, url) {
|
|
292
325
|
const i18n = config.i18n;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RequestEvent } from "./handler.js";
|
|
2
|
+
/**
|
|
3
|
+
* The `Deferred<T>` handle of a started deferred loader. Its only property (`then`) is non-enumerable: the resume encoder
|
|
4
|
+
* writes it as an empty object (a resumed page's layout data signal holds it; client navigations carry resolved values).
|
|
5
|
+
* Dev: a Proxy that throws `DeferredDataError` when code reads a field (`data.title`) instead of rendering the handle
|
|
6
|
+
* through a boundary.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deferredHandle(promise: Promise<unknown>, dev: boolean): object;
|
|
9
|
+
/** Start deferred loader `i` now (with the request); a rejection is the boundary's, never an unhandled one. */
|
|
10
|
+
export declare function startDeferred(run: () => unknown): Promise<unknown>;
|
|
11
|
+
/** A deferred loader's event view: reads like its loader view, but `setHeader(s)` / `cookies.set` / `cookies.delete` throw. */
|
|
12
|
+
export declare function lateEvent(event: RequestEvent): RequestEvent;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// One handle per deferred slot per request: a frozen object whose non-enumerable `then` follows the loader's promise, so
|
|
2
|
+
// every await form works (`Async` / `resource` fetchers, `await data`, `Promise.all`). Ruling (PPR-T01): per request, not
|
|
3
|
+
// spec D3's request-independent handle resolved against the render scope — that one failed `await data` (the
|
|
4
|
+
// continuation runs outside the owner, the render scope is gone) and would need AsyncLocalStorage; shell-cache H-replay
|
|
5
|
+
// (T3) re-runs the page with live handles anyway, H-holes (T6) must swap a retained handle for the hit's (frame swap).
|
|
6
|
+
const DOCS = "https://rivetjs.severyn.pro/guides/routing/loading-data#deferred-loaders";
|
|
7
|
+
// The only string keys a handle answers in dev: what promise / boundary / encoder / JSON code reads.
|
|
8
|
+
const ALLOWED = new Set(["then", "toJSON", "constructor", "valueOf", "toString"]);
|
|
9
|
+
const devGet = (target, key, receiver) => {
|
|
10
|
+
if (typeof key === "symbol" || ALLOWED.has(key))
|
|
11
|
+
return Reflect.get(target, key, receiver);
|
|
12
|
+
const error = new TypeError("Deferred loader data read synchronously (`data." +
|
|
13
|
+
key +
|
|
14
|
+
"`): `load(fn, { defer: true })` data is a Deferred handle — render it through `<Async load={() => data}>` or `resource(() => data)` (" +
|
|
15
|
+
DOCS +
|
|
16
|
+
")");
|
|
17
|
+
error.name = "DeferredDataError";
|
|
18
|
+
throw error;
|
|
19
|
+
};
|
|
20
|
+
const DEV_TRAPS = { get: devGet };
|
|
21
|
+
/**
|
|
22
|
+
* The `Deferred<T>` handle of a started deferred loader. Its only property (`then`) is non-enumerable: the resume encoder
|
|
23
|
+
* writes it as an empty object (a resumed page's layout data signal holds it; client navigations carry resolved values).
|
|
24
|
+
* Dev: a Proxy that throws `DeferredDataError` when code reads a field (`data.title`) instead of rendering the handle
|
|
25
|
+
* through a boundary.
|
|
26
|
+
*/
|
|
27
|
+
export function deferredHandle(promise, dev) {
|
|
28
|
+
const handle = Object.freeze(Object.defineProperty({}, "then", { value: (ok, fail) => promise.then(ok, fail) }));
|
|
29
|
+
return dev ? new Proxy(handle, DEV_TRAPS) : handle;
|
|
30
|
+
}
|
|
31
|
+
/** Start deferred loader `i` now (with the request); a rejection is the boundary's, never an unhandled one. */
|
|
32
|
+
export function startDeferred(run) {
|
|
33
|
+
const promise = new Promise((resolve) => resolve(run()));
|
|
34
|
+
promise.catch(() => { });
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
const late = (what) => () => {
|
|
38
|
+
throw new TypeError(`A deferred loader cannot ${what}: the response head is sent before it settles (use a non-deferred loader or a guard)`);
|
|
39
|
+
};
|
|
40
|
+
const lateCookies = (cookies) => ({
|
|
41
|
+
get: cookies.get,
|
|
42
|
+
getAll: cookies.getAll,
|
|
43
|
+
set: late("set cookies"),
|
|
44
|
+
delete: late("delete cookies"),
|
|
45
|
+
});
|
|
46
|
+
/** A deferred loader's event view: reads like its loader view, but `setHeader(s)` / `cookies.set` / `cookies.delete` throw. */
|
|
47
|
+
export function lateEvent(event) {
|
|
48
|
+
return new Proxy(event, {
|
|
49
|
+
get(target, key) {
|
|
50
|
+
if (key === "setHeader")
|
|
51
|
+
return late("set headers");
|
|
52
|
+
if (key === "setHeaders")
|
|
53
|
+
return late("set headers");
|
|
54
|
+
if (key === "cookies")
|
|
55
|
+
return lateCookies(target.cookies);
|
|
56
|
+
const value = Reflect.get(target, key, target);
|
|
57
|
+
return value;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
package/server/handler.d.ts
CHANGED
|
@@ -63,9 +63,9 @@ export interface Platform {
|
|
|
63
63
|
type Result = unknown;
|
|
64
64
|
type Handler = (event: RequestEvent) => Response | undefined | void | Promise<Response | undefined | void>;
|
|
65
65
|
/**
|
|
66
|
-
* A route's or layout's server exports, normalized
|
|
67
|
-
* (
|
|
68
|
-
* handlers run outermost first before guards; a returned `Response` ends the request.
|
|
66
|
+
* A route's or layout's server exports, normalized by tag (`server/module.ts`): each key is the kind of
|
|
67
|
+
* the wrapper that made the export (`export const loader = load(…)` arrives as `load`). `onRequest` and
|
|
68
|
+
* `on<Method>` request handlers run outermost first before guards; a returned `Response` ends the request.
|
|
69
69
|
*/
|
|
70
70
|
export interface ServerModule {
|
|
71
71
|
onRequest?: Handler;
|
|
@@ -84,11 +84,6 @@ export interface ServerModule {
|
|
|
84
84
|
csrf?: boolean;
|
|
85
85
|
actions?: Record<string, (event: RequestEvent) => Result | Promise<Result>>;
|
|
86
86
|
headers?(event: RequestEvent): HeadersInit | undefined;
|
|
87
|
-
GET?(event: RequestEvent): Response | Promise<Response>;
|
|
88
|
-
POST?(event: RequestEvent): Response | Promise<Response>;
|
|
89
|
-
PUT?(event: RequestEvent): Response | Promise<Response>;
|
|
90
|
-
PATCH?(event: RequestEvent): Response | Promise<Response>;
|
|
91
|
-
DELETE?(event: RequestEvent): Response | Promise<Response>;
|
|
92
87
|
}
|
|
93
88
|
type Load<T> = () => Promise<T>;
|
|
94
89
|
/** One generated route-table entry. */
|
|
@@ -117,11 +112,47 @@ export interface RenderInput {
|
|
|
117
112
|
readonly locals: Record<string, unknown>;
|
|
118
113
|
/** Merged head (layouts → page) from `head` exports. */
|
|
119
114
|
readonly head: Head | undefined;
|
|
115
|
+
/** Set when a `data` slot is a deferred loader's `Deferred` handle (`load(fn, { defer: true })`): the page streams. */
|
|
116
|
+
readonly deferred?: true;
|
|
117
|
+
/**
|
|
118
|
+
* A deferred loader's `redirect()` (same origin, or `{ external: true }`): its first target, read when the document
|
|
119
|
+
* ends — the status is long sent, so the page navigates there in the browser (`location.replace`).
|
|
120
|
+
*/
|
|
121
|
+
readonly moved?: () => string | undefined;
|
|
122
|
+
/** @internal Route rule `shell` (`shell-cache.ts`): this render fills or replays a stored shell (always streams). */
|
|
123
|
+
readonly shell?: ShellPhase;
|
|
120
124
|
/** Layouts (indices into `data`) whose loader read the request URL: their data follows every URL change. */
|
|
121
125
|
readonly byURL?: readonly number[];
|
|
122
126
|
/** Keys the loaders passed to `event.depends()` (index into `data`); absent when none called it. */
|
|
123
127
|
readonly deps?: LoaderDeps;
|
|
124
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* @internal Route rule `shell` (`server/shell-cache.ts`): the route rules wrapper sets it on a document GET's Request
|
|
131
|
+
* (`SHELL`). After the guards and `headers()` the handler asks it how to load: a hit's stored shell data (the composed
|
|
132
|
+
* response already went out with the stored shell), a fill's read tracking, or nothing (render as usual).
|
|
133
|
+
*/
|
|
134
|
+
export interface ShellRequest {
|
|
135
|
+
begin(route: AppRoute, event: RequestEvent, headers: Headers): ShellPhase | undefined;
|
|
136
|
+
}
|
|
137
|
+
/** @internal One shell render (fill or hit replay). */
|
|
138
|
+
export interface ShellPhase {
|
|
139
|
+
/** Hit: the fill's non-deferred loader results (those loaders are skipped) and URL / depends facts. */
|
|
140
|
+
readonly data?: readonly unknown[];
|
|
141
|
+
readonly byURL?: readonly number[];
|
|
142
|
+
readonly deps?: LoaderDeps;
|
|
143
|
+
/** Fill: a non-deferred loader's event view that records its request reads. */
|
|
144
|
+
event?(view: RequestEvent, index: number): RequestEvent;
|
|
145
|
+
/** Fill: the non-deferred loaders' results (deferred slots undefined), stored with the shell. */
|
|
146
|
+
loaded?(data: readonly unknown[], byURL: readonly number[], deps: LoaderDeps | undefined): void;
|
|
147
|
+
/** Fill: a deferred slot's handle (delivers only after the shell is out: in-order use is detected). */
|
|
148
|
+
handle?(promise: Promise<unknown>, dev: boolean): object;
|
|
149
|
+
/** Fill: the page's URL for the render (named query reads outside the key recorded). */
|
|
150
|
+
readonly url?: URL;
|
|
151
|
+
/** Set by `app.ts`: this render does not stream (dev `devResume` check mode), deferred data waits in place. */
|
|
152
|
+
plain?: boolean;
|
|
153
|
+
}
|
|
154
|
+
/** @internal Request property carrying a `ShellRequest` (`Symbol.for("rivet.shell")`). */
|
|
155
|
+
export declare const SHELL: unique symbol;
|
|
125
156
|
/** What the error renderer receives: request, matched route, status and a user-safe message. */
|
|
126
157
|
export interface ErrorInput {
|
|
127
158
|
readonly request: Request;
|
|
@@ -133,7 +164,7 @@ export interface ErrorInput {
|
|
|
133
164
|
readonly message: string;
|
|
134
165
|
readonly headers: Headers;
|
|
135
166
|
readonly nonce: string | undefined;
|
|
136
|
-
/** The request's `locals` as guards/loaders left them (
|
|
167
|
+
/** The request's `locals` as guards/loaders left them (only the handler's seed, `context.locals`, when no guard ran, e.g. unmatched URLs). */
|
|
137
168
|
readonly locals: Record<string, unknown>;
|
|
138
169
|
/**
|
|
139
170
|
* Data of the outermost layouts the error page renders inside (route data order): those whose guards
|
|
@@ -181,12 +212,25 @@ export interface ErrorInfo {
|
|
|
181
212
|
}
|
|
182
213
|
/** @internal Short random id tying a hidden 5xx message to its server log entry. */
|
|
183
214
|
export declare const _digest: () => string;
|
|
184
|
-
/**
|
|
185
|
-
export
|
|
215
|
+
/** What the caller of the app handler passes with a request (adapters: `nonce`, `address`, `platform`). */
|
|
216
|
+
export interface AppHandlerContext {
|
|
217
|
+
/** CSP nonce of this response (node adapter: fresh per request). */
|
|
186
218
|
nonce?: string;
|
|
219
|
+
/** Client address (`event.platform.clientAddress`). */
|
|
187
220
|
address?: string;
|
|
221
|
+
/** Adapter-provided request facts (`event.platform`). */
|
|
188
222
|
platform?: Platform;
|
|
189
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Seeds `event.locals`: each request starts with a shallow copy, before any request handler, guard, loader or action
|
|
225
|
+
* runs (the caller's object is never changed). Only from the code that calls the handler (no adapter fills it from the
|
|
226
|
+
* request), e.g. a view model another server computed. Never sent to the browser unless a loader returns it. Requests
|
|
227
|
+
* that pass it bypass the route rules' page cache (their page depends on more than the URL). Client-navigation data
|
|
228
|
+
* requests (`?_rivet=data`) need the same seed.
|
|
229
|
+
*/
|
|
230
|
+
locals?: Readonly<Record<string, unknown>>;
|
|
231
|
+
}
|
|
232
|
+
/** The app's web-standard request handler. */
|
|
233
|
+
export type AppHandler = (request: Request, context?: AppHandlerContext) => Promise<Response>;
|
|
190
234
|
/**
|
|
191
235
|
* Encode a value with the resume table (Date/Map/Set/BigInt/undefined/cycles survive). With built-in entries (RegExp /
|
|
192
236
|
* URLSearchParams / binary / errors) and a `builtins` chunk URL, `x` names it: the client imports it before decoding.
|
package/server/handler.js
CHANGED
|
@@ -11,6 +11,8 @@ import { crossSite, limitBody } from "./security.js";
|
|
|
11
11
|
import { ABSOLUTE, unsafeTarget } from "../shared/redirect.js";
|
|
12
12
|
import { slashPath, TRAILING_SLASH } from "../shared/slash.js";
|
|
13
13
|
import { parseCookies } from "../shared/cookie.js";
|
|
14
|
+
/** @internal Request property carrying a `ShellRequest` (`Symbol.for("rivet.shell")`). */
|
|
15
|
+
export const SHELL = Symbol.for("rivet.shell");
|
|
14
16
|
/** @internal Short random id tying a hidden 5xx message to its server log entry. */
|
|
15
17
|
export const _digest = () => {
|
|
16
18
|
const bytes = crypto.getRandomValues(new Uint8Array(5));
|
|
@@ -106,6 +108,9 @@ const seeOther = (failure) => {
|
|
|
106
108
|
return out;
|
|
107
109
|
};
|
|
108
110
|
const NO_PLATFORM = Object.freeze({});
|
|
111
|
+
/** `load(fn, { defer: true })` marker (`shared/server-exports.ts` `DEFER`). */
|
|
112
|
+
const DEFER = Symbol.for("rivet.defer");
|
|
113
|
+
let deferredModule;
|
|
109
114
|
// `event.depends()` outside loaders.
|
|
110
115
|
const noDeps = () => { };
|
|
111
116
|
// Dev hint, once per layout: a layout loader that denies or redirects acts as access control, but
|
|
@@ -116,7 +121,7 @@ function accessHint(route, index, failure) {
|
|
|
116
121
|
if (isHttpError(failure) && (failure.status === 401 || failure.status === 403 || isRedirect(failure)) && !hinted.has(layout)) {
|
|
117
122
|
hinted.add(layout);
|
|
118
123
|
console.warn(`[rivet] The loader of layout "${layout}" ${isRedirect(failure) ? "redirected" : "answered " + failure.status}: loaders are not access control. ` +
|
|
119
|
-
"Client navigations that keep this layout's data skip its loader, so pages below it would load anyway. Check access in `
|
|
124
|
+
"Client navigations that keep this layout's data skip its loader, so pages below it would load anyway. Check access in a `guard(…)` export (guards always run).");
|
|
120
125
|
}
|
|
121
126
|
throw failure;
|
|
122
127
|
}
|
|
@@ -205,7 +210,9 @@ export function createHandler(options) {
|
|
|
205
210
|
url.search = withoutRivet(url.search);
|
|
206
211
|
const headers = new Headers();
|
|
207
212
|
const nonce = context.nonce;
|
|
208
|
-
|
|
213
|
+
// Seeded by the caller (`context.locals`): a copy, so guards writing `locals` never reach the caller's object.
|
|
214
|
+
const seed = context.locals;
|
|
215
|
+
const locals = seed ? { ...seed } : {};
|
|
209
216
|
const found = match(url);
|
|
210
217
|
const route = found ? found.route.render(undefined, found) : undefined;
|
|
211
218
|
const method = request.method === "HEAD" ? "GET" : request.method;
|
|
@@ -425,7 +432,7 @@ export function createHandler(options) {
|
|
|
425
432
|
const allow = (options) => [
|
|
426
433
|
...(route.page
|
|
427
434
|
? ["GET, HEAD, POST", ...["PUT", "PATCH", "DELETE"].filter((m) => own?.[ON[m]])]
|
|
428
|
-
: Object.keys(ON).filter((m) => own?.[ON[m]]
|
|
435
|
+
: Object.keys(ON).filter((m) => own?.[ON[m]]).map((m) => (m === "GET" ? "GET, HEAD" : m))),
|
|
429
436
|
...(options ? ["OPTIONS"] : []),
|
|
430
437
|
].join(", ");
|
|
431
438
|
if (route.page && method !== "GET" && method !== "OPTIONS" && action === undefined && !own?.[on]) {
|
|
@@ -481,8 +488,10 @@ export function createHandler(options) {
|
|
|
481
488
|
if (extra)
|
|
482
489
|
new Headers(extra).forEach((value, name) => headers.set(name, value));
|
|
483
490
|
}
|
|
491
|
+
// Route rule `shell` (document GET of a page): a hit sends the stored shell now; a fill tracks request reads.
|
|
492
|
+
const shell = route.page && action === undefined && !data ? request[SHELL]?.begin(route, event, headers) : undefined;
|
|
484
493
|
if (!route.page) {
|
|
485
|
-
const endpoint = own?.[on]
|
|
494
|
+
const endpoint = own?.[on];
|
|
486
495
|
if (!endpoint) {
|
|
487
496
|
headers.set("allow", allow(false));
|
|
488
497
|
return fail(405, "Method not allowed");
|
|
@@ -491,7 +500,7 @@ export function createHandler(options) {
|
|
|
491
500
|
if (body.exceeded)
|
|
492
501
|
return fail(413, "Payload too large");
|
|
493
502
|
if (!(response instanceof Response))
|
|
494
|
-
throw new Error(`${route.id}: ${
|
|
503
|
+
throw new Error(`${route.id}: ${on} must return a Response`);
|
|
495
504
|
return merge(response);
|
|
496
505
|
}
|
|
497
506
|
let performed;
|
|
@@ -568,13 +577,40 @@ export function createHandler(options) {
|
|
|
568
577
|
}
|
|
569
578
|
// Layout loaders that read the URL (or request) cannot be kept across URL changes: the client learns which.
|
|
570
579
|
phase = "loader";
|
|
580
|
+
// Deferred loaders (`load(fn, { defer: true })`, document requests only; data requests await them like any loader):
|
|
581
|
+
// started now, not awaited; their slot is a `Deferred` handle (`deferred.ts`, imported on first use).
|
|
582
|
+
let lazy, promises, moved;
|
|
583
|
+
if (!data && modules.some((module) => module?.load && module.load[DEFER]))
|
|
584
|
+
lazy = await (deferredModule ??= import("./deferred.js"));
|
|
571
585
|
const loaded = await Promise.all(modules.map((module, i) => {
|
|
572
586
|
if (skip.has(i) || !module?.load)
|
|
573
587
|
return (loads[i] = undefined);
|
|
574
|
-
|
|
588
|
+
if (lazy && module.load[DEFER]) {
|
|
589
|
+
// Deferred loaders are the shell's holes: live on every request, never tracked.
|
|
590
|
+
const view = lazy.lateEvent(loaderEvent(i, i < route.layouts.length));
|
|
591
|
+
// Error pages await it like the other loaders (`errorLayouts`): they render after the failure, not streamed.
|
|
592
|
+
const started = ((promises ??= [])[i] = loads[i] = lazy.startDeferred(() => module.load(view)));
|
|
593
|
+
// A redirect after the head is out: a client redirect at the document's end (other origins need `external`).
|
|
594
|
+
started.catch((failure) => {
|
|
595
|
+
if (isHttpError(failure) &&
|
|
596
|
+
isRedirect(failure) &&
|
|
597
|
+
(failure.external || !ABSOLUTE.test(failure.location) || new URL(failure.location).origin === url.origin))
|
|
598
|
+
moved ??= failure.location;
|
|
599
|
+
});
|
|
600
|
+
// The handle is a thenable: set after `Promise.all`, which would await it.
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
// Shell hit: the stored shell's data (its loaders read no request input; the composed response is out).
|
|
604
|
+
if (shell?.data)
|
|
605
|
+
return (loads[i] = undefined), shell.data[i];
|
|
606
|
+
const view = loaderEvent(i, i < route.layouts.length);
|
|
607
|
+
const load = Promise.resolve(module.load(shell?.event ? shell.event(view, i) : view));
|
|
575
608
|
// Dev: a layout loader used as access control is skipped for kept layouts (`_rivet_keep`).
|
|
576
609
|
return (loads[i] = dev && i < route.layouts.length ? load.catch((failure) => accessHint(route, i, failure)) : load);
|
|
577
610
|
}));
|
|
611
|
+
shell?.loaded?.(loaded.slice(), [...byURL], depsOf().deps);
|
|
612
|
+
if (promises)
|
|
613
|
+
promises.forEach((promise, i) => (loaded[i] = shell?.handle ? shell.handle(promise, dev) : lazy.deferredHandle(promise, dev)));
|
|
578
614
|
const head = await options.head?.(route, loaded, { params: found.params, url });
|
|
579
615
|
if (data) {
|
|
580
616
|
if (!headers.has("cache-control"))
|
|
@@ -607,8 +643,10 @@ export function createHandler(options) {
|
|
|
607
643
|
action: performed,
|
|
608
644
|
locals: event.locals,
|
|
609
645
|
head,
|
|
610
|
-
byURL: byURL.size ? [...byURL] : NO_LAYOUTS,
|
|
611
|
-
...depsOf(),
|
|
646
|
+
byURL: shell?.byURL ?? (byURL.size ? [...byURL] : NO_LAYOUTS),
|
|
647
|
+
...(shell?.data ? (shell.deps ? { deps: shell.deps } : {}) : depsOf()),
|
|
648
|
+
...(promises ? { deferred: true, moved: () => moved } : {}),
|
|
649
|
+
...(shell ? { shell } : {}),
|
|
612
650
|
});
|
|
613
651
|
}
|
|
614
652
|
catch (failure) {
|
package/server/index.d.ts
CHANGED
|
@@ -5,5 +5,5 @@ export { strictCSP } from "./strict-csp.js";
|
|
|
5
5
|
export { createApp, mergeHeads, type AppConfig, type GeneratedEntry, type PageInput } from "./app.js";
|
|
6
6
|
export { renderPage, renderDocument, failureMarker, headTags, type DocumentShell, type DocumentInput, type DocumentParts, type RenderPageOptions, } from "./render.js";
|
|
7
7
|
export { serverExports, mergeServer, loadServer } from "./module.js";
|
|
8
|
-
export type { CacheRule, RouteCacheOptions, RouteRule, RouteRules } from "./route-cache.js";
|
|
8
|
+
export type { CacheRule, RouteCacheOptions, RouteRule, RouteRules, ShellRule } from "./route-cache.js";
|
|
9
9
|
export type { EarlyHintsOptions } from "./early-hints.js";
|
package/server/module.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { ServerModule } from "./handler.js";
|
|
2
2
|
/**
|
|
3
|
-
* Normalize one module's exports. `
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Normalize one module's exports by tag. `server`: a raw-loaded server module (sibling `*.server.ts`,
|
|
4
|
+
* plain `.ts` endpoint) — its former sibling names (`load`, `GET`…) are checked too. Errors (thrown at the
|
|
5
|
+
* first request that loads the module): two exports of one kind; an untagged export with a former magic name.
|
|
6
6
|
*/
|
|
7
|
-
export declare function serverExports(module: Record<string, unknown> | undefined,
|
|
7
|
+
export declare function serverExports(module: Record<string, unknown> | undefined, file?: string, server?: boolean): ServerModule;
|
|
8
8
|
/** Merge a route module's in-file server exports with its sibling `*.server.ts`. */
|
|
9
9
|
export declare function mergeServer(inline: Record<string, unknown>, sibling: Record<string, unknown>, files?: readonly string[]): ServerModule;
|
|
10
10
|
/**
|
|
11
|
-
* Load
|
|
12
|
-
*
|
|
11
|
+
* Load a route table entry's server module. Generated loaders (`build/codegen.ts`) return it normalized
|
|
12
|
+
* (`serverExports` / `mergeServer`): `AppRoute.server` / `layouts` return `ServerModule`s.
|
|
13
13
|
*/
|
|
14
|
-
export declare function loadServer(load: (() => Promise<
|
|
14
|
+
export declare function loadServer(load: (() => Promise<ServerModule>) | undefined): Promise<ServerModule> | undefined;
|
package/server/module.js
CHANGED
|
@@ -1,50 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { SERVER_EXPORT } from "../shared/server-exports.js";
|
|
2
|
+
/** Former magic export names → the wrapper that replaces them (an untagged export with one is an error). */
|
|
3
|
+
const MAGIC = {
|
|
3
4
|
serverLoad: "load",
|
|
4
5
|
serverGuard: "guard",
|
|
5
6
|
serverActions: "actions",
|
|
6
7
|
serverEntries: "entries",
|
|
7
8
|
serverHeaders: "headers",
|
|
9
|
+
onRequest: "onRequest",
|
|
10
|
+
onGet: "onGet",
|
|
11
|
+
onPost: "onPost",
|
|
12
|
+
onPut: "onPut",
|
|
13
|
+
onPatch: "onPatch",
|
|
14
|
+
onDelete: "onDelete",
|
|
15
|
+
onOptions: "onOptions",
|
|
16
|
+
};
|
|
17
|
+
/** Former sibling `*.server.ts` names (raw-loaded server modules only). */
|
|
18
|
+
const LEGACY = {
|
|
19
|
+
load: "load",
|
|
20
|
+
guard: "guard",
|
|
21
|
+
actions: "actions",
|
|
22
|
+
entries: "entries",
|
|
23
|
+
headers: "headers",
|
|
24
|
+
GET: "onGet",
|
|
25
|
+
POST: "onPost",
|
|
26
|
+
PUT: "onPut",
|
|
27
|
+
PATCH: "onPatch",
|
|
28
|
+
DELETE: "onDelete",
|
|
8
29
|
};
|
|
9
|
-
const KEPT = ["prerender", "csrf", "onRequest", "onGet", "onPost", "onPut", "onPatch", "onDelete", "onOptions"];
|
|
10
|
-
/** Sibling-only names (migration; `rivet migrate server-exports` renames them). */
|
|
11
|
-
const LEGACY = ["load", "guard", "actions", "entries", "headers", "GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
12
30
|
const cache = new WeakMap();
|
|
13
|
-
const
|
|
31
|
+
const kindOf = (value) => typeof value === "function" || (typeof value === "object" && value !== null) ? value[SERVER_EXPORT] : undefined;
|
|
14
32
|
/**
|
|
15
|
-
* Normalize one module's exports. `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
33
|
+
* Normalize one module's exports by tag. `server`: a raw-loaded server module (sibling `*.server.ts`,
|
|
34
|
+
* plain `.ts` endpoint) — its former sibling names (`load`, `GET`…) are checked too. Errors (thrown at the
|
|
35
|
+
* first request that loads the module): two exports of one kind; an untagged export with a former magic name.
|
|
18
36
|
*/
|
|
19
|
-
export function serverExports(module,
|
|
37
|
+
export function serverExports(module, file = "route module", server = false) {
|
|
20
38
|
if (!module)
|
|
21
39
|
return {};
|
|
22
40
|
const cached = cache.get(module);
|
|
23
41
|
if (cached)
|
|
24
42
|
return cached;
|
|
25
43
|
const out = {};
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
const names = {};
|
|
45
|
+
for (const name of Object.keys(module)) {
|
|
46
|
+
const value = module[name];
|
|
47
|
+
const kind = kindOf(value);
|
|
48
|
+
if (kind === undefined) {
|
|
49
|
+
const wrapper = MAGIC[name] ?? (server ? LEGACY[name] : undefined);
|
|
50
|
+
if (wrapper !== undefined && value !== undefined)
|
|
51
|
+
throw new Error(`${file}: \`${name}\` is not a server export: wrap it — \`export const x = ${wrapper}(…)\` (\`${wrapper}\` is exported by @rivetjs/router)`);
|
|
28
52
|
continue;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
}
|
|
54
|
+
if (out[kind] !== undefined && out[kind] !== value)
|
|
55
|
+
throw new Error(`${file}: \`${names[kind]}\` and \`${name}\` are both \`${kind}(…)\`; keep one`);
|
|
56
|
+
out[kind] = value;
|
|
57
|
+
names[kind] = name;
|
|
32
58
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const name of LEGACY)
|
|
38
|
-
if (module[name] !== undefined && out[name] === undefined)
|
|
39
|
-
out[name] = module[name];
|
|
59
|
+
if (module.prerender !== undefined)
|
|
60
|
+
out.prerender = module.prerender;
|
|
61
|
+
if (module.csrf !== undefined)
|
|
62
|
+
out.csrf = module.csrf;
|
|
40
63
|
const result = out;
|
|
41
64
|
cache.set(module, result);
|
|
42
|
-
normalized.add(result);
|
|
43
65
|
return result;
|
|
44
66
|
}
|
|
45
67
|
/**
|
|
46
68
|
* Merge a page/layout module's in-file server exports with its sibling `*.server.ts` (generated
|
|
47
|
-
* loaders call this for routes that have both). One
|
|
69
|
+
* loaders call this for routes that have both). One kind defined in both is an error.
|
|
48
70
|
*/
|
|
49
71
|
const merges = new WeakMap();
|
|
50
72
|
/** Merge a route module's in-file server exports with its sibling `*.server.ts`. */
|
|
@@ -53,13 +75,12 @@ export function mergeServer(inline, sibling, files = ["page", "sibling"]) {
|
|
|
53
75
|
const cached = merges.get(inline)?.get(sibling);
|
|
54
76
|
if (cached)
|
|
55
77
|
return cached;
|
|
56
|
-
const a = serverExports(inline,
|
|
57
|
-
const b = serverExports(sibling,
|
|
78
|
+
const a = serverExports(inline, files[0]);
|
|
79
|
+
const b = serverExports(sibling, files[1], true);
|
|
58
80
|
for (const key of Object.keys(b))
|
|
59
81
|
if (key in a)
|
|
60
82
|
throw new Error(`${files[0]} and ${files[1]} both define the route's \`${key}\`; keep one`);
|
|
61
83
|
const merged = { ...b, ...a };
|
|
62
|
-
normalized.add(merged);
|
|
63
84
|
let bySibling = merges.get(inline);
|
|
64
85
|
if (!bySibling)
|
|
65
86
|
merges.set(inline, (bySibling = new WeakMap()));
|
|
@@ -67,12 +88,9 @@ export function mergeServer(inline, sibling, files = ["page", "sibling"]) {
|
|
|
67
88
|
return merged;
|
|
68
89
|
}
|
|
69
90
|
/**
|
|
70
|
-
* Load
|
|
71
|
-
*
|
|
91
|
+
* Load a route table entry's server module. Generated loaders (`build/codegen.ts`) return it normalized
|
|
92
|
+
* (`serverExports` / `mergeServer`): `AppRoute.server` / `layouts` return `ServerModule`s.
|
|
72
93
|
*/
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
if (!module)
|
|
76
|
-
return undefined;
|
|
77
|
-
return normalized.has(module) ? module : serverExports(module, true, "server module");
|
|
94
|
+
export function loadServer(load) {
|
|
95
|
+
return load?.();
|
|
78
96
|
}
|
package/server/render.d.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface RenderPageOptions {
|
|
|
71
71
|
* page's root state and loader are cut (the browser client-renders). Not with `stream`.
|
|
72
72
|
*/
|
|
73
73
|
discardState?: boolean;
|
|
74
|
+
/** Markup written after the app's output (and its streamed boundaries), before `around.end` (deferred loaders' client redirect). */
|
|
75
|
+
epilogue?: () => string;
|
|
74
76
|
}
|
|
75
77
|
/** Mid-stream failure marker: tells the page (and monitoring) the document is incomplete. */
|
|
76
78
|
export declare function failureMarker(nonce: string | undefined, digest: string | undefined): string;
|
package/server/render.js
CHANGED
|
@@ -433,6 +433,9 @@ function stream(factory, input, options, init, deferred) {
|
|
|
433
433
|
await wait;
|
|
434
434
|
}
|
|
435
435
|
release();
|
|
436
|
+
const epilogue = options.epilogue?.();
|
|
437
|
+
if (epilogue && (wait = writer.write(epilogue)))
|
|
438
|
+
await wait;
|
|
436
439
|
if (options.around && (wait = writer.write(options.around.end)))
|
|
437
440
|
await wait;
|
|
438
441
|
if ((wait = writer.write(parts.end)))
|