@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
@@ -53,6 +53,25 @@ export interface RouteRule {
53
53
  * cache's own `maxAge` / `swr`, `isr` (and `maxAge: Infinity`) rules `s-maxage=0, must-revalidate`.
54
54
  */
55
55
  readonly cdn?: CdnRule;
56
+ /**
57
+ * Shell cache (partial prerendering, `server/shell-cache.ts`): the document up to its first streamed boundary (the shell:
58
+ * everything outside `<Async fallback>` holes reading deferred loaders) is stored once and sent at once on later
59
+ * requests — signed-in ones too (cookies never bypass it, never key it) — while the holes render live per request in
60
+ * the same response. `true`: the rule's `cache` / `swr` / `isr` times, else stored until `revalidate()` (needs a fixed
61
+ * `origin`, like `isr`); an object sets them. Fills that read request input outside the key (cookies, headers, locals,
62
+ * unkeyed query parameters) are not stored (dev logs why). `false` turns off a less specific rule's shell. Not with `cdn`.
63
+ */
64
+ readonly shell?: boolean | ShellRule;
65
+ }
66
+ /** `RouteRule.shell` times and keys (defaults: the rule's `cache` / `swr` / `isr`, else stored until `revalidate()`). */
67
+ export interface ShellRule {
68
+ readonly maxAge?: number;
69
+ readonly swr?: number | true;
70
+ readonly tags?: readonly string[];
71
+ /** Request headers whose values key separate shells (never `cookie` / `authorization`). */
72
+ readonly varies?: readonly string[];
73
+ /** `true` (default): one shell per query string; `false`: query-less URLs only; a list: those parameters (other URLs render without the shell). */
74
+ readonly query?: boolean | readonly string[];
56
75
  }
57
76
  /**
58
77
  * Rules by path pattern (pathname below the app's base): `/about` exact, `*` one segment (`/blog/*`), a trailing
@@ -124,15 +143,45 @@ interface Cache {
124
143
  /** Headers responses named in `Vary` (beyond `varies`): part of the key from then on. */
125
144
  readonly learned: string[];
126
145
  }
