Summary
The only way to get a value from <script server> into <script client> is the implicit data bridge, which publishes a server binding when its name appears as a free identifier in the client source. There is no declaration, no schema, and no type. A page cannot state what it expects, so client code has to defend against the bridge not having emitted a name, and the payload's shape is verified by nothing.
stx already has the typed alternative in the tree — serializeStores() / generateHydrationScript() emit window.__STX_STORE_STATE__ and the runtime defineStore reads it — but nothing calls either function, so server-side store hydration is dead code.
Impact (real app)
In analyticshq's dashboard (a 25-query SSR page), every crossing is defensive or dead:
resources/views/dashboard.stx:788 — a runtime typeof guard, because nothing guarantees the bridge emitted the name:
const liveCount = state(typeof liveNow === 'number' ? liveNow : 0)resources/views/dashboard.stx:720-721 — a server const that exists only to be scraped, which the linter correctly flags as unused:
// eslint-disable-next-line no-unused-vars
const activeSiteId = String(siteId)resources/views/dashboard.stx:817-820 — the rule written out in prose because there is nowhere else to put it:
rangeis a server const reaching this block through stx's server->client data bridge, which inlines any server name that appears as a word in the client source and is not re-declared here (client-script.js:458-478).
types/dashboard.d.ts:9-13 and types/session.d.ts:11-15 both concede that none of it is checked:
tsc cannot see inside .stx, so an annotation written in a
<script server>block is erased by the transpiler and verified by nothing.
The drift is already visible: the comment at dashboard.stx:704-706 still describes a data-export="" attribute, and grep -n 'data-export' resources/views/dashboard.stx matches only that comment — the 25-section export payload now rides the implicit bridge instead (const data = exportPayload at :956), inlined into the HTML on every request with no declared size bound.
Evidence in stx
packages/stx/src/client-script.ts:1019-1073 — the whole contract is name matching:
export function generateServerDataBridge(code: string, serverData?: Record<string, unknown>): string {
const searchable = stripCommentsAndLiterals(code)
for (const [name, value] of Object.entries(serverData)) {
if (!referencesIdentifier(searchable, name)) continue // ← the only selection rule
if (declaresClientIdentifier(code, name)) continue
…
lines.push(` var ${name} = ${json.replace(/</g, '\\u003c')};`)
}
}referencesIdentifier (:962-964) is new RegExp('(?<![.#\\w$])' + name + '(?![\\w$])'). Nothing types the emitted var.
The framework's own guardrails confirm the payload is unbounded and unaudited — client-script.ts:986 and :1000-1016:
const CREDENTIAL_NAME = /token|secret|passw|apikey|…/i
…
const oversized = json.length > 32_768analyticshq trips the credential warning on every render, because shareToken (dashboard.stx:215) matches /token/.
The typed path exists and is unwired — packages/stx/src/state-management.ts:758 and :790:
export function serializeStores(): string { … }
export function generateHydrationScript(): string {
return `<script>window.__STX_STORE_STATE__=${serializeStores()};…</script>`
}packages/stx/src/signals.ts:5881 reads it:
var hydrationData = window.__STX_STORE_STATE__ && window.__STX_STORE_STATE__[id];but grep -rn 'serializeStores\|generateHydrationScript' packages/stx/src returns only the two definitions above (plus an unrelated same-named function in hydration.ts:967). No render path calls either, so window.__STX_STORE_STATE__ is never emitted and the hydration branch is unreachable.
Prior art
- Remix:
useLoaderData<typeof loader>()— the loader's return type flows to the component. - Next.js:
getServerSideProps→ typedprops. - Nuxt:
useState(key, () => serverValue)+ payload serialization;useAsyncDatapayload. - Inertia: page props +
defineProps<PageProps>().
Ask
- Add an explicit declaration in the server block, e.g.
defineClientPayload({ liveNow, range, exportPayload }), and havegenerateServerDataBridgepublish only the declared set — so a name is either declared or absent, never "present if you happened to mention it". - Emit a matching ambient
.d.tsfor the declared payload (or a per-page generated type) so a client block gets a checkedliveNow: numberinstead of atypeofguard. - Wire
generateHydrationScript()into the render path (or delete it) — right now the runtime reads a global that nothing writes. - Warn on the implicit path once a declaration exists, so apps can migrate off name-scraping.
Related: #1831 (the bridge publishing names found in comments — the hardening that made the current scraping rule), #1832.