@rivetjs/router 0.0.0-stage → 0.1.0-alpha.0

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/adapters/assemble.d.ts +32 -0
  4. package/adapters/assemble.js +32 -0
  5. package/adapters/build.d.ts +24 -0
  6. package/adapters/build.js +43 -0
  7. package/adapters/bun.d.ts +18 -0
  8. package/adapters/bun.js +44 -0
  9. package/adapters/cloudflare.d.ts +5 -0
  10. package/adapters/cloudflare.js +8 -0
  11. package/adapters/cluster.d.ts +7 -0
  12. package/adapters/cluster.js +130 -0
  13. package/adapters/csr.d.ts +12 -0
  14. package/adapters/csr.js +11 -0
  15. package/adapters/deno.d.ts +13 -0
  16. package/adapters/deno.js +35 -0
  17. package/adapters/fetch.d.ts +55 -0
  18. package/adapters/fetch.js +72 -0
  19. package/adapters/files.d.ts +7 -0
  20. package/adapters/files.js +103 -0
  21. package/adapters/idle-memory.d.ts +23 -0
  22. package/adapters/idle-memory.js +91 -0
  23. package/adapters/netlify.d.ts +13 -0
  24. package/adapters/netlify.js +15 -0
  25. package/adapters/node.d.ts +163 -0
  26. package/adapters/node.js +940 -0
  27. package/adapters/pack.d.ts +39 -0
  28. package/adapters/pack.js +360 -0
  29. package/adapters/server-target.d.ts +17 -0
  30. package/adapters/server-target.js +8 -0
  31. package/adapters/static.d.ts +84 -0
  32. package/adapters/static.js +231 -0
  33. package/adapters/target-build.d.ts +92 -0
  34. package/adapters/target-build.js +118 -0
  35. package/adapters/vercel-edge.d.ts +7 -0
  36. package/adapters/vercel-edge.js +9 -0
  37. package/adapters/vercel.d.ts +33 -0
  38. package/adapters/vercel.js +45 -0
  39. package/build/codegen.d.ts +84 -0
  40. package/build/codegen.js +920 -0
  41. package/build/dev-errors.d.ts +19 -0
  42. package/build/dev-errors.js +236 -0
  43. package/build/migrate.d.ts +15 -0
  44. package/build/migrate.js +146 -0
  45. package/build/route-css.d.ts +54 -0
  46. package/build/route-css.js +216 -0
  47. package/build/routes.d.ts +50 -0
  48. package/build/routes.js +202 -0
  49. package/build/sync.d.ts +20 -0
  50. package/build/sync.js +16 -0
  51. package/build/vite.d.ts +195 -0
  52. package/build/vite.js +703 -0
  53. package/cli/rivet.d.ts +2 -0
  54. package/cli/rivet.js +100 -0
  55. package/client/active.d.ts +6 -0
  56. package/client/active.js +22 -0
  57. package/client/announce.d.ts +2 -0
  58. package/client/announce.js +21 -0
  59. package/client/dev.d.ts +41 -0
  60. package/client/dev.js +74 -0
  61. package/client/index.d.ts +152 -0
  62. package/client/index.js +680 -0
  63. package/client/layouts.d.ts +110 -0
  64. package/client/layouts.js +310 -0
  65. package/client/leave.d.ts +40 -0
  66. package/client/leave.js +75 -0
  67. package/client/link.d.ts +48 -0
  68. package/client/link.jsx +65 -0
  69. package/client/live.d.ts +57 -0
  70. package/client/live.js +170 -0
  71. package/client/navigate.d.ts +71 -0
  72. package/client/navigate.js +120 -0
  73. package/client/navigation.d.ts +28 -0
  74. package/client/navigation.js +14 -0
  75. package/client/params.d.ts +16 -0
  76. package/client/params.js +28 -0
  77. package/client/route-context.d.ts +32 -0
  78. package/client/route-context.js +21 -0
  79. package/client/scroll.d.ts +16 -0
  80. package/client/scroll.js +80 -0
  81. package/client/search.d.ts +8 -0
  82. package/client/search.js +17 -0
  83. package/client/tailwind.css +8 -0
  84. package/client/transition-helpers.d.ts +28 -0
  85. package/client/transition-helpers.js +63 -0
  86. package/client/transitions.css +90 -0
  87. package/client/transitions.d.ts +21 -0
  88. package/client/transitions.js +156 -0
  89. package/index.d.ts +138 -0
  90. package/index.js +7 -0
  91. package/package.json +156 -4
  92. package/server/app.d.ts +169 -0
  93. package/server/app.js +302 -0
  94. package/server/body.d.ts +20 -0
  95. package/server/body.js +8 -0
  96. package/server/early-hints.d.ts +34 -0
  97. package/server/early-hints.js +63 -0
  98. package/server/handler.d.ts +196 -0
  99. package/server/handler.js +623 -0
  100. package/server/index.d.ts +9 -0
  101. package/server/index.js +6 -0
  102. package/server/inline-styles.d.ts +21 -0
  103. package/server/inline-styles.js +28 -0
  104. package/server/module.d.ts +14 -0
  105. package/server/module.js +78 -0
  106. package/server/render.d.ts +94 -0
  107. package/server/render.js +489 -0
  108. package/server/route-cache.d.ts +175 -0
  109. package/server/route-cache.js +742 -0
  110. package/server/security.d.ts +18 -0
  111. package/server/security.js +47 -0
  112. package/server/strict-csp.d.ts +7 -0
  113. package/server/strict-csp.js +18 -0
  114. package/server-only.d.ts +1 -0
  115. package/server-only.js +3 -0
  116. package/shared/actions.d.ts +10 -0
  117. package/shared/actions.js +18 -0
  118. package/shared/base.d.ts +8 -0
  119. package/shared/base.js +20 -0
  120. package/shared/cookie.d.ts +7 -0
  121. package/shared/cookie.js +32 -0
  122. package/shared/csp-directives.d.ts +9 -0
  123. package/shared/csp-directives.js +85 -0
  124. package/shared/csp-hash.d.ts +39 -0
  125. package/shared/csp-hash.js +121 -0
  126. package/shared/csp.d.ts +26 -0
  127. package/shared/csp.js +13 -0
  128. package/shared/data.d.ts +175 -0
  129. package/shared/data.js +22 -0
  130. package/shared/errors.d.ts +31 -0
  131. package/shared/errors.js +51 -0
  132. package/shared/head.d.ts +57 -0
  133. package/shared/head.js +242 -0
  134. package/shared/nonce.d.ts +2 -0
  135. package/shared/nonce.js +23 -0
  136. package/shared/prefetch-fallback.min.d.ts +1 -0
  137. package/shared/prefetch-fallback.min.js +2 -0
  138. package/shared/prefetch.d.ts +54 -0
  139. package/shared/prefetch.js +74 -0
  140. package/shared/redirect.d.ts +6 -0
  141. package/shared/redirect.js +25 -0
  142. package/shared/slash.d.ts +8 -0
  143. package/shared/slash.js +24 -0
