@statorjs/stator 2.7.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.7.0"
98
+ "@statorjs/stator": "2.9.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,175 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import type { BundleIslandsOptions, IslandAsset, IslandBundler } from './islands.ts'
5
+
6
+ /**
7
+ * Implementation #2 of the island-bundling seam (spike for Option E of the
8
+ * Vite exit): one esbuild build over every island entry, `splitting: true` so
9
+ * shared modules — the client runtime above all — land in shared chunks
10
+ * instead of duplicating per island.
11
+ *
12
+ * Same contract as the Vite implementation in `islands.ts`: entries are
13
+ * `.stator` sources (compiled to their client module here) or prebuilt client
14
+ * entry modules; server-machine imports collapse to `{ name }` stubs; nothing
15
+ * is written — callers own the assets.
16
+ */
17
+
18
+ const norm = (p: string): string => p.replace(/\\/g, '/')
19
+
20
+ const STUB_NS = 'stator-machine-stub'
21
+
22
+ /** Extensions an island may reference by URL; emitted as hashed files (the
23
+ * seam contract — never inlined), mirroring Vite's `assetsInlineLimit: 0`. */
24
+ const FILE_LOADERS: Record<string, 'file'> = Object.fromEntries(
25
+ ['.wasm', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.woff', '.woff2'].map((e) => [
26
+ e,
27
+ 'file' as const,
28
+ ]),
29
+ )
30
+
31
+ export const bundleIslandsEsbuild: IslandBundler = async (opts: BundleIslandsOptions) => {
32
+ const [{ build }, { compile, regionResolverFor }] = await Promise.all([
33
+ import('esbuild'),
34
+ import('../compiler/index.ts'),
35
+ ])
36
+ const publicPath = opts.publicPath ?? '/static/assets/'
37
+ const root = resolve(opts.root)
38
+ const machinesDir = resolve(opts.machinesDir)
39
+ const outDir = resolve(root, '.stator-island-out') // virtual — write: false
40
+ const inMachinesDir = (abs: string): boolean =>
41
+ abs === machinesDir || abs.startsWith(machinesDir + sep)
42
+
43
+ const entryPoints = opts.entries.map((entry) => ({
44
+ in: resolve(entry.file),
45
+ out: entry.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_'),
46
+ }))
47
+ const byFile = new Map(opts.entries.map((e) => [norm(resolve(e.file)), e]))
48
+
49
+ // Rollup/Vite rewrite `new URL('./x', import.meta.url)` to an emitted asset;
50
+ // esbuild does not, so the seam does it here: each URL-referenced relative
51
+ // asset becomes an import (the file loader emits it hashed and exports its
52
+ // public URL, root-absolute under `publicPath`, so the `new URL` still
53
+ // resolves). Only string-literal relative specifiers — same limit as Vite.
54
+ const URL_ASSET_RE = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"\n]+)\1\s*,\s*import\.meta\.url\s*\)/g
55
+ const rewriteUrlAssets = (code: string): string => {
56
+ let n = 0
57
+ const imports: string[] = []
58
+ const out = code.replace(URL_ASSET_RE, (_m, _q: string, spec: string) => {
59
+ const id = `__stator_url_asset_${n++}`
60
+ imports.push(`import ${id} from ${JSON.stringify(spec)}`)
61
+ return `new URL(${id}, import.meta.url)`
62
+ })
63
+ return imports.length === 0 ? code : `${imports.join('\n')}\n${out}`
64
+ }
65
+
66
+ const statorPlugin = {
67
+ name: 'stator-client',
68
+ setup(b: import('esbuild').PluginBuild) {
69
+ b.onLoad({ filter: /\.stator$/ }, async (args) => {
70
+ const source = await readFile(args.path, 'utf8')
71
+ const kind = /[\\/]routes[\\/].*\.stator$/.test(args.path) ? 'route' : 'component'
72
+ const result = compile(source, {
73
+ id: args.path,
74
+ kind,
75
+ resolveRegions: regionResolverFor(args.path, source),
76
+ })
77
+ return {
78
+ contents: result.isClient ? rewriteUrlAssets(result.clientCode) : 'export {}',
79
+ loader: 'ts',
80
+ resolveDir: dirname(args.path),
81
+ }
82
+ })
83
+ b.onLoad({ filter: /\.(ts|mts|js|mjs)$/ }, async (args) => {
84
+ const source = await readFile(args.path, 'utf8')
85
+ const rewritten = rewriteUrlAssets(source)
86
+ if (rewritten === source) return undefined
87
+ return {
88
+ contents: rewritten,
89
+ loader: args.path.endsWith('js') ? 'js' : 'ts',
90
+ resolveDir: dirname(args.path),
91
+ }
92
+ })
93
+ },
94
+ }
95
+
96
+ const machineStubPlugin = {
97
+ name: 'stator-machine-stub',
98
+ setup(b: import('esbuild').PluginBuild) {
99
+ b.onResolve({ filter: /^[./]/ }, (args) => {
100
+ if (args.namespace === STUB_NS) return null
101
+ const base = isAbsolute(args.path) ? args.path : resolve(args.resolveDir, args.path)
102
+ const abs = [base, `${base}.ts`, `${base}.js`].find(inMachinesDir)
103
+ if (!abs) return null
104
+ return { path: abs, namespace: STUB_NS }
105
+ })
106
+ b.onLoad({ filter: /.*/, namespace: STUB_NS }, async (args) => {
107
+ let name: unknown
108
+ try {
109
+ const mod = (await import(pathToFileURL(args.path).href)) as {
110
+ default?: { name?: unknown }
111
+ }
112
+ name = mod.default?.name
113
+ } catch (err) {
114
+ throw new Error(
115
+ `stator: cannot stub server-machine import "${args.path}" for the client bundle — ` +
116
+ `importing it in Node failed: ${(err as Error).message}`,
117
+ )
118
+ }
119
+ if (typeof name !== 'string') {
120
+ throw new Error(
121
+ `stator: cannot stub "${args.path}" — it does not default-export a machine with a name. ` +
122
+ `Only machine defs may be imported from the machines directory in client code.`,
123
+ )
124
+ }
125
+ return { contents: `export default { name: ${JSON.stringify(name)} }\n`, loader: 'js' }
126
+ })
127
+ },
128
+ }
129
+
130
+ const result = await build({
131
+ absWorkingDir: root,
132
+ entryPoints,
133
+ outdir: outDir,
134
+ bundle: true,
135
+ splitting: true,
136
+ format: 'esm',
137
+ write: false,
138
+ metafile: true,
139
+ minify: true,
140
+ publicPath,
141
+ sourcemap: opts.sourcemap ? 'inline' : false,
142
+ entryNames: '[name]-[hash]',
143
+ chunkNames: 'chunks/[name]-[hash]',
144
+ assetNames: '[name]-[hash]',
145
+ loader: FILE_LOADERS,
146
+ logLevel: 'warning',
147
+ plugins: [machineStubPlugin, statorPlugin],
148
+ })
149
+
150
+ const assets: IslandAsset[] = result.outputFiles.map((f) => {
151
+ const fileName = norm(relative(outDir, f.path))
152
+ return fileName.endsWith('.js')
153
+ ? { fileName, source: f.text }
154
+ : { fileName, source: f.contents }
155
+ })
156
+
157
+ const modules = new Set<string>()
158
+ for (const key of Object.keys(result.metafile.inputs)) {
159
+ if (key.startsWith(`${STUB_NS}:`)) modules.add(norm(key.slice(STUB_NS.length + 1)))
160
+ else modules.add(norm(resolve(root, key)))
161
+ }
162
+
163
+ const islandUrls: Record<string, string> = {}
164
+ for (const [outPath, meta] of Object.entries(result.metafile.outputs)) {
165
+ if (!meta.entryPoint) continue
166
+ const entry = byFile.get(norm(resolve(root, meta.entryPoint)))
167
+ if (entry) islandUrls[entry.rel] = publicPath + norm(relative(outDir, resolve(root, outPath)))
168
+ }
169
+ for (const entry of opts.entries) {
170
+ if (!islandUrls[entry.rel])
171
+ throw new Error(`stator: island "${entry.rel}" missing from the bundle output`)
172
+ }
173
+
174
+ return { islandUrls, assets, modules: [...modules] }
175
+ }
@@ -0,0 +1,88 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Rollup } from 'vite'
3
+ import type { BundleIslandsOptions, IslandAsset, IslandBundler } from './islands.ts'
4
+
5
+ const norm = (p: string): string => p.replace(/\\/g, '/')
6
+ const stripQuery = (id: string): string => {
7
+ const q = id.indexOf('?')
8
+ return q === -1 ? id : id.slice(0, q)
9
+ }
10
+
11
+ /** Implementation #1: one Vite build over every island entry. Vite is imported
12
+ * lazily — server-only apps never load it. */
13
+ export const bundleIslandsVite: IslandBundler = async (opts: BundleIslandsOptions) => {
14
+ const [{ build: viteBuild }, { CLIENT_QUERY, MACHINE_STUB_PREFIX, machineStub, stator }] =
15
+ await Promise.all([import('vite'), import('../vite/index.ts')])
16
+ const publicPath = opts.publicPath ?? '/static/assets/'
17
+
18
+ const input: Record<string, string> = {}
19
+ const byFile = new Map<string, BundleIslandsOptions['entries'][number]>()
20
+ for (const entry of opts.entries) {
21
+ const file = resolve(entry.file)
22
+ input[entry.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_')] = file.endsWith('.stator')
23
+ ? `${file}?${CLIENT_QUERY}`
24
+ : file
25
+ byFile.set(norm(file), entry)
26
+ }
27
+
28
+ const result = await viteBuild({
29
+ root: opts.root,
30
+ // Assets referenced by URL from island code (`new URL(...)`, CSS `url()`)
31
+ // render against `base`, so it must be where the assets are served from.
32
+ base: publicPath,
33
+ logLevel: 'warn',
34
+ configFile: false,
35
+ plugins: [stator(), machineStub({ machinesDir: resolve(opts.machinesDir) })],
36
+ build: {
37
+ write: false,
38
+ // Seam contract: a URL-referenced asset (`new URL('./x.wasm',
39
+ // import.meta.url)`) is always emitted as a hashed FILE, never inlined
40
+ // as a data: URL — deterministic for callers and identical to what the
41
+ // esbuild implementation (file loader) produces.
42
+ assetsInlineLimit: 0,
43
+ sourcemap: opts.sourcemap ? 'inline' : false,
44
+ rollupOptions: {
45
+ input,
46
+ output: {
47
+ entryFileNames: '[name]-[hash].js',
48
+ chunkFileNames: 'chunks/[name]-[hash].js',
49
+ assetFileNames: '[name]-[hash][extname]',
50
+ },
51
+ },
52
+ },
53
+ })
54
+ const outputs = (Array.isArray(result) ? result : [result]).filter(
55
+ (r): r is Rollup.RollupOutput => 'output' in r,
56
+ )
57
+
58
+ const islandUrls: Record<string, string> = {}
59
+ const assets: IslandAsset[] = []
60
+ const modules = new Set<string>()
61
+ const sourcePathOf = (id: string): string | null => {
62
+ const raw = id.startsWith(MACHINE_STUB_PREFIX) ? id.slice(MACHINE_STUB_PREFIX.length) : id
63
+ if (raw.startsWith('\0')) return null
64
+ return norm(stripQuery(raw))
65
+ }
66
+ for (const out of outputs) {
67
+ for (const item of out.output) {
68
+ if (item.type === 'chunk') {
69
+ assets.push({ fileName: item.fileName, source: item.code })
70
+ for (const id of Object.keys(item.modules)) {
71
+ const p = sourcePathOf(id)
72
+ if (p) modules.add(p)
73
+ }
74
+ if (item.isEntry && item.facadeModuleId) {
75
+ const entry = byFile.get(norm(stripQuery(item.facadeModuleId)))
76
+ if (entry) islandUrls[entry.rel] = publicPath + item.fileName
77
+ }
78
+ } else {
79
+ assets.push({ fileName: item.fileName, source: item.source })
80
+ }
81
+ }
82
+ }
83
+ for (const entry of opts.entries) {
84
+ if (!islandUrls[entry.rel])
85
+ throw new Error(`stator: island "${entry.rel}" missing from the bundle output`)
86
+ }
87
+ return { islandUrls, assets, modules: [...modules] }
88
+ }
@@ -1,6 +1,5 @@
1
1
  import { readdir, readFile } from 'node:fs/promises'
2
2
  import { join, resolve } from 'node:path'
3
- import type { Rollup } from 'vite'
4
3
 
5
4
  /**
6
5
  * The island-bundling seam — Lock 1 of the Vite exit (spec
@@ -59,89 +58,23 @@ export interface BundleIslandsOptions {
59
58
 
60
59
  export type IslandBundler = (opts: BundleIslandsOptions) => Promise<IslandBundle>
61
60
 
62
- const norm = (p: string): string => p.replace(/\\/g, '/')
63
- const stripQuery = (id: string): string => {
64
- const q = id.indexOf('?')
65
- return q === -1 ? id : id.slice(0, q)
66
- }
67
-
68
- /** Implementation #1: one Vite build over every island entry. Vite is imported
69
- * lazily — server-only apps never load it. */
61
+ /**
62
+ * The seam's one export: dispatches to an implementation. Vite (impl #1,
63
+ * `islands-vite.ts`) is the default; `STATOR_ISLAND_BUNDLER=esbuild` selects
64
+ * impl #2 (`islands-esbuild.ts`, the Option E candidate — Spike 1). Both run
65
+ * the same contract tests; the default flips (and #1 is deleted) when the
66
+ * Phase 2 gate in the toolchain-adapter spec passes.
67
+ */
70
68
  export const bundleIslands: IslandBundler = async (opts) => {
71
- const [{ build: viteBuild }, { CLIENT_QUERY, MACHINE_STUB_PREFIX, machineStub, stator }] =
72
- await Promise.all([import('vite'), import('../vite/index.ts')])
73
- const publicPath = opts.publicPath ?? '/static/assets/'
74
-
75
- const input: Record<string, string> = {}
76
- const byFile = new Map<string, IslandEntry>()
77
- for (const entry of opts.entries) {
78
- const file = resolve(entry.file)
79
- input[entry.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_')] = file.endsWith('.stator')
80
- ? `${file}?${CLIENT_QUERY}`
81
- : file
82
- byFile.set(norm(file), entry)
83
- }
84
-
85
- const result = await viteBuild({
86
- root: opts.root,
87
- // Assets referenced by URL from island code (`new URL(...)`, CSS `url()`)
88
- // render against `base`, so it must be where the assets are served from.
89
- base: publicPath,
90
- logLevel: 'warn',
91
- configFile: false,
92
- plugins: [stator(), machineStub({ machinesDir: resolve(opts.machinesDir) })],
93
- build: {
94
- write: false,
95
- // Seam contract: a URL-referenced asset (`new URL('./x.wasm',
96
- // import.meta.url)`) is always emitted as a hashed FILE, never inlined
97
- // as a data: URL — deterministic for callers and identical to what an
98
- // esbuild implementation (file loader) produces.
99
- assetsInlineLimit: 0,
100
- sourcemap: opts.sourcemap ? 'inline' : false,
101
- rollupOptions: {
102
- input,
103
- output: {
104
- entryFileNames: '[name]-[hash].js',
105
- chunkFileNames: 'chunks/[name]-[hash].js',
106
- assetFileNames: '[name]-[hash][extname]',
107
- },
108
- },
109
- },
110
- })
111
- const outputs = (Array.isArray(result) ? result : [result]).filter(
112
- (r): r is Rollup.RollupOutput => 'output' in r,
113
- )
114
-
115
- const islandUrls: Record<string, string> = {}
116
- const assets: IslandAsset[] = []
117
- const modules = new Set<string>()
118
- const sourcePathOf = (id: string): string | null => {
119
- const raw = id.startsWith(MACHINE_STUB_PREFIX) ? id.slice(MACHINE_STUB_PREFIX.length) : id
120
- if (raw.startsWith('\0')) return null
121
- return norm(stripQuery(raw))
122
- }
123
- for (const out of outputs) {
124
- for (const item of out.output) {
125
- if (item.type === 'chunk') {
126
- assets.push({ fileName: item.fileName, source: item.code })
127
- for (const id of Object.keys(item.modules)) {
128
- const p = sourcePathOf(id)
129
- if (p) modules.add(p)
130
- }
131
- if (item.isEntry && item.facadeModuleId) {
132
- const entry = byFile.get(norm(stripQuery(item.facadeModuleId)))
133
- if (entry) islandUrls[entry.rel] = publicPath + item.fileName
134
- }
135
- } else {
136
- assets.push({ fileName: item.fileName, source: item.source })
137
- }
138
- }
139
- }
140
- for (const entry of opts.entries) {
141
- if (!islandUrls[entry.rel])
142
- throw new Error(`stator: island "${entry.rel}" missing from the bundle output`)
69
+ const choice = process.env.STATOR_ISLAND_BUNDLER ?? 'vite'
70
+ if (choice === 'esbuild') {
71
+ const { bundleIslandsEsbuild } = await import('./islands-esbuild.ts')
72
+ return bundleIslandsEsbuild(opts)
143
73
  }
144
- return { islandUrls, assets, modules: [...modules] }
74
+ if (choice !== 'vite')
75
+ throw new Error(`stator: unknown STATOR_ISLAND_BUNDLER "${choice}" (vite | esbuild)`)
76
+ const { bundleIslandsVite } = await import('./islands-vite.ts')
77
+ return bundleIslandsVite(opts)
145
78
  }
146
79
 
147
80
  /**
@@ -67,7 +67,17 @@ function init(): void {
67
67
  initLiveChannel()
68
68
  }
69
69
 
70
+ /** The page's one live channel. `initLiveChannel` is NOT idempotent on its
71
+ * own — each call mints an EventSource and a watchdog interval — so the
72
+ * handle is parked here and a second call tears the first one down. Without
73
+ * this a re-entered `init()` stacked channels that nothing could reach,
74
+ * each holding a socket against the browser's per-origin cap. */
75
+ let liveChannel: { close(): void } | undefined
76
+
70
77
  function initLiveChannel(): { close(): void } | undefined {
78
+ liveChannel?.close()
79
+ liveChannel = undefined
80
+
71
81
  const meta = document.querySelector('meta[name="stator-live"][content="true"]')
72
82
  if (!meta) return
73
83
 
@@ -83,8 +93,12 @@ function initLiveChannel(): { close(): void } | undefined {
83
93
  // (CSS hook for offline banners etc.) plus a `stator:connection-state`
84
94
  // event. Change-guarded — EventSource fires `error` on every failed
85
95
  // auto-reconnect attempt, and only transitions are worth announcing.
96
+ //
97
+ // `idle` is a DELIBERATE release, not a fault, and is deliberately its own
98
+ // state: apps hang offline banners off `disconnected`/`stale`, and reusing
99
+ // either would flash "you're offline" every time a tab loses focus.
86
100
  let connectionState: string | undefined
87
- const setConnectionState = (state: 'connected' | 'disconnected' | 'stale'): void => {
101
+ const setConnectionState = (state: 'connected' | 'disconnected' | 'stale' | 'idle'): void => {
88
102
  if (state === connectionState) return
89
103
  connectionState = state
90
104
  document.documentElement.setAttribute('data-stator-connection', state)
@@ -103,6 +117,10 @@ function initLiveChannel(): { close(): void } | undefined {
103
117
  console.error('stator: malformed SSE message', err)
104
118
  return
105
119
  }
120
+ // Raw envelope, before interpretation — the observability seam for
121
+ // inspectors and for the dev client, which reads its rebuild/error
122
+ // signals off this instead of opening a second event-stream.
123
+ emit('stator:live-message', { envelope: data, timestamp: Date.now() })
106
124
  if (data.patches) {
107
125
  // SSE is server-pushed — no client round-trip to time — so report the
108
126
  // apply duration (still the useful "how expensive was this update?").
@@ -142,33 +160,118 @@ function initLiveChannel(): { close(): void } | undefined {
142
160
  })
143
161
  }
144
162
 
163
+ /** Hand the socket back. `es.close()` also stops EventSource's own
164
+ * auto-reconnect, so a released channel stays released until we say
165
+ * otherwise. */
166
+ const release = (): void => {
167
+ if (!sse) return
168
+ sse.close()
169
+ sse = null
170
+ setConnectionState('idle')
171
+ }
172
+
145
173
  // Zombie watchdog. A half-open connection (device sleep, silent NAT drop)
146
174
  // never fires `error` — EventSource just sits "open" and silent forever,
147
175
  // and the page quietly stops being live. The server sends an observable
148
176
  // ping every 25s; two missed pings while the page is VISIBLE means the
149
- // channel is dead, and a rebuilt connection re-syncs. Hidden tabs wait for
150
- // visibility — no churn for pages nobody is watching.
177
+ // channel is dead, and a rebuilt connection re-syncs. Hidden tabs are
178
+ // either still in their grace period or already released — neither wants a
179
+ // staleness reconnect.
151
180
  const STALE_MS = 65_000
152
181
  const checkStale = (): void => {
153
- if (document.hidden) return
182
+ if (document.hidden || !sse) return
154
183
  if (Date.now() - lastSeen > STALE_MS) {
155
184
  console.warn('stator: SSE channel stale — reconnecting to re-sync')
156
185
  setConnectionState('stale')
157
186
  connect()
158
187
  }
159
188
  }
189
+
190
+ // ── Proactive release ────────────────────────────────────────────────────
191
+ // A live page holds one HTTP/1.1 socket for as long as it is open, and the
192
+ // browser's per-origin pool (6 in Chrome) is shared across every tab in the
193
+ // profile — so background tabs spend the foreground tab's budget, and
194
+ // enough of them wedge every request to the origin. Browsers never reclaim
195
+ // these on their own: hiding a tab throttles timers but leaves sockets
196
+ // untouched.
197
+ //
198
+ // Releasing is cheap here because reconnect is a full resync: a fresh
199
+ // /__sse gets the server's initial sync, so a released page converges in
200
+ // place on return. Nothing durable rides the connection — session state
201
+ // lives in the Store, `after` timers in a process-wide registry, in-flight
202
+ // effects in a process-wide map — so dropping one costs a re-render, not a
203
+ // fact.
204
+ const HIDDEN_GRACE_MS = 30_000
205
+ let releaseTimer: ReturnType<typeof setTimeout> | undefined
206
+
207
+ const cancelRelease = (): void => {
208
+ if (releaseTimer === undefined) return
209
+ clearTimeout(releaseTimer)
210
+ releaseTimer = undefined
211
+ }
212
+
213
+ const onVisibility = (): void => {
214
+ if (document.hidden) {
215
+ // Grace period, not an immediate drop: alt-tabbing away for a moment is
216
+ // not abandonment, and every reconnect costs a full route render server
217
+ // side. Hidden-tab timer throttling is 1/s, so this fires ~on time; the
218
+ // 1/min intensive throttling only starts at 5 minutes, long after.
219
+ cancelRelease()
220
+ releaseTimer = setTimeout(release, HIDDEN_GRACE_MS)
221
+ } else {
222
+ cancelRelease()
223
+ if (!sse) connect()
224
+ else checkStale()
225
+ }
226
+ }
227
+
228
+ // A frozen tab runs no timers at all, so the grace period would never
229
+ // elapse — release synchronously on the way in. `resume` fires when the
230
+ // browser thaws the page without a navigation.
231
+ const onFreeze = (): void => {
232
+ cancelRelease()
233
+ release()
234
+ }
235
+ const onResume = (): void => {
236
+ if (!document.hidden && !sse) connect()
237
+ }
238
+
239
+ // pagehide covers both exits: `persisted` means bfcache (the document
240
+ // survives, and would otherwise sit in the cache still holding a socket and
241
+ // pinning a SessionRuntime), plain unload means it's going away. Either way
242
+ // the connection has no further use. pageshow with `persisted` is the
243
+ // bfcache restore — reconnect and resync.
244
+ const onPageHide = (): void => {
245
+ cancelRelease()
246
+ release()
247
+ }
248
+ const onPageShow = (e: PageTransitionEvent): void => {
249
+ if (e.persisted && !document.hidden && !sse) connect()
250
+ }
251
+
160
252
  const watchdog = setInterval(checkStale, 10_000)
161
- document.addEventListener('visibilitychange', checkStale)
253
+ document.addEventListener('visibilitychange', onVisibility)
254
+ document.addEventListener('freeze', onFreeze)
255
+ document.addEventListener('resume', onResume)
256
+ window.addEventListener('pagehide', onPageHide)
257
+ window.addEventListener('pageshow', onPageShow)
162
258
 
163
259
  connect()
164
260
 
165
- return {
261
+ liveChannel = {
166
262
  close(): void {
167
263
  clearInterval(watchdog)
168
- document.removeEventListener('visibilitychange', checkStale)
264
+ cancelRelease()
265
+ document.removeEventListener('visibilitychange', onVisibility)
266
+ document.removeEventListener('freeze', onFreeze)
267
+ document.removeEventListener('resume', onResume)
268
+ window.removeEventListener('pagehide', onPageHide)
269
+ window.removeEventListener('pageshow', onPageShow)
169
270
  sse?.close()
271
+ sse = null
170
272
  },
171
273
  }
274
+ return liveChannel
172
275
  }
173
276
 
174
277
  function handleEvent(e: Event): void {
@@ -323,5 +426,6 @@ if (document.readyState === 'loading') {
323
426
 
324
427
  // Exported for tests only — the runtime self-initializes above, and the
325
428
  // client bundle (esbuild IIFE) ignores exports. Re-calling init() is safe:
326
- // addEventListener dedupes an identical listener reference.
429
+ // addEventListener dedupes an identical listener reference, and the live
430
+ // channel closes its predecessor before opening a replacement.
327
431
  export { init, initLiveChannel }
@@ -33,7 +33,10 @@ import { logger, setLogLevel } from './logger.ts'
33
33
  import { codeHashOf, codeInputsOf } from './machine-hash.ts'
34
34
  import { MachineStore } from './machine-store.ts'
35
35
  import { discoverMiddleware } from './middleware.ts'
36
+ import type { DiscoveredRoute } from './route-discovery.ts'
36
37
  import { discoverRoutes } from './route-discovery.ts'
38
+ import { isStatorQueryRoute } from './routing.ts'
39
+ import { broadcastEnvelope } from './sse.ts'
37
40
  import { InMemoryStore } from './store.ts'
38
41
 
39
42
  /**
@@ -71,10 +74,29 @@ export interface NativeDevApp {
71
74
  close: () => Promise<void>
72
75
  }
73
76
 
74
- // The dev browser client (replaces Vite's `/@vite/client`): reconnecting-EventSource
75
- // that reloads on a successful rebuild and renders a full-screen overlay with the
76
- // message on a build failure, so a compile error is visible instead of a frozen page.
77
- const DEV_CLIENT_SCRIPT = `(()=>{let o;const show=(m)=>{if(o)o.remove();o=document.createElement('div');o.style.cssText='position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px;overflow:auto;background:#1a1015;color:#f8d7da;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap';o.textContent='stator \\u2014 build failed\\n\\n'+m;document.body.appendChild(o)};const es=new EventSource('/__stator_dev');es.onmessage=(e)=>{let m;try{m=JSON.parse(e.data)}catch{return}if(m.type==='reload')location.reload();else if(m.type==='error')show(m.message)}})()`
77
+ // The dev browser client (replaces Vite's `/@vite/client`): reloads on a successful
78
+ // rebuild and renders a full-screen overlay with the message on a build failure, so
79
+ // a compile error is visible instead of a frozen page.
80
+ //
81
+ // Transport depends on the page. A `live: true` page already holds a /__sse stream,
82
+ // so the dev signals ride it and this script opens NOTHING — a second event-stream
83
+ // would take a second slot from the browser's six-per-origin pool (shared across
84
+ // every tab in the profile), and two-per-tab is what wedges a dev session at three
85
+ // tabs. Reload arrives there as the wire protocol's own directive, applied by the
86
+ // page runtime; only the dev-only error overlay needs listening for. Pages without
87
+ // a live channel keep their own /__stator_dev stream — still one per tab.
88
+ const DEV_OVERLAY = `let o;const show=(m)=>{if(o)o.remove();o=document.createElement('div');o.style.cssText='position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px;overflow:auto;background:#1a1015;color:#f8d7da;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap';o.textContent='stator \\u2014 build failed\\n\\n'+m;document.body.appendChild(o)};const go=(m)=>{if(m.type==='reload')location.reload();else if(m.type==='error')show(m.message)};`
89
+
90
+ /** A page route (not a data route) declared `live: true` — the pages that hold
91
+ * a /__sse stream, and so need no second one for dev signals. */
92
+ const isLiveRoute = (def: DiscoveredRoute['GET']): boolean =>
93
+ def !== undefined && !isStatorQueryRoute(def) && def.live === true
94
+
95
+ /** `live` pages piggyback on /__sse; everything else opens /__stator_dev. */
96
+ const devClientScript = (live: boolean): string =>
97
+ live
98
+ ? `(()=>{${DEV_OVERLAY}window.addEventListener('stator:live-message',(e)=>{const d=e.detail&&e.detail.envelope&&e.detail.envelope.dev;if(d)go(d)})})()`
99
+ : `(()=>{${DEV_OVERLAY}const es=new EventSource('/__stator_dev');es.onmessage=(e)=>{let m;try{m=JSON.parse(e.data)}catch{return}go(m)}})()`
78
100
 
79
101
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'tests', 'test', '__tests__'])
80
102
  const APP_EXT = /\.(stator|ts|tsx|mts|cts|js|mjs)$/
@@ -111,9 +133,13 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
111
133
  const machinesDir = real(resolve(config.machinesDir))
112
134
  const routesDir = real(resolve(config.routesDir))
113
135
  const staticDir = config.staticDir ? real(resolve(config.staticDir)) : resolve(root, 'static')
114
- // One build-id per dev process, as the Vite dev server did — rebuilds reload
115
- // through the dev channel, a restart is a new id for the SSE reconnect handshake.
116
- const buildId = randomUUID()
136
+ // One build-id per successful BUILD, not per process. Connected pages reload
137
+ // through the dev signal, but a page whose channel was released while it sat
138
+ // in the background (or dropped over a blip) never sees that signal — it
139
+ // reconnects later carrying the id it was rendered against, and the /__sse
140
+ // handshake is the only thing that can tell it its slot-ID map is stale. A
141
+ // per-process id would answer "same build" to a page from three rebuilds ago.
142
+ let buildId = randomUUID()
117
143
 
118
144
  // Loaders, BEFORE the first app import. The CLI's TS loader first — a no-op
119
145
  // extra link when the CLI already registered it, required when the dev server
@@ -166,6 +192,11 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
166
192
  import(`${pathToFileURL(file).href}?v=${versions.get(file) ?? 1}`)
167
193
 
168
194
  // ── Dev live-reload channel (replaces Vite's `/@vite/client` HMR socket) ────
195
+ // Only pages WITHOUT a live channel open this. A `live: true` page already
196
+ // holds a /__sse stream, and its dev signals ride that one instead — a
197
+ // second event-stream would spend another slot from the browser's six-per-
198
+ // origin pool (shared across every tab in the profile), which is what wedges
199
+ // a multi-tab dev session at three tabs.
169
200
  const reloadClients = new Set<ReadableStreamDefaultController<Uint8Array>>()
170
201
  const enc = new TextEncoder()
171
202
  type DevMessage = { type: 'reload' } | { type: 'error'; message: string }
@@ -178,15 +209,29 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
178
209
  reloadClients.delete(c)
179
210
  }
180
211
  }
212
+ // Live pages: a reload rides the wire protocol's own directive (the page
213
+ // runtime applies it with no dev-specific code), an error rides a `dev`
214
+ // key the production runtime ignores and the dev client picks up off
215
+ // `stator:live-message`.
216
+ void broadcastEnvelope(
217
+ msg.type === 'reload' ? { directives: [{ type: 'reload' }] } : { dev: msg },
218
+ )
181
219
  }
182
- const devReloadResponse = (): Response =>
183
- new Response(
220
+ const devReloadResponse = (): Response => {
221
+ let mine: ReadableStreamDefaultController<Uint8Array> | undefined
222
+ return new Response(
184
223
  new ReadableStream<Uint8Array>({
185
224
  start(controller) {
225
+ mine = controller
186
226
  controller.enqueue(enc.encode(': connected\n\n'))
187
227
  reloadClients.add(controller)
188
228
  },
189
- // A closed stream's controller throws on the next enqueue and is pruned then.
229
+ // Disconnect arrives as a stream cancel; without this the controller
230
+ // lingers until the next broadcast's enqueue throws, and this channel
231
+ // has no heartbeat to provoke one.
232
+ cancel() {
233
+ if (mine) reloadClients.delete(mine)
234
+ },
190
235
  }),
191
236
  {
192
237
  headers: {
@@ -197,6 +242,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
197
242
  },
198
243
  },
199
244
  )
245
+ }
200
246
 
201
247
  // ── Source scan: scoped CSS + island-ness of every `.stator` ───────────────
202
248
  // The loader compiles each `.stator` into its server module on import; this
@@ -304,7 +350,12 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
304
350
  if (url) head.push(`<script type="module" src="${url}"></script>`)
305
351
  }
306
352
  if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
307
- head.push(`<script>${DEV_CLIENT_SCRIPT}</script>`)
353
+ // Liveness is decided HERE, not sniffed from the DOM: headExtras is injected
354
+ // ahead of the `stator-live` meta, so a script querying for it would always
355
+ // find nothing and open a redundant stream.
356
+ const abs = resolve(routeFile)
357
+ const live = routes.some((r) => resolve(r.filePath) === abs && isLiveRoute(r.GET))
358
+ head.push(`<script>${devClientScript(live)}</script>`)
308
359
  return head.join('\n')
309
360
  }
310
361
 
@@ -435,6 +486,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
435
486
  machinesChanged = await rebuildStore()
436
487
  storeRebuilt = true
437
488
  }
489
+ buildId = randomUUID()
438
490
  await rebuildServer()
439
491
  broadcast({ type: 'reload' })
440
492
  logger.info(
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { readFile, stat } from 'node:fs/promises'
2
2
  import { dirname, extname, resolve, sep } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { build } from 'esbuild'
@@ -282,6 +282,38 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
282
282
  return c.text('forbidden', 403)
283
283
  }
284
284
  try {
285
+ const st = await stat(full)
286
+ if (!st.isFile()) return c.text('not found', 404)
287
+
288
+ // Caching contract: `/static/assets/*` is the framework's hashed-output
289
+ // namespace (island bundles, emitted URL assets) — content-addressed by
290
+ // construction, so a year of `immutable` is correct. Everything else
291
+ // under `/static/` is user files with stable names: always revalidate,
292
+ // but revalidation is a bodyless 304 (ETag from size+mtime, plus
293
+ // Last-Modified for validators that only speak dates).
294
+ const etag = `"${st.size.toString(16)}-${Math.trunc(st.mtimeMs).toString(16)}"`
295
+ c.header(
296
+ 'Cache-Control',
297
+ rel.startsWith('assets/')
298
+ ? 'public, max-age=31536000, immutable'
299
+ : 'public, max-age=0, must-revalidate',
300
+ )
301
+ c.header('ETag', etag)
302
+ c.header('Last-Modified', st.mtime.toUTCString())
303
+
304
+ const inm = c.req.header('if-none-match')
305
+ if (inm) {
306
+ if (inm.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) {
307
+ return c.body(null, 304)
308
+ }
309
+ } else {
310
+ const ims = Date.parse(c.req.header('if-modified-since') ?? '')
311
+ // HTTP dates have second precision; compare mtime truncated to match.
312
+ if (!Number.isNaN(ims) && Math.trunc(st.mtimeMs / 1000) * 1000 <= ims) {
313
+ return c.body(null, 304)
314
+ }
315
+ }
316
+
285
317
  const buf = await readFile(full)
286
318
  c.header('Content-Type', contentTypeFor(rel))
287
319
  return c.body(buf)
@@ -344,6 +376,26 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
344
376
  const runtime = new SessionRuntime(sessionId, config.store)
345
377
  await runtime.loadGraph(route.reads)
346
378
  const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
379
+
380
+ // The handler parks on `finished` until the connection ends. Both ways
381
+ // out resolve it: the client aborting, and the server hanging up on a
382
+ // superseded connection. A server-side `stream.close()` does NOT raise
383
+ // onAbort, so without the second path an evicted connection would leave
384
+ // its heartbeat interval running for the life of the process.
385
+ //
386
+ // Subscribe BEFORE registering — and before the ': open' flush and the
387
+ // initial sync below. Hono fires its abort subscribers exactly once, at
388
+ // abort time (utils/stream.ts), so a listener added after the client has
389
+ // already gone is never called: the connection would sit in the registry
390
+ // for the life of the process, pinning its SessionRuntime and taking a
391
+ // slice of every fan-out. A page that opens the channel and immediately
392
+ // navigates away hits that window every time.
393
+ let finish!: () => void
394
+ const finished = new Promise<void>((resolveFn) => {
395
+ finish = resolveFn
396
+ })
397
+ stream.onAbort(() => finish())
398
+
347
399
  const conn = registerConnection({
348
400
  sessionId,
349
401
  clientId: c.req.query('client'),
@@ -355,6 +407,10 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
355
407
  send: async (data: string) => {
356
408
  await stream.writeSSE({ data })
357
409
  },
410
+ close: () => {
411
+ void stream.close()
412
+ finish()
413
+ },
358
414
  })
359
415
 
360
416
  // Force an immediate flush so edge proxies commit response headers
@@ -383,9 +439,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
383
439
  }, config.ssePingMs ?? 25_000)
