@voltro/cli 0.50.1 → 0.52.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 (170) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  3. package/dist/agentsMd-SDDSkyl4.js +2 -0
  4. package/dist/apiBuild-BYBpL7Pz.js +2 -0
  5. package/dist/{apiBuild-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
  6. package/dist/appGraph-CFmFKHb2.js +2 -0
  7. package/dist/appGraph-yRuKjOba.js +111 -0
  8. package/dist/bin.js +1 -1
  9. package/dist/build-CPgcMQug.js +793 -0
  10. package/dist/checkCommand-2SbqzukH.js +2 -0
  11. package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
  12. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  13. package/dist/codegen-CctkDO-1.js +2 -0
  14. package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
  15. package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
  16. package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
  17. package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
  18. package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
  19. package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
  20. package/dist/dbCommand-DNb6yeOG.js +2 -0
  21. package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
  22. package/dist/dev--A3nsxA3.js +3 -0
  23. package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
  24. package/dist/doctorCommand-CqoWA2p5.js +2 -0
  25. package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
  26. package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
  27. package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
  28. package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
  29. package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
  30. package/dist/fileConventions-DOqD3lPS.js +34 -0
  31. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
  32. package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
  33. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
  34. package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
  37. package/dist/inspectMetrics-CGF94puw.js +143 -0
  38. package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
  39. package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
  40. package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
  41. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
  42. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  43. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  44. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
  45. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
  46. package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
  47. package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
  48. package/dist/serveCommand-DLc-BznW.js +2 -0
  49. package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/start-DfL3fOiN.js +3 -0
  52. package/dist/start-jw89Xbqy.js +1339 -0
  53. package/dist/startEntry.js +1 -1
  54. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
  55. package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
  56. package/dist/updateCommand-5gFVfK5q.js +2 -0
  57. package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
  58. package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
  59. package/dist/webDev-CZbTsDcH.js +2 -0
  60. package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
  61. package/package.json +48 -17
  62. package/templates/AGENTS.core.md +26 -5
  63. package/templates/AGENTS.md +30 -9
  64. package/templates/agent-docs/_index.md +4 -4
  65. package/templates/agent-docs/_manifest.json +11 -11
  66. package/templates/agent-docs/cli.md +125 -14
  67. package/templates/agent-docs/data.md +210 -7
  68. package/templates/agent-docs/database/schema.md +1 -1
  69. package/templates/agent-docs/database/seedsdialects.md +1 -1
  70. package/templates/agent-docs/deployment.md +22 -3
  71. package/templates/agent-docs/internationalization.md +131 -13
  72. package/templates/agent-docs/introduction.md +46 -0
  73. package/templates/agent-docs/local-first-mobile.md +34 -7
  74. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  75. package/templates/agent-docs/plugins/audit.md +5 -5
  76. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  77. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  78. package/templates/agent-docs/plugins/storage.md +2 -2
  79. package/templates/agent-docs/plugins.md +29 -7
  80. package/templates/agent-docs/reference.md +39 -2
  81. package/templates/agent-docs/routing.md +341 -47
  82. package/templates/agent-docs/schema-driven-ui.md +78 -2
  83. package/templates/agent-docs/security.md +125 -8
  84. package/templates/agent-docs/templates/apibackends.md +14 -14
  85. package/templates/agent-docs/templates/overview.md +1 -1
  86. package/templates/agent-docs/whats-new.md +100 -29
  87. package/templates/apps/api-ai/package.json +6 -7
  88. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  89. package/templates/apps/api-auth/package.json +8 -8
  90. package/templates/apps/api-backend/package.json +7 -7
  91. package/templates/apps/api-backend-deactivation/package.json +7 -7
  92. package/templates/apps/api-backend-mail/package.json +8 -8
  93. package/templates/apps/api-backend-mariadb/package.json +9 -9
  94. package/templates/apps/api-backend-sqlite/package.json +8 -8
  95. package/templates/apps/api-backend-storage/package.json +8 -8
  96. package/templates/apps/api-cms/package.json +9 -10
  97. package/templates/apps/api-collab/package.json +8 -8
  98. package/templates/apps/api-data-advanced/package.json +8 -8
  99. package/templates/apps/api-durable/package.json +8 -8
  100. package/templates/apps/api-feature-flags/package.json +9 -9
  101. package/templates/apps/api-governance/package.json +8 -8
  102. package/templates/apps/api-kv/package.json +8 -8
  103. package/templates/apps/api-moderation/package.json +8 -8
  104. package/templates/apps/api-observability/package.json +8 -8
  105. package/templates/apps/api-ratelimit/package.json +8 -8
  106. package/templates/apps/api-rbac/package.json +8 -8
  107. package/templates/apps/api-rest/package.json +7 -7
  108. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  109. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  110. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  111. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  112. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  113. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  114. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  115. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  116. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  117. package/templates/apps/api-row-history/template.json +6 -0
  118. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  119. package/templates/apps/api-saas/app.config.ts +1 -0
  120. package/templates/apps/api-saas/package.json +10 -11
  121. package/templates/apps/api-saas-starter/package.json +10 -10
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/package.json +8 -8
  124. package/templates/apps/api-webhooks/package.json +9 -9
  125. package/templates/apps/changelog/package.json +6 -6
  126. package/templates/apps/edge-functions/package.json +2 -2
  127. package/templates/apps/frontend-admin/package.json +8 -8
  128. package/templates/apps/frontend-app/package.json +9 -9
  129. package/templates/apps/frontend-auth/package.json +8 -8
  130. package/templates/apps/frontend-blank/package.json +7 -7
  131. package/templates/apps/frontend-cms/package.json +9 -9
  132. package/templates/apps/frontend-collab/package.json +10 -10
  133. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  134. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  135. package/templates/apps/frontend-contact/package.json +7 -7
  136. package/templates/apps/frontend-dashboard/package.json +7 -7
  137. package/templates/apps/frontend-docs/package.json +6 -7
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +6 -7
  140. package/templates/apps/frontend-portal/package.json +8 -8
  141. package/templates/apps/frontend-saas/package.json +8 -8
  142. package/templates/apps/frontend-spa/package.json +7 -7
  143. package/templates/apps/frontend-ssr/package.json +7 -7
  144. package/templates/apps/frontend-ssr-api/package.json +8 -8
  145. package/templates/apps/frontend-static-blog/package.json +6 -6
  146. package/templates/apps/frontend-status/package.json +8 -8
  147. package/templates/apps/mobile-app/package.json +4 -4
  148. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  149. package/dist/apiBuild-BZ-plaQN.js +0 -2
  150. package/dist/appGraph-CM9wvUmD.js +0 -2
  151. package/dist/appGraph-Cv2W_wO5.js +0 -111
  152. package/dist/build-CFmqF39c.js +0 -721
  153. package/dist/checkCommand-DomvYDi1.js +0 -2
  154. package/dist/codegen-DiMn2KkZ.js +0 -2
  155. package/dist/dbCommand-Bnl7MSGC.js +0 -2
  156. package/dist/dev-Dz9GqA6P.js +0 -3
  157. package/dist/doctorCommand-BlciWUJb.js +0 -2
  158. package/dist/fileConventions-Cof68_BL.js +0 -33
  159. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  160. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  161. package/dist/serveCommand-Bbgg7Paq.js +0 -2
  162. package/dist/start-7b_bL8Uc.js +0 -3
  163. package/dist/start-B1nH9ahf.js +0 -1150
  164. package/dist/updateCommand-BweY17z-.js +0 -2
  165. package/dist/webDev-BcRFTsMA.js +0 -2
  166. package/templates/apps/api-versioning/template.json +0 -6
  167. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  168. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  169. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  170. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -209,36 +209,51 @@ src/pages/[...rest]/page.tsx # everything else
209
209
 
210
210
  ## Query strings
211
211
 
212
- Voltro doesn't bake query params into the query — they're orthogonal to the URL pattern:
212
+ Query params are orthogonal to the URL pattern — they never appear in the file path. A page declares its query contract as a **`searchParams` schema export**, the same page-export convention as `meta`, `loader`, and `renderMode`:
213
213
 
214
214
  ```tsx
215
- import { useLocation } from '@voltro/web'
215
+ // src/pages/search/page.tsx
216
+ import { Schema } from 'effect'
217
+ import { useSearchParams } from '@voltro/web'
216
218
 
217
- const Page = () => {
218
- const pathname = useLocation()
219
+ export const searchParams = Schema.Struct({
220
+ q: Schema.optionalWith(Schema.String, { default: () => '' }),
221
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
222
+ })
223
+
224
+ export default function SearchPage() {
225
+ const { q, page } = useSearchParams(searchParams) // q: string · page: number
219
226
  // …
220
- // For the search string, parse it from the request URL via useServerRequest()
221
- // (SSR) or window.location.search (client after hydration).
222
227
  }
223
228
  ```
224
229
 
225
- For SSR pages that need server-side query parsing:
230
+ `useSearchParams(searchParams)` — the page passes its **own** export — returns the decoded, typed shape, SSR-aware: the same call site reads the request URL on the server and `window.location.search` on the client. An invalid query string falls back to the schema's defaults instead of crashing the render, so every field must be optional or carry a default (`Schema.optionalWith(..., { default })` — a schema that cannot decode an empty query throws at the first read, naming the fix). Links to the route type-check against the same schema via [typed `withQuery`](/docs/routing/navigation#typed-withquery).
226
231
 
227
- ```tsx
228
- import { useServerRequest } from '@voltro/web'
232
+ Two boundaries worth knowing:
229
233
 
230
- export const renderMode = 'ssr' as const
234
+ - **`renderMode: 'isr'` + a `searchParams` export is refused at boot** — the isr cache is keyed by path (plus tenant + locale), so the first query's variant would be cached and served for every other query. Use `ssr`, or drop the export and read the query client-side only. See [Render modes](/docs/routing/render-modes#isr-incremental-static-regeneration).
235
+ - **`renderMode: 'static'` build renders see only the schema's defaults** — a build has no query string. The client decodes the live query after hydration; a static page keyed off search params is a client-side concern.
231
236
 
232
- export default function SearchPage() {
233
- const req = useServerRequest()
234
- const q = req
235
- ? new URL(req.url, 'http://x').searchParams.get('q') ?? ''
236
- : new URLSearchParams(window.location.search).get('q') ?? ''
237
- //
238
- }
237
+ ### Mirror routes share ONE schema
238
+
239
+ Bilingual apps with mirrored trees (`pages/x/page.tsx` + `pages/[locale]/x/page.tsx`) re-export the original page's schema instead of copying it:
240
+
241
+ ```tsx
242
+ // src/pages/[locale]/search/page.tsx
243
+ export { searchParams } from '../../search/page'
239
244
  ```
240
245
 
241
- Parse the query string explicitly via `useServerRequest()` on the server and `window.location.search` on the client, as shown above.
246
+ One schema, no drift the mirror page decodes exactly what the original declares.
247
+
248
+ ### Routes without a schema
249
+
250
+ `useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:
251
+
252
+ ```tsx
253
+ import { useSearchParams } from '@voltro/web'
254
+
255
+ const q = useSearchParams().get('q') ?? ''
256
+ ```
242
257
 
243
258
  ## Co-locating components, hooks and tests
244
259
 
@@ -637,6 +652,25 @@ Cache backends:
637
652
  - `memory` *(default)* — in-process, doesn't survive restarts.
638
653
  - `postgres` — `SSR_CACHE=postgres`. Survives restarts, shared across api instances.
639
654
 
655
+ An `isr` page that also declares a [`searchParams` schema](/docs/routing/pages#query-strings) is refused at boot — the cache is keyed by path (plus tenant + locale), not by query, so the first query's variant would be served for every other query; use `ssr`, or drop the export and read the query client-side only.
656
+
657
+ ### isr renders are anonymous
658
+
659
+ An `isr` render is a **shared** render: the HTML it produces is cached and served
660
+ to every visitor inside the revalidate window. The framework therefore strips
661
+ credential material before the render runs — the cookie jar (except the
662
+ `voltro:locale` cookie), the `authorization` header, and every `x-voltro-*`
663
+ header never reach an isr page's loaders, `ctx.query`, or `useServerRequest()`.
664
+ `x-tenant` and `accept-language` survive, because the cache key (tenant + locale)
665
+ is derived from them.
666
+
667
+ Concretely: a loader on an isr page that reads subject-scoped data gets the
668
+ **anonymous** answer — the same one every visitor will see — instead of caching
669
+ the first visitor's data for everyone. This applies identically under
670
+ `voltro dev` and `voltro start`, so a page cannot look personalised in dev and
671
+ silently serve shared HTML in production. A page whose loader needs the signed-in
672
+ subject belongs on `renderMode: 'ssr'`.
673
+
640
674
  ## Tenant-aware ISR
641
675
 
642
676
  For multi-tenant ISR (each tenant gets its own cache entry):
@@ -662,6 +696,63 @@ The framework reads Postgres logical replication; writes to `posts` or `comments
662
696
 
663
697
  Requires `SSR_CACHE=postgres` and a `wal_level=logical` Postgres.
664
698
 
699
+ ## On-demand revalidation
700
+
701
+ The third invalidation axis, next to time (`revalidate`) and CDC
702
+ (`cacheInvalidatesOn`): server code in the api process drops ISR cache entries
703
+ imperatively, on **every** web replica — including on dialects that have no
704
+ CDC at all (sqlite, mysql, memory), which is the case this exists for.
705
+
706
+ ```ts
707
+ import { revalidatePath, revalidateTable, revalidateTag } from '@voltro/runtime'
708
+
709
+ // inside a mutation / action / webhook receiver / REST route handler:
710
+ await revalidateTable('posts') // drop every route whose cacheInvalidatesOn lists 'posts'
711
+ await revalidatePath('/blog/[slug]') // drop every cached instance of the route
712
+ await revalidatePath('/pricing') // drop one concrete path (all tenant+locale variants)
713
+ await revalidatePath('/pricing', { tenant: 'acme' }) // …one tenant's variants only
714
+ await revalidateTag('pricing') // drop every route whose cacheInvalidatesOn lists the tag
715
+ ```
716
+
717
+ **Tags are tables that never were one.** `cacheInvalidatesOn` accepts free
718
+ strings, so one mechanism covers both: declare `cacheInvalidatesOn:
719
+ ['posts', 'pricing']` on any number of routes and `revalidateTag('pricing')`
720
+ drops them all — the `revalidateTag` thinking Next.js users bring works
721
+ unchanged.
722
+
723
+ **How it travels.** The api process publishes; every `voltro start` replica
724
+ subscribes. Two transports, either or both:
725
+
726
+ - **postgres**: a `pg_notify` on the same LISTEN connection the CDC
727
+ invalidator already holds — a postgres deployment needs **no broker**.
728
+ - **a broker**: set `BROADCAST_URL` (`redis://` or `nats://`) on **both** the
729
+ api and the web deployment. This is the path for non-postgres dialects. On
730
+ a broker shared by several projects, also set `VOLTRO_BROADCAST_NAMESPACE`
731
+ on both sides — the channel is namespaced by that variable (the api's and
732
+ the web app's names differ, so a name-derived namespace can't pair them).
733
+
734
+ A web process with ISR routes and **neither** transport warns at boot
735
+ (`NO revalidation transport`) — calls then change nothing and cached pages
736
+ live out their own `revalidate` window. Under `voltro dev` there is no ISR
737
+ cache; the calls are debug-logged no-ops.
738
+
739
+ Three edges, all deliberate:
740
+
741
+ - **Only `isr` routes.** `revalidatePath` against a `static` route logs a
742
+ named error on the web process — static HTML is a build artifact `voltro
743
+ start` never re-renders; rebuild to change it. (The transport is
744
+ fire-and-forget, so the error surfaces in the web replica's log, not at the
745
+ call site.)
746
+ - **Purge-during-render is guarded.** A background SWR refresh (or miss fill)
747
+ that started before the purge landed is discarded instead of writing the
748
+ pre-purge page back with a full TTL — a per-key generation counter, on both
749
+ cache backends.
750
+ - **On postgres you don't need this for the plain publish case** — a route
751
+ declaring `cacheInvalidatesOn: ['<table>']` is already dropped by CDC when
752
+ the table changes. Reach for the imperative API for non-postgres dialects,
753
+ pattern purges of routes whose loaders read data indirectly, and tag
754
+ fanout.
755
+
665
756
  ## spa (client-only, with an optional SSR layout shell)
666
757
 
667
758
  ```tsx
@@ -1423,7 +1514,7 @@ import { routes } from './.framework/routes' // generated by `voltro dev`
1423
1514
  ```
1424
1515
 
1425
1516
  - `routes['/pattern'](params)` → `VoltroRouteUrl`. Missing/extra params are a type error.
1426
- - `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand.
1517
+ - `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand. On a route whose page declares a `searchParams` schema, the params type-check against it (below).
1427
1518
  - `withHash(url, 'section-3')` — append a `#hash`, keeps the brand.
1428
1519
  - `externalUrl('https://example.com')` — the escape hatch for anything the
1429
1520
  codegen can't model: cross-origin, `mailto:`, `tel:`, hash-only, or a
@@ -1438,6 +1529,28 @@ import { withQuery, withHash, externalUrl } from '@voltro/web'
1438
1529
  <Link to={externalUrl('mailto:hi@x.com')}>Email us</Link>
1439
1530
  ```
1440
1531
 
1532
+ ### Typed `withQuery`
1533
+
1534
+ For a route whose page exports a [`searchParams` schema](/docs/routing/pages#query-strings),
1535
+ the generated builder brands the URL with the schema's decoded shape — through a
1536
+ **type-only** import, so no page module enters the routes file's value graph and
1537
+ code-splitting stays intact. `withQuery` then type-checks the params against the
1538
+ page's contract: a misspelt key or a wrong value type is a compile error.
1539
+
1540
+ ```tsx
1541
+ <Link to={withQuery(routes['/notes'](), { page: 2 })}>Page 2</Link>
1542
+ // withQuery(routes['/notes'](), { pgae: 2 }) → compile error (unknown key)
1543
+ // withQuery(routes['/notes'](), { page: 'x' }) → compile error (wrong type)
1544
+ ```
1545
+
1546
+ The encode is canonical and schema-free: strings pass through, numbers and
1547
+ booleans via `String()`, arrays become repeated keys (`?tag=a&tag=b`), and
1548
+ `undefined` params are omitted. A `Date` (or any object) is refused loudly —
1549
+ there is no canonical URL form the type layer could guarantee; declare the field
1550
+ as a string/number transform in the page's `searchParams` schema and pass that
1551
+ instead. Routes of `siblingApps` stay untyped — their pages live in another
1552
+ app's compile graph.
1553
+
1441
1554
  ## `<Link>`
1442
1555
 
1443
1556
  ```tsx
@@ -1563,6 +1676,59 @@ The router restores the previous scroll position on **back/forward** navigations
1563
1676
 
1564
1677
  Push/replace navigations still scroll to top (or to the hash target); only back/forward restores.
1565
1678
 
1679
+ ## View transitions
1680
+
1681
+ Opt in to the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) for SPA navigations — the browser cross-fades the old and new page (and lets you animate individual elements) with zero animation library:
1682
+
1683
+ ```ts
1684
+ // app.config.ts
1685
+ export default {
1686
+ type: 'web' as const,
1687
+ name: 'MyApp',
1688
+ router: {
1689
+ viewTransitions: true,
1690
+ },
1691
+ }
1692
+ ```
1693
+
1694
+ With the flag on, every route swap — `<Link>` clicks, `navigate(...)`, back/forward — runs through `document.startViewTransition`. Individual navigations override the default in either direction:
1695
+
1696
+ ```tsx
1697
+ navigate('/reports', { transition: false }) // this one swaps plainly
1698
+ <Link to={routes['/photos/[id]']({ id })} transition>Open</Link> // this one transitions even when the app default is off
1699
+ ```
1700
+
1701
+ **Fallback is exact.** In a browser without the API, and for users with `prefers-reduced-motion: reduce`, navigation behaves precisely as without the flag — same timing, no animation, nothing to feature-detect yourself.
1702
+
1703
+ **Styling is plain CSS, not a framework DSL.** The default is a full-page cross-fade. To animate a specific element independently (the classic shared-element move), give it a `view-transition-name` and style the browser's pseudo-elements:
1704
+
1705
+ ```css
1706
+ .post-cover { view-transition-name: post-cover; }
1707
+
1708
+ /* Tune the root cross-fade */
1709
+ ::view-transition-old(root) { animation-duration: 150ms; }
1710
+ ::view-transition-new(root) { animation-duration: 150ms; }
1711
+
1712
+ /* The named element morphs between its old and new position */
1713
+ ::view-transition-group(post-cover) { animation-duration: 300ms; }
1714
+ ```
1715
+
1716
+ An element that keeps its `view-transition-name` across both pages is morphed from its old to its new position automatically — that is the whole shared-element recipe.
1717
+
1718
+ Three behaviors worth knowing, all deliberate:
1719
+
1720
+ - **`defer()` fields resolve outside the transition.** The transition animates to the committed page — with a deferred field still showing its fallback. The field's later resolution is an ordinary React update, not a second animation. Same rule for an explicit `Pending` skeleton: the swap **to** the skeleton is the transition; the settled content arrives un-animated.
1721
+ - **Rapid navigation skips, never queues.** Navigating again while a transition is animating skips the running one (per the API's spec) and the last navigation wins — no queue, no dead time.
1722
+ - **Overlays and modals do not transition.** A view transition snapshots the whole viewport, so running one on an overlay opening would cross-fade the entire page for a change that visually touches one layer. Router view transitions therefore apply to **route navigations only**; overlay/dialog state changes never trigger one.
1723
+
1724
+ **Static / multi-page documents:** a full-document navigation (between `renderMode: 'static'` pages, or any MPA link) never goes through the SPA router — opt those into the browser's cross-document transitions with CSS alone, no framework involvement:
1725
+
1726
+ ```css
1727
+ @view-transition { navigation: auto; }
1728
+ ```
1729
+
1730
+ **Coming from Astro?** There is no `transition:persist` equivalent because none is needed — persistent state lives in a [layout](/docs/routing/layouts), and layouts stay mounted across SPA navigations natively.
1731
+
1566
1732
  ## Blocking navigation (unsaved changes)
1567
1733
 
1568
1734
  `useBlocker` holds a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
@@ -1631,7 +1797,37 @@ window.history.forward() // forward
1631
1797
 
1632
1798
  ## Reading + writing search params
1633
1799
 
1634
- Read the query string with `useSearchParams()` — a plain `URLSearchParams`. It's SSR-aware (on the server it reads the request URL; on the client, `window.location.search`):
1800
+ The recommended way to read the query string is **typed**: declare the page's
1801
+ query contract as a `searchParams` schema export and pass that same export to
1802
+ `useSearchParams(...)`:
1803
+
1804
+ ```tsx
1805
+ import { Schema } from 'effect'
1806
+ import { useSearchParams } from '@voltro/web'
1807
+
1808
+ export const searchParams = Schema.Struct({
1809
+ tab: Schema.optionalWith(Schema.String, { default: () => 'overview' }),
1810
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
1811
+ tags: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
1812
+ })
1813
+
1814
+ export default function Notes() {
1815
+ const { tab, page, tags } = useSearchParams(searchParams)
1816
+ // tab: string · page: number · tags: readonly string[]
1817
+ }
1818
+ ```
1819
+
1820
+ - **SSR-aware** — the same call site decodes the request URL on the server and `window.location.search` on the client.
1821
+ - **Total** — an invalid query string is never a crash or a 500: the decode falls back to the schema's defaults, exactly like visiting without a query.
1822
+ - **Every field must be optional or carry a default** (`Schema.optionalWith(..., { default })`). A schema that cannot decode an empty query throws at the first read, naming the fix — that is a definition error, not a runtime input problem.
1823
+ - **Array fields keep their shape** — `?tag=a&tag=b` decodes to `['a', 'b']`, and a single `?tag=a` decodes to `['a']`, not a bare string.
1824
+
1825
+ The same schema types links to the route — see [typed `withQuery`](#typed-withquery)
1826
+ above — and the page-export convention itself is documented in
1827
+ [Pages → Query strings](/docs/routing/pages#query-strings).
1828
+
1829
+ `useSearchParams()` **without** an argument stays the raw `URLSearchParams` —
1830
+ the fallback for routes that declare no schema:
1635
1831
 
1636
1832
  ```tsx
1637
1833
  import { useSearchParams } from '@voltro/web'
@@ -1639,7 +1835,7 @@ import { useSearchParams } from '@voltro/web'
1639
1835
  const tab = useSearchParams().get('tab') ?? 'overview'
1640
1836
  ```
1641
1837
 
1642
- Write it with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:
1838
+ Write the query with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:
1643
1839
 
1644
1840
  ```tsx
1645
1841
  import { useSearchParams, useSetSearchParams } from '@voltro/web'
@@ -1673,6 +1869,32 @@ setParams({ page: '2' }, { push: true })
1673
1869
 
1674
1870
  During SSR there is no history to write — read `useSearchParams()` off the request URL for the first paint and call `useSetSearchParams()` on the client after hydration.
1675
1871
 
1872
+ ### Typed writes
1873
+
1874
+ Pass the page's `searchParams` schema to get the **typed** setter. Its object form REPLACES the query — same semantics as the untyped form; a field you leave out decodes to its default on the next read. Its updater form receives the **current decoded params**, so a merge is one explicit spread — the pagination flip that keeps `?filter` stops being a hand-rolled merge:
1875
+
1876
+ ```tsx
1877
+ import { useSetSearchParams } from '@voltro/web'
1878
+ import { searchParams } from './page'
1879
+
1880
+ const setParams = useSetSearchParams(searchParams)
1881
+ setParams({ page: 2 }) // replaces → ?page=2 (filter dropped)
1882
+ setParams((p) => ({ ...p, page: p.page + 1 })) // keeps ?filter — typed merge
1883
+ ```
1884
+
1885
+ A misspelt key or wrong value type in the object form is a compile error; in the updater, the typed `p` is the guard (`p.pgae` does not compile).
1886
+
1887
+ For a plain `<Link>` that keeps the current query, compose the two primitives you already have — decode the current params, spread them into `withQuery`:
1888
+
1889
+ ```tsx
1890
+ const current = useSearchParams(searchParams)
1891
+ <Link to={withQuery(routes['/search'](), { ...current, page: current.page + 1 })}>Next</Link>
1892
+ ```
1893
+
1894
+ That composition is also the whole story on **retaining params across navigations**: there is no implicit retain list — a param survives a navigation only if the link (or setter) encodes it, which keeps every URL self-describing. Spread what must survive; everything else resets to its schema default.
1895
+
1896
+ **Layout-level schemas are deliberately not a layer of their own:** the schema is a page export. A layout (or any co-located component) that needs the same params imports the page's schema and calls `useSearchParams(searchParams)` with it — composition per schema import, one schema, no drift.
1897
+
1676
1898
  ## Prefetching programmatically
1677
1899
 
1678
1900
  ```tsx
@@ -1719,15 +1941,13 @@ export const renderMode = 'static' as const
1719
1941
  export const interactive = 'islands' as const
1720
1942
  ```
1721
1943
 
1722
- > **Islands cut hydration WORK, not DOWNLOAD — read this before you pick the mode.**
1944
+ > **Islands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.**
1723
1945
  >
1724
- > `interactive: 'islands'` ships **exactly the same JavaScript** as `interactive: 'full'`. Measured on the framework's reference fixture, the same page: `full` = 195,229 bytes gzipped of first-load JS, `islands` = 195,231 bytes. That is the whole difference two bytes of noise.
1725
- >
1726
- > The reason is structural, not a missing optimisation pass: the generated browser entry imports `mount` and your `App` at value level, so the browser has already downloaded, parsed and evaluated the entire app bundle before the islands branch is even reached. Only `interactive: 'none'` removes bytes today — it strips every `<script type="module">` and `<link rel="modulepreload">` from the page's HTML.
1727
- >
1728
- > So islands are the right choice when the cost you want back is **CPU on the main thread** (hydration walking a large tree, effects firing across a page of prose). They are the wrong choice if you adopted them to make the download smaller — for that, use `interactive: 'none'` and put the interactive bits behind a separate page, or accept the full payload.
1946
+ > `voltro build` emits a dedicated browser entry per `interactive: 'islands'` page: react + the island runtime + exactly that page's islands — not the router, not the Effect runtime, not the subscription cache, not the app shell. Measured on the framework's reference fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25): an islands page is **59.6 KB gzipped first-load** vs **181.9 KB gzipped** for the same page as `full` a factor of ~3. The bundle-budget test additionally pins a hard <70 KB bound AND the ratio (<50 % of the full page). `interactive: 'none'` stays at 0 B.
1729
1947
 
1730
- With `interactive: 'islands'`, the page's HTML keeps its script tags and the app bundle still loads; what changes is that `mount()` skips hydrating the page tree and instead scans for island markers, hydrating each one on its own schedule.
1948
+ With `interactive: 'islands'`, the page's HTML is server-rendered and its script tag points at the page's own entry. That entry registers the page's islands, scans for island markers, and hydrates each one on its own schedule — the page component itself never runs in the browser.
1949
+
1950
+ Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is planned as **partial prerendering (PPR)**, not part of islands mode.
1731
1951
 
1732
1952
  ## When to use islands
1733
1953
 
@@ -1735,15 +1955,19 @@ With `interactive: 'islands'`, the page's HTML keeps its script tags and the app
1735
1955
  - **Docs** that are mostly text but have a search modal + theme toggle.
1736
1956
  - **Blog posts** with an embedded poll or comment widget.
1737
1957
 
1738
- In each case what you get back is hydration time, not bytes: React's *runtime* overhead applies only to the interactive parts, while the *download* is unchanged. If the page has no interactive part at all, `interactive: 'none'` is strictly better — it ships no JavaScript.
1958
+ In each case you get back both halves: hydration work runs only inside the islands, and the download shrinks to react + the island runtime + those islands. If the page has no interactive part at all, `interactive: 'none'` is strictly better — it ships no JavaScript.
1959
+
1960
+ `interactive: 'none'` does not take forms with it. The strip removes every module script and modulepreload, but leaves `<form>` markup — and the form-flash JSON script (`#__voltro_form_flash__`, inert JSON, not executable code) — in place. An `<AutoForm>` on an `interactive: 'none'` page is therefore fully usable without a single byte of JavaScript: it renders `action="/form/<mutationTag>"` + `method="post"` and submits as a native form POST. Details: [Forms without JavaScript](/docs/ui/forms-and-tables).
1739
1961
 
1740
1962
  ## Writing an island
1741
1963
 
1742
1964
  Wrap a component with `island(Component, { name, hydrate })` and default-export the result. The plain component is NOT enough — without the `island()` call the component is never registered, and at hydration time the runtime logs `island "…" not registered`.
1743
1965
 
1966
+ `island` comes from the react-only subpath **`@voltro/web/islands`** (only react + react-dom/client in its graph). Importing the `@voltro/web` barrel inside an island file is a BUILD ERROR — see the import rules below.
1967
+
1744
1968
  ```tsx
1745
1969
  // src/components/LikeButton.island.tsx
1746
- import { island } from '@voltro/web'
1970
+ import { island } from '@voltro/web/islands'
1747
1971
  import { useState } from 'react'
1748
1972
 
1749
1973
  const LikeButton = ({ initial }: { initial: number }) => {
@@ -1759,7 +1983,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
1759
1983
  ```
1760
1984
 
1761
1985
  - **`name`** — the stable id under which the component is registered. Must be unique within the app. Both the SSR and the client bundle import the file, so the same `island()` call runs on both sides and registers the component in each.
1762
- - **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The five strategies are in the table below.
1986
+ - **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The six strategies are in the table below.
1763
1987
 
1764
1988
  Use it in a page:
1765
1989
 
@@ -1790,8 +2014,8 @@ What happens at build:
1790
2014
  <button>❤ 42</button>
1791
2015
  </div>
1792
2016
  ```
1793
- 2. The island bundles into the client chunk.
1794
- 3. The client runtime scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.
2017
+ 2. The island compiles into the page's own browser entry — react + the island runtime + this page's islands (see [How the per-page entry works](#how-the-per-page-entry-works)).
2018
+ 3. That entry scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.
1795
2019
 
1796
2020
  The rest of the page stays as inert HTML.
1797
2021
 
@@ -1805,33 +2029,57 @@ Each island declares WHEN it hydrates via the `hydrate` option (default `'visibl
1805
2029
  | `idle` | When the browser is idle (`requestIdleCallback`, `setTimeout` fallback) | Important widgets that don't need instant interactivity — analytics, secondary nav. |
1806
2030
  | `visible` (default) | When the element scrolls into the viewport (IntersectionObserver) | Anything below the fold — comment box, related-articles carousel. |
1807
2031
  | `interaction` | On the first pointer / keyboard event on the element | Heavy widgets users *might* touch — embedded playground, deep tree viewer. Defers cost until commitment. |
2032
+ | `only` | Client-only: the server renders an empty placeholder, the client mounts fresh with `createRoot` instead of hydrating | Components that touch `window` during render — chart/map libraries. |
1808
2033
  | `never` | Never — the server-rendered HTML stays inert | Server-only displays that never change after SSR (a build-time status badge). |
1809
2034
 
1810
2035
  Mix freely inside one page: a `load` search box, a `visible` comment widget, and a `never` build banner can all coexist.
1811
2036
 
1812
2037
  ## What each mode actually costs
1813
2038
 
1814
- Measured on the framework's reference web fixture — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:
2039
+ Measured on the framework's reference web fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25) — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:
1815
2040
 
1816
2041
  | Mode | JS shipped | Hydration |
1817
2042
  |---|---|---|
1818
- | `interactive: 'full'` | 195,229 B gz | The whole page tree |
1819
- | `interactive: 'islands'` | 195,231 B gz | Only the marked islands, each on its own strategy |
2043
+ | `interactive: 'full'` | ≈181.9 KB gz — the app entry: router, Effect runtime, subscription cache, app shell | The whole page tree |
2044
+ | `interactive: 'islands'` | ≈59.6 KB gz — a per-page entry: react + the island runtime + this page's islands | Only the marked islands, each on its own strategy |
1820
2045
  | `interactive: 'none'` | 0 B — every module script and modulepreload is stripped from the HTML | None |
1821
2046
 
1822
- Two things to take from that table. **`islands` is not a download optimisation** — it is level with `full` to within two bytes, and the numbers above are the whole story, not a "before we finish the work" snapshot. And **`none` is the one that removes bytes**, because it is the only mode that removes the script tags.
2047
+ Two things to take from that table. **`islands` IS a download optimisation now** — an islands page ships roughly a third of the full page's first-load JS, because its entry carries no router, no Effect runtime, no subscription cache and no app shell. And **`none` remains the floor**: it is the only mode that removes the script tags entirely.
1823
2048
 
1824
- The floor those first two numbers sit on is the framework's browser runtime React plus the Effect-based RPC client and it is pinned in CI so it cannot drift silently. Reproduce it yourself:
2049
+ Both islands numbers are pinned in CI — the hard <70 KB bound and the <50 %-of-full ratio so the gap cannot drift shut silently. Reproduce it yourself:
1825
2050
 
1826
2051
  ```sh
1827
2052
  node packages/web/scripts/bundle-budget.mjs
1828
2053
  ```
1829
2054
 
1830
- ## Making islands reduce bytes too
2055
+ ## How the per-page entry works
2056
+
2057
+ `voltro build` emits one browser entry per `interactive: 'islands'` page. The build finds the page's islands by walking the page's **relative import graph** for `*.island.tsx` files — transitively, through intermediate components. Only what is reachable from an island file ships; the page component itself may import anything, because on an islands page it never runs in the browser.
2058
+
2059
+ Two rules to know:
1831
2060
 
1832
- It is a real gap, and it is a build-pipeline change rather than a runtime one: the browser entry would have to be emitted *per page*, so an islands page's entry imports the island runtime and its own islands instead of the whole app. Nothing in the page's own code can shortcut it — the bytes are pulled in above `mount()`, by the entry that imports it.
2061
+ - **`interactive` must be a source LITERAL.** `export const interactive = 'islands' as const` selects the lean entry; a computed value does not the page then ships the full entry as before, and the build says so loudly.
2062
+ - **It applies on all three paths.** `voltro build` (SSG), `voltro start` (ssr/isr islands pages) and `voltro dev` (the same entry mechanism, on demand) — including the import-rule violations below, which fire in dev already, not first in the build.
1833
2063
 
1834
- Until then, if download size is what you are optimising, reach for `interactive: 'none'`.
2064
+ ### What an island may import (build errors, not runtime crashes)
2065
+
2066
+ An island file — or anything in its relative import graph — must NOT import:
2067
+
2068
+ - `@voltro/web` (the barrel — router hooks, `<Link>`)
2069
+ - `@voltro/i18n` (`useT`)
2070
+
2071
+ An island hydrates provider-less in its own root, so these hooks would throw there — and the barrel would additionally drag the Effect runtime into the lean entry. The build error names the file and the specifier.
2072
+
2073
+ Allowed: `@voltro/web/islands`, react, relative browser-safe imports — and `@voltro/client` / `@voltro/ui` (both count as framework usage and trigger the client boot below).
2074
+
2075
+ ### Framework islands: `useSubscription` and friends
2076
+
2077
+ An `@voltro/client` import in the island graph is DETECTED — that page's entry then boots the rpc client (a `VoltroRuntimeProvider` around each island root), and the island receives live data. Pages whose islands are purely presentational never pay the client core.
2078
+
2079
+ ### Limits
2080
+
2081
+ - An islands page reached via **SPA navigation** from a full page runs inside the already-loaded app bundle — the saving applies to the first visit / hard load of the islands page.
2082
+ - All islands of one page **share one entry** (no per-island lazy chunk) — the hydrate strategies control WHEN an island hydrates, not when it loads.
1835
2083
 
1836
2084
  ## Island boundaries
1837
2085
 
@@ -1851,7 +2099,7 @@ Each island is independent — there is no shared React root across islands. To
1851
2099
 
1852
2100
  ## Props serialisation
1853
2101
 
1854
- Props passed to an island must be **JSON-serialisable**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values.
2102
+ Island props cross the boundary **as JSON in an HTML attribute**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values. `Date` arrives as an ISO string, `Map`/`Set` as `{}`, and functions are lost — in dev the framework warns, naming the island and the prop. Pass JSON shapes and reconstruct richer types inside the island.
1855
2103
 
1856
2104
  OK:
1857
2105
 
@@ -1887,13 +2135,13 @@ If you need to pass a function reference, define it INSIDE the island.
1887
2135
 
1888
2136
  ## Inspecting
1889
2137
 
1890
- Each `*.island.tsx` becomes its own chunk, so `.framework/dist/assets/` carries an `island-<Name>-<hash>.js` per island alongside the app chunks. Listing that directory is the whole report:
2138
+ Each islands page gets its own lean entry in `.framework/dist/assets/`, alongside the app entry that full pages share. Listing that directory is the whole report:
1891
2139
 
1892
2140
  ```sh
1893
2141
  ls -l .framework/dist/assets
1894
2142
  ```
1895
2143
 
1896
- Per-island chunks are cached independently across deploys and fetched in parallel but note they are *additional* files reachable from the app bundle, not a replacement for it. See the mode table above for what actually reaches the browser.
2144
+ All islands of one page share that page's entrythere is no per-island lazy chunk. See the mode table above for what actually reaches the browser.
1897
2145
 
1898
2146
  ## Anti-patterns
1899
2147
 
@@ -1987,9 +2235,9 @@ the `@source` is misconfigured.
1987
2235
  <!-- source: en/routing/middleware.md -->
1988
2236
  ## Middleware
1989
2237
 
1990
- _'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, and say which routes it runs on.'_
2238
+ _'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, shape the response (headers, a CSP nonce), and say which routes it runs on.'_
1991
2239
 
1992
- `middleware.ts` at the web app root runs **before** a server render binds its data. It exists for one job — renewing a credential — and it is deliberately narrow about everything else.
2240
+ `middleware.ts` at the web app root runs **before** a server render binds its data. It exists for two jobs — renewing a credential the render is about to use, and shaping the RESPONSE (`responseHeaders`, a `cspNonce`) — and it is deliberately narrow about everything else.
1993
2241
 
1994
2242
  ```ts
1995
2243
  // middleware.ts — server-only. NOT app.config.ts, which is imported into the
@@ -2017,7 +2265,7 @@ A cookie older than the IdP's token lifetime — practically every first page vi
2017
2265
 
2018
2266
  ## What it receives, and what it can return
2019
2267
 
2020
- `run` gets a read-only request and returns `{ headers?, setCookies? }` — or nothing, to change nothing.
2268
+ `run` gets a read-only request and returns `{ headers?, setCookies?, responseHeaders?, cspNonce? }` — or nothing, to change nothing. `headers` and `setCookies` shape the REQUEST this render sees; `responseHeaders` and `cspNonce` shape the RESPONSE it produces (their own sections below).
2021
2269
 
2022
2270
  | Field | |
2023
2271
  |---|---|
@@ -2046,6 +2294,52 @@ The middleware produces one view of the request that everything downstream reads
2046
2294
 
2047
2295
  So a hook that renews **only** via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the `Cookie` header is rebuilt from the updated jar. A `maxAge` of `0` deletes, so a hook that signs someone out renders them signed out. If you return an explicit `cookie` header yourself, yours wins.
2048
2296
 
2297
+ ## Response headers — `responseHeaders`
2298
+
2299
+ `responseHeaders` is applied to what this render SENDS — every render-shaped response on both boot paths: `ssr` and `isr` renders, the `spa` shell, and a loader's redirect or 404.
2300
+
2301
+ ```ts
2302
+ export const session = defineMiddleware({
2303
+ match: { under: '/app' },
2304
+ run: async () => ({
2305
+ responseHeaders: {
2306
+ 'x-frame-options': 'DENY',
2307
+ 'referrer-policy': 'no-referrer',
2308
+ },
2309
+ }),
2310
+ })
2311
+ ```
2312
+
2313
+ Two boundaries, stated rather than implied:
2314
+
2315
+ - **`responseHeaders` act on the RENDER — an isr cache HIT does not re-run the middleware,** so a HIT does not carry the headers the MISS's render produced. For an `isr` page, either set cache-independent headers at the proxy, or accept that only MISS/refresh responses carry them.
2316
+ - **Prerendered `static` pages never render at request time,** so there is no middleware run to attach headers to. That is the documented proxy recipe: headers on static files belong on whatever serves them.
2317
+
2318
+ ## A per-request CSP nonce — `cspNonce`
2319
+
2320
+ Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, and React's own bootstrap/settle scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.
2321
+
2322
+ ```ts
2323
+ import { randomBytes } from 'node:crypto'
2324
+ import { defineMiddleware } from '@voltro/web/middleware'
2325
+
2326
+ export const csp = defineMiddleware({
2327
+ match: { under: '/app' },
2328
+ run: async () => {
2329
+ const nonce = randomBytes(16).toString('base64url') // fresh per request
2330
+ return {
2331
+ cspNonce: nonce,
2332
+ responseHeaders: {
2333
+ 'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`,
2334
+ },
2335
+ }
2336
+ },
2337
+ })
2338
+ ```
2339
+
2340
+ - **`isr` + `cspNonce` refuses the render, loudly.** A cached nonce is a lie the browser enforces — the second visitor gets HTML whose nonce the policy header no longer matches. The ways out: `ssr` for nonce'd pages, or a hash-based CSP for `isr`.
2341
+ - The PPR variant of that question is open until partial prerendering exists; a component for client-injected script tags (a `Script` component) is planned.
2342
+
2049
2343
  ## `match` — where it runs
2050
2344
 
2051
2345
  Without a `match`, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.