127
- interface Resolved {
146
+ /** @internal A rule's normalised cache (exported for `shell-cache.ts`). */
147
+ export type { Cache as _Cache };
148
+ /** @internal */
149
+ export interface Resolved {
128
150
  readonly headers: readonly (readonly [string, string])[];
129
151
  readonly cache: Cache | undefined;
152
+ /** Shell cache (`RouteRule.shell`): wins over `cache` for documents. */
153
+ readonly shell?: Cache;
130
154
  }
131
155
  /**
132
156
  * Compile `rules` (throws `TypeError` on a malformed pattern or rule) into a pathname → rule lookup. `origin`: a fixed
133
157
  * origin is configured (`isr` rules need one).
134
158
  */
135
159
  export declare function compileRules(rules: RouteRules, origin?: boolean): (pathname: string) => Resolved | undefined;
160
+ /** Bounded page store, CLOCK eviction (a hit sets one bit). Shell entries (`shell-cache.ts`) live here too (key `…\0shell`). */
161
+ declare class Store {
162
+ readonly pages: Map<string, CachedPage>;
163
+ /** Shell cache: rejected fills per route id (3 in a row → no more attempts until a `revalidate()`). */
164
+ readonly sticky: Map<string, number>;
165
+ bytes: number;
166
+ /** Fills running now (`path`, `tags`): a `revalidate()` matching one voids it (it answers but does not store). */
167
+ readonly filling: Set<{
168
+ readonly path: string;
169
+ readonly tags: readonly string[];
170
+ void: boolean;
171
+ }>;
172
+ readonly maxBytes: number;
173
+ readonly maxEntries: number;
174
+ readonly base: string;
175
+ constructor(maxBytes: number, maxEntries: number, base: string);
176
+ get(key: string): CachedPage | undefined;
177
+ set(page: CachedPage): void;
178
+ /** `page` (stored) grew by `bytes`. */
179
+ grow(page: CachedPage, bytes: number): void;
180
+ private evict;
181
+ delete(key: string): boolean;
182
+ }
183
+ /** @internal The page store type (`shell-cache.ts` keeps its entries in it). */
184
+ export type { Store as _Store };
136
185
  /**
137
186
  * The router's view of a raw pathname: `match` — segments percent-decoded (a decoded `/` stays `%2F`: one segment),
138
187
  * what rules match and keys use; `canonical` — the raw path is that path's one encoding (only those are stored, so
@@ -167,9 +216,30 @@ export interface CachePolicy {
167
216
  }
168
217
  /** Own property the app sets on a request whose render reported a failure (`onError`: an error fallback was rendered). */
169
218
  export declare const RENDER_FAILED: unique symbol;
219
+ /** @internal What `withRouteRules` hands the shell cache (`shell-cache.ts` `createShells`). */
220
+ export interface ShellHost {
221
+ readonly handler: AppHandler;
222
+ readonly store: Store;
223
+ readonly csp: CachePolicy;
224
+ readonly fixed: string | undefined;
225
+ readonly dev: boolean;
226
+ /** Build identity (the resume runtime's URL): part of each shell's `h`. */
227
+ readonly build: string;
228
+ keyOf(cache: Cache, origin: string, path: string, search: string, header: HeaderOf): string;
229
+ secondMiss(cache: Cache, search: string, header: HeaderOf): boolean;
230
+ readonly doorkeeper: Set<string>;
231
+ withHeaders(response: Response, headers: Resolved["headers"]): Response;
232
+ }
233
+ /** @internal The shell cache's request entry (`shell-cache.ts`); undefined: render without it. */
234
+ export type ShellServe = (request: Request, context: Parameters<AppHandler>[1], resolved: Resolved, origin: string, path: string, search: string) => Promise<Response> | undefined;
170
235
  /**
171
236
  * Wrap the app's handler with route rules. `base`: the app's base path (stripped before matching). The result
172
- * carries `ROUTE_CACHE` (node adapter fast path).
237
+ * carries `ROUTE_CACHE` (node adapter fast path). `shells`: the shell cache module (`createApp` loads it only for
238
+ * rules with `shell`); `internal.dev`: shell rules only probe (never store; reasons logged), `internal.build`: build id.
173
239
  */
174
- export declare function withRouteRules(handler: AppHandler, rules: RouteRules, csp: CachePolicy, base?: string, options?: RouteCacheOptions): CachingHandler;
175
- export {};
240
+ export declare function withRouteRules(handler: AppHandler, rules: RouteRules, csp: CachePolicy, base?: string, options?: RouteCacheOptions, shells?: {
241
+ createShells(host: ShellHost): ShellServe;
242
+ }, internal?: {
243
+ dev?: boolean;
244
+ build?: string;
245
+ }): CachingHandler;
@@ -3,7 +3,8 @@ import { hashPolicy, noncedSources } from "../shared/csp-hash.js";
3
3
  import { DATA_PARAM } from "../shared/data.js";
4
4
  /** Own property of a handler with route rules: the cache lookup. */
5
5
  export const ROUTE_CACHE = Symbol.for("rivet.routeCache");
6
- const RULE_KEYS = new Set(["headers", "cache", "swr", "isr", "cdn"]);
6
+ const RULE_KEYS = new Set(["headers", "cache", "swr", "isr", "cdn", "shell"]);
7
+ const SHELL_KEYS = new Set(["maxAge", "swr", "tags", "varies", "query"]);
7
8
  const CREDENTIALS = new Set(["cookie", "authorization"]);
8
9
  // Request headers a fill / refresh never forwards (unless the rule's `varies` names them): credentials and what
9
10
  // identifies the visitor behind a proxy — a stored page must not depend on the visitor whose miss rendered it.
@@ -82,6 +83,37 @@ function cacheOf(rule, pattern, origin) {
82
83
  learned: [],
83
84
  };
84
85
  }
