@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.
Files changed (49) hide show
  1. package/adapters/files.d.ts +13 -0
  2. package/adapters/files.js +58 -6
  3. package/adapters/node.d.ts +22 -5
  4. package/adapters/node.js +113 -67
  5. package/adapters/static.js +1 -1
  6. package/adapters/target-build.d.ts +5 -2
  7. package/adapters/vercel.d.ts +4 -2
  8. package/adapters/vercel.js +1 -1
  9. package/build/codegen.d.ts +13 -0
  10. package/build/codegen.js +152 -35
  11. package/build/dev-errors.d.ts +6 -2
  12. package/build/dev-errors.js +15 -4
  13. package/build/lazy-rows.d.ts +19 -0
  14. package/build/lazy-rows.js +59 -0
  15. package/build/migrate.d.ts +20 -4
  16. package/build/migrate.js +300 -61
  17. package/build/reach.d.ts +60 -0
  18. package/build/reach.js +302 -0
  19. package/build/routes.d.ts +9 -4
  20. package/build/routes.js +30 -13
  21. package/build/vite.d.ts +13 -0
  22. package/build/vite.js +165 -7
  23. package/cli/rivet.js +3 -2
  24. package/client/dev.js +17 -0
  25. package/client/index.d.ts +4 -0
  26. package/client/index.js +34 -0
  27. package/client/route-context.d.ts +1 -1
  28. package/index.d.ts +22 -25
  29. package/index.js +2 -1
  30. package/package.json +4 -4
  31. package/server/app.d.ts +7 -1
  32. package/server/app.js +38 -5
  33. package/server/deferred.d.ts +12 -0
  34. package/server/deferred.js +60 -0
  35. package/server/handler.d.ts +56 -12
  36. package/server/handler.js +46 -8
  37. package/server/index.d.ts +1 -1
  38. package/server/module.d.ts +7 -7
  39. package/server/module.js +52 -34
  40. package/server/render.d.ts +2 -0
  41. package/server/render.js +3 -0
  42. package/server/route-cache.d.ts +74 -4
  43. package/server/route-cache.js +77 -6
  44. package/server/shell-build.d.ts +3 -0
  45. package/server/shell-build.js +2 -0
  46. package/server/shell-cache.d.ts +8 -0
  47. package/server/shell-cache.js +836 -0
  48. package/shared/server-exports.d.ts +78 -0
  49. 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
- ...(config.stream ? { stream: config.stream } : {}),
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
- url: input.url,
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]) => [pattern, rule.headers ? { headers: rule.headers } : {}]))
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
- const loading = import("./route-cache.js").then(({ withRouteRules }) => (cached = withRouteRules(app, rules, policy, entry.base ?? "", config.routeCache)));
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
+ }
@@ -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: in-file `serverLoad`… arrive here as `load`…
67
- * (sibling `*.server.ts` modules may still use the old names). `onRequest` and `on<Method>` request
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 (empty when no guard ran, e.g. unmatched URLs). */
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
- /** The app's web-standard request handler. */
185
- export type AppHandler = (request: Request, context?: {
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
- }) => Promise<Response>;
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 `serverGuard` (guards always run).");
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
- const locals = {};
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]] || own?.[m]).map((m) => (m === "GET" ? "GET, HEAD" : 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] ?? own?.[method];
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}: ${own?.[on] ? on : method} must return a Response`);
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
- const load = Promise.resolve(module.load(loaderEvent(i, i < route.layouts.length)));
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";
@@ -1,14 +1,14 @@
1
1
  import type { ServerModule } from "./handler.js";
2
2
  /**
3
- * Normalize one module's exports. `legacy`: a sibling `*.server.ts` (old names accepted; a module
4
- * exporting both `load` and `serverLoad` is an error). In-file (page/layout) modules use the new names
5
- * only: their `load`/`guard`… are ordinary exports.
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, legacy: boolean, file?: string): ServerModule;
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 and normalize a route table entry's server module. Generated loaders return in-file and merged
12
- * modules normalized, and sibling `*.server.ts` modules raw (old names accepted).
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<unknown>) | undefined): Promise<ServerModule | undefined>;
14
+ export declare function loadServer(load: (() => Promise<ServerModule>) | undefined): Promise<ServerModule> | undefined;
package/server/module.js CHANGED
@@ -1,50 +1,72 @@
1
- /** In-file name → `ServerModule` key. */
2
- const RENAMED = {
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 normalized = new WeakSet();
31
+ const kindOf = (value) => typeof value === "function" || (typeof value === "object" && value !== null) ? value[SERVER_EXPORT] : undefined;
14
32
  /**
15
- * Normalize one module's exports. `legacy`: a sibling `*.server.ts` (old names accepted; a module
16
- * exporting both `load` and `serverLoad` is an error). In-file (page/layout) modules use the new names
17
- * only: their `load`/`guard`… are ordinary exports.
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, legacy, file = "route 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
- for (const [name, key] of Object.entries(RENAMED)) {
27
- if (module[name] === undefined)
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
- if (legacy && module[key] !== undefined)
30
- throw new Error(`${file}: exports both \`${key}\` and \`${name}\`; keep \`${name}\``);
31
- out[key] = module[name];
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
- for (const name of KEPT)
34
- if (module[name] !== undefined)
35
- out[name] = module[name];
36
- if (legacy)
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 export defined in both is an error.
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, false, files[0]);
57
- const b = serverExports(sibling, true, files[1]);
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 and normalize a route table entry's server module. Generated loaders return in-file and merged
71
- * modules normalized, and sibling `*.server.ts` modules raw (old names accepted).
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 async function loadServer(load) {
74
- const module = (await load?.());
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
  }
@@ -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)))