@statorjs/stator 2.5.1 → 2.7.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/README.md +1 -1
- package/package.json +5 -5
- package/src/build/build.ts +93 -115
- package/src/build/head.ts +11 -4
- package/src/build/index.ts +9 -0
- package/src/build/islands.ts +228 -0
- package/src/build/source-id.ts +15 -0
- package/src/cli/commands/build.ts +11 -1
- package/src/cli/commands/dev.ts +7 -3
- package/src/cli/commands/start.ts +2 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/stator.js +9 -0
- package/src/client/inspector.css +198 -0
- package/src/client/inspector.ts +330 -7
- package/src/compiler/diagnostics.ts +10 -0
- package/src/compiler/index.ts +2 -1
- package/src/compiler/regions.ts +30 -0
- package/src/engine/actor.ts +10 -3
- package/src/engine/describe.ts +129 -0
- package/src/engine/index.ts +7 -0
- package/src/engine/types.ts +24 -1
- package/src/server/create-app.ts +7 -1
- package/src/server/dev-loader.mjs +124 -0
- package/src/server/dev-native.ts +562 -0
- package/src/server/dev-vite.ts +362 -0
- package/src/server/dev.ts +44 -318
- package/src/server/discovery.ts +45 -1
- package/src/server/effects.ts +10 -1
- package/src/server/http.ts +29 -1
- package/src/server/index.ts +3 -0
- package/src/server/inspect.ts +101 -0
- package/src/server/machine-hash.ts +195 -0
- package/src/server/machine-store.ts +9 -2
- package/src/server/session-runtime.ts +9 -4
- package/src/server/snapshot-policy.ts +110 -0
- package/src/vite/index.ts +10 -4
- package/src/vite/plugin.ts +12 -32
- package/src/vite/stub.ts +4 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ pnpm add @statorjs/stator hono
|
|
|
29
29
|
| `@statorjs/stator/build` | `buildApp`, `loadProductionHead`, `syncTypes` |
|
|
30
30
|
| `@statorjs/stator/components` | built-ins (`<JsonLd>`) |
|
|
31
31
|
|
|
32
|
-
`compiler` and `vite` subpaths exist but are internal — their shape may change in minor releases.
|
|
32
|
+
`compiler` and `vite` subpaths exist but are internal — their shape may change in minor releases. Neither is a plugin surface: Stator reads no `vite.config.*` and accepts no bundler plugins.
|
|
33
33
|
|
|
34
34
|
## Minimal app
|
|
35
35
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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
|
|
3
|
+
"version": "2.7.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
"state-machine",
|
|
18
18
|
"ssr",
|
|
19
19
|
"sse",
|
|
20
|
-
"islands"
|
|
21
|
-
"vite"
|
|
20
|
+
"islands"
|
|
22
21
|
],
|
|
23
22
|
"files": [
|
|
24
23
|
"src",
|
|
@@ -71,6 +70,7 @@
|
|
|
71
70
|
},
|
|
72
71
|
"dependencies": {
|
|
73
72
|
"@hono/node-server": "^1.13.0",
|
|
73
|
+
"chokidar": "^4.0.3",
|
|
74
74
|
"esbuild": "^0.24.0",
|
|
75
75
|
"hono": "^4.6.0",
|
|
76
76
|
"ioredis": "^5.10.1",
|
|
@@ -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.7.0"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
package/src/build/build.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative, resolve, sep } from 'node:path'
|
|
4
|
-
import { compile } from '../compiler/index.ts'
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { compile, regionResolverFor } from '../compiler/index.ts'
|
|
5
|
+
import { hashMachines } from '../server/machine-hash.ts'
|
|
6
|
+
import { bundleIslands, routeIslandMap, walkFiles } from './islands.ts'
|
|
7
|
+
import { sourceId } from './source-id.ts'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Production build: compile a `.stator` app to a `dist/` of plain `.ts` that the
|
|
@@ -13,17 +16,17 @@ import { compile } from '../compiler/index.ts'
|
|
|
13
16
|
* client entry as a sibling `*.stator.client.ts`
|
|
14
17
|
* 3. rewrite `.stator` import specifiers (`'./x.stator'` → `'./x.stator.ts'`)
|
|
15
18
|
* 4. write the concatenated scoped CSS to `dist/static/components.css`
|
|
16
|
-
* 5. when the app has client components: bundle every island entry
|
|
17
|
-
*
|
|
18
|
-
* imports stubbed to `{ name }`
|
|
19
|
-
* import graph to find which islands it reaches, and
|
|
20
|
-
* `dist/stator-manifest.json` mapping route files → island script URLs
|
|
19
|
+
* 5. when the app has client components: bundle every island entry through
|
|
20
|
+
* the `bundleIslands` seam (hashed output written under
|
|
21
|
+
* `dist/static/assets/`, server-machine imports stubbed to `{ name }`),
|
|
22
|
+
* walk each route's import graph to find which islands it reaches, and
|
|
23
|
+
* write `dist/stator-manifest.json` mapping route files → island script URLs
|
|
21
24
|
*
|
|
22
25
|
* The prod server runs `createApp` over `dist/` with the `headExtras` hook
|
|
23
26
|
* from `loadProductionHead(dist)` — it links `components.css` and injects the
|
|
24
27
|
* manifest's `<script type="module">` tags per route. File discovery + dynamic
|
|
25
|
-
* import work unchanged on the precompiled output;
|
|
26
|
-
* build time, and only when islands exist.
|
|
28
|
+
* import work unchanged on the precompiled output; the island bundler is
|
|
29
|
+
* needed only at build time, and only when islands exist.
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
export interface BuildConfig {
|
|
@@ -46,6 +49,13 @@ export interface BuildResult {
|
|
|
46
49
|
hasCss: boolean
|
|
47
50
|
/** Number of client components bundled for the browser. */
|
|
48
51
|
islands: number
|
|
52
|
+
/** Machines hashed for the snapshot hydration policy, and how long it took. */
|
|
53
|
+
machines: number
|
|
54
|
+
machineHashMs: number
|
|
55
|
+
/** Machine files (relative to `machines/`) whose code hash differs from the
|
|
56
|
+
* previous build's manifest — their sessions reset on deploy. `undefined`
|
|
57
|
+
* when there was no previous manifest to compare against. */
|
|
58
|
+
resetMachines?: string[]
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/** Shape of `dist/stator-manifest.json` (always written — carries `buildId`). */
|
|
@@ -57,6 +67,10 @@ export interface StatorManifest {
|
|
|
57
67
|
islands: Record<string, string>
|
|
58
68
|
/** Route file (dist-relative) → script URLs for every island it reaches. */
|
|
59
69
|
routes: Record<string, string[]>
|
|
70
|
+
/** Machine file (relative to `machines/`) → code hash. Consumed by
|
|
71
|
+
* `stator start` for the snapshot hydration policy; the build fails if a
|
|
72
|
+
* machine's closure cannot be bundled, so this lands in CI, not at boot. */
|
|
73
|
+
machines: Record<string, string>
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
const NEVER_COPY = new Set(['node_modules', 'tests', 'test', '__tests__'])
|
|
@@ -79,6 +93,12 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
|
|
|
79
93
|
const outDir = resolve(config.outDir)
|
|
80
94
|
const dirs = config.dirs ?? (await discoverSourceDirs(root, outDir))
|
|
81
95
|
|
|
96
|
+
// Remember the previous build's machine hashes (if any) so the build can
|
|
97
|
+
// report which machines' sessions this deploy resets.
|
|
98
|
+
const previous = await readFile(join(outDir, 'stator-manifest.json'), 'utf8')
|
|
99
|
+
.then((t) => (JSON.parse(t) as Partial<StatorManifest>).machines)
|
|
100
|
+
.catch(() => undefined)
|
|
101
|
+
|
|
82
102
|
await rm(outDir, { recursive: true, force: true })
|
|
83
103
|
await mkdir(outDir, { recursive: true })
|
|
84
104
|
for (const d of dirs) {
|
|
@@ -95,14 +115,19 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
|
|
|
95
115
|
if (await exists(bootSrc)) await cp(bootSrc, join(outDir, 'boot.ts'))
|
|
96
116
|
|
|
97
117
|
// Compile every .stator into a sibling .stator.ts; collect CSS and islands.
|
|
98
|
-
|
|
118
|
+
// The sources are deleted only after the whole set compiles — cross-file
|
|
119
|
+
// region validation reads sibling `.stator` files mid-compile.
|
|
120
|
+
const statorFiles = await walkFiles(outDir, (f) => f.endsWith('.stator'))
|
|
99
121
|
let css = ''
|
|
100
122
|
const islands: Array<{ rel: string; entry: string }> = []
|
|
101
123
|
for (const file of statorFiles) {
|
|
102
124
|
const source = await readFile(file, 'utf8')
|
|
103
|
-
const rel =
|
|
104
|
-
const
|
|
105
|
-
|
|
125
|
+
const { id: rel, kind } = sourceId(outDir, file)
|
|
126
|
+
const result = compile(source, {
|
|
127
|
+
id: rel,
|
|
128
|
+
kind,
|
|
129
|
+
resolveRegions: regionResolverFor(file, source),
|
|
130
|
+
})
|
|
106
131
|
await writeFile(`${file}.ts`, result.serverCode)
|
|
107
132
|
if (result.isClient) {
|
|
108
133
|
// The generated client entry, written as a sibling so the authored
|
|
@@ -110,12 +135,12 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
|
|
|
110
135
|
await writeFile(`${file}.client.ts`, result.clientCode)
|
|
111
136
|
islands.push({ rel, entry: `${file}.client.ts` })
|
|
112
137
|
}
|
|
113
|
-
await rm(file)
|
|
114
138
|
if (result.css) css += `/* ${rel} */\n${result.css}\n`
|
|
115
139
|
}
|
|
140
|
+
for (const file of statorFiles) await rm(file)
|
|
116
141
|
|
|
117
142
|
// Rewrite `.stator` import specifiers to the compiled `.stator.ts` sibling.
|
|
118
|
-
const tsFiles = await
|
|
143
|
+
const tsFiles = await walkFiles(outDir, (f) => f.endsWith('.ts'))
|
|
119
144
|
for (const file of tsFiles) {
|
|
120
145
|
const code = await readFile(file, 'utf8')
|
|
121
146
|
const rewritten = code.replace(/(['"])([^'"]+\.stator)\1/g, '$1$2.ts$1')
|
|
@@ -127,115 +152,79 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
|
|
|
127
152
|
await writeFile(join(outDir, 'static', 'components.css'), css)
|
|
128
153
|
}
|
|
129
154
|
|
|
155
|
+
// Machine code hashes for the snapshot hydration policy: one esbuild pass
|
|
156
|
+
// over dist/machines. Throws (failing the build) if a closure can't bundle.
|
|
157
|
+
const machinesDir = join(outDir, 'machines')
|
|
158
|
+
const machineFiles = (
|
|
159
|
+
await walkFiles(machinesDir, (f) => /\.(ts|js)$/.test(f)).catch(() => [] as string[])
|
|
160
|
+
).filter((f) => resolve(f, '..') === resolve(machinesDir))
|
|
161
|
+
const t0 = performance.now()
|
|
162
|
+
const hashed = await hashMachines(machineFiles, { machinesDir })
|
|
163
|
+
const machineHashMs = Math.round(performance.now() - t0)
|
|
164
|
+
const machines: Record<string, string> = {}
|
|
165
|
+
for (const file of machineFiles.sort()) {
|
|
166
|
+
machines[relative(machinesDir, file).replace(/\\/g, '/')] = hashed.get(file)!.hash
|
|
167
|
+
}
|
|
168
|
+
const resetMachines = previous
|
|
169
|
+
? Object.keys(machines).filter((k) => previous[k] !== machines[k])
|
|
170
|
+
: undefined
|
|
171
|
+
|
|
130
172
|
// Always write the manifest — it carries the build-id even for an app with no
|
|
131
173
|
// islands (a live route without islands still needs the reload handshake).
|
|
132
174
|
const manifest: StatorManifest =
|
|
133
175
|
islands.length > 0
|
|
134
|
-
? { buildId: randomUUID(), ...(await buildClientAssets(outDir, islands)) }
|
|
135
|
-
: { buildId: randomUUID(), islands: {}, routes: {} }
|
|
176
|
+
? { buildId: randomUUID(), ...(await buildClientAssets(outDir, islands)), machines }
|
|
177
|
+
: { buildId: randomUUID(), islands: {}, routes: {}, machines }
|
|
136
178
|
await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
137
179
|
|
|
138
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
outDir,
|
|
182
|
+
compiled: statorFiles.length,
|
|
183
|
+
hasCss: Boolean(css),
|
|
184
|
+
islands: islands.length,
|
|
185
|
+
machines: machineFiles.length,
|
|
186
|
+
machineHashMs,
|
|
187
|
+
...(resetMachines ? { resetMachines } : {}),
|
|
188
|
+
}
|
|
139
189
|
}
|
|
140
190
|
|
|
141
191
|
/**
|
|
142
|
-
* Bundle every island entry
|
|
143
|
-
*
|
|
192
|
+
* Bundle every island entry through the seam, write the emitted files under
|
|
193
|
+
* `static/assets/`, and derive the route → island-script manifest.
|
|
144
194
|
*/
|
|
145
195
|
async function buildClientAssets(
|
|
146
196
|
outDir: string,
|
|
147
197
|
islands: Array<{ rel: string; entry: string }>,
|
|
148
|
-
): Promise<
|
|
149
|
-
const
|
|
150
|
-
import('vite'),
|
|
151
|
-
import('../vite/stub.ts'),
|
|
152
|
-
])
|
|
153
|
-
|
|
154
|
-
const input: Record<string, string> = {}
|
|
155
|
-
for (const island of islands) {
|
|
156
|
-
input[island.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_')] = island.entry
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const assetsDir = join(outDir, 'static', 'assets')
|
|
160
|
-
await viteBuild({
|
|
198
|
+
): Promise<Pick<StatorManifest, 'islands' | 'routes'>> {
|
|
199
|
+
const bundle = await bundleIslands({
|
|
161
200
|
root: outDir,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
plugins: [machineStub({ machinesDir: join(outDir, 'machines') })],
|
|
165
|
-
build: {
|
|
166
|
-
outDir: assetsDir,
|
|
167
|
-
emptyOutDir: true,
|
|
168
|
-
manifest: true,
|
|
169
|
-
rollupOptions: {
|
|
170
|
-
input,
|
|
171
|
-
output: {
|
|
172
|
-
entryFileNames: '[name]-[hash].js',
|
|
173
|
-
chunkFileNames: 'chunks/[name]-[hash].js',
|
|
174
|
-
assetFileNames: '[name]-[hash][extname]',
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
},
|
|
201
|
+
machinesDir: join(outDir, 'machines'),
|
|
202
|
+
entries: islands.map((i) => ({ rel: i.rel, file: i.entry })),
|
|
178
203
|
})
|
|
179
204
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const key = relative(outDir, island.entry).replace(/\\/g, '/')
|
|
187
|
-
const entry = viteManifest[key]
|
|
188
|
-
if (!entry) throw new Error(`stator: island "${island.rel}" missing from Vite manifest`)
|
|
189
|
-
islandUrls[island.rel] = `/static/assets/${entry.file}`
|
|
205
|
+
const assetsDir = join(outDir, 'static', 'assets')
|
|
206
|
+
await rm(assetsDir, { recursive: true, force: true })
|
|
207
|
+
for (const asset of bundle.assets) {
|
|
208
|
+
const target = join(assetsDir, asset.fileName)
|
|
209
|
+
await mkdir(dirname(target), { recursive: true })
|
|
210
|
+
await writeFile(target, asset.source)
|
|
190
211
|
}
|
|
191
212
|
|
|
192
|
-
// Per-route reachability
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
213
|
+
// Per-route reachability over the rewritten dist tree: island shells appear
|
|
214
|
+
// as `<island>.stator.ts`.
|
|
215
|
+
const shells = new Map(islands.map((i) => [resolve(outDir, `${i.rel}.ts`), i.rel]))
|
|
216
|
+
const byRoute = await routeIslandMap({
|
|
217
|
+
routesDir: join(outDir, 'routes'),
|
|
218
|
+
baseDir: outDir,
|
|
219
|
+
shells,
|
|
220
|
+
})
|
|
197
221
|
const routes: Record<string, string[]> = {}
|
|
198
|
-
for (const routeFile of
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (island) reached.add(island)
|
|
203
|
-
})
|
|
204
|
-
if (reached.size > 0) {
|
|
205
|
-
routes[relative(outDir, routeFile).replace(/\\/g, '/')] = [...reached]
|
|
206
|
-
.sort()
|
|
207
|
-
.map((rel) => islandUrls[rel]!)
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return { islands: islandUrls, routes }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const IMPORT_SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
|
|
215
|
-
|
|
216
|
-
/** Depth-first walk of a file's relative-import graph, bounded to `outDir`. */
|
|
217
|
-
async function walkImports(
|
|
218
|
-
file: string,
|
|
219
|
-
outDir: string,
|
|
220
|
-
seen: Set<string>,
|
|
221
|
-
visit: (file: string) => void,
|
|
222
|
-
): Promise<void> {
|
|
223
|
-
if (seen.has(file)) return
|
|
224
|
-
seen.add(file)
|
|
225
|
-
visit(file)
|
|
226
|
-
let code: string
|
|
227
|
-
try {
|
|
228
|
-
code = await readFile(file, 'utf8')
|
|
229
|
-
} catch {
|
|
230
|
-
return
|
|
231
|
-
}
|
|
232
|
-
for (const match of code.matchAll(IMPORT_SPECIFIER_RE)) {
|
|
233
|
-
const spec = match[1]!
|
|
234
|
-
if (!spec.startsWith('.')) continue
|
|
235
|
-
const target = resolve(join(file, '..'), spec)
|
|
236
|
-
if (!target.startsWith(outDir)) continue
|
|
237
|
-
await walkImports(target, outDir, seen, visit)
|
|
222
|
+
for (const [routeFile, rels] of byRoute) {
|
|
223
|
+
routes[relative(outDir, routeFile).replace(/\\/g, '/')] = rels.map(
|
|
224
|
+
(rel) => bundle.islandUrls[rel]!,
|
|
225
|
+
)
|
|
238
226
|
}
|
|
227
|
+
return { islands: bundle.islandUrls, routes }
|
|
239
228
|
}
|
|
240
229
|
|
|
241
230
|
async function exists(path: string): Promise<boolean> {
|
|
@@ -246,14 +235,3 @@ async function exists(path: string): Promise<boolean> {
|
|
|
246
235
|
return false
|
|
247
236
|
}
|
|
248
237
|
}
|
|
249
|
-
|
|
250
|
-
async function walk(dir: string, match: (file: string) => boolean): Promise<string[]> {
|
|
251
|
-
const out: string[] = []
|
|
252
|
-
const entries = await readdir(dir, { withFileTypes: true })
|
|
253
|
-
for (const e of entries) {
|
|
254
|
-
const full = join(dir, e.name)
|
|
255
|
-
if (e.isDirectory()) out.push(...(await walk(full, match)))
|
|
256
|
-
else if (match(full)) out.push(full)
|
|
257
|
-
}
|
|
258
|
-
return out
|
|
259
|
-
}
|
package/src/build/head.ts
CHANGED
|
@@ -13,9 +13,14 @@ import type { StatorManifest } from './build.ts'
|
|
|
13
13
|
* Everything is optional — a server-only app without styles gets an empty hook,
|
|
14
14
|
* and a build with no `buildId` in its manifest just skips the reload handshake.
|
|
15
15
|
*/
|
|
16
|
-
export async function loadProductionHead(
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export async function loadProductionHead(distDir: string): Promise<{
|
|
17
|
+
headExtras: (filePath: string) => string
|
|
18
|
+
buildId?: string
|
|
19
|
+
/** Machine file → code hash from the build manifest; pass to `createApp` as
|
|
20
|
+
* `machineHashes` so hydration compares against what was built. Absent on
|
|
21
|
+
* a dist built before hashes existed — `createApp` then hashes live. */
|
|
22
|
+
machines?: Record<string, string>
|
|
23
|
+
}> {
|
|
19
24
|
const dist = resolve(distDir)
|
|
20
25
|
|
|
21
26
|
let cssTag = ''
|
|
@@ -28,12 +33,14 @@ export async function loadProductionHead(
|
|
|
28
33
|
|
|
29
34
|
let routes: StatorManifest['routes'] = {}
|
|
30
35
|
let buildId: string | undefined
|
|
36
|
+
let machines: Record<string, string> | undefined
|
|
31
37
|
try {
|
|
32
38
|
const manifest = JSON.parse(
|
|
33
39
|
await readFile(join(dist, 'stator-manifest.json'), 'utf8'),
|
|
34
40
|
) as StatorManifest
|
|
35
41
|
routes = manifest.routes ?? {}
|
|
36
42
|
buildId = manifest.buildId
|
|
43
|
+
machines = manifest.machines
|
|
37
44
|
} catch {
|
|
38
45
|
// no manifest
|
|
39
46
|
}
|
|
@@ -45,5 +52,5 @@ export async function loadProductionHead(
|
|
|
45
52
|
.filter(Boolean)
|
|
46
53
|
.join('\n')
|
|
47
54
|
}
|
|
48
|
-
return { headExtras, buildId }
|
|
55
|
+
return { headExtras, buildId, ...(machines ? { machines } : {}) }
|
|
49
56
|
}
|
package/src/build/index.ts
CHANGED
|
@@ -6,5 +6,14 @@
|
|
|
6
6
|
export type { BuildConfig, BuildResult, StatorManifest } from './build.ts'
|
|
7
7
|
export { buildApp } from './build.ts'
|
|
8
8
|
export { loadProductionHead } from './head.ts'
|
|
9
|
+
export type {
|
|
10
|
+
BundleIslandsOptions,
|
|
11
|
+
IslandAsset,
|
|
12
|
+
IslandBundle,
|
|
13
|
+
IslandBundler,
|
|
14
|
+
IslandEntry,
|
|
15
|
+
} from './islands.ts'
|
|
16
|
+
export { bundleIslands, routeIslandMap } from './islands.ts'
|
|
17
|
+
export { sourceId } from './source-id.ts'
|
|
9
18
|
export type { SyncResult } from './sync.ts'
|
|
10
19
|
export { syncTypes } from './sync.ts'
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import type { Rollup } from 'vite'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The island-bundling seam — Lock 1 of the Vite exit (spec
|
|
7
|
+
* `toolchain-adapter-seam-and-the-vite-exit`).
|
|
8
|
+
*
|
|
9
|
+
* `bundleIslands` is a pure inputs→outputs function: island entries in; emitted
|
|
10
|
+
* files, a rel→URL map, and the list of source modules that went in, out.
|
|
11
|
+
* Nothing is written or served here — `buildApp` writes the assets under
|
|
12
|
+
* `dist/static/assets/`, the native dev server keeps them in memory. That is
|
|
13
|
+
* what makes the bundler a one-implementation swap (Vite today; esbuild is the
|
|
14
|
+
* Option E candidate) with the same core feeding both sides.
|
|
15
|
+
*
|
|
16
|
+
* The entry contract is deliberately tiny: an entry is either the island's
|
|
17
|
+
* `.stator` SOURCE (the bundler compiles its `<script>` to the client module)
|
|
18
|
+
* or a prebuilt client entry module. Server-machine imports inside an island
|
|
19
|
+
* collapse to `{ name }` identity stubs; the machine body never ships.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface IslandEntry {
|
|
23
|
+
/** App-relative `.stator` path, `/`-separated — the island's manifest identity. */
|
|
24
|
+
rel: string
|
|
25
|
+
/** Absolute entry file: the `.stator` source, or a prebuilt client entry. */
|
|
26
|
+
file: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IslandAsset {
|
|
30
|
+
/** Path relative to the assets root (entries, shared chunks, emitted assets). */
|
|
31
|
+
fileName: string
|
|
32
|
+
source: string | Uint8Array
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IslandBundle {
|
|
36
|
+
/** Island rel → public URL of its entry script. */
|
|
37
|
+
islandUrls: Record<string, string>
|
|
38
|
+
assets: IslandAsset[]
|
|
39
|
+
/** Absolute path of every source module in the bundle (queries and stub
|
|
40
|
+
* prefixes stripped, `/`-separated) — lets a watcher decide whether an edit
|
|
41
|
+
* needs a rebundle without knowing anything about the bundler. */
|
|
42
|
+
modules: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BundleIslandsOptions {
|
|
46
|
+
/** Resolution root (the app dir, or dist) — must reach node_modules. */
|
|
47
|
+
root: string
|
|
48
|
+
/** Directory whose modules are stubbed to `{ name }` in browser bundles. */
|
|
49
|
+
machinesDir: string
|
|
50
|
+
entries: IslandEntry[]
|
|
51
|
+
/** URL prefix the assets are served under. Default `/static/assets/`. */
|
|
52
|
+
publicPath?: string
|
|
53
|
+
/** Seam contract: `true` appends an INLINE sourcemap to each emitted chunk
|
|
54
|
+
* (a `sourceMappingURL=data:` comment — self-contained, so in-memory dev
|
|
55
|
+
* serving needs no extra `.map` routes and browser devtools show island
|
|
56
|
+
* source). Default off — production bundles ship unmapped. */
|
|
57
|
+
sourcemap?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type IslandBundler = (opts: BundleIslandsOptions) => Promise<IslandBundle>
|
|
61
|
+
|
|
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. */
|
|
70
|
+
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`)
|
|
143
|
+
}
|
|
144
|
+
return { islandUrls, assets, modules: [...modules] }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Per-route island reachability: walk each route file's relative-import graph
|
|
149
|
+
* (bounded to `baseDir`) and record which island shells it reaches. `shells`
|
|
150
|
+
* maps an island's server-side module file (the `.stator` source in dev, the
|
|
151
|
+
* compiled `.stator.ts` in dist) to the island's rel. Returns absolute route
|
|
152
|
+
* file → sorted island rels, only for routes that reach at least one island.
|
|
153
|
+
*/
|
|
154
|
+
export async function routeIslandMap(opts: {
|
|
155
|
+
routesDir: string
|
|
156
|
+
baseDir: string
|
|
157
|
+
shells: Map<string, string>
|
|
158
|
+
}): Promise<Map<string, string[]>> {
|
|
159
|
+
const baseDir = resolve(opts.baseDir)
|
|
160
|
+
const routes = new Map<string, string[]>()
|
|
161
|
+
const routeFiles = await walkFiles(opts.routesDir, (f) => /\.(ts|js|stator)$/.test(f)).catch(
|
|
162
|
+
() => [] as string[],
|
|
163
|
+
)
|
|
164
|
+
for (const routeFile of routeFiles) {
|
|
165
|
+
const reached = new Set<string>()
|
|
166
|
+
await walkImports(routeFile, baseDir, new Set(), (file) => {
|
|
167
|
+
const island = opts.shells.get(file)
|
|
168
|
+
if (island) reached.add(island)
|
|
169
|
+
})
|
|
170
|
+
if (reached.size > 0) routes.set(routeFile, [...reached].sort())
|
|
171
|
+
}
|
|
172
|
+
return routes
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const IMPORT_SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
|
|
176
|
+
|
|
177
|
+
/** Absolute targets of a file's relative import specifiers, bounded to
|
|
178
|
+
* `baseDir` (a static regex read — no module evaluation; an unreadable file
|
|
179
|
+
* yields none). Specifiers carry explicit extensions by convention. */
|
|
180
|
+
export async function localImports(file: string, baseDir: string): Promise<string[]> {
|
|
181
|
+
let code: string
|
|
182
|
+
try {
|
|
183
|
+
code = await readFile(file, 'utf8')
|
|
184
|
+
} catch {
|
|
185
|
+
return []
|
|
186
|
+
}
|
|
187
|
+
const out: string[] = []
|
|
188
|
+
for (const match of code.matchAll(IMPORT_SPECIFIER_RE)) {
|
|
189
|
+
const spec = match[1]!
|
|
190
|
+
if (!spec.startsWith('.')) continue
|
|
191
|
+
const target = resolve(join(file, '..'), spec)
|
|
192
|
+
if (target.startsWith(baseDir)) out.push(target)
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Depth-first walk of a file's relative-import graph, bounded to `baseDir`. */
|
|
198
|
+
export async function walkImports(
|
|
199
|
+
file: string,
|
|
200
|
+
baseDir: string,
|
|
201
|
+
seen: Set<string>,
|
|
202
|
+
visit: (file: string) => void,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
if (seen.has(file)) return
|
|
205
|
+
seen.add(file)
|
|
206
|
+
visit(file)
|
|
207
|
+
for (const target of await localImports(file, baseDir)) {
|
|
208
|
+
await walkImports(target, baseDir, seen, visit)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Recursive file listing. `skipDir` prunes a directory by name. */
|
|
213
|
+
export async function walkFiles(
|
|
214
|
+
dir: string,
|
|
215
|
+
match: (file: string) => boolean,
|
|
216
|
+
skipDir?: (name: string) => boolean,
|
|
217
|
+
): Promise<string[]> {
|
|
218
|
+
const out: string[] = []
|
|
219
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
220
|
+
for (const e of entries) {
|
|
221
|
+
const full = join(dir, e.name)
|
|
222
|
+
if (e.isDirectory()) {
|
|
223
|
+
if (skipDir?.(e.name)) continue
|
|
224
|
+
out.push(...(await walkFiles(full, match, skipDir)))
|
|
225
|
+
} else if (match(full)) out.push(full)
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { relative } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The identity a `.stator` file compiles under, derived from its path relative
|
|
5
|
+
* to the app (or dist) root: `id` seeds the scope hash and labels diagnostics,
|
|
6
|
+
* `kind` selects the route-page vs component capability set. Every compile
|
|
7
|
+
* path — `buildApp`, the native dev server's CSS scan, and the dev loader — must
|
|
8
|
+
* agree on this, or markup compiled on one thread won't match CSS scoped on
|
|
9
|
+
* another. Always `/`-separated so ids (and manifest keys) are stable across
|
|
10
|
+
* operating systems.
|
|
11
|
+
*/
|
|
12
|
+
export function sourceId(base: string, file: string): { id: string; kind: 'route' | 'component' } {
|
|
13
|
+
const id = relative(base, file).replace(/\\/g, '/')
|
|
14
|
+
return { id, kind: /(^|\/)routes\//.test(id) ? 'route' : 'component' }
|
|
15
|
+
}
|
|
@@ -10,6 +10,16 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
10
10
|
const result = await buildApp({ root: ctx.root, outDir: resolve(ctx.root, 'dist') })
|
|
11
11
|
process.stdout.write(
|
|
12
12
|
`stator build: ${result.compiled} components → ${result.outDir}` +
|
|
13
|
-
`${result.islands ? ` (${result.islands} island${result.islands === 1 ? '' : 's'})` : ''}
|
|
13
|
+
`${result.islands ? ` (${result.islands} island${result.islands === 1 ? '' : 's'})` : ''}` +
|
|
14
|
+
` · ${result.machines} machine${result.machines === 1 ? '' : 's'} hashed in ${result.machineHashMs} ms\n`,
|
|
14
15
|
)
|
|
16
|
+
// Deploy awareness: which machines' sessions this build resets (see the
|
|
17
|
+
// snapshot hydration policy — sessions never outlive the code that made them).
|
|
18
|
+
if (result.resetMachines) {
|
|
19
|
+
process.stdout.write(
|
|
20
|
+
result.resetMachines.length
|
|
21
|
+
? ` machine code changed — sessions reset on deploy for: ${result.resetMachines.join(', ')}\n`
|
|
22
|
+
: ' no machine code changed — sessions carry over\n',
|
|
23
|
+
)
|
|
24
|
+
}
|
|
15
25
|
}
|