@voltro/cli 0.51.0 → 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.
- package/CHANGELOG.md +127 -0
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/apiBuild-BYBpL7Pz.js +2 -0
- package/dist/{apiBuild-CPDHXF72.js → apiBuild-CSFI8QGq.js} +3 -3
- package/dist/bin.js +1 -1
- package/dist/build-CPgcMQug.js +793 -0
- package/dist/checkCommand-2SbqzukH.js +2 -0
- package/dist/{checkCommand-DNuPiWMc.js → checkCommand-COmqc2cB.js} +92 -46
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-CctkDO-1.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
- package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-DCdG2JN-.js} +12 -12
- package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-r7J9lIa7.js} +588 -540
- package/dist/{commands-B1OiS9bX.js → commands-Cc_nV8WI.js} +35 -35
- package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-C-gKvwqh.js} +5 -5
- package/dist/{dataCommand-C1GxXW5q.js → dataCommand-BgpBHnlB.js} +27 -27
- package/dist/dbCommand-DNb6yeOG.js +2 -0
- package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-sHedr-NJ.js} +2 -2
- package/dist/dev--A3nsxA3.js +3 -0
- package/dist/{dev-kdAg9Q7l.js → dev-CRHoCEiy.js} +2142 -2103
- package/dist/doctorCommand-CqoWA2p5.js +2 -0
- package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-DtfJ3FA6.js} +314 -234
- package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-Drn7o0No.js} +1 -1
- package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
- package/dist/{envCommand-C6V_xVlT.js → envCommand-D4gCrrTZ.js} +8 -8
- package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CMROeKeA.js} +2 -2
- package/dist/fileConventions-DOqD3lPS.js +34 -0
- package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
- package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
- package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BnRFEF1o.js → infoCommand-DXM868o_.js} +1 -1
- package/dist/inspectMetrics-CGF94puw.js +143 -0
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
- package/dist/{migrate-DehuBakM.js → migrate-D0F-eTlK.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
- package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-BPQyCmC5.js} +1 -1
- package/dist/{sdkgen-O4XqWOjM.js → sdkgen-Se88ifTd.js} +1 -1
- package/dist/serveCommand-DLc-BznW.js +2 -0
- package/dist/{serveCommand-DdiYNBBu.js → serveCommand-DkP3OT0W.js} +885 -868
- package/dist/serveEntry.js +1 -1
- package/dist/start-DfL3fOiN.js +3 -0
- package/dist/start-jw89Xbqy.js +1339 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
- package/dist/updateCommand-5gFVfK5q.js +2 -0
- package/dist/{updateCommand-Bs322Q78.js → updateCommand-BMk2e4ky.js} +1 -1
- package/dist/{webDev-B-ubQEMX.js → webDev-BgWL9gKV.js} +1156 -835
- package/dist/webDev-CZbTsDcH.js +2 -0
- package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-CoIO3jbj.js} +1 -1
- package/package.json +30 -17
- package/templates/AGENTS.core.md +11 -0
- package/templates/AGENTS.md +15 -4
- package/templates/agent-docs/_index.md +4 -4
- package/templates/agent-docs/_manifest.json +11 -11
- package/templates/agent-docs/cli.md +96 -14
- package/templates/agent-docs/data.md +210 -7
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +1 -1
- package/templates/agent-docs/deployment.md +22 -3
- package/templates/agent-docs/introduction.md +46 -0
- package/templates/agent-docs/local-first-mobile.md +34 -7
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +29 -7
- package/templates/agent-docs/reference.md +39 -2
- package/templates/agent-docs/routing.md +341 -47
- package/templates/agent-docs/schema-driven-ui.md +78 -2
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +76 -53
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +6 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +6 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-GDKuGOMV.js +0 -2
- package/dist/build-DETLZAFt.js +0 -752
- package/dist/checkCommand-CWcnDArJ.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-C27HIsGE.js +0 -2
- package/dist/dev-CK522MV5.js +0 -3
- package/dist/doctorCommand-BK4l18eG.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/serveCommand-BRnPCxVd.js +0 -2
- package/dist/start-BLNmWkLa.js +0 -1154
- package/dist/start-Dzicuyw8.js +0 -3
- package/dist/updateCommand-eXB35SEv.js +0 -2
- package/dist/webDev-DposiF3j.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /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
|
-
|
|
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
|
-
|
|
215
|
+
// src/pages/search/page.tsx
|
|
216
|
+
import { Schema } from 'effect'
|
|
217
|
+
import { useSearchParams } from '@voltro/web'
|
|
216
218
|
|
|
217
|
-
const
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
import { useServerRequest } from '@voltro/web'
|
|
232
|
+
Two boundaries worth knowing:
|
|
229
233
|
|
|
230
|
-
export
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1944
|
+
> **Islands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.**
|
|
1723
1945
|
>
|
|
1724
|
-
> `interactive: 'islands'`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1794
|
-
3.
|
|
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'` |
|
|
1819
|
-
| `interactive: 'islands'` |
|
|
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`
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2144
|
+
All islands of one page share that page's entry — there 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
|
|
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.
|