package/package.json CHANGED
@@ -1,6 +1,158 @@
1
1
  {
2
2
  "name": "@rivetjs/router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "File routing for Rivet: loaders, actions, guards, a web-standard request handler, Node / static / edge adapters.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "bin": {
9
+ "rivet": "./cli/rivet.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "default": "./index.js"
15
+ },
16
+ "./server": {
17
+ "types": "./server/index.d.ts",
18
+ "default": "./server/index.js"
19
+ },
20
+ "./route-cache": {
21
+ "types": "./server/route-cache.d.ts",
22
+ "default": "./server/route-cache.js"
23
+ },
24
+ "./server-only": {
25
+ "types": "./server-only.d.ts",
26
+ "default": "./server-only.js"
27
+ },
28
+ "./client": {
29
+ "types": "./client/index.d.ts",
30
+ "development": "./client/dev.js",
31
+ "default": "./client/index.js"
32
+ },
33
+ "./route-context": {
34
+ "types": "./client/route-context.d.ts",
35
+ "default": "./client/route-context.js"
36
+ },
37
+ "./leave": {
38
+ "types": "./client/leave.d.ts",
39
+ "default": "./client/leave.js"
40
+ },
41
+ "./navigation": {
42
+ "types": "./client/navigation.d.ts",
43
+ "default": "./client/navigation.js"
44
+ },
45
+ "./search": {
46
+ "types": "./client/search.d.ts",
47
+ "default": "./client/search.js"
48
+ },
49
+ "./transitions": {
50
+ "types": "./client/transitions.d.ts",
51
+ "default": "./client/transitions.js"
52
+ },
53
+ "./prefetch": {
54
+ "types": "./shared/prefetch.d.ts",
55
+ "default": "./shared/prefetch.js"
56
+ },
57
+ "./link": {
58
+ "types": "./client/link.d.ts",
59
+ "default": "./client/link.jsx"
60
+ },
61
+ "./transitions.css": "./client/transitions.css",
62
+ "./tailwind.css": "./client/tailwind.css",
63
+ "./routes": {
64
+ "types": "./build/routes.d.ts",
65
+ "default": "./build/routes.js"
66
+ },
67
+ "./node": {
68
+ "types": "./adapters/node.d.ts",
69
+ "default": "./adapters/node.js"
70
+ },
71
+ "./fetch": {
72
+ "types": "./adapters/fetch.d.ts",
73
+ "default": "./adapters/fetch.js"
74
+ },
75
+ "./static": {
76
+ "types": "./adapters/static.d.ts",
77
+ "default": "./adapters/static.js"
78
+ },
79
+ "./csr": {
80
+ "types": "./adapters/csr.d.ts",
81
+ "default": "./adapters/csr.js"
82
+ },
83
+ "./vercel": {
84
+ "types": "./adapters/vercel.d.ts",
85
+ "default": "./adapters/vercel.js"
86
+ },
87
+ "./vercel-edge": {
88
+ "types": "./adapters/vercel-edge.d.ts",
89
+ "default": "./adapters/vercel-edge.js"
90
+ },
91
+ "./netlify": {
92
+ "types": "./adapters/netlify.d.ts",
93
+ "default": "./adapters/netlify.js"
94
+ },
95
+ "./cloudflare": {
96
+ "types": "./adapters/cloudflare.d.ts",
97
+ "default": "./adapters/cloudflare.js"
98
+ },
99
+ "./bun": {
100
+ "types": "./adapters/bun.d.ts",
101
+ "default": "./adapters/bun.js"
102
+ },
103
+ "./deno": {
104
+ "types": "./adapters/deno.d.ts",
105
+ "default": "./adapters/deno.js"
106
+ },
107
+ "./head": {
108
+ "types": "./shared/head.d.ts",
109
+ "default": "./shared/head.js"
110
+ },
111
+ "./vite": {
112
+ "types": "./build/vite.d.ts",
113
+ "default": "./build/vite.js"
114
+ }
115
+ },
116
+ "dependencies": {
117
+ "@rivetjs/runtime": "0.1.0-alpha.0",
118
+ "@rivetjs/reactivity": "0.1.0-alpha.0",
119
+ "@rivetjs/compiler": "0.1.0-alpha.0"
120
+ },
121
+ "engines": {
122
+ "node": ">=24"
123
+ },
124
+ "peerDependencies": {
125
+ "vite": "^8.3.2"
126
+ },
127
+ "peerDependenciesMeta": {
128
+ "vite": {
129
+ "optional": true
130
+ }
131
+ },
132
+ "repository": {
133
+ "type": "git",
134
+ "url": "git+https://github.com/N1XUS/rivetjs.git",
135
+ "directory": "libs/router"
136
+ },
137
+ "homepage": "https://github.com/N1XUS/rivetjs/tree/main/libs/router#readme",
138
+ "bugs": {
139
+ "url": "https://github.com/N1XUS/rivetjs/issues"
140
+ },
141
+ "publishConfig": {
142
+ "access": "public"
143
+ },
144
+ "files": [
145
+ "LICENSE",
146
+ "README.md",
147
+ "adapters",
148
+ "build",
149
+ "cli",
150
+ "client",
151
+ "index.d.ts",
152
+ "index.js",
153
+ "server",
154
+ "server-only.d.ts",
155
+ "server-only.js",
156
+ "shared"
157
+ ]
158
+ }
@@ -0,0 +1,169 @@
1
+ import type { ServerValue } from "@rivetjs/runtime/server";
2
+ import type { ResumeManifest } from "@rivetjs/runtime/server-resume";
3
+ import type { RenderCache } from "@rivetjs/runtime/server-cache";
4
+ import type { ActionResult, Head, HeadSink, LoaderDeps } from "../shared/data.js";
5
+ import { type CSPOptions } from "../shared/csp.js";
6
+ import { type InlineStyleManifest } from "./inline-styles.js";
7
+ import type { TrailingSlash } from "../shared/slash.js";
8
+ import { type AppHandler, type AppRoute, type BodyLimit, type ErrorInfo } from "./handler.js";
9
+ import { type DocumentShell } from "./render.js";
10
+ import type { EarlyHintsOptions } from "./early-hints.js";
11
+ import type { RouteCacheOptions, RouteRules } from "./route-cache.js";
12
+ /** How a dev page renders (`AppConfig.devResume`). */
13
+ export type DevResumeMode = "check" | "resume";
14
+ /** What the generated page factory receives. */
15
+ export interface PageInput {
16
+ readonly data: readonly unknown[];
17
+ readonly url: URL;
18
+ readonly head: Head | undefined;
19
+ readonly error?: {
20
+ readonly status: number;
21
+ readonly message: string;
22
+ };
23
+ /** Component head collector (entries generated with `@rivetjs/kit`); the app provides it through `ServerData`. */
24
+ readonly headSink?: HeadSink;
25
+ /** The form action this request performed (no-JS POST): pages read it with `actionResult()`. */
26
+ readonly action?: ActionResult;
27
+ /** Layouts whose loader read the request URL (client navigation reloads them on any URL change). */
28
+ readonly byURL?: readonly number[];
29
+ /** Loaders' `event.depends()` keys (absent when none called it): resumed with the layout state for `invalidate(key)`. */
30
+ readonly deps?: LoaderDeps;
31
+ }
32
+ /** Shape of the generated `.rivet/entry.server.tsx` module. */
33
+ export interface GeneratedEntry {
34
+ readonly routes: readonly AppRoute[];
35
+ readonly universal: Readonly<Record<string, () => Promise<[number, {
36
+ head?: unknown;
37
+ }][]>>>;
38
+ page(input: PageInput): () => ServerValue;
39
+ /** The app's document shell, when codegen was given one. */
40
+ readonly document?: DocumentShell;
41
+ /** `createApp` from the same bundle as the app (one runtime instance). */
42
+ readonly createApp?: typeof createApp;
43
+ /** Generated with component head (`@rivetjs/kit`): pages hold the document start until the shell rendered. */
44
+ readonly headComponents?: boolean;
45
+ /** URL base path the route patterns carry (`""` or e.g. `/app`; `rivetRouter({ base })` / Vite `base`). */
46
+ readonly base?: string;
47
+ /** `rivetRouter({ trailingSlash })` when `"never"` / `"always"` (the handler's 308s use the bundle's build-time constant; the static adapter reads this). */
48
+ readonly trailingSlash?: Exclude<TrailingSlash, "ignore">;
49
+ /** `rivetRouter({ preload: { background } })`: the default `preload.background` of this app's pages. */
50
+ readonly preloadBackground?: number;
51
+ /** Document prefetch on (`rivetRouter({ prefetch })`): the default `preload.ahead` of this app's pages. */
52
+ readonly fetchAhead?: boolean;
53
+ /** `rivetRouter({ routeRules })`: the app's route rules (JSON data; `createApp({ routeRules })` replaces them). */
54
+ readonly routeRules?: RouteRules;
55
+ }
56
+ /** Config of the server app built from the generated routes entry. */
57
+ export interface AppConfig {
58
+ readonly entry: GeneratedEntry;
59
+ /** Document shell; defaults to the entry's `document`. */
60
+ readonly document?: DocumentShell;
61
+ /** Stylesheet URLs passed to the document. */
62
+ readonly styles?: readonly string[];
63
+ /**
64
+ * Per-route stylesheets (route modules' CSS), linked after `styles`: the client build's
65
+ * `.vite/rivet-route-css.json` (see `loadBuild`) or, in dev, a lookup of the route's module graph.
66
+ */
67
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>> | ((routeId: string) => readonly string[] | Promise<readonly string[]>);
68
+ /**
69
+ * Stylesheets pages may write inline instead of linking: the client build's `.vite/rivet-inline-css.json`
70
+ * (`rivetRouter({ inlineStylesheets })`, see `loadBuild`), decided per page from its `styles` + `routeStyles` list
71
+ * (`inlineStylesFor`). Dev links every sheet.
72
+ */
73
+ readonly inlineStyles?: InlineStyleManifest;
74
+ /** Document defaults below every route (level −1): e.g. `{ titleTemplate: "%s · Shop", meta: [...] }`. */
75
+ readonly head?: Head;
76
+ /** Production resume manifest; omit in dev (see `devResume`). */
77
+ readonly manifest?: ResumeManifest;
78
+ readonly dev?: boolean;
79
+ /**
80
+ * Dev (no manifest): module scripts that client-render the page (e.g. the Vite client and the
81
+ * generated `entry.dev.tsx`). The app renders inside `[data-rivet-dev]` with its route data embedded.
82
+ * A function gets the page's locale (i18n), e.g. to load that locale's dev module graph.
83
+ */
84
+ readonly devScripts?: readonly string[] | ((locale: string | undefined) => readonly string[]);
85
+ /**
86
+ * Dev server (`rivetRouter` sets it; no production `manifest`): pages render through the resume
87
+ * recorder with this dev manifest, so serialization and chunk errors surface as in production.
88
+ * `mode` (per page locale when a function): `"check"` leaves the recorded state and loader out of
89
+ * the page and the browser client-renders (`devScripts`, embedded data); `"resume"` serves the
90
+ * page resumable like production, inside `[data-rivet-dev="resume"]` followed by `devScripts`.
91
+ */
92
+ readonly devResume?: {
93
+ readonly manifest: ResumeManifest;
94
+ readonly mode: DevResumeMode | ((locale: string | undefined) => DevResumeMode);
95
+ };
96
+ readonly stream?: {
97
+ deadline?: number;
98
+ };
99
+ /**
100
+ * Preload planner. `background`: byte budget (minified) of `visible` chunks' static closures also prefetched at idle,
101
+ * in plan order (default 1 MiB, `0` off; Save-Data / 2G never). A function decides per page URL (`undefined`: default),
102
+ * e.g. `0` for embedded frames that a page shows many of. `lean` (per page URL when a function): nothing loads before
103
+ * the first interaction — no scheduled prefetch (eager / idle / visible / background) and no router fetch-ahead;
104
+ * handlers load on intent (pointer dwell, focus, touch) and interaction, as under Save-Data.
105
+ */
106
+ readonly preload?: {
107
+ readonly background?: number | ((url: URL) => number | undefined);
108
+ readonly lean?: boolean | ((url: URL) => boolean | undefined);
109
+ /**
110
+ * Islands-only lean pages of a file-router app (bridge D-lean: the router's region + link listeners deferred until
111
+ * the first link intent / click) still fetch the runtime + router chunks ahead (low priority after `load`, fetched,
112
+ * never evaluated). On `resume.chunks: "route"` builds every page also fetches its own handler chunks ahead the
113
+ * same way. Default: the app's document prefetch (`rivetRouter({ prefetch })` on → true). Ignored with `lean`.
114
+ */
115
+ readonly ahead?: boolean;
116
+ };
117
+ /**
118
+ * Content-Security-Policy for HTML pages, given the response's nonce (inline scripts carry it).
119
+ * Default: strict nonce policy (`strictCSP`); `CSPOptions` (`{ trustedTypes, directives }`): `strictCSP` with them (directives checked at startup);
120
+ * `false` sends none. A `headers()` CSP wins.
121
+ */
122
+ readonly csp?: false | CSPOptions | ((nonce: string) => string);
123
+ /** Server failures (`digest` set for 5xx responses, matching the message the client sees; `phase`/`route` where it failed). */
124
+ onError?(error: unknown, request: Request, info?: ErrorInfo): void;
125
+ /**
126
+ * i18n: locales in compiler variant order and per-locale resume manifests. A page renders the
127
+ * variant and manifest of `locals.locale` (set by a guard, e.g. `i18nGuard`); `locals.dir` reaches
128
+ * the document as `dir`. Error pages use the request's `locals` too; without a locale there
129
+ * (unmatched URLs, guards that failed first) `resolve(request)` decides, else a first path segment
130
+ * naming a locale, else the first locale.
131
+ */
132
+ readonly i18n?: {
133
+ readonly locales: readonly string[];
134
+ readonly manifests?: Readonly<Record<string, ResumeManifest>>;
135
+ resolve?(request: Request): string | undefined | Promise<string | undefined>;
136
+ };
137
+ /** Request body limit for unsafe methods (413 past it); default 1 MiB, multipart 10 MiB. */
138
+ readonly maxBody?: BodyLimit;
139
+ /**
140
+ * Route rules by path pattern (`{ "/blog/**": { swr: 60 }, "/about": { isr: true, headers: { … } } }`): response headers,
141
+ * and an in-process cache of rendered pages (fresh, stale-while-revalidate, ISR; ETag / Last-Modified → 304).
142
+ * Defaults to the entry's (`rivetRouter({ routeRules })`). The rules module loads only when there are rules.
143
+ * See `server/route-cache.ts` / docs "Caching".
144
+ */
145
+ readonly routeRules?: RouteRules;
146
+ /** The route rules' page store: `maxBytes` (default 32 MiB), `maxEntries` (default 5000), `origin` (the public origin pages are stored for; `isr` needs it). */
147
+ readonly routeCache?: RouteCacheOptions;
148
+ /**
149
+ * Cross-request fragment cache (`createRenderCache()` from `@rivetjs/runtime/server-cache`): every page renders inside
150
+ * its scope (`withRenderCache`: locale variant, nonce, abort signal, resumable), so `cached()` / `shell()` / compiler
151
+ * `_cached` components hit across requests. Node-only (AsyncLocalStorage; Workers: `nodejs_als`); loaded only when set.
152
+ */
153
+ readonly renderCache?: RenderCache;
154
+ /**
155
+ * Early hints for HTML pages (production only): linked stylesheets, the head's build-asset font preloads (learned
156
+ * per route from its last render), `links` (e.g. preconnects), and with `runtime: true` the resume runtime entry. Sent as `103 Early
157
+ * Hints` by the node adapter before guards and loaders run (`start({ earlyHints })`), and as a `Link` header on every
158
+ * page response (proxies such as Cloudflare turn it into 103s). Browsers act on 103s over HTTP/2+ only. Pages served
159
+ * from the route cache get the stored `Link` header but no 103 (no render runs): it helps uncached pages. Default off.
160
+ */
161
+ readonly earlyHints?: boolean | EarlyHintsOptions;
162
+ }
163
+ /**
164
+ * Merge heads outer → inner: inner wins per key (title; meta by name/property/http-equiv/itemprop
165
+ * + media; one canonical; alternates by hreflang; other links by rel + href; scripts by id/src).
166
+ */
167
+ export declare function mergeHeads(heads: readonly (Head | undefined)[]): Head | undefined;
168
+ /** Create the app's request handler. */
169
+ export declare function createApp(config: AppConfig): AppHandler;
package/server/app.js ADDED
@@ -0,0 +1,302 @@
1
+ import { headTags, rankMerge } from "../shared/head.js";
2
+ import { nonceCSP } from "../shared/csp.js";
3
+ import { inlineStylesFor } from "./inline-styles.js";
4
+ import { escapeJSON } from "@rivetjs/runtime/resume-format";
5
+ import { createHandler, _digest, encodeData } from "./handler.js";
6
+ import { renderDocument } from "./render.js";
7
+ // `route-cache.ts` `ROUTE_CACHE` (same registered symbol; that module loads only with rules).
8
+ const ROUTE_CACHE = Symbol.for("rivet.routeCache");
9
+ // `early-hints.ts` `EARLY_HINTS` (same registered symbol; that module loads only with `earlyHints`).
10
+ const EARLY_HINTS = Symbol.for("rivet.earlyHints");
11
+ const RENDER_FAILED = Symbol.for("rivet.renderFailed");
12
+ /** Route export levels: layouts outermost first, then the page (undefined entries skipped). */
13
+ const ranked = (heads, defaults) => [
14
+ ...(defaults ? [{ level: -1, phase: 0, seq: 0, head: defaults }] : []),
15
+ ...heads.flatMap((head, level) => (head ? [{ level, phase: 0, seq: 0, head }] : [])),
16
+ ];
17
+ /**
18
+ * Merge heads outer → inner: inner wins per key (title; meta by name/property/http-equiv/itemprop
19
+ * + media; one canonical; alternates by hreflang; other links by rel + href; scripts by id/src).
20
+ */
21
+ export function mergeHeads(heads) {
22
+ return rankMerge(ranked(heads));
23
+ }
24
+ /** Component head collector of one request: entries until the hold releases, then late entries render in place. */
25
+ function createSink(nonce, late) {
26
+ let seq = 0, closed = false;
27
+ const entries = [];
28
+ return {
29
+ nonce,
30
+ entries,
31
+ close: () => void (closed = true),
32
+ push(level, head, component) {
33
+ if (!closed) {
34
+ entries.push({ level, phase: 1, seq: seq++, head });
35
+ return undefined;
36
+ }
37
+ // After the shell: only body-valid tags (links, scripts, styles) still apply, where they render.
38
+ if (head.title !== undefined || head.titleTemplate !== undefined || head.meta?.length || head.html || head.body || head.base)
39
+ late(`${component ?? "A head component"} rendered after the shell (inside a streamed Async boundary): title/meta/html changes are ignored there`);
40
+ return (headTags({ ...(head.links ? { links: head.links } : {}), ...(head.scripts ? { scripts: head.scripts } : {}), ...(head.styles ? { styles: head.styles } : {}) }, { nonce }) || undefined);
41
+ },
42
+ };
43
+ }
44
+ /** Create the app's request handler. */
45
+ export function createApp(config) {
46
+ const { entry, manifest, dev = false, styles = [], routeStyles } = config;
47
+ const inlineFor = config.inlineStyles ? inlineStylesFor(config.inlineStyles) : undefined;
48
+ const option = config.csp ?? nonceCSP;
49
+ let csp = typeof option === "object" ? (nonce) => nonceCSP(nonce, option) : option;
50
+ // `directives`: the merge module loads at startup (pay-per-use, dynamic import) and checks them once —
51
+ // a mistake rejects at startup (unhandled: the process fails like a synchronous throw would); requests
52
+ // arriving before it settles wait for it, later ones take the synchronous path.
53
+ let directives = typeof option === "object" && option.directives
54
+ ? import("./strict-csp.js").then(({ strictCSP }) => {
55
+ strictCSP("", option);
56
+ csp = (nonce) => strictCSP(nonce, option);
57
+ directives = null;
58
+ })
59
+ : null;
60
+ // Route export contributions behind each merged head (rank merge with component entries).
61
+ const levels = new WeakMap();
62
+ // Early hints: the per-route Link values live in `early-hints.ts`, loaded at startup only with `earlyHints`.
63
+ const hintOption = dev ? undefined : config.earlyHints;
64
+ let hinter;
65
+ let hintLoading = hintOption
66
+ ? import("./early-hints.js").then(({ createHinter }) => {
67
+ hinter = createHinter(hintOption, { styles, routeStyles, inlineFor, base: entry.base ?? "", runtime: manifest?.runtime });
68
+ hintLoading = null;
69
+ })
70
+ : null;
71
+ const warned = new Set();
72
+ const document = config.document ?? entry.document;
73
+ if (!document)
74
+ throw new TypeError("createApp: no document shell (pass `document` or generate the entry with one)");
75
+ const scriptAttrs = (nonce) => (nonce !== undefined ? ` nonce="${nonce.replace(/[^\w+/=-]/g, "")}"` : "");
76
+ // Dev pages: what the data endpoint would answer for this URL, so the client mount needs no request.
77
+ const devAround = (page, status, nonce, locale, mode) => {
78
+ const list = typeof config.devScripts === "function" ? config.devScripts(locale) : (config.devScripts ?? []);
79
+ const scripts = list.map((src) => `<script type="module" src="${src}"${scriptAttrs(nonce)}></script>`).join("");
80
+ // Resumed dev pages: the dev entry only takes over (client render) on the first hot update.
81
+ if (mode === "resume")
82
+ return { start: `<div data-rivet-dev="resume" style="display:contents">`, end: `</div>${scripts}` };
83
+ const body = page.error
84
+ ? { error: page.error }
85
+ : {
86
+ data: page.data,
87
+ status,
88
+ ...(page.head ? { head: page.head } : {}),
89
+ ...(page.action ? { action: page.action } : {}),
90
+ ...(page.deps ? { deps: page.deps } : {}),
91
+ };
92
+ return {
93
+ start: `<div data-rivet-dev="" style="display:contents">`,
94
+ end: `</div><script type="application/json" id="rivet-dev-data">${escapeJSON(encodeData(body))}</script>${scripts}`,
95
+ };
96
+ };
97
+ const respond = (page, status, headers, nonce, request, locals, routeId) => {
98
+ if (directives || fragments || hintLoading)
99
+ return Promise.all([directives, fragments, hintLoading]).then(() => respond(page, status, headers, nonce, request, locals, routeId));
100
+ const extra = routeId === undefined || !routeStyles ? undefined : typeof routeStyles === "function" ? routeStyles(routeId) : routeStyles[routeId];
101
+ if (extra && "then" in extra)
102
+ return extra.then((list) => render(page, status, headers, nonce, request, locals, list, routeId));
103
+ return render(page, status, headers, nonce, request, locals, extra, routeId);
104
+ };
105
+ // Failures after the document started (the status is sent): reported with a digest, which the page's failure marker carries.
106
+ const report = config.onError ?? ((error, _request, info) => console.error("[rivet] digest " + info?.digest + ":", error));
107
+ const render = (page, status, headers, nonce, request, locals, extra, routeId) => {
108
+ const pageStyles = extra?.length ? [...styles, ...extra] : styles;
109
+ const inlineStyles = inlineFor?.(pageStyles);
110
+ let sink;
111
+ if (entry.headComponents) {
112
+ sink = createSink(nonce, (message) => {
113
+ if (dev)
114
+ console.error("[rivet] " + message);
115
+ // Once per message (component + text: bounded by the app's code), not per URL (unbounded).
116
+ else if (!warned.has(message))
117
+ (warned.add(message), console.warn("[rivet] " + message));
118
+ });
119
+ page = { ...page, headSink: sink };
120
+ }
121
+ const signal = request.signal;
122
+ if (hinter && routeId !== undefined) {
123
+ if (!sink)
124
+ hinter.learn(routeId, page.head);
125
+ const links = hinter.links(routeId);
126
+ if (links.length && !headers.has("link"))
127
+ headers.set("link", links.join(", "));
128
+ }
129
+ if (csp && nonce !== undefined && !headers.has("content-security-policy"))
130
+ headers.set("content-security-policy", csp(nonce));
131
+ const i18n = config.i18n;
132
+ const locale = i18n && typeof locals?.locale === "string" && i18n.locales.includes(locals.locale) ? locals.locale : undefined;
133
+ const variant = locale ? i18n.locales.indexOf(locale) : 0;
134
+ const built = (i18n && i18n.manifests?.[locale ?? i18n.locales[0]]) || manifest;
135
+ const devMode = built || !config.devResume ? undefined : typeof config.devResume.mode === "function" ? config.devResume.mode(locale) : config.devResume.mode;
136
+ const pageManifest = built || config.devResume?.manifest;
137
+ const dir = locals?.dir === "rtl" || locals?.dir === "ltr" ? locals.dir : undefined;
138
+ const exports = page.head ? (levels.get(page.head) ?? ranked([page.head], config.head)) : ranked([], config.head);
139
+ // Root layout component entries stay in the browser (that layout never re-renders there).
140
+ const persistent = new Set();
141
+ const option = config.preload?.background ?? entry.preloadBackground;
142
+ const background = typeof option === "function" ? option(page.url) : option;
143
+ const leanOption = config.preload?.lean;
144
+ const lean = typeof leanOption === "function" ? leanOption(page.url) : leanOption;
145
+ const ahead = config.preload?.ahead ?? entry.fetchAhead;
146
+ const run = () => renderDocument(entry.page(page), {
147
+ head: page.head,
148
+ nonce,
149
+ url: page.url,
150
+ status,
151
+ styles: pageStyles,
152
+ ...(inlineStyles ? { inlineStyles } : {}),
153
+ ...(locale ? { locale } : {}),
154
+ ...(dir ? { dir } : {}),
155
+ ...(sink ? { managed: { persistent } } : {}),
156
+ }, {
157
+ document,
158
+ ...(sink
159
+ ? {
160
+ hold: {
161
+ head: () => {
162
+ sink.close();
163
+ for (const { level, head } of sink.entries)
164
+ if (level === 0)
165
+ for (const entry of [...(head.meta ?? []), ...(head.links ?? []), ...(head.scripts ?? [])])
166
+ persistent.add(entry);
167
+ const merged = rankMerge([...exports, ...sink.entries]);
168
+ if (routeId !== undefined)
169
+ hinter?.learn(routeId, merged);
170
+ return merged;
171
+ },
172
+ },
173
+ }
174
+ : {}),
175
+ ...(pageManifest ? { manifest: pageManifest } : {}),
176
+ ...(variant ? { variant } : {}),
177
+ ...(config.stream ? { stream: config.stream } : {}),
178
+ ...(background !== undefined || lean || ahead
179
+ ? { preload: { ...(background !== undefined ? { background } : {}), ...(lean ? { lean } : {}), ...(ahead ? { ahead } : {}) } }
180
+ : {}),
181
+ signal,
182
+ ...(nonce !== undefined ? { nonce } : {}),
183
+ onError: (error) => {
184
+ // Route rules: a page that rendered an error fallback is not stored (`route-cache.ts` `RENDER_FAILED`).
185
+ request[RENDER_FAILED] = true;
186
+ const digest = _digest();
187
+ report(error, request, { digest, phase: "render", ...(routeId !== undefined ? { route: routeId } : {}) });
188
+ return digest;
189
+ },
190
+ ...(config.devScripts && !built ? { around: devAround(page, status, nonce, locale, devMode) } : {}),
191
+ ...(devMode === "check" ? { discardState: true } : {}),
192
+ }, { status, headers });
193
+ return withCache
194
+ ? withCache({
195
+ cache: config.renderCache,
196
+ ...(variant ? { variant } : {}),
197
+ ...(nonce !== undefined ? { nonce } : {}),
198
+ signal,
199
+ resumable: pageManifest !== undefined,
200
+ }, run)
201
+ : run();
202
+ };
203
+ // `renderCache`: the scope module (AsyncLocalStorage) loads at startup, pay-per-use like `directives`.
204
+ let withCache;
205
+ let fragments = config.renderCache
206
+ ? import("@rivetjs/runtime/server-cache").then((module) => {
207
+ withCache = module.withRenderCache;
208
+ fragments = null;
209
+ })
210
+ : null;
211
+ const app = createHandler({
212
+ routes: entry.routes,
213
+ dev,
214
+ // Every locale's build shares one runtime; any manifest names the built-in decoders' chunk.
215
+ builtins: () => (manifest ?? (config.i18n?.manifests && Object.values(config.i18n.manifests)[0]) ?? config.devResume?.manifest)?.builtins,
216
+ ...(config.onError ? { onError: config.onError } : {}),
217
+ ...(config.maxBody !== undefined ? { maxBody: config.maxBody } : {}),
218
+ ...(hintOption
219
+ ? {
220
+ earlyHints(route, request) {
221
+ const send = request[EARLY_HINTS];
222
+ if (send) {
223
+ const links = hinter?.links(route.id) ?? [];
224
+ if (links.length)
225
+ send(links);
226
+ }
227
+ },
228
+ }
229
+ : {}),
230
+ async head(route, data, match) {
231
+ const modules = await entry.universal[route.id]?.();
232
+ if (!modules)
233
+ return config.head ? rankMerge(ranked([], config.head)) : undefined;
234
+ const heads = [];
235
+ for (const [index, module] of modules) {
236
+ const head = module.head;
237
+ heads[index] = typeof head === "function" ? head({ data: data[index], params: match.params, url: match.url }) : head;
238
+ }
239
+ const contributions = ranked(heads, config.head);
240
+ const merged = rankMerge(contributions);
241
+ if (merged)
242
+ levels.set(merged, contributions);
243
+ return merged;
244
+ },
245
+ // A layout whose `head` is a function of its data reruns its loader (the merged head needs it).
246
+ async keepable(route) {
247
+ const modules = await entry.universal[route.id]?.();
248
+ const out = [];
249
+ for (const [index, module] of modules ?? [])
250
+ if (typeof module.head === "function")
251
+ out[index] = false;
252
+ return out;
253
+ },
254
+ render: (input) => respond({
255
+ data: input.data,
256
+ url: input.url,
257
+ head: input.head,
258
+ ...(input.action ? { action: input.action } : {}),
259
+ ...(input.byURL ? { byURL: input.byURL } : {}),
260
+ ...(input.deps ? { deps: input.deps } : {}),
261
+ }, input.status, input.headers, input.nonce, input.request, input.locals, input.route.id),
262
+ renderError: async (input) => respond({
263
+ // The outermost layouts' data (error pages render inside the layouts that loaded).
264
+ data: input.layouts ?? [],
265
+ url: input.url,
266
+ head: { title: input.status === 404 ? "Not found" : "Error" },
267
+ error: { status: input.status, message: input.message },
268
+ }, input.status, input.headers, input.nonce, input.request, await errorLocals(input.locals, input.request, input.url)),
269
+ });
270
+ const given = config.routeRules ?? entry.routeRules;
271
+ if (!given || !Object.keys(given).length)
272
+ return app;
273
+ // Dev: headers only (a stored page would hide edits).
274
+ const rules = dev
275
+ ? Object.fromEntries(Object.entries(given).map(([pattern, rule]) => [pattern, rule.headers ? { headers: rule.headers } : {}]))
276
+ : given;
277
+ // Route rules: their module loads at startup (pay-per-use); malformed rules reject there (unhandled: the process
278
+ // fails like a synchronous throw would). Requests before it settles wait for it.
279
+ const policy = {
280
+ policy: (nonce) => (csp ? csp(nonce) : false),
281
+ // The default / `CSPOptions` nonce policy becomes hashes in stored pages; a `csp` function keeps its nonce per page.
282
+ hash: config.csp === undefined ? {} : typeof config.csp === "object" ? config.csp : null,
283
+ };
284
+ let cached = null;
285
+ const loading = import("./route-cache.js").then(({ withRouteRules }) => (cached = withRouteRules(app, rules, policy, entry.base ?? "", config.routeCache)));
286
+ const handler = (request, context) => (cached ? cached(request, context) : loading.then((ready) => ready(request, context)));
287
+ // The node adapter's fast path (a stored page before a Request is built), once the module loaded.
288
+ Object.defineProperty(handler, ROUTE_CACHE, { get: () => (cached ? cached[ROUTE_CACHE] : undefined) });
289
+ return handler;
290
+ // Error pages without a settled locale: the app's resolver, else the URL's locale prefix.
291
+ async function errorLocals(locals, request, url) {
292
+ const i18n = config.i18n;
293
+ if (!i18n || (typeof locals.locale === "string" && i18n.locales.includes(locals.locale)))
294
+ return locals;
295
+ let locale = await i18n.resolve?.(request);
296
+ if (!locale || !i18n.locales.includes(locale)) {
297
+ const first = url.pathname.split("/")[1]?.toLowerCase();
298
+ locale = i18n.locales.find((l) => l.toLowerCase() === first);
299
+ }
300
+ return locale ? { ...locals, locale } : locals;
301
+ }
302
+ }
@@ -0,0 +1,20 @@
1
+ /** Where a directly attached page body goes. Web-standard: no Node types. */
2
+ export interface BodySink {
3
+ /**
4
+ * One chunk: a view into a pooled slab. Call `done` once its bytes are no longer needed (written out,
5
+ * copied, or dropped) — the slab is reused after every handed-off view of it is done. Returns a wait
6
+ * when the sink applies backpressure; throws once the sink is closed (the render then ends).
7
+ */
8
+ write(chunk: Uint8Array, done: () => void): Promise<void> | undefined;
9
+ /** The body is complete. */
10
+ end(): void;
11
+ /** The render failed after the Response was handed out. */
12
+ error(error: unknown): void;
13
+ }
14
+ /**
15
+ * Own property of `renderPage` / `renderDocument` Responses: `(sink) => boolean`. True: every body byte
16
+ * (already rendered and later) goes to `sink` and the Response's `body` stream stays unread forever;
17
+ * false: the stream was already read (a copied Response, a consumer that started) — read the body.
18
+ */
19
+ export declare const DIRECT_BODY: unique symbol;
20
+ export type DirectBody = (sink: BodySink) => boolean;
package/server/body.js ADDED
@@ -0,0 +1,8 @@
1
+ // Direct page bodies: a host adapter (node) takes a rendered page's bytes straight from the renderer's
2
+ // slabs instead of reading the web `ReadableStream` (no queue, read promises or per-chunk races).
3
+ /**
4
+ * Own property of `renderPage` / `renderDocument` Responses: `(sink) => boolean`. True: every body byte
5
+ * (already rendered and later) goes to `sink` and the Response's `body` stream stays unread forever;
6
+ * false: the stream was already read (a copied Response, a consumer that started) — read the body.
7
+ */
8
+ export const DIRECT_BODY = Symbol.for("rivet.body");