@voltro/client 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/form.d.ts +127 -0
- package/dist/form.js +2 -0
- package/dist/formData-Bw07C0wr.js +155 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +1095 -1176
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,133 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.52.0] — 2026-08-25
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/web, @voltro/plugin-storage, @voltro/plugin-ratelimit** — The HTTP surface is complete — seven gaps closed, each proven against the real listener.
|
|
47
|
+
|
|
48
|
+
**Binary byte streams.** REST handlers return `bytes(stream, { contentType, contentLength?, contentDisposition? })` (lazy thunk form defers opening the source); plugin routes return `byteStream`. Piped, never buffered (a 256 MiB export streams with bounded heap), never compressed. Idempotency × stream is DECIDED: `streaming: true` on an idempotent method with an idempotency binding is a mount error (a stream cannot cache a replayable body — retries would 409 until the TTL); an undeclared stream releases its claim at return. Storage's full-object download rides the generic path now.
|
|
49
|
+
|
|
50
|
+
**Negotiated compression + conditional GET.** brotli/gzip negotiated with a compressible-type allowlist, 1 KiB threshold, `Vary: Accept-Encoding` always on compressible types — on the api's buffered responses AND `voltro start`'s HTML (`http.compression.{enabled,minBytes}`). The BREACH position is structural: `POST /rpc` responses are NEVER compressed. ISR keeps ONE uncompressed entry and compresses per hit. `voltro start` answers `If-None-Match` with 304 (weak md5 tags over the uncompressed body); REST routes can declare `etag: true` (GET, weak SHA-1 content tag).
|
|
51
|
+
|
|
52
|
+
**Raw WebSocket gateways.** `defineWebSocket({ path, auth, onConnection })` in a `*.ws.ts` file — for FOREIGN protocols (a Yjs provider, a device fleet); app realtime stays the subscription protocol. `auth` is required with no default: `'subject'` runs the same chain as rpc/SSR BEFORE the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code 4001); `'public'` is a written-down decision. Every gateway path joins the upgrade origin guard (cross-site WebSocket hijacking → 403). Teardown at construction; plain GET → 426; duplicate paths refuse the boot.
|
|
53
|
+
|
|
54
|
+
**Body caps everywhere.** The 8 MiB cap used to guard only `/rpc`; plugin routes read uncapped and webhooks read uncapped AND UTF-8-round-tripped (corrupting binary bodies — fixed, proven byte-for-byte). `http.maxBodyBytes` (env `VOLTRO_MAX_BODY_BYTES`), per-route overrides on `defineRestRoute` and webhook handlers; a shared path takes its group's widest override. 413 for both request shapes.
|
|
55
|
+
|
|
56
|
+
**Full method unions.** PATCH/HEAD/OPTIONS are first-class on plugin and REST routes; HEAD is admitted wherever GET is (RFC 9110) with the transport dropping the body.
|
|
57
|
+
|
|
58
|
+
**BREAKING — the interceptor chain is fail-closed.** A throwing `onHttpRequest` interceptor is a 500 + a log line now; it used to be swallowed, which let a crashed security gate silently stop guarding. The manual codemod tells interceptor authors where the decision lives: propagate (a gate) or catch-and-degrade-loudly (protection with a dependency) — plugin-ratelimit's httpShield now does the latter, so a Redis outage cannot become a self-inflicted API outage. No app-authored call sites change shape, hence `apiSurface: compatible` — the break is the error POLARITY of one plugin-author hook, carried by the manual note.
|
|
59
|
+
|
|
60
|
+
**Middleware response headers + CSP nonce.** `middleware.ts` can return `responseHeaders` (applied on every render response shape, both boot paths; prerendered static files are the documented proxy-side limit) and `cspNonce` — the framework stamps every script tag of that render (React's own included) while the policy header stays the middleware's. `isr` + `cspNonce` is refused loudly: a cached nonce is a lie the browser enforces.
|
|
61
|
+
|
|
62
|
+
Deliberate limits: no multipart parser on REST/webhook routes (the storage upload routes are the sanctioned file path); static-file response headers belong at the proxy.
|
|
63
|
+
- **@voltro/plugin-row-history, @voltro/cli** — `@voltro/plugin-versioning` is renamed to `@voltro/plugin-row-history` — the name now says what it does.
|
|
64
|
+
|
|
65
|
+
The old name collided head-on with API versioning (versioned REST routes, `/v1` → `/v2`, sunset flow), which `defineRestRoute` now supports as a first-class `version:` field. What this plugin does is row history + time travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions` over `_voltro_row_history`); every comparison table that filed it under API versioning was reading the name, not the feature.
|
|
66
|
+
|
|
67
|
+
Renamed with it: the factory (`versioningPlugin` → `rowHistoryPlugin`), the options type (`VersioningPluginOptions` → `RowHistoryPluginOptions`), and the default instance alias (`versioning` → `row-history` — the inspect endpoint path and boot-log lines; an explicit `alias:` you passed is untouched). The table name (`_voltro_row_history`) and `VOLTRO_ROW_HISTORY_TTL_HOURS` already carried the new name: no data movement, no env change, no migration.
|
|
68
|
+
|
|
69
|
+
The codemod rewrites imports, the factory call sites and the options-type references, and prints the one step it cannot do — swapping the dependency in package.json.
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- **@voltro/cli** — `voltro doctor` now reports every declared `@voltro/*` dependency with no import site — the residue a migration off a framework package leaves in `package.json`, where it keeps getting installed, walked by `voltro update`, and read as evidence the package is in use, its breaking-change notes included.
|
|
74
|
+
|
|
75
|
+
Scoped to `@voltro/*` deliberately (third-party packages have too many legitimate no-import shapes), and three states are distinguished and printed: exempt by name with a reason (`@voltro/cli` is the binary, `@voltro/devtools` is mounted by `voltro dev`, `@voltro/sql-*` drivers are loaded from config); not-measurable-yet for `@voltro/client`/`@voltro/web` on a tree where codegen has never run (a missing measurement, not a dead dependency); and unimported — advisory, never fatal. A mention in a comment or an error string does not count as an import, and a commented-out import counts least of all: it is the artefact the rule exists to see past. Also in `voltro doctor --json` under `unimportedDeps`, `null` when no `package.json` could be read.
|
|
76
|
+
- **@voltro/web, @voltro/cli** — Islands now save real bytes — every `interactive: 'islands'` page gets its OWN browser entry.
|
|
77
|
+
|
|
78
|
+
Measured on the reference fixture (pinned in `bundle-budget.json`, 2026-08-25): an islands page's first load is **59.6 KB gz** against **181.9 KB gz** for a fully hydrated page — react + the island runtime + that page's islands, no router, no Effect runtime, no subscription cache. The bundle-budget gate pins a hard <70 KB bound AND the ratio (<50 % of a full page), so a regression that re-couples the entries fails loudly.
|
|
79
|
+
|
|
80
|
+
- **`@voltro/web/islands`** is the new react-only subpath — `island()` and the hydration runtime import only react + react-dom/client. Importing the `@voltro/web` BARREL (or `@voltro/i18n`) anywhere in an island's import graph is now a BUILD error naming file and specifier: an island hydrates provider-less, router hooks and `useT()` throw there anyway, and the barrel would pull the Effect runtime into the slim entry. - **The build finds each page's islands** through its relative import graph (transitively, through components in between) and emits one shell + entry per islands page. `interactive` must be a source LITERAL to select the slim entry — a computed value ships the full entry as before, and the build says so. - **All three paths**: `voltro build` (SSG renders into the per-page shell, main-shell stylesheets folded in), `voltro start` (ssr/isr islands routes serve their shell), `voltro dev` (same mechanism on demand — violations fire in dev, not first in CI). Fixed on the way: a STATIC islands page fell through dev's render gate to the SPA fallback and loaded the full app entry — dev now server-renders it like production. - **Framework islands**: an island importing `@voltro/client` (`useSubscription`, …) is detected — that page's entry boots the rpc client and wraps each island root in `VoltroRuntimeProvider`, so the island receives live data. Presentational pages never pay for the client core. - **`hydrate: 'only'`** (Astro's `client:only`): the server renders an empty placeholder — a browser-only lib touching `window` in render no longer crashes the SSR pass — and the client mounts fresh with `createRoot`. - **Island props are declared lossy where they are**: props cross an HTML attribute as JSON, so a `Date` arrives as an ISO string and `Map`/`Set`/ functions do not arrive at all — dev warns naming the island and prop. - The per-island `manualChunks` rule is gone: with per-page entries as additional rollup inputs it MERGED the shared react modules into the island group (a second-React-instance shape); per-island chunks were also redundant — the entry already scopes to the page's islands, and they were statically preloaded anyway.
|
|
81
|
+
|
|
82
|
+
Limits: an islands page reached via SPA navigation from a full page runs in the already-loaded app bundle (the saving applies to hard loads of the islands page); a page's islands share one entry (hydrate strategies control WHEN each hydrates, not when it downloads).
|
|
83
|
+
|
|
84
|
+
Why the golden churn is compatible: `hydrate: 'only'` widens a union, `hydrateIslandsOnPage` gains an optional options argument, and the subpath is a new export.
|
|
85
|
+
- **@voltro/ui, @voltro/client, @voltro/web, @voltro/cli** — Forms without JavaScript — `<AutoForm>` on a server-rendered page now works with JS disabled, end to end.
|
|
86
|
+
|
|
87
|
+
The form always renders `action="/form/<mutationTag>"` + `method="post"`; with JS alive, `onSubmit` intercepts exactly as before (optimistic rpc path unchanged). The `/form/*` endpoint is mounted by the WEB listener on BOTH boot paths (`voltro dev` and `voltro start`, one shared builder):
|
|
88
|
+
|
|
89
|
+
- **Origin-checked at the door** with the same `classifyRequestOrigin` the api's rpc listener runs — a cross-origin form POST is a 403 before a byte of the body is parsed. (The server-side forward reaches the api as a no-browser-origin request, so the web listener's check is the one that guards this surface.) - **One validation path.** The urlencoded body maps through the schema-driven `formDataToInput` (checkbox present/absent → true/false, `''` on number/date omits the field — never a silent 0 —, repeated keys → arrays, non-numeric strings pass through RAW so the decode fails honestly instead of minting NaN, unknown keys dropped) and validates with the SAME `validateFields` the client-side submit runs — byte-identical field errors. - **PRG on success**: 303 back to the submitting page, or to `<AutoForm redirectTo>` (same-origin relative paths only — anything else is refused, a hidden field must not become an open redirect). Reloading the redirected-to page cannot resubmit. - **422 re-render on validation failure**: the referring page renders in the same response with field errors + submitted values in the SAME error UI (`role="alert"`, aria unchanged), `cache-control: no-store`, bypassing the ISR cache in both directions. An rpc refusal after valid input (guard, server error) renders as a form-level error. The flash also embeds as a JSON script, so a page whose bundle arrives late hydrates to the identical state. - **Valid submits forward server-side over `POST /rpc`** with the request's cookie — auth middleware, guards and the rpc interceptor chain run identically to every other mutation.
|
|
90
|
+
|
|
91
|
+
New `<AutoForm>` props: `formKey` (several forms per page — the 422 re-render re-fills only the submitted one), `redirectTo`, and `action={false}` for purely static deploys where `/form/*` does not exist. Headless: `useFormBinding` gains `flash` + `formError`, `@voltro/web` gains `useFormFlash(formKey)`.
|
|
92
|
+
|
|
93
|
+
Also fixed on the way: a required `Schema.Boolean` field with no default used to block the JS submit as "missing" while its checkbox rendered visibly unchecked — boolean fields now seed `false`, agreeing with what the user sees (and with the no-JS mapping).
|
|
94
|
+
|
|
95
|
+
Deliberate limits: the no-JS error re-render needs an `ssr`/`isr` page (a static page cannot be re-rendered with request state; a minimal error page is the fallback); a purely static deploy has no `/form/*` endpoint (use `action={false}`); file uploads stay JS-only (multipart → 415). On an ssr page pass `schema` explicitly — descriptor resolution is a client-runtime feature and the SSR render would otherwise show no fields.
|
|
96
|
+
|
|
97
|
+
Bundle note: the `serverContext` chunk group is renamed `serverRequest` by the context's move to @voltro/client — identical 190 B gz, re-pinned in `bundle-budget.json` (fresh full measurement 2026-08-25: firstLoad 185,494 B gz, was 185,309 — +185 B from the form-flash read in the request context).
|
|
98
|
+
|
|
99
|
+
Why the golden churn is compatible: every addition is a new export or an optional prop; `FormBinding.formError` is a new member of the hook's RETURN type (nothing in the public API accepts a caller-built `FormBinding`), and `ServerRequestContextValue.formFlash` is optional.
|
|
100
|
+
- **@voltro/runtime, @voltro/cli, @voltro/cms, @voltro/plugin-broadcast** — On-demand ISR revalidation — the third invalidation axis next to time (`revalidate`) and CDC (`cacheInvalidatesOn`). Server code in the api process calls `revalidatePath('/blog/[slug]')` / `revalidateTable('posts')` / `revalidateTag('pricing')` (exported from `@voltro/runtime`; callable from mutations, actions, webhook receivers and REST routes) and the matching ISR cache entries fall on EVERY `voltro start` replica — including on dialects with no CDC at all, which is the case this exists for.
|
|
101
|
+
|
|
102
|
+
Transport is dialect-shaped, either or both: on postgres a `pg_notify` rides the SAME LISTEN connection the CDC invalidator already holds (no broker needed); everywhere else the broadcast broker carries it (`BROADCAST_URL` on both deployments; channel namespaced by `VOLTRO_BROADCAST_NAMESPACE` — deliberately env-derived, because this channel pairs an api with its WEB app and no shared name is derivable). A web process with isr routes and neither transport warns loudly at boot; under `voltro dev` the calls are documented debug-logged no-ops. Tags share ONE mechanism with tables — `cacheInvalidatesOn` accepts free strings, so `revalidateTag` is the same sink under another name.
|
|
103
|
+
|
|
104
|
+
Correctness edges built in: `revalidatePath` against a `static` route is a NAMED error on the web process (never a silent no-op); a purge landing while an SWR refresh or miss fill renders is guarded by a per-key generation counter on BOTH cache backends — the pre-purge page cannot be written back with a full TTL, and a refused write also suppresses the postgres backend's fire-and-forget upsert so no replica resurrects a deleted row. A content type declares `revalidate: { paths, tags }` and `publish()`/`unpublish()` fire them after commit.
|
|
105
|
+
|
|
106
|
+
Proven end-to-end (`scripts/revalidate-e2e.mjs`): 1 api + 2 `voltro start` replicas behind Redis (warm → purge → both fresh, with a negative control), the sqlite dialect leg, and a broker-less postgres leg where the NOTIFY line alone carries the purge to a LISTEN-only replica.
|
|
107
|
+
|
|
108
|
+
`apiSurface: compatible` — additive only: the new `@voltro/runtime` revalidation exports, an optional `revalidate` on `ContentTypeSpec`, the optional `onRevalidate`/`revalidateChannel` on the CDC invalidator options, a widened `BroadcastChannelKind`, and `IsrCache.set`'s new optional generation guard (plus `generation()`).
|
|
109
|
+
- **@voltro/protocol, @voltro/plugin-openapi** — `defineRestRoute` takes an opt-in `version:` — versioned REST APIs with a sunset flow.
|
|
110
|
+
|
|
111
|
+
`version: 'v2'` + `path: '/customers'` mounts the route at `/v2/customers`, the same `/vN/` convention the `publicApi:` projection and the built-in `/v1/api-keys` surface already use. The path is normalised ONCE at definition time, so every consumer — the mount, path-param matching, the idempotency scope, tracing spans, the OpenAPI generator — sees the mounted path and none can disagree.
|
|
112
|
+
|
|
113
|
+
Deliberate edges:
|
|
114
|
+
|
|
115
|
+
- **Opt-in, no auto-prefix.** A route without `version:` keeps its literal path untouched — an automatic prefix would silently move every deployed route. A path that already starts with `/vN/` AND declares `version:` is refused loudly at definition (both spellings at once is never what the author meant). - **Two versions are two descriptors.** The old version is ordinary code — visible, testable, deletable — carrying `deprecated:` (the replacement pointer) and `sunset:` (the date it starts answering `410 Gone`; the 410 body now also names which `version` died). - **One OpenAPI document for every version.** The `/vN/` prefix already separates the paths; each versioned operation is grouped under a version tag and carries `x-voltro-api-version` for tooling. No `?version=` filtered spec — a second document shape for information the paths already state. - The rpc socket stays outside URL versioning on purpose: the generated client is versioned with the server it was generated from. A stale browser tab runs the previous client until reload — that skew window exists and is documented, not solved by URLs.
|
|
116
|
+
- **@voltro/web, @voltro/cli** — Opt-in View Transitions for SPA navigations. `router.viewTransitions: true` in a web `app.config.ts` runs every route swap — `<Link>` clicks, `navigate(...)`, back/forward — through `document.startViewTransition`; individual navigations override the default in either direction with `navigate(to, { transition })` / `<Link transition>`.
|
|
117
|
+
|
|
118
|
+
The visual swap is the router's deferred-navigation commit, flushed synchronously inside the transition callback — by that point the target's lazy chunk and loaders have settled, so the flushed tree renders with data in hand. Three deliberate behaviors: `defer()` fields (and an explicit `Pending` skeleton's settled content) resolve AFTER the transition as ordinary updates, never a second animation; navigating while a transition is animating skips the running one (last navigation wins, nothing queues); overlay/dialog state changes never trigger one — a root snapshot would cross-fade the whole viewport for a one-layer change.
|
|
119
|
+
|
|
120
|
+
Fallback is exact: a browser without the API, and any user with `prefers-reduced-motion: reduce`, gets today's untransitioned swap — same timing, nothing to feature-detect. Styling is plain `::view-transition-*` CSS (no animation DSL); cross-document transitions for static/MPA pages are a one-line `@view-transition` CSS opt-in with no framework involvement.
|
|
121
|
+
|
|
122
|
+
Proven in a real chromium (`scripts/browser-view-transitions.mjs`): called on navigation, silent under reduced-motion, harmless with the API deleted, one transition across a `defer()` commit, rapid double-navigation lands on the last target — plus the jsdom wiring suite and the generated-entry flag check shared by all three web boot paths.
|
|
123
|
+
|
|
124
|
+
`apiSurface: compatible` — additive only: a new optional `viewTransitions` on `RouterProps`, optional `transition` on `NavigateOptions`/`LinkProps`, and the optional `router` block on the web app config.
|
|
125
|
+
- **@voltro/web, @voltro/cli** — Schema-typed search params — the query-string half of the URL is now part of the type graph.
|
|
126
|
+
|
|
127
|
+
A page declares its contract once:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
export const searchParams = Schema.Struct({
|
|
131
|
+
q: Schema.optionalWith(Schema.String, { default: () => '' }),
|
|
132
|
+
page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
and gets, end to end:
|
|
137
|
+
|
|
138
|
+
- **Typed reads** — `useSearchParams(searchParams)` returns the decoded, defaulted shape, SSR-aware; the zero-arg call keeps returning the raw `URLSearchParams`. Decoding is TOTAL: an invalid query falls back to the schema's defaults instead of crashing a render; only a schema that cannot even decode `{}` (a required field with no default) throws, naming the fix. - **Typed links** — the generated `routes` builder brands the route's URL with the schema's shape through a TYPE-ONLY page import (zero value edges: code-splitting is untouched, pinned by test), and `withQuery` type-checks against it — a misspelt key or wrong value type is a compile error. The link-side encode is canonical and schema-free (strings/numbers/booleans, arrays as repeated keys); a roundtrip test pins that it produces exactly what the schema's decode accepts. Deliberately ONE generic signature rather than overloads: with overloads, a wrong key would silently fall through to the permissive untyped form and the compile error would never fire. - **Typed writes** — `useSetSearchParams(searchParams)` returns the typed setter: its object form replaces the query (same semantics as the untyped form), and its updater form receives the CURRENT decoded params, so keeping `?filter` across a page flip is one explicit spread — `setParams((p) => ({ ...p, page: p.page + 1 }))` — instead of a hand-rolled merge. - **A fail-closed isr gate** — `renderMode: 'isr'` plus a `searchParams` export is refused at boot: the isr cache is keyed by path (+tenant+locale), not query, so the first variant would be cached for every query — and the gate catches the re-exported spelling (`export { searchParams } from …`, the mirror-route pattern) too, not only the local declaration. - **A doctor rule** — a page that exports the schema but keeps reading the query with zero-arg `useSearchParams()` is flagged with the typed spelling.
|
|
139
|
+
|
|
140
|
+
Deliberate limits: array fields decode a single occurrence as a one-element array (link shape stays stable); `siblingApps` routes stay untyped (their schemas live in a foreign compile graph); `static` pages see the defaults at build time and decode live on the client.
|
|
141
|
+
|
|
142
|
+
Why the golden churn is compatible: `VoltroRouteUrl` gains a type parameter with a DEFAULT (`<TSearch = unknown>`), so every existing bare `VoltroRouteUrl` spelling still compiles, and the new brand member is an OPTIONAL phantom property of type `unknown` — assignability in both directions is unchanged. `withQuery`'s parameter for the untyped case is strictly WIDER than before (adds `boolean` and array values); no call that compiled stops compiling.
|
|
143
|
+
|
|
144
|
+
### Fixed
|
|
145
|
+
|
|
146
|
+
- **@voltro/cli** — `voltro agents-md`'s copied `agent-docs/` mirror no longer keeps orphaned modules across a re-seed.
|
|
147
|
+
|
|
148
|
+
The copy fallback (projects where `@voltro/cli` isn't resolvable) merged into an existing `agent-docs/` directory: when a module was renamed upstream (`plugins/versioning.md` → `plugins/row-history.md`), a `--force` re-seed brought the new file and left the old one sitting beside it — a stale generated doc teaching a package name that no longer exists, which is exactly the claim≠code drift the guide exists to prevent. The mirror is wholly framework-owned, so a re-seed now replaces it (the generator prunes its own output dir the same way).
|
|
149
|
+
- **@voltro/cli** — `voltro check`'s observed-diff footer now accounts for every declared procedure, and no result line states a verdict without the count it is a verdict about.
|
|
150
|
+
|
|
151
|
+
Two defects, both found by readers doing arithmetic on the output:
|
|
152
|
+
|
|
153
|
+
- The diff computes TWO kinds of blindness — a procedure that never ran, and one that ran with no table access recorded — and the printer named only the first. The printed counts came out short of the total, with no way to tell an unshown category from a defect in `check` itself. The buckets are now derived from the result type, so a third one cannot be added without the label map failing to compile, and a partition that does not close prints as such instead of quietly under-counting. - `no declared/observed mismatches` was a bare verdict sitting under its denominator, and was quotable — and quoted — without it, as a clean bill of health for a surface where almost nothing had been exercised. Every result line now carries its scope (`no declared/observed mismatch among the 12 that ran`), and at zero coverage the section reports the ABSENCE of a comparison (`nothing was compared — a declaration is only checked against a procedure that RAN`) rather than the absence of findings. The two are different facts and only one of them is evidence.
|
|
154
|
+
- **@voltro/cli** — An `isr` render no longer sees the requesting visitor's credentials — in `voltro dev` and `voltro start` alike.
|
|
155
|
+
|
|
156
|
+
An isr page's HTML is cached under tenant+locale and served to every visitor inside the revalidate window, but the render itself ran with the FULL request: loaders received the session cookie, and `ctx.query` was bound to it. A loader that read subject-scoped data on an isr page therefore cached the first visitor's data and served it to everyone — cache poisoning by construction.
|
|
157
|
+
|
|
158
|
+
The render boundary is fail-closed now: before an isr render runs, the cookie jar (except `voltro:locale`), the `authorization` header and every `x-voltro-*` header are stripped, for the loaders, `ctx.query` AND the `useServerRequest()` snapshot. What survives is exactly what the cache key and locale resolution read: `x-tenant`, `accept-language`, and the locale cookie — so a `de` visitor's fill still lands under the `de` key. One shared helper (`isrCredentialStrip.ts`), called by both boot paths, so dev renders isr anonymously exactly as production does — a page can no longer look personalised in dev and silently serve shared HTML in production.
|
|
159
|
+
|
|
160
|
+
Behavioural consequence, on purpose: a subject-reading loader on an isr page now gets the anonymous answer. A page whose loader needs the signed-in subject belongs on `renderMode: 'ssr'`.
|
|
161
|
+
- **@voltro/cli, @voltro/runtime** — **`voltro serve` registers plugin rpc routes again — every plugin-contributed procedure answered `Unknown request tag` in production while `voltro dev` registered all of them.** The serve path mirrored dev's plugin-route block by hand and mirrored exactly half of it: the collision check ran (so nothing warned) and the returned routes were discarded as a bare expression statement, never reaching the buckets the rpc registry is built from. Every plugin route was indistinguishable from a tag that never existed — `useUpload`'s storage tags, presence, every inspect-less plugin rpc — while the app's own procedures answered normally, so the registry looked alive.
|
|
162
|
+
|
|
163
|
+
Three changes, each aimed at the way this stayed invisible:
|
|
164
|
+
|
|
165
|
+
- Both boot paths now call ONE shared builder (`mergePluginRoutesInto`) whose buckets are required parameters — a returned list can be discarded by a statement that typechecks; a function you cannot call without handing it the sinks cannot have its effect dropped. A reachability test drives a real socket with a plugin tag and an invented-tag control, and a source pin keeps the helper pair from being reassembled by hand in either path. - The boot line `plugin routes registered` prints WHENEVER plugins are installed, count included — zero is a finding, and silence is how this shipped. `voltro check` against a running server now also diffs source-declared tags against the live registry (`declared vs live:`), and its offline manifest includes plugin routes, which it previously did not. - Every `Defect` frame the server sends is now also a server log line (`rpc defect sent to client`, ws and http rpc). The defect string used to exist only inside the WebSocket frame — visible in whoever's browser console, invisible to the operator whose server produced it.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
42
169
|
## [0.51.0] — 2026-08-24
|
|
43
170
|
|
|
44
171
|
### ⚠ BREAKING
|
package/dist/form.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Schema } from 'effect';
|
|
2
|
+
|
|
3
|
+
export declare interface FieldDescriptor {
|
|
4
|
+
/** Property name on the input object. */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** Human label — the Schema `title` annotation, else the humanised name. */
|
|
7
|
+
readonly label: string;
|
|
8
|
+
/** The default widget kind. `'custom'` ⇒ supply a render-prop. */
|
|
9
|
+
readonly widget: WidgetKind;
|
|
10
|
+
/** True when the property must be present (in JSON-Schema `required`). */
|
|
11
|
+
readonly required: boolean;
|
|
12
|
+
/** True when the value may be `null` (e.g. `Schema.NullOr`). */
|
|
13
|
+
readonly nullable: boolean;
|
|
14
|
+
/** Present for closed value sets (a `Schema.Literal` union → `select`). */
|
|
15
|
+
readonly options?: ReadonlyArray<FieldOption>;
|
|
16
|
+
/** The resolved JSON-Schema node — carries `maxLength` / `pattern` /
|
|
17
|
+
* `minimum` / `format` / `description` for widgets + validation hints. */
|
|
18
|
+
readonly jsonSchema: Readonly<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare interface FieldErrors {
|
|
22
|
+
readonly [field: string]: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export declare interface FieldOption {
|
|
26
|
+
readonly value: string;
|
|
27
|
+
readonly label: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** DOM id of the JSON script the 422 re-render embeds (survives the
|
|
31
|
+
* `interactive:'none'` strip — it is neither `type="module"` nor `src`). */
|
|
32
|
+
export declare const FORM_FLASH_SCRIPT_ID = "__voltro_form_flash__";
|
|
33
|
+
|
|
34
|
+
/** Hidden field carrying the form's instance key (multi-form pages). */
|
|
35
|
+
export declare const FORM_KEY_FIELD = "__voltro_form";
|
|
36
|
+
|
|
37
|
+
/** URL prefix the web listener mounts the no-JS endpoint under. */
|
|
38
|
+
export declare const FORM_PATH_PREFIX = "/form/";
|
|
39
|
+
|
|
40
|
+
/** Hidden field carrying the declared success-redirect path. */
|
|
41
|
+
export declare const FORM_REDIRECT_FIELD = "__voltro_redirect";
|
|
42
|
+
|
|
43
|
+
/** The path a native `<AutoForm>` POST goes to. */
|
|
44
|
+
export declare const formActionPath: (mutationTag: string) => string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert posted form entries into the input object the mutation's schema
|
|
48
|
+
* decodes — the shape `validateFields` (and the RPC payload) expects.
|
|
49
|
+
*
|
|
50
|
+
* Mapping rules (the documented contract):
|
|
51
|
+
* - **checkbox/switch**: present → `true`, absent → `false`. A native POST
|
|
52
|
+
* omits an unchecked checkbox entirely, so absence is data, not a gap.
|
|
53
|
+
* - **number**: `Number(raw)`; `''` → the field is omitted (`undefined`), so
|
|
54
|
+
* an optional number stays absent and a required one reports "missing"
|
|
55
|
+
* instead of silently becoming `0`.
|
|
56
|
+
* - **date/datetime**: the string passes through (the schema's encoded side);
|
|
57
|
+
* `''` → omitted, same reasoning as number.
|
|
58
|
+
* - **arrays** (multi-select): `getAll` semantics — every entry under the key,
|
|
59
|
+
* items converted per the array's item type (number items → `Number`).
|
|
60
|
+
* - **unknown keys are dropped**: only names the schema declares are mapped,
|
|
61
|
+
* so protocol fields (`__voltro_form`, …) never reach the input object —
|
|
62
|
+
* the server rejects undeclared fields, and a hidden bookkeeping field must
|
|
63
|
+
* not turn every no-JS submit into a validation error.
|
|
64
|
+
*/
|
|
65
|
+
export declare const formDataToInput: (schema: Schema.Schema.Any, entries: Iterable<FormEntry>) => Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
/** One posted entry. File entries must be filtered out by the caller —
|
|
68
|
+
* uploads are a declared limit of the no-JS path. */
|
|
69
|
+
export declare type FormEntry = readonly [name: string, value: string];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The error/values payload a failed no-JS form POST carries back into the
|
|
73
|
+
* 422 re-render. Keyed by `formKey` so a page with several forms re-fills
|
|
74
|
+
* only the one that was submitted. The SSR render passes it through the
|
|
75
|
+
* server-request context; the client reads the same payload back off the
|
|
76
|
+
* `#__voltro_form_flash__` JSON script, so a late-hydrating page shows the
|
|
77
|
+
* identical state (no mismatch, no vanished errors).
|
|
78
|
+
*/
|
|
79
|
+
export declare interface FormFlashPayload {
|
|
80
|
+
/** The submitted form's key — `<AutoForm formKey>` or its mutation tag. */
|
|
81
|
+
readonly formKey: string;
|
|
82
|
+
/** The mapped input values as posted (pre-decode), to re-fill the fields. */
|
|
83
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
84
|
+
/** First error per field — the `validateFields` shape. */
|
|
85
|
+
readonly errors: Readonly<Record<string, string>>;
|
|
86
|
+
/** A non-field error (the RPC refused after valid input: guard, server). */
|
|
87
|
+
readonly formError?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive table COLUMNS from a query's output `Schema` — an
|
|
92
|
+
* `Schema.Array(Schema.Struct({...}))`. Drills into the array's element and maps
|
|
93
|
+
* its properties (same descriptor shape as `schemaToFields`, reused for
|
|
94
|
+
* `<DataTable>` cells). Returns `[]` if the output isn't an array of objects.
|
|
95
|
+
*/
|
|
96
|
+
export declare const schemaToColumns: (schema: Schema.Schema.Any) => ReadonlyArray<FieldDescriptor>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Derive the ordered field list for a `Schema.Struct` input. Returns `[]` for a
|
|
100
|
+
* schema with no object properties (a scalar, `Schema.Void`, or anything
|
|
101
|
+
* `JSONSchema.make` cannot represent).
|
|
102
|
+
*/
|
|
103
|
+
export declare const schemaToFields: (schema: Schema.Schema.Any) => ReadonlyArray<FieldDescriptor>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate `values` against an input `schema`, returning the first error per
|
|
107
|
+
* top-level field (`{ [field]: message }`) — the shape `<AutoForm>` and custom
|
|
108
|
+
* widgets render inline. Uses the same `decodeUnknownEither` + `ArrayFormatter`
|
|
109
|
+
* path the store's `.validate(...)` uses, so client and server speak ONE
|
|
110
|
+
* schema. `{ errors: 'all' }` collects every field's error in one pass.
|
|
111
|
+
*
|
|
112
|
+
* Client-side validation is non-authoritative UX — the server re-validates the
|
|
113
|
+
* same schema on the mutation; this just gives instant inline feedback.
|
|
114
|
+
*/
|
|
115
|
+
export declare const validateFields: (schema: Schema.Schema.Any, values: unknown) => ValidationResult;
|
|
116
|
+
|
|
117
|
+
export declare interface ValidationResult {
|
|
118
|
+
readonly valid: boolean;
|
|
119
|
+
readonly errors: FieldErrors;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The default widget kinds the framework can render from a schema. `'custom'`
|
|
123
|
+
* means "no default widget" — the field needs a render-prop (a nested object,
|
|
124
|
+
* an array of objects, or a multi-branch union). Mirrors innovation/01's set. */
|
|
125
|
+
export declare type WidgetKind = 'text' | 'textarea' | 'number' | 'checkbox' | 'switch' | 'select' | 'radio' | 'async-select' | 'multi-select' | 'date' | 'datetime' | 'daterange' | 'file' | 'hidden' | 'custom';
|
|
126
|
+
|
|
127
|
+
export { }
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as e, c as t, i as n, l as r, n as i, o as a, r as o, s, t as c } from "./formData-Bw07C0wr.js";
|
|
2
|
+
export { c as FORM_FLASH_SCRIPT_ID, i as FORM_KEY_FIELD, o as FORM_PATH_PREFIX, n as FORM_REDIRECT_FIELD, e as formActionPath, a as formDataToInput, t as schemaToColumns, r as schemaToFields, s as validateFields };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { JSONSchema as e, Schema as t } from "effect";
|
|
2
|
+
import { ArrayFormatter as n } from "effect/ParseResult";
|
|
3
|
+
//#region src/schemaFields.ts
|
|
4
|
+
var r = "#/$defs/", i = (e) => e.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim().replace(/^./, (e) => e.toUpperCase()), a = (e, t) => {
|
|
5
|
+
let n = e;
|
|
6
|
+
for (let e = 0; e < 8; e += 1) {
|
|
7
|
+
let e = n.$ref;
|
|
8
|
+
if (typeof e != "string" || !e.startsWith(r)) return n;
|
|
9
|
+
let i = t[e.slice(8)];
|
|
10
|
+
if (!i || typeof i != "object") return n;
|
|
11
|
+
n = i;
|
|
12
|
+
}
|
|
13
|
+
return n;
|
|
14
|
+
}, o = (e) => {
|
|
15
|
+
if (!e || typeof e != "object") return !1;
|
|
16
|
+
let t = e;
|
|
17
|
+
if (t.type === "null" || t.const === null) return !0;
|
|
18
|
+
let n = t.enum;
|
|
19
|
+
return Array.isArray(n) && n.length === 1 && n[0] === null;
|
|
20
|
+
}, s = (e, t) => {
|
|
21
|
+
let n = e.type;
|
|
22
|
+
if (Array.isArray(n) && n.includes("null")) {
|
|
23
|
+
let t = n.filter((e) => e !== "null");
|
|
24
|
+
return {
|
|
25
|
+
node: {
|
|
26
|
+
...e,
|
|
27
|
+
type: t.length === 1 ? t[0] : t
|
|
28
|
+
},
|
|
29
|
+
nullable: !0
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
for (let n of ["anyOf", "oneOf"]) {
|
|
33
|
+
let r = e[n];
|
|
34
|
+
if (!Array.isArray(r)) continue;
|
|
35
|
+
let i = r.filter((e) => !o(e));
|
|
36
|
+
if (i.length !== r.length) return i.length === 1 ? {
|
|
37
|
+
node: a(i[0], t),
|
|
38
|
+
nullable: !0
|
|
39
|
+
} : {
|
|
40
|
+
node: {
|
|
41
|
+
...e,
|
|
42
|
+
[n]: i
|
|
43
|
+
},
|
|
44
|
+
nullable: !0
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
node: e,
|
|
49
|
+
nullable: !1
|
|
50
|
+
};
|
|
51
|
+
}, c = (e) => e.map((e) => ({
|
|
52
|
+
value: String(e),
|
|
53
|
+
label: i(String(e))
|
|
54
|
+
})), l = (e) => {
|
|
55
|
+
let t = e.enum;
|
|
56
|
+
if (Array.isArray(t) && t.length > 0) return {
|
|
57
|
+
widget: "select",
|
|
58
|
+
options: c(t)
|
|
59
|
+
};
|
|
60
|
+
let n = e.type, r = Array.isArray(n) ? n.find((e) => e !== "null") : n, i = e.format;
|
|
61
|
+
switch (r) {
|
|
62
|
+
case "string": return i === "date" ? { widget: "date" } : i === "date-time" ? { widget: "datetime" } : { widget: "text" };
|
|
63
|
+
case "integer":
|
|
64
|
+
case "number": return { widget: "number" };
|
|
65
|
+
case "boolean": return { widget: "checkbox" };
|
|
66
|
+
case "array": return { widget: "multi-select" };
|
|
67
|
+
default: return { widget: "custom" };
|
|
68
|
+
}
|
|
69
|
+
}, u = (e, t, n) => Object.entries(e).map(([e, r]) => {
|
|
70
|
+
let { node: o, nullable: c } = s(a(r, n), n), u = a(o, n), { widget: d, options: f } = l(u);
|
|
71
|
+
return {
|
|
72
|
+
name: e,
|
|
73
|
+
label: i(e),
|
|
74
|
+
widget: d,
|
|
75
|
+
required: t.has(e),
|
|
76
|
+
nullable: c,
|
|
77
|
+
...f ? { options: f } : {},
|
|
78
|
+
jsonSchema: u
|
|
79
|
+
};
|
|
80
|
+
}), d = (t) => {
|
|
81
|
+
try {
|
|
82
|
+
return e.make(t);
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}, f = (e) => {
|
|
87
|
+
let t = d(e);
|
|
88
|
+
if (t === void 0) return [];
|
|
89
|
+
let n = t.$defs ?? {}, r = a(t, n), i = r.properties;
|
|
90
|
+
return i ? u(i, new Set(Array.isArray(r.required) ? r.required : []), n) : [];
|
|
91
|
+
}, p = (e) => {
|
|
92
|
+
let t = d(e);
|
|
93
|
+
if (t === void 0) return [];
|
|
94
|
+
let n = t.$defs ?? {}, r = a(t, n);
|
|
95
|
+
if (r.type !== "array") return [];
|
|
96
|
+
let i = r.items;
|
|
97
|
+
if (!i || typeof i != "object") return [];
|
|
98
|
+
let o = a(i, n), s = o.properties;
|
|
99
|
+
return s ? u(s, new Set(Array.isArray(o.required) ? o.required : []), n) : [];
|
|
100
|
+
}, m = (e, r) => {
|
|
101
|
+
let i = t.decodeUnknownEither(e, { errors: "all" })(r);
|
|
102
|
+
if (i._tag === "Right") return {
|
|
103
|
+
valid: !0,
|
|
104
|
+
errors: {}
|
|
105
|
+
};
|
|
106
|
+
let a = {};
|
|
107
|
+
for (let e of n.formatErrorSync(i.left)) {
|
|
108
|
+
let t = e.path.length === 0 ? "" : String(e.path[0]);
|
|
109
|
+
t !== "" && !(t in a) && (a[t] = e.message);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
valid: !1,
|
|
113
|
+
errors: a
|
|
114
|
+
};
|
|
115
|
+
}, h = "__voltro_form_flash__", g = "/form/", _ = (e) => `${g}${encodeURIComponent(e)}`, v = "__voltro_form", y = "__voltro_redirect", b = (e) => {
|
|
116
|
+
let t = Number(e);
|
|
117
|
+
return Number.isNaN(t) ? e : t;
|
|
118
|
+
}, x = (e) => {
|
|
119
|
+
let t = e.jsonSchema.items;
|
|
120
|
+
if (!t || typeof t != "object") return;
|
|
121
|
+
let n = t.type;
|
|
122
|
+
return typeof n == "string" ? n : void 0;
|
|
123
|
+
}, S = (e, t) => {
|
|
124
|
+
switch (e.widget) {
|
|
125
|
+
case "number": return t === "" ? void 0 : b(t);
|
|
126
|
+
case "date":
|
|
127
|
+
case "datetime": return t === "" ? void 0 : t;
|
|
128
|
+
default: return t;
|
|
129
|
+
}
|
|
130
|
+
}, C = (e, t) => {
|
|
131
|
+
let n = /* @__PURE__ */ new Map();
|
|
132
|
+
for (let [e, r] of t) {
|
|
133
|
+
let t = n.get(e);
|
|
134
|
+
t === void 0 ? n.set(e, [r]) : t.push(r);
|
|
135
|
+
}
|
|
136
|
+
let r = {};
|
|
137
|
+
for (let t of f(e)) {
|
|
138
|
+
let e = n.get(t.name);
|
|
139
|
+
if (t.widget === "checkbox" || t.widget === "switch") {
|
|
140
|
+
r[t.name] = e !== void 0;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (t.widget === "multi-select") {
|
|
144
|
+
let n = x(t), i = e ?? [];
|
|
145
|
+
r[t.name] = n === "number" || n === "integer" ? i.filter((e) => e !== "").map(b) : i.filter((e) => e !== "");
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (e === void 0) continue;
|
|
149
|
+
let i = S(t, e[0]);
|
|
150
|
+
i !== void 0 && (r[t.name] = i);
|
|
151
|
+
}
|
|
152
|
+
return r;
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
export { _ as a, p as c, y as i, f as l, v as n, C as o, g as r, m as s, h as t };
|
package/dist/index.d.ts
CHANGED
|
@@ -961,6 +961,22 @@ export declare type FilterKind = 'text' | 'select' | 'multi-select' | 'number-ra
|
|
|
961
961
|
* input field → one FilterDescriptor. Pure. */
|
|
962
962
|
export declare const filtersFromSchema: (schema: Schema.Schema.Any) => ReadonlyArray<FilterDescriptor>;
|
|
963
963
|
|
|
964
|
+
/** DOM id of the JSON script the 422 re-render embeds (survives the
|
|
965
|
+
* `interactive:'none'` strip — it is neither `type="module"` nor `src`). */
|
|
966
|
+
export declare const FORM_FLASH_SCRIPT_ID = "__voltro_form_flash__";
|
|
967
|
+
|
|
968
|
+
/** Hidden field carrying the form's instance key (multi-form pages). */
|
|
969
|
+
export declare const FORM_KEY_FIELD = "__voltro_form";
|
|
970
|
+
|
|
971
|
+
/** URL prefix the web listener mounts the no-JS endpoint under. */
|
|
972
|
+
export declare const FORM_PATH_PREFIX = "/form/";
|
|
973
|
+
|
|
974
|
+
/** Hidden field carrying the declared success-redirect path. */
|
|
975
|
+
export declare const FORM_REDIRECT_FIELD = "__voltro_redirect";
|
|
976
|
+
|
|
977
|
+
/** The path a native `<AutoForm>` POST goes to. */
|
|
978
|
+
export declare const formActionPath: (mutationTag: string) => string;
|
|
979
|
+
|
|
964
980
|
export declare interface FormBinding<Input, Output> {
|
|
965
981
|
/** Ordered, render-agnostic field list derived from the input schema. */
|
|
966
982
|
readonly fields: ReadonlyArray<FieldDescriptor>;
|
|
@@ -972,6 +988,9 @@ export declare interface FormBinding<Input, Output> {
|
|
|
972
988
|
readonly pending: boolean;
|
|
973
989
|
/** The mutation's typed failure, if the last submit threw. */
|
|
974
990
|
readonly submitError: unknown | undefined;
|
|
991
|
+
/** A form-level (non-field) error carried in from a failed no-JS POST —
|
|
992
|
+
* the RPC refused after valid input. Cleared by the next submit/reset. */
|
|
993
|
+
readonly formError: string | undefined;
|
|
975
994
|
readonly data: Output | undefined;
|
|
976
995
|
readonly setValue: (name: string, value: unknown) => void;
|
|
977
996
|
readonly setValues: (patch: Partial<Input>) => void;
|
|
@@ -981,6 +1000,50 @@ export declare interface FormBinding<Input, Output> {
|
|
|
981
1000
|
readonly submit: () => Promise<Output | undefined>;
|
|
982
1001
|
}
|
|
983
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* Convert posted form entries into the input object the mutation's schema
|
|
1005
|
+
* decodes — the shape `validateFields` (and the RPC payload) expects.
|
|
1006
|
+
*
|
|
1007
|
+
* Mapping rules (the documented contract):
|
|
1008
|
+
* - **checkbox/switch**: present → `true`, absent → `false`. A native POST
|
|
1009
|
+
* omits an unchecked checkbox entirely, so absence is data, not a gap.
|
|
1010
|
+
* - **number**: `Number(raw)`; `''` → the field is omitted (`undefined`), so
|
|
1011
|
+
* an optional number stays absent and a required one reports "missing"
|
|
1012
|
+
* instead of silently becoming `0`.
|
|
1013
|
+
* - **date/datetime**: the string passes through (the schema's encoded side);
|
|
1014
|
+
* `''` → omitted, same reasoning as number.
|
|
1015
|
+
* - **arrays** (multi-select): `getAll` semantics — every entry under the key,
|
|
1016
|
+
* items converted per the array's item type (number items → `Number`).
|
|
1017
|
+
* - **unknown keys are dropped**: only names the schema declares are mapped,
|
|
1018
|
+
* so protocol fields (`__voltro_form`, …) never reach the input object —
|
|
1019
|
+
* the server rejects undeclared fields, and a hidden bookkeeping field must
|
|
1020
|
+
* not turn every no-JS submit into a validation error.
|
|
1021
|
+
*/
|
|
1022
|
+
export declare const formDataToInput: (schema: Schema.Schema.Any, entries: Iterable<FormEntry>) => Record<string, unknown>;
|
|
1023
|
+
|
|
1024
|
+
/** One posted entry. File entries must be filtered out by the caller —
|
|
1025
|
+
* uploads are a declared limit of the no-JS path. */
|
|
1026
|
+
export declare type FormEntry = readonly [name: string, value: string];
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* The error/values payload a failed no-JS form POST carries back into the
|
|
1030
|
+
* 422 re-render. Keyed by `formKey` so a page with several forms re-fills
|
|
1031
|
+
* only the one that was submitted. The SSR render passes it through the
|
|
1032
|
+
* server-request context; the client reads the same payload back off the
|
|
1033
|
+
* `#__voltro_form_flash__` JSON script, so a late-hydrating page shows the
|
|
1034
|
+
* identical state (no mismatch, no vanished errors).
|
|
1035
|
+
*/
|
|
1036
|
+
export declare interface FormFlashPayload {
|
|
1037
|
+
/** The submitted form's key — `<AutoForm formKey>` or its mutation tag. */
|
|
1038
|
+
readonly formKey: string;
|
|
1039
|
+
/** The mapped input values as posted (pre-decode), to re-fill the fields. */
|
|
1040
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
1041
|
+
/** First error per field — the `validateFields` shape. */
|
|
1042
|
+
readonly errors: Readonly<Record<string, string>>;
|
|
1043
|
+
/** A non-field error (the RPC refused after valid input: guard, server). */
|
|
1044
|
+
readonly formError?: string;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
984
1047
|
export declare interface FrameworkRuntimes {
|
|
985
1048
|
/** Look up an api handle (runtime + cache) by name. Throws if no such api was mounted. */
|
|
986
1049
|
readonly get: (name: string) => ApiHandle;
|
|
@@ -1010,6 +1073,32 @@ export declare const getMutationNotifier: () => MutationNotifier | undefined;
|
|
|
1010
1073
|
/** Read the current feed. Newest-first; up to MAX_MUTATIONS entries. */
|
|
1011
1074
|
export declare const getMutations: () => ReadonlyArray<MutationEvent>;
|
|
1012
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* A process-wide singleton React context, pinned on the global symbol registry.
|
|
1078
|
+
*
|
|
1079
|
+
* WHY this exists: the SSG prerender (`voltro build`) loads the framework
|
|
1080
|
+
* through TWO separate module instances — the renderer comes in via Vite's
|
|
1081
|
+
* `ssrLoadModule('@voltro/web/ssr')` (Vite-transformed) while a page's own
|
|
1082
|
+
* framework import is externalised to Node — so a plain `createContext()`
|
|
1083
|
+
* in a shared module is evaluated twice and yields two DISTINCT context
|
|
1084
|
+
* objects. The `<Router>` provider then holds one while the page's
|
|
1085
|
+
* `useLocation()` reads the other, which throws "Router hooks must be used
|
|
1086
|
+
* inside <Router>." The same duplicate-instance hazard shows up wherever a
|
|
1087
|
+
* deployment ends up with two copies of a package (the classic dual-package
|
|
1088
|
+
* hazard, monorepo hoisting quirks, separate SSR vs client bundles).
|
|
1089
|
+
*
|
|
1090
|
+
* Resolving every context through `Symbol.for(...)` on `globalThis` makes all
|
|
1091
|
+
* module copies share ONE instance, so provider and consumer can never diverge.
|
|
1092
|
+
* A duplicated copy of THIS helper is harmless — both copies hit the same
|
|
1093
|
+
* global registry entry.
|
|
1094
|
+
*
|
|
1095
|
+
* Lives in @voltro/client (the lowest React-carrying package) so both
|
|
1096
|
+
* @voltro/web and @voltro/ui reach it without a cycle; the key prefix keeps
|
|
1097
|
+
* its historical spelling on purpose — a mixed dist/src world must resolve
|
|
1098
|
+
* to the same registry entries.
|
|
1099
|
+
*/
|
|
1100
|
+
export declare const globalContext: <T>(key: string, initial: T) => Context<T>;
|
|
1101
|
+
|
|
1013
1102
|
/** Is this the framework's `Unauthenticated`? Matched on `_tag`, the wire
|
|
1014
1103
|
* contract, rather than on an instance — the error crosses a package boundary
|
|
1015
1104
|
* and may be re-created by the decoder. */
|
|
@@ -1832,6 +1921,29 @@ export declare type SequenceResult<Ctx> = {
|
|
|
1832
1921
|
}>;
|
|
1833
1922
|
};
|
|
1834
1923
|
|
|
1924
|
+
export declare const ServerRequestContext: Context<ServerRequestContextValue | null>;
|
|
1925
|
+
|
|
1926
|
+
export declare interface ServerRequestContextValue {
|
|
1927
|
+
readonly cookies: Readonly<Record<string, string>>;
|
|
1928
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
1929
|
+
/** Raw request URL as it came off the wire (path + query). Useful
|
|
1930
|
+
* for SSR pages that need to read `?q=…` style search params
|
|
1931
|
+
* without touching anything client-only. Empty string for build-
|
|
1932
|
+
* time SSG renders where there is no incoming request. */
|
|
1933
|
+
readonly url: string;
|
|
1934
|
+
/** Present ONLY on the 422 re-render of a failed no-JS form POST:
|
|
1935
|
+
* the submitted values + field errors, keyed by formKey. Travels on
|
|
1936
|
+
* the EXISTING request context deliberately — a dedicated provider
|
|
1937
|
+
* would add a fiber fork the client boot does not have, and every
|
|
1938
|
+
* ancestor arity difference shifts every useId in the app. */
|
|
1939
|
+
readonly formFlash?: FormFlashPayload;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
export declare const ServerRequestProvider: ({ value, children, }: {
|
|
1943
|
+
readonly value: ServerRequestContextValue;
|
|
1944
|
+
readonly children: ReactNode;
|
|
1945
|
+
}) => ReactNode;
|
|
1946
|
+
|
|
1835
1947
|
/** Register (or clear) the app-wide notifier that `notify:` routes to. Call once
|
|
1836
1948
|
* at boot, next to your toast provider. */
|
|
1837
1949
|
export declare const setMutationNotifier: (notifier: MutationNotifier | undefined) => void;
|
|
@@ -3048,8 +3160,27 @@ export declare interface UseFormBindingOptions<Input> {
|
|
|
3048
3160
|
readonly schema?: Schema.Schema.Any;
|
|
3049
3161
|
/** Initial field values. */
|
|
3050
3162
|
readonly defaults?: Partial<Input>;
|
|
3163
|
+
/** The failed no-JS POST's payload for THIS form (`useFormFlash` from
|
|
3164
|
+
* @voltro/web resolves it, SSR and client alike). When present it seeds
|
|
3165
|
+
* the initial values + field errors, so the 422 re-render shows the
|
|
3166
|
+
* submitted state server-side and hydrates to the identical state. */
|
|
3167
|
+
readonly flash?: FormFlashPayload | undefined;
|
|
3051
3168
|
}
|
|
3052
3169
|
|
|
3170
|
+
/**
|
|
3171
|
+
* The failed-no-JS-POST payload for ONE form, or `undefined`.
|
|
3172
|
+
*
|
|
3173
|
+
* On the server (the 422 re-render) it comes off the request context; on the
|
|
3174
|
+
* client it is read back from the `#__voltro_form_flash__` JSON script the
|
|
3175
|
+
* same response embedded. Both carry the SAME payload, so a page that loads
|
|
3176
|
+
* its bundle after a native submit hydrates to exactly the server-rendered
|
|
3177
|
+
* state — errors visible, values filled, no mismatch.
|
|
3178
|
+
*
|
|
3179
|
+
* Keyed: only the form whose `formKey` was submitted receives the payload —
|
|
3180
|
+
* two `<AutoForm>`s on one page re-fill only the one that POSTed.
|
|
3181
|
+
*/
|
|
3182
|
+
export declare const useFormFlash: (formKey: string) => FormFlashPayload | undefined;
|
|
3183
|
+
|
|
3053
3184
|
/** The field shape a `<AutoForm mutation=…>` will render — from the mutation's
|
|
3054
3185
|
* input Schema. Use it to render a matching skeleton while anything the form
|
|
3055
3186
|
* depends on is still loading. */
|
|
@@ -3198,6 +3329,8 @@ export declare interface UseSequenceResult {
|
|
|
3198
3329
|
readonly failedStep: string | undefined;
|
|
3199
3330
|
}
|
|
3200
3331
|
|
|
3332
|
+
export declare const useServerRequest: () => ServerRequestContextValue | null;
|
|
3333
|
+
|
|
3201
3334
|
export declare function useSubscription<T = unknown>(apiName: string, rpcTag: string, input: Readonly<Record<string, unknown>>, options: SubscriptionOptions<T> & {
|
|
3202
3335
|
readonly initialSnapshot: T;
|
|
3203
3336
|
}): SubscriptionStateWithFallback<T>;
|