86
+ /** Check and normalise one rule's `shell` (defaults: the rule's own cache times, else stored until `revalidate()`). */
87
+ function shellOf(rule, pattern, origin, cache) {
88
+ const where = `"${pattern}"`;
89
+ const given = rule.shell;
90
+ if (given === undefined)
91
+ return undefined;
92
+ if (given === false)
93
+ return false;
94
+ if (given !== true && (typeof given !== "object" || given === null))
95
+ throw new TypeError(`routeRules ${where}: shell must be true, false or { maxAge, swr, tags, varies, query }`);
96
+ if (rule.cdn !== undefined)
97
+ throw new TypeError(`routeRules ${where}: shell cannot take cdn (shell documents carry per-visitor holes: private, no-cache)`);
98
+ const own = given === true ? {} : given;
99
+ for (const key of Object.keys(own))
100
+ if (!SHELL_KEYS.has(key))
101
+ throw new TypeError(`routeRules ${where}: unknown shell option "${key}"`);
102
+ const base = cache || undefined;
103
+ const maxAge = own.maxAge ?? base?.maxAge ?? Infinity;
104
+ const swr = own.swr ?? (base ? (base.swr === Infinity ? true : base.swr) : undefined);
105
+ const normal = cacheOf({
106
+ cache: {
107
+ maxAge,
108
+ ...(swr !== undefined ? { swr } : {}),
109
+ ...((own.tags ?? base?.tags)?.length ? { tags: own.tags ?? base.tags } : {}),
110
+ ...((own.varies ?? base?.varies)?.length ? { varies: own.varies ?? base.varies } : {}),
111
+ ...(own.query !== undefined ? { query: own.query } : base ? { query: base.query } : {}),
112
+ },
113
+ }, pattern + " shell", origin);
114
+ // Documents with live holes: never shared-cacheable, revalidated by the browser every time.
115
+ return normal ? { ...normal, control: "private, no-cache" } : undefined;
116
+ }
85
117
  /**
86
118
  * Compile `rules` (throws `TypeError` on a malformed pattern or rule) into a pathname → rule lookup. `origin`: a fixed
87
119
  * origin is configured (`isr` rules need one).
@@ -89,6 +121,7 @@ function cacheOf(rule, pattern, origin) {
89
121
  export function compileRules(rules, origin = false) {
90
122
  const compiled = [];
91
123
  const caches = new Map();
124
+ const shells = new Map();
92
125
  for (const [pattern, rule] of Object.entries(rules)) {
93
126
  const segments = pattern.split("/");
94
127
  if (!pattern.startsWith("/") ||
@@ -103,7 +136,10 @@ export function compileRules(rules, origin = false) {
103
136
  for (const [name, value] of Object.entries(rule.headers))
104
137
  if (!/^[!#$%&'*+.^`|~\w-]+$/.test(name) || typeof value !== "string" || /[\r\n\0]/.test(value))
105
138
  throw new TypeError(`routeRules "${pattern}": invalid header ${JSON.stringify(name)}`);
106
- caches.set(rule, cacheOf(rule, pattern, origin));
139
+ const own = cacheOf(rule, pattern, origin);
140
+ caches.set(rule, own);
141
+ if (rule.shell !== undefined)
142
+ shells.set(rule, shellOf(rule, pattern, origin, own));
107
143
  const any = pattern.endsWith("/**");
108
144
  const body = any ? pattern.slice(0, -3) : pattern;
109
145
  const source = body
@@ -120,6 +156,7 @@ export function compileRules(rules, origin = false) {
120
156
  if (hit === undefined) {
121
157
  let headers;
122
158
  let cache;
159
+ let shell;
123
160
  let matched = false;
124
161
  for (const { test, rule } of compiled) {
125
162
  if (!test.test(pathname))
@@ -131,8 +168,16 @@ export function compileRules(rules, origin = false) {
131
168
  const own = caches.get(rule);
132
169
  if (own !== undefined)
133
170
  cache = own;
171
+ const shelled = shells.get(rule);
172
+ if (shelled !== undefined)
173
+ shell = shelled;
134
174
  }
135
- hit = matched ? { headers: headers ? [...headers] : [], cache: cache || undefined } : null;
175
+ // A shell rule wins for documents: no whole-page copy of them (their holes are per visitor).
176
+ hit = matched
177
+ ? shell
178
+ ? { headers: headers ? [...headers] : [], cache: undefined, shell }
179
+ : { headers: headers ? [...headers] : [], cache: cache || undefined }
180
+ : null;
136
181
  // Bounded: request paths are client-controlled.
137
182
  if (memo.size >= 2048)
138
183
  memo.clear();
@@ -141,9 +186,11 @@ export function compileRules(rules, origin = false) {
141
186
  return hit ?? undefined;
142
187
  };
143
188
  }
144
- /** Bounded page store, CLOCK eviction (a hit sets one bit). */
189
+ /** Bounded page store, CLOCK eviction (a hit sets one bit). Shell entries (`shell-cache.ts`) live here too (key `…\0shell`). */
145
190
  class Store {
146
191
  pages = new Map();
192
+ /** Shell cache: rejected fills per route id (3 in a row → no more attempts until a `revalidate()`). */
193
+ sticky = new Map();
147
194
  bytes = 0;
148
195
  /** Fills running now (`path`, `tags`): a `revalidate()` matching one voids it (it answers but does not store). */
149
196
  filling = new Set();
@@ -268,6 +315,7 @@ function dropLocal(target) {
268
315
  for (const fill of store.filling)
269
316
  if (matches(fill))
270
317
  fill.void = true;
318
+ store.sticky.clear();
271
319
  for (const page of [...store.pages.values()])
272
320
  if (matches(page) && store.delete(page.key))
273
321
  dropped++;
@@ -398,9 +446,10 @@ const warn = (path, why) => {
398
446
  const backoff = (failures) => Math.min(1000 * 2 ** (failures - 1), 300_000);
399
447
  /**
400
448
  * Wrap the app's handler with route rules. `base`: the app's base path (stripped before matching). The result
401
- * carries `ROUTE_CACHE` (node adapter fast path).
449
+ * carries `ROUTE_CACHE` (node adapter fast path). `shells`: the shell cache module (`createApp` loads it only for
450
+ * rules with `shell`); `internal.dev`: shell rules only probe (never store; reasons logged), `internal.build`: build id.
402
451
  */
403
- export function withRouteRules(handler, rules, csp, base = "", options = {}) {
452
+ export function withRouteRules(handler, rules, csp, base = "", options = {}, shells, internal = {}) {
404
453
  const fixed = options.origin === undefined ? undefined : new URL(options.origin).origin;
405
454
  const resolve = compileRules(rules, fixed !== undefined);
406
455
  const store = new Store(options.maxBytes ?? 32 * 1024 * 1024, options.maxEntries ?? 5000, base);
@@ -491,6 +540,18 @@ export function withRouteRules(handler, rules, csp, base = "", options = {}) {
491
540
  return copy;
492
541
  }
493
542
  };
543
+ const shellServe = shells?.createShells({
544
+ handler,
545
+ store,
546
+ csp,
547
+ fixed,
548
+ dev: internal.dev === true,
549
+ build: internal.build ?? "",
550
+ keyOf,
551
+ secondMiss,
552
+ doorkeeper,
553
+ withHeaders,
554
+ });
494
555
  const age = (page, now) => String(Math.max(0, Math.floor((now - page.created) / 1000)));
495
556
  /** Serve a stored page (`state`: HIT / STALE), 304 when the request's validators hold. */
496
557
  const serve = (page, request, state) => {
@@ -698,6 +759,16 @@ export function withRouteRules(handler, rules, csp, base = "", options = {}) {
698
759
  const resolved = raw === null ? undefined : resolve(path);
699
760
  if (!resolved)
700
761
  return handler(request, context);
762
+ // Seeded `locals` (`context.locals`): the page depends on input that is not in the URL, so it is neither read
763
+ // from nor written to the store (rule headers still apply).
764
+ if (context?.locals)
765
+ return withHeaders(await handler(request, context), resolved.headers);
766
+ // Shell rules (documents): stored shell + live holes (`shell-cache.ts`); undefined = render as usual.
767
+ if (resolved.shell && shellServe && canonical) {
768
+ const served = shellServe(request, context, resolved, origin, path, search);
769
+ if (served)
770
+ return served;
771
+ }
701
772
  const header = (name) => request.headers.get(name);
702
773
  const cache = cacheFor(resolved, request.method, origin, canonical, search, header);
703
774
  if (!cache)
@@ -0,0 +1,3 @@
1
+ import type * as ShellCache from "./shell-cache.js";
2
+ /** Loads the shell cache module, or undefined when this bundle has none (rewritten by `build/vite.ts`). */
3
+ export declare const SHELL_BUILD: (() => Promise<typeof ShellCache>) | undefined;
@@ -0,0 +1,2 @@
1
+ /** Loads the shell cache module, or undefined when this bundle has none (rewritten by `build/vite.ts`). */
2
+ export const SHELL_BUILD = undefined;
@@ -0,0 +1,8 @@
1
+ import type { ShellHost, ShellServe } from "./route-cache.js";
2
+ /**
3
+ * Where the shell ends in `text` (latin1 view of the document so far): after the root state tail — the state script,
4
+ * the loader script and (with pending boundaries, `data-stream`) the stream add-on. -1: not yet (or never).
5
+ */
6
+ export declare function shellCut(text: string): number;
7
+ /** The route rules' shell entry point (`withRouteRules`): undefined = render without the shell. */
8
+ export declare function createShells(host: ShellHost): ShellServe;