384
440
 
385
441
  try {
386
- await new Promise<void>((resolveFn) => {
387
- stream.onAbort(() => resolveFn())
388
- })
442
+ await finished
389
443
  } finally {
390
444
  clearInterval(keepAlive)
391
445
  unregisterConnection(conn.id)
@@ -684,10 +738,13 @@ export function contentTypeFor(path: string): string {
684
738
  if (ext === '.png') return 'image/png'
685
739
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
686
740
  if (ext === '.webp') return 'image/webp'
741
+ if (ext === '.avif') return 'image/avif'
687
742
  if (ext === '.gif') return 'image/gif'
688
743
  if (ext === '.ico') return 'image/x-icon'
689
744
  if (ext === '.wasm') return 'application/wasm'
690
745
  if (ext === '.woff2') return 'font/woff2'
691
746
  if (ext === '.woff') return 'font/woff'
747
+ if (ext === '.ttf') return 'font/ttf'
748
+ if (ext === '.otf') return 'font/otf'
692
749
  return 'application/octet-stream'
693
750
  }
@@ -41,10 +41,12 @@ export interface DiscoveredRoute {
41
41
  * Walk the routes directory recursively and build URL patterns from file
42
42
  * paths. Conventions:
43
43
  *
44
- * - `routes/foo.ts` → `/foo`
45
- * - `routes/foo/index.ts` → `/foo`
46
- * - `routes/foo/[id].ts` → `/foo/:id` (path param `id`)
47
- * - `routes/[a]/[b].ts` → `/:a/:b`
44
+ * - `routes/foo.ts` → `/foo`
45
+ * - `routes/foo/index.ts` → `/foo`
46
+ * - `routes/foo/[id].ts` → `/foo/:id` (path param `id`)
47
+ * - `routes/[a]/[b].ts` → `/:a/:b`
48
+ * - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
49
+ * zero+ segments, param is the raw remainder incl. slashes)
48
50
  *
49
51
  * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
50
52
  * GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
package/src/server/sse.ts CHANGED
@@ -35,6 +35,9 @@ export interface Connection {
35
35
  runtime: SessionRuntime
36
36
  renderState: RenderState
37
37
  send: (data: string) => Promise<void>
38
+ /** Tear the underlying stream down from the server side. Supplied by the
39
+ * transport; absent in tests that register a bare connection. */
40
+ close?: () => void
38
41
  closed: boolean
39
42
  }
40
43
 
@@ -44,6 +47,23 @@ let nextId = 0
44
47
  export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Connection {
45
48
  const id = `sse${nextId++}`
46
49
  const conn: Connection = { ...init, id, closed: false }
50
+ // A page-load holds at most one channel per route, so an existing
51
+ // connection with the same (clientId, routeKey) is a corpse whose abort we
52
+ // never observed — a half-open socket, or a reconnect that raced its own
53
+ // teardown. Left registered it would pin a SessionRuntime forever and take
54
+ // a slice of every fan-out. Evict it before the new one lands.
55
+ if (init.clientId !== undefined) {
56
+ for (const prior of connections.values()) {
57
+ if (prior.clientId === init.clientId && prior.routeKey === init.routeKey) {
58
+ sseLog.debug(
59
+ { id: prior.id, sid: prior.sessionId, route: prior.routeKey, replacedBy: id },
60
+ 'evicting superseded connection',
61
+ )
62
+ unregisterConnection(prior.id)
63
+ prior.close?.()
64
+ }
65
+ }
66
+ }
47
67
  connections.set(id, conn)
48
68
  sseLog.debug(
49
69
  { id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
@@ -52,6 +72,25 @@ export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Con
52
72
  return conn
53
73
  }
54
74
 
75
+ /**
76
+ * Push a pre-serialized envelope to every open connection, bypassing the
77
+ * recompute path. The dev server's rebuild/error signals ride this so a dev
78
+ * page holds ONE event-stream instead of two — see `dev-native.ts`. Not a
79
+ * fan-out: no diffing, no baseline advance, no per-connection filtering.
80
+ */
81
+ export async function broadcastEnvelope(payload: unknown): Promise<void> {
82
+ if (connections.size === 0) return
83
+ const data = JSON.stringify(payload)
84
+ for (const conn of connections.values()) {
85
+ if (conn.closed) continue
86
+ try {
87
+ await conn.send(data)
88
+ } catch (err) {
89
+ sseLog.warn({ id: conn.id, err: String(err) }, 'broadcast failed')
90
+ }
91
+ }
92
+ }
93
+
55
94
  export function unregisterConnection(id: string): void {
56
95
  const conn = connections.get(id)
57
96
  if (!conn) return
@@ -92,11 +92,14 @@ export interface ElementSpecificAttributes {
92
92
  autocomplete?: string
93
93
  inputmode?: string
94
94
  autofocus?: boolean
95
+ accept?: string
96
+ multiple?: boolean
95
97
  }
96
98
  label: { for?: string }
97
99
  form: {
98
100
  action?: string
99
101
  method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
102
+ enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
100
103
  novalidate?: boolean
101
104
  }
102
105
  select: {
@@ -123,7 +126,11 @@ export interface ElementSpecificAttributes {
123
126
  alt?: string
124
127
  width?: number | string
125
128
  height?: number | string
129
+ srcset?: string
130
+ sizes?: string
126
131
  loading?: 'eager' | 'lazy'
132
+ decoding?: 'sync' | 'async' | 'auto'
133
+ fetchpriority?: 'high' | 'low' | 'auto'
127
134
  }
128
135
  }
129
136