@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 +2 -2
- package/src/build/islands-esbuild.ts +175 -0
- package/src/build/islands-vite.ts +88 -0
- package/src/build/islands.ts +15 -82
- package/src/client/runtime.ts +112 -8
- package/src/server/dev-native.ts +63 -11
- package/src/server/http.ts +61 -4
- package/src/server/route-discovery.ts +6 -4
- package/src/server/sse.ts +39 -0
- package/src/template/html-attributes.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
+
}
|
package/src/build/islands.ts
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/client/runtime.ts
CHANGED
|
@@ -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
|
|
150
|
-
//
|
|
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',
|
|
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
|
-
|
|
261
|
+
liveChannel = {
|
|
166
262
|
close(): void {
|
|
167
263
|
clearInterval(watchdog)
|
|
168
|
-
|
|
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 }
|
package/src/server/dev-native.ts
CHANGED
|
@@ -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`):
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
|
115
|
-
// through the dev
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
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`
|
|
45
|
-
* - `routes/foo/index.ts`
|
|
46
|
-
* - `routes/foo/[id].ts`
|
|
47
|
-
* - `routes/[a]/[b].ts`
|
|
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
|
|