@voltro/protocol 0.7.0 → 0.9.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 +40 -0
- package/dist/jwt.d.ts +39 -3
- package/dist/jwt.js +7 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,46 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.9.0] — 2026-07-21
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/web, @voltro/cli** — A `renderMode: 'spa'` page whose route has an SSR layout chain now renders that LAYOUT on the server — an SSR shell — instead of rendering nothing server-side. This decouples layout SSR from page render mode: the layout (nav shell, sidebar, auth gate) is server-rendered for an instant first paint and SEO, while the page itself stays client-only. Concretely, on both `voltro dev` and `voltro start`, a spa page under a layout used to ship the empty client shell (`<div id="root"></div>`) and mount the whole tree — layout included — in the browser. Now the server renders the layout chain around an empty page slot (`<div data-voltro-page-slot>`), runs the LAYOUT loaders, and inlines their data + a `pageClientOnly` flag; the browser hydrates that shell and mounts the client-only page into the slot after hydration. The page's OWN loader still runs in the browser, exactly as before. A spa page with NO layout is unchanged (still a pure client mount); `static`/`ssr`/`isr` pages are unchanged. Why this is BREAKING: a layout component AND its `loader` now execute during the server render for spa routes. Layouts shared with any `static`/`ssr`/`isr` page already ran server-side (and `static` is the default), so they are unaffected — the only newly-server-rendered layout is one whose ENTIRE page subtree is `'spa'`. Such a layout must be SSR-safe: no unguarded `window`/`document` at render time or in its `loader`. The `voltro update` codemod (`0.9.0/01`) prints this review step, and only for projects that actually have both a spa page and a layout. Out of scope this release (follow-ups): build-time prerender of the spa layout shell (`voltro build` still skips spa pages, so `voltro start` renders the shell on demand rather than from a prerendered file), `defer()`/streaming inside a spa-shell layout (the shell is buffered), and Fast-Refresh coverage of the new shell path.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **@voltro/protocol** — `jwtBearerStrategy` (and `extractBearerOrCookie`) accept an optional `cookieToToken` hook (`CookieTokenExtractor`) that transforms the request's cookie transport into the JWT to verify. It receives a `getCookie(name)` accessor (so a strategy can read sibling / chunked cookies) plus the configured cookie name. Applied to the cookie path only — the Bearer header stays a raw token — and defaulting to a verbatim read, so the raw-JWT cookie path for WorkOS / Kinde / Clerk / Auth0 / OIDC is byte-identical. `supabaseStrategy` supplies one to unwrap the `@supabase/ssr` session envelope. Additive: existing `jwtBearerStrategy` configs and 2-arg `extractBearerOrCookie` calls are unaffected.
|
|
51
|
+
- **@voltro/cli** — `voltro doctor` now flags pages that are safe `renderMode:'spa'` candidates. A page is listed when ALL hold: it is a page file (not `layout`/`loading`/`error`/`not-found`), it exports no `loader`, its `renderMode` is `'ssr'` or unset/default (not already `'spa'`/`'static'`/`'isr'`), AND a `layout.tsx` sits somewhere in its directory chain (root, an ancestor, or the page's own dir). That last condition is load-bearing: only with a layout does switching to `'spa'` keep a server-rendered shell (the layout SSRs while the page body goes client-only) — a page with no layout would, as `'spa'`, ship no server HTML at all, so it is never flagged. The hint is ADVISORY and names the tradeoff: `renderMode:'spa'` skips the page's per-page SSR compile while its layout shell still renders server-side — adopt it for internal/authenticated pages whose body needs no SSR; keep `'ssr'` when the page content needs SEO or server first-paint. We deliberately ship NO codemod to auto-flip pages, because dropping a page body's server render is a per-page product decision, not a mechanically-safe transform. The scan reuses the framework's own page discovery (`walkPagesTree`), so it can't drift from what dev/build classify as a page. The full list is retrievable via `voltro doctor --json` (a new `spaCandidates` array); the human view caps at 10 with a "+N more" pointer.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **@voltro/cli** — `voltro dev` no longer OOM-kills itself when many `renderMode:'ssr'` pages compile at once. The dev SSR renderer compiles each page (and every layout in its chain) on demand via Vite's `ssrLoadModule`, and that call was unbounded: two browser tabs on the same cold route, or a health-check sweep hitting hundreds of distinct routes, each started its OWN esbuild module tree with no dedupe and no concurrency cap, so the transient heaps added up and the process was killed (a downstream app with ~224 SSR pages reached >14 GB in seconds; `--max-old-space-size=8192` died after ~5 concurrent cold pages). The fix is a cold-compile gate (`coldCompileGate.ts`) around every `ssrLoadModule` in the dev SSR handler — the page, each layout/error/loading segment, and the shared `@voltro/web/ssr` helpers, so a sweep can't fan out on layouts either. It does two things: (1) DEDUPES concurrent requests for the same module — N tabs on one cold route trigger ONE compile they all await; (2) BOUNDS how many DISTINCT cold compiles run at once (default 4). A WARM module (already in Vite's graph) bypasses the gate entirely, so a hot app stays fully concurrent — the gate only tames the cold stampede, it never serializes warm serving. Verified: a concurrent sweep of 100 distinct cold routes runs exactly 4 concurrent esbuild trees under the default instead of 100, and 8 concurrent requests to one cold route compile the page once. The bound is overridable with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` — drop it to `1`/`2` on a low-memory box, raise it on a beefy one. Default 4 balances memory against cold-sweep throughput (a lower value is safer but serializes first-paint of freshly-hit routes). Production `voltro start` is unaffected: it renders from a precompiled bundle and refuses to boot without one, so it never compiles on demand. The dev-like `voltro start` middleware FALLBACK (used only when `NODE_ENV!=='production'` and no bundle exists) shares the same `ssrLoadModule` path and now goes through the same gate. Covered by `coldCompileGate.test.ts` (dedupe, bound, warm bypass, failure-clears-and-retries, env parsing); the heavy end-to-end reproduction is a scratchpad (`scripts/measure-ssr-compile-oom.mjs`), not a CI gate.
|
|
56
|
+
- **@voltro/cli** — `voltro update` (and `voltro update --codemods-only`) no longer aborts with `ELOOP: too many symbolic links` when the app tree contains a circular symlink. The codemod file scan built its ts-morph `Project` with `addSourceFilesAtPaths([...globs])`, whose underlying glob FOLLOWS symbolic links and applies the `!**/node_modules/**` negations only to the RESULTS — so a self-referential symlink anywhere under the app root (pnpm's package layout inside `node_modules`, or any stray symlinked scratch dir) made the walk descend forever and throw before any negation could apply. The scan now enumerates source files itself with `followSymbolicLinks: false` and prunes heavy directories (`node_modules`, `.git`, `dist`, `build`, `.framework`, `.turbo`, `.next`, `.cache`, `.output`, `.voltro-*`, …) at the traversal level rather than by post-filtering — the crawler never descends into them, and no symlink is followed, so a cycle put there by anything is harmless. App source a codemod rewrites is always real files on disk, so files reachable only through a symlink are deliberately not scanned (and never rewritten). A codemod's explicit `scope` still narrows the file set exactly as before.
|
|
57
|
+
- **@voltro/plugin-auth-supabase** — `supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' })` now reads `@supabase/ssr` session cookies. Those cookies do not hold a raw JWT — the SDK stores the GoTrue session as a JSON envelope, optionally `base64-`-encoded and split across `sb-<ref>-auth-token.0`, `.1`, … chunk cookies. The strategy previously handed that envelope straight to the JWT verifier, so every cookie-mode request failed with `malformed jwt` and fell back to anonymous (including `ctx.query` from a `type:'web'` loader, which forwards the browser cookie to the api). The strategy now unwraps the envelope — URL-decode, strip the `base64-` prefix and base64url-decode when present, concatenate chunks in order, then lift the inner `access_token` — before verification. The `Authorization: Bearer` path is unchanged (always a raw token). Hostile / malformed / oversized cookies resolve to anonymous (skip), never throw. Auth strategies belong on the `type:'api'` app, not the web app — the api verifies the forwarded cookie.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## [0.8.0] — 2026-07-20
|
|
62
|
+
|
|
63
|
+
### ⚠ BREAKING
|
|
64
|
+
|
|
65
|
+
- **@voltro/web, @voltro/cli** — **`defer()` + `<Await>`: stream one slow part of a page instead of blocking the whole response.** A loader blocks the entire response, so one slow field costs every byte of the page. A loader may now return `defer(eager, deferred)` — two explicit buckets. The eager half is awaited and renders into the shell; the deferred half is handed to the renderer as promises, read through the new `<Await>` component, and **flushed into the same response as each promise settles**. ```tsx export const renderMode = 'ssr' as const export const loader = async ({ query }) => defer( { user: await query('users.me') }, // in the shell { report: query('reports.quarterly') }, // streamed after it ) const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>() <Await value={report} fallback={<ReportSkeleton />}>{(r) => <ReportTable rows={r.rows} />}</Await> ``` Two buckets rather than "any promise-valued field is deferred": deferral is then something the author wrote down, not something inferred from a value's runtime shape, and `useLoaderData()` can type it — eager fields come back as values, deferred fields as `Promise<T>`, so the compiler says which ones need an `<Await>`. **Streaming is now wired for `renderMode: 'ssr'` on BOTH boot paths.** `voltro dev` and `voltro start` share one shell splitter + stream driver (`cli/src/ssrShell.ts`) rather than hand-mirroring the sequence — the same reason `buildSegmentChain` is shared. `renderPageToStream` was previously present but deliberately unwired, on the (correct, measured) grounds that streaming a Suspense-FREE tree changes event-loop blocking by zero. `defer()` is what makes the tree no longer Suspense-free, so that reasoning no longer applies and the doc comment saying so has been rewritten instead of left contradicting the code. Measured against a real `voltro start` (`scripts/measure-deferred-stream.mjs`, one 400ms deferred field): first body byte at 7ms, deferred chunk at 408ms, and a probe firing every 10ms for the duration of the streamed request was served 30 times at a 3ms median / 7ms max. The event loop stays free while the deferred value is pending — that, not TTFB alone, is the point. **`defer()` is a hard error, naming the page, on every mode where it provably cannot work** — each verified against React 19.2.7 rather than assumed: `renderMode: 'static'` (`renderToString` does not support Suspense; it emits an errored boundary plus a "switched to client rendering" template with no warning, so the artefact would ship a permanent fallback), `renderMode: 'isr'` (caches a completed HTML string), `interactive: 'none'` (no JS to run React's reveal scripts), `interactive: 'islands'` (the root never hydrates, so nothing consumes the streamed value). Awaiting-and-inlining on the artefact modes was considered and rejected: it makes `defer()` a silent no-op that still reads like it streams, and it needs a second wire format and a second `<Await>` path for no user-visible gain. **Why BREAKING.** Two things can turn code that compiled into code that does not, or change deployed behaviour: - `useLoaderData<T>()` now returns `LoaderData<T>`. For every concrete `T` that IS `T`, so ordinary call sites are unaffected — but a helper that passes an unresolved generic through (`<D,>(): D => useLoaderData<D>()`) stops compiling and must propagate `LoaderData<D>` instead. A transform cannot tell those apart without typechecking, hence a `manual` codemod. - `ssr` responses are chunked and carry no `content-length`, and `voltro start` needs an SSR bundle built by this version — so a deploy must re-run `voltro build`, and a buffering reverse proxy in front of the app will absorb the win until buffering is disabled for those routes. `<Await>` owns the `<Suspense>` boundary, the `use()`, and the settle `<script>` that publishes the resolved value to the client, so the server render and the hydration render are identical by construction rather than by a `typeof document` guard or `suppressHydrationWarning`. A rejected deferred value travels as a resolved error envelope and renders `errorFallback` in place on both sides — Fizz errors the whole boundary on a rejected `use()`, which would otherwise blow past `errorFallback` entirely. Coverage: `deferredStream.test.tsx` asserts chunk ordering + the concurrent-probe event-loop claim, `deferredHydration.test.tsx` hydrates a real streamed document in jsdom with zero console errors, `deferred.test.ts` pins all four hard errors, `ssrShell.test.ts` covers the shell split and the three stream-driver ordering hazards, and `webDevSsrLayoutLoader.test.ts` runs the SAME streaming assertions against a real `voltro dev` and a real `voltro start`. Non-deferring pages emit a byte-identical `__voltro_state__` payload, asserted against the exact strings a real build and a real serve produce.
|
|
66
|
+
- **@voltro/plugin-auth** — `UserRecord.passwordHash` is now optional (`string | null | undefined`), and the shipped `users` table's `passwordHash` column is nullable to match. It was required, which asserted that every identity has a password. SSO-only, magic-link-only, passkey-only and provider-PAT apps have none, so the only way they could call `subjectFromUser(...)` was to invent a fake hash — strictly worse than storing nothing, because a fake hash is a real value sitting in the column a verifier compares against. The alternative several apps took was to hand-build the `Subject` and lose the helper. The built `Subject` never needed the hash in the first place. **Why this is BREAKING even though nothing was removed:** widening is additive for code that WRITES a `UserRecord` and breaking for code that READS one. `const h: string = user.passwordHash`, `user.passwordHash.length` and `verifyPassword(pw, user.passwordHash)` all compiled before and do not now. The codemod is `manual` because a transform cannot typecheck and therefore cannot tell those sites apart — and both mechanical edits available to it are dangerous. `!` re-asserts the invariant that just stopped holding; `?? ''` manufactures a hash-shaped value and feeds it to a comparison, which is precisely the "a user with no password signs in with any password" failure this change exists to make impossible. An absent hash must be a refusal decision, never a default value. **The security half, which is where the work went.** `handleSignIn` treats an absent hash exactly as it treats an unknown email: it still burns a decoy scrypt, then returns the identical 401 `{ error: 'invalid credentials' }` with no `Set-Cookie`. So a password-less account can never be signed into with the password strategy, and the response reveals neither that the account exists nor that it lacks a password — a divergence there would be an oracle for which accounts are worth attacking through a different strategy. `null`, `undefined` and `''` are all treated as "no password". `passwordlessSignIn.test.ts` pins this across every shape of absence against every shape of submitted password (arbitrary, empty, `undefined`), plus the two indistinguishability properties. It was the only verification site in the package; nothing else reads the field to make a decision. Password RESET is unaffected and still promotes a password-less user: `updatePassword` assigns a hash to a user that had none, after which sign-in works normally and an arbitrary password still fails. `createdAt` stays required — every store can supply it (`insert` takes `Omit<UserRecord, 'createdAt'>`, so callers never pass one), so there is nothing to relax. The nullable column needs no migration work from you — it rides the declarative differ on `voltro db apply` / next boot.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **@voltro/testing, @voltro/runtime** — `makeTestContext` now makes its seeded store available to `runAsSystem`, so a row filter over a SHARED resource can be tested. `RowFilter.load` is `Effect<Ctx, unknown>` with `R = never`, so it cannot `yield*` an EffectStore service. A filter over rows the user OWNS needs no store (the subject carries the id), but a filter over rows SHARED with the user — "you see a list you are a member of" — must read a membership table, and `runAsSystem` is its only route there. Under `makeTestContext` that threw `runAsSystem: no data store available`, so the one rule most worth a test ("user B cannot see the row shared with user A") could not be asserted in-repo at all. The reported consequence was an app declining to ship a process-global filter over shared data it had no way to verify. `makeTestContext` registers its RAW `dataStore` + `schemaRegistry` via `setSystemStoreHandle` — never `ctx.store`, since `runAsSystem` applies `wrapStoreWithMixinBehaviour` for the system subject itself and a wrapped store would be wrapped twice (the system subject also bypasses row filters in production, so raw is the semantically correct layer). It is the outer store, not a transactional view: a `runAsSystem` block inside a mutation reads outside that mutation's transaction in production, and does here too. **Isolation, since the handle is a process global.** Registration is last-wins and never auto-cleared, so a bare `await runAsSystem(...)` written directly in a test resolves. Last-wins alone is not enough: two `makeTestContext` calls in one test allocate two separate `InMemoryDataStore`s even from one seed object, so a filter `load` on the first context would otherwise read the second's rows. The harness therefore re-points the handle at its own context for the span of that context's `load` and restores the previous value, making filter resolution independent of build order. Cross-file leakage does not arise — vitest isolates each test file's module registry — and within a file a test that wants the refusal back calls `clearSystemStoreHandle()`. Production is untouched: only the harness registers, so an app that registered no handle still gets the throw. This scopes an exception to the harness rather than relaxing the refusal. `@voltro/runtime` gains `getSystemStoreHandle()` (read the current handle so a caller can save/restore around a bounded span) and exports the `SystemStoreHandle` type. Both are additive. One behavioural caveat worth knowing: a test that built a `makeTestContext` and then asserted `runAsSystem` refuses in the same file will now see it succeed. Call `clearSystemStoreHandle()` first.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **@voltro/database, @voltro/cli** — `timestampMs` / `timestampMsOrNull` are now importable from a descriptor. 0.6.0 shipped them — and `rowSchema(table)` — documented for a `*.query.ts` `output`, but the browser-safety guard rejects `@voltro/database` and every subpath under it, so the documented usage aborted `voltro dev` with an import-chain error. A downstream app hit exactly that. The field schemas now ship from a new browser-safe entry, **`@voltro/database/wire`**, which contains plain `effect/Schema` values and imports `effect` and nothing else; the guard permits that one subpath. It is not a blanket allowlist entry — the guard resolves a workspace package to its `./src/*.ts` source and keeps walking, so a server import added to that module is still caught and still aborts boot. Both directions are covered by tests (`browserSafetyGuard.test.ts`): the subpath passes, bare `@voltro/database` still fails even with the subpath present, and a deliberately regressed wire module is caught. The package root keeps exporting the same values, so server-side code that already imports the root needs no second import. `rowSchema(table)` / `columnSchema(def)` are corrected rather than changed: they take the table as a VALUE, and reaching a table means importing an app's `database/schema.ts`, which imports `@voltro/database` — so a descriptor can never use them, no matter where they are packaged. That is a consequence of the browser/server boundary, not a packaging accident, and it is now stated where it was previously mis-stated. They remain the row CODEC for server-only code (`*.server.ts`, `*.seed.ts`, jobs, scripts): encoding rows for a file export or queue payload, decoding seed / import data against the real table shape. The doc comments, the docs site (en + de), the seeded agent guide, and the `voltro doctor` `hand-serialized-date` rule — which recommended `rowSchema(table)` in a descriptor's `output` and was therefore actively steering users into the boot crash — all now point at the field schemas instead. No API was removed or narrowed, and no user code that compiled stops compiling: the previously-recommended usage never got as far as a boot, so there is nothing to migrate. Apps that worked around this with hand-written `Date → epoch` converters can delete them and declare `timestampMs` on the field.
|
|
75
|
+
- **@voltro/cli** — `voltro dev` now runs LAYOUT loaders during SSR, like `voltro start` always did. A `layout.tsx` exporting a `loader` had it skipped entirely on the dev server's SSR pass: `useLoaderData()` inside that layout was `undefined` on the server-rendered first paint and only populated after hydration, while the identical code rendered correctly in production. The dev log gave the one visible tell — the `loadChain` phase reported `0ms`, because the phase only imported layout modules and never awaited a loader. This is dev/production parity drift, so the fix is structural rather than a second copy of the loop. Chain assembly plus the layout-loader run now live in ONE `buildSegmentChain`, called by both SSR paths; only module loading differs between them (`voltro start` resolves through its SSR module provider, `voltro dev` through Vite's `ssrLoadModule`) and that is injected. Everything a user can observe is single-sourced: the loader argument shape (`params`, `pathname`, `signal`, `headers`, `query` — identical to what a page loader receives, including the server-side rpc `query()` bound to the request's session cookie), the parallel run across chain segments, and the keying of each result by CHAIN index so a layout can never be handed a sibling's data. Dev also now shares ONE `AbortController` across the page loader and every layout loader on a request, wired to the response `close` event — navigating away cancels all in-flight loader fetches, not just the page's. A throwing layout loader is surfaced exactly as a throwing page loader already was on each path: `voltro start` maps `NotFoundError` / `RedirectError` onto a 404 / 3xx and lets anything else propagate, `voltro dev` logs the failure and falls back to the SPA shell. It is never swallowed into a silently empty layout. No documented behavior changes — layout-level loaders were already specified to run server-side, and dev was the outlier. Apps that worked around this by refetching in the layout after hydration can drop the workaround; nothing needs to change to pick up the fix.
|
|
76
|
+
- **@voltro/web** — The router no longer discards loader data it already holds while a route's loaders are still in flight. Its `pending` branch rendered the page under `<LoaderDataContext.Provider value={undefined}>` unconditionally, so `useLoaderData()` returned `undefined` for that window even when the router was holding that exact route's data. Where it actually bit: a `renderMode: 'static'` page. The prerender runs the PAGE loader and inlines its result into `__voltro_state__`, but it runs no LAYOUT loaders — so after hydration the client re-runs them and the router sits in `pending` until they settle, with the page's own data committed the whole time. During that window the page re-rendered with `undefined`, and an unguarded page (`data.title`, exactly what the docs tell you to write) threw into its `RouteErrorBoundary`. A fast layout loader hides this — it settles in the same microtask checkpoint and React batches the bad render away — so it only surfaces for real when a layout loader is slow, which is the case a real network call produces. The reuse is GATED, because the inverse is a worse bug: on a client-side navigation the committed data belongs to the route being left, and handing it to the incoming page would typecheck (both sides are `unknown` at that seam) and often look plausible on screen. The committed chain data now carries the pathname it was produced for, and the page-level provider reuses it only when that pathname is the one being rendered. The two diverge in exactly one situation — a route that opts into a `Pending` skeleton is displayed before its loaders settle — and that is the situation the gate exists for. Per-layout data got the same treatment one level down, with a finer key: during that same skeleton window a layout's committed value is reused only if the SAME `Layout` component still occupies that chain position. A shared shell therefore stays populated behind the incoming route's skeleton (the point of opting into one), while a DIFFERENT layout at that chain index now gets nothing instead of inheriting its predecessor's value by index. No public API changed (the api-extractor golden is unchanged) and no code that compiled stops compiling; a page that previously flashed `undefined` mid-pending now simply keeps its data. Covered end-to-end in `hydrateLoaderData.test.tsx` — a real SSR render → real state script → real `mount()` → real `hydrateRoot`, with a deliberately slow layout loader, plus both navigation directions and empty-console assertions.
|
|
77
|
+
- **@voltro/cli** — React Fast Refresh now works for pages and layouts in `voltro dev`. Until now **nothing** in a Voltro web app hot-updated: every edit — a page component, a layout component, a stylesheet-adjacent TSX tweak — forced a FULL PAGE RELOAD, discarding form input, scroll position, open dialogs and all client state. Measured in a real browser across both the streamed and the buffered SSR paths, so it was boot-path-agnostic and long-standing. Editing a page component now applies as a hot update with `useState` intact; editing a `loader` still reloads, deliberately. Two causes, both fixed. **(1)** react-refresh only accepts a module whose exports are all components, and a page exports `loader` / `renderMode` / `meta` beside its component, so every route module rejected itself and invalidated upward. **(2)** The rejection then reached the generated `.framework/app.tsx`, which was ALSO ineligible because it exported `preloadCurrentRoute` beside `App`, so it bubbled on to `main.tsx` — which accepts nothing, i.e. a full reload. The obvious fix — strip server-only exports from the client bundle, the Remix approach — is not available here: Voltro's loaders are **isomorphic** (`router.tsx` runs a route's `loader` in the browser on client-side navigation), so the loader must stay in the client graph. Instead, `voltro dev` registers each route module's non-component export names with `@vitejs/plugin-react`'s ignored-exports hook, so react-refresh judges only the components, and the framework decides the reload itself by comparing those exports' VALUES across the update — a function by its source text, everything else by its JSON form. A JSX-only edit recreates the `loader` function object but not its text, so it correctly reads as unchanged and hot-updates; an actual loader edit reads as changed and forces a reload. That reload is the correct outcome, not a limitation: a `loader` also runs server-side, the visible page was rendered from the old one, and the router caches loader results per route + params, so a silent hot swap would leave stale data on screen. The transform is **client-only and append-only** — it never removes an export, and it bails on the SSR environment entirely, because the dev renderer imports each page/layout through `ssrLoadModule` and reads `loader` / `renderMode` straight off the namespace. The generated dev entry is now split in three so the boundaries are clean: `app.tsx` exports `App` and nothing else (a valid refresh boundary), the route table + the mutable module registry HMR patches move to a new `.framework/routeTable.ts` — a plain `.ts` module that is deliberately NOT a boundary, so refreshing `app.tsx` cannot drop already-loaded page modules — and `main.tsx` stays side-effect-only. These are generated files, rewritten on every `voltro dev` boot; nothing user-authored changes. Covered by `routeFastRefresh.test.ts` (client transform injects, SSR transform is a no-op, and the reload decision — including the "re-evaluated but unedited loader is not a change" case that the whole thing turns on) and `webDevEntrySplit.test.ts` (app.tsx has exactly one export). The behaviour itself is browser-only and is driven by `scripts/browser-deferred-stream.mjs`, which now bumps a live `useState` counter before each edit and asserts it survives a component edit and resets on a loader edit — the only assertion that distinguishes a hot update from a reload.
|
|
78
|
+
- **@voltro/web, @voltro/cli** — Server-rendered loader data now reaches the client's FIRST render. Every `renderMode: 'ssr'` page with a loader — and every `layout.tsx` with one — previously hydrated with `useLoaderData()` returning `undefined`, because the inlined `__voltro_state__` payload was written by the SSR pipeline and read by nobody: `mount()` used the script tag as a boolean to pick `hydrateRoot` over `createRoot` and never parsed its contents, and `<Router>` started with no committed loader data, re-running every loader in an effect. Layout (chain-segment) data was not inlined in any shape at all. The consequences were not cosmetic. An ordinary SSR page that dereferences its own loader data (`data.value`) threw `TypeError: Cannot read properties of undefined` on the hydration render and fell into `RouteErrorBoundary`; a layout rendering its loader's value produced a genuine React hydration mismatch (server `ROOT_LAYOUT_LOADER_RAN`, client `ROOT_LAYOUT_LOADER_MISSING`), after which the tree was regenerated from scratch. Both are now measured in a real browser against `e2e-fixtures/web-layout-loader`, with zero console errors and zero page errors, and reproduced in the unit suite through a real `hydrateRoot`. The payload is one coherent object — `{ page, segments: { <chainIndex>: … }, ran }` — defined once in `@voltro/web`'s `routerState` module and emitted through a single `renderRouterStateScript()` helper that `voltro dev`, `voltro start` and `voltro build` all call. That is deliberate: the dev and serve SSR paths are independent assemblies, and hand-mirroring the shape into each is exactly the drift that let layout loaders go missing in dev in the first place. `ran` exists because JSON cannot express `undefined` — without it a loader that resolved to `undefined` is indistinguishable from one that never ran, and the client would re-run it. Values are keyed by the same chain index the renderer wraps layouts with, so a layout can never be handed its neighbour's data; escaping is unchanged (`<` is escaped, so a `</script>` inside loader data cannot break out of the tag). **Two behaviour changes worth knowing about, neither of which stops any code compiling.** First, `useLoaderData()` now returns the server's value on the initial render of a server-rendered page instead of `undefined`; components that branched on `undefined` to show a skeleton will simply stop showing it on first paint. Second, loaders no longer re-run on initial hydration — that re-run was the source of the post-hydration flash. A loader whose client-side re-execution an app was relying on (to refresh data or to trigger a side effect after mount) will no longer fire on first load; move that work into an effect. Client-side navigation is unaffected and runs loaders exactly as before, as does a fresh client mount with no server markup. Static prerender (`voltro build`) inlines the page loader's result the same way. It does not run layout loaders — it never did — so a static page's layouts continue to resolve their data on the client after mount.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
42
82
|
## [0.7.0] — 2026-07-20
|
|
43
83
|
|
|
44
84
|
### ⚠ BREAKING
|
package/dist/jwt.d.ts
CHANGED
|
@@ -16,6 +16,28 @@ declare interface AuthStrategyInput {
|
|
|
16
16
|
readonly clientId: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A per-strategy transform from the request's COOKIE transport to the bearer
|
|
21
|
+
* token to verify. It receives `getCookie` — a reader that returns any cookie
|
|
22
|
+
* by exact name, URL-decoded exactly as `readCookie` does — plus the configured
|
|
23
|
+
* `cookieName`, and returns the JWT (or `undefined` when the cookie(s) carry no
|
|
24
|
+
* usable token).
|
|
25
|
+
*
|
|
26
|
+
* Why an accessor and not a single value: some SDKs don't store a raw JWT in
|
|
27
|
+
* one cookie. `@supabase/ssr`, for one, stores a JSON envelope that can be
|
|
28
|
+
* base64-wrapped AND split across `<name>.0`, `<name>.1`, … chunk cookies —
|
|
29
|
+
* unwrapping it means reading sibling cookies by name, not just the base one.
|
|
30
|
+
*
|
|
31
|
+
* The DEFAULT — used by every raw-JWT IdP (WorkOS / Kinde / Clerk / Auth0 /
|
|
32
|
+
* OIDC) — is to read the named cookie verbatim, i.e. `getCookie(cookieName)`,
|
|
33
|
+
* which keeps the raw-JWT cookie path byte-identical to a plain cookie read.
|
|
34
|
+
*
|
|
35
|
+
* SECURITY: the cookie is attacker-controlled. An implementation MUST NOT throw
|
|
36
|
+
* — a malformed / oversized / wrong-shape value must resolve to `undefined`
|
|
37
|
+
* (the strategy then returns `skip`), never an exception.
|
|
38
|
+
*/
|
|
39
|
+
export declare type CookieTokenExtractor = (getCookie: (name: string) => string | undefined, cookieName: string) => string | undefined;
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
* Fetch (and per-URL cache) an OIDC discovery document — the
|
|
21
43
|
* `.well-known/openid-configuration` an IdP publishes so consumers can
|
|
@@ -28,9 +50,13 @@ declare interface AuthStrategyInput {
|
|
|
28
50
|
*/
|
|
29
51
|
export declare const discoverOidc: (url: string) => Promise<OidcDiscoveryDocument>;
|
|
30
52
|
|
|
31
|
-
/** Pull a JWT from either `Authorization: Bearer …`
|
|
32
|
-
*
|
|
33
|
-
|
|
53
|
+
/** Pull a JWT from either `Authorization: Bearer …` (always a raw token) or
|
|
54
|
+
* the named cookie. The optional `cookieToToken` transform unwraps a cookie
|
|
55
|
+
* transport that is NOT itself a raw JWT (e.g. Supabase's `@supabase/ssr`
|
|
56
|
+
* session envelope); it is applied to the COOKIE path ONLY — the Bearer header
|
|
57
|
+
* is a raw token for every IdP. Default: read the named cookie verbatim.
|
|
58
|
+
* Returns undefined when neither transport yields a token (strategy `skip`s). */
|
|
59
|
+
export declare const extractBearerOrCookie: (headers: Readonly<Record<string, string | undefined>>, cookieName: string | null, cookieToToken?: CookieTokenExtractor) => string | undefined;
|
|
34
60
|
|
|
35
61
|
/**
|
|
36
62
|
* JWKS-backed verification (asymmetric: ES256/RS256/PS256/EdDSA) — the
|
|
@@ -93,6 +119,16 @@ declare interface JwtBearerStrategyBase {
|
|
|
93
119
|
* handler-side scope gate then denies). Return `[]` to grant none.
|
|
94
120
|
*/
|
|
95
121
|
readonly scopesFromClaims?: (claims: Record<string, unknown>) => ReadonlyArray<string>;
|
|
122
|
+
/**
|
|
123
|
+
* Transform the request's cookie transport into the JWT to verify. Default:
|
|
124
|
+
* read the named cookie verbatim — the raw-JWT cookie every hosted IdP here
|
|
125
|
+
* uses (WorkOS / Kinde / Clerk / Auth0 / OIDC). A strategy whose cookie is
|
|
126
|
+
* NOT a raw JWT (Supabase's `@supabase/ssr` JSON envelope) supplies one to
|
|
127
|
+
* unwrap it. Applied to the cookie path ONLY; the Bearer header stays a raw
|
|
128
|
+
* token. See {@link CookieTokenExtractor} — it MUST NOT throw on hostile
|
|
129
|
+
* input (it resolves to `undefined` → `skip` instead).
|
|
130
|
+
*/
|
|
131
|
+
readonly cookieToToken?: CookieTokenExtractor;
|
|
96
132
|
}
|
|
97
133
|
|
|
98
134
|
/** JWT-based strategy config — verified EITHER via a JWKS endpoint
|
package/dist/jwt.js
CHANGED
|
@@ -78,22 +78,22 @@ var i = 3600 * 1e3, a = /* @__PURE__ */ new Map(), o = (e, n) => {
|
|
|
78
78
|
if (e instanceof n.JOSEAlgNotAllowed) return new c("jwt algorithm not allowed", "unsupported_algorithm");
|
|
79
79
|
let t = e?.message ?? String(e);
|
|
80
80
|
return /fetch|network|enotfound|econnrefused/i.test(t) ? new c(`jwks unreachable: ${t}`, "jwks_unreachable") : new c(`jwt verify failed: ${t}`, "other");
|
|
81
|
-
}, v = (t, n) => {
|
|
82
|
-
let
|
|
83
|
-
if (
|
|
84
|
-
let e =
|
|
81
|
+
}, v = (t, n, r) => {
|
|
82
|
+
let i = t.authorization ?? t.Authorization;
|
|
83
|
+
if (i?.startsWith("Bearer ")) {
|
|
84
|
+
let e = i.slice(7).trim();
|
|
85
85
|
if (e) return e;
|
|
86
86
|
}
|
|
87
87
|
if (n) {
|
|
88
|
-
let
|
|
89
|
-
if (
|
|
88
|
+
let i = (n) => e(t.cookie, n), a = r ? r(i, n) : i(n);
|
|
89
|
+
if (a) return a;
|
|
90
90
|
}
|
|
91
91
|
}, y = (e) => {
|
|
92
92
|
let t = e.cookieName === void 0 ? null : e.cookieName;
|
|
93
93
|
return {
|
|
94
94
|
id: e.id,
|
|
95
95
|
resolve: async (n) => {
|
|
96
|
-
let r = v(n.headers, t);
|
|
96
|
+
let r = v(n.headers, t, e.cookieToToken);
|
|
97
97
|
if (!r) return { kind: "skip" };
|
|
98
98
|
try {
|
|
99
99
|
let t = e.jwtSecret === void 0 ? await u(r, e.jwksUrl, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@effect/sql": "^0.51.1",
|
|
56
|
-
"@voltro/database": "0.
|
|
56
|
+
"@voltro/database": "0.9.0",
|
|
57
57
|
"jose": "^6.2.3"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|