@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 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.5.1",
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 (Vite/tsx-native by design).",
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.5.1"
98
+ "@statorjs/stator": "2.7.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -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 in one
17
- * Vite build (hashed output under `dist/static/assets/`, server-machine
18
- * imports stubbed to `{ name }` via `machineStub`), walk each route's
19
- * import graph to find which islands it reaches, and write
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; Vite is needed only at
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
- const statorFiles = await walk(outDir, (f) => f.endsWith('.stator'))
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 = relative(outDir, file)
104
- const kind = /(^|[\\/])routes[\\/]/.test(rel) ? ('route' as const) : ('component' as const)
105
- const result = compile(source, { id: rel, kind })
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 walk(outDir, (f) => f.endsWith('.ts'))
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 { outDir, compiled: statorFiles.length, hasCss: Boolean(css), islands: islands.length }
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 in one Vite build and derive the manifest.
143
- * Vite is imported lazily — server-only apps never need it at build time.
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<Omit<StatorManifest, 'buildId'>> {
149
- const [{ build: viteBuild }, { machineStub }] = await Promise.all([
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
- logLevel: 'warn',
163
- configFile: false,
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
- // Vite's manifest keys inputs by root-relative path; map back to islands.
181
- const viteManifest = JSON.parse(
182
- await readFile(join(assetsDir, '.vite', 'manifest.json'), 'utf8'),
183
- ) as Record<string, { file: string; isEntry?: boolean }>
184
- const islandUrls: Record<string, string> = {}
185
- for (const island of islands) {
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: walk each route file's relative-import graph
193
- // (post-rewrite, so island shells appear as `<island>.ts`) and record which
194
- // islands it reaches. Mirrors the dev server's module-graph walk.
195
- const shellToIsland = new Map(islands.map((i) => [resolve(outDir, `${i.rel}.ts`), i.rel]))
196
- const routeFiles = await walk(join(outDir, 'routes'), (f) => f.endsWith('.ts'))
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 routeFiles) {
199
- const reached = new Set<string>()
200
- await walkImports(routeFile, outDir, new Set(), (file) => {
201
- const island = shellToIsland.get(file)
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
- distDir: string,
18
- ): Promise<{ headExtras: (filePath: string) => string; buildId?: string }> {
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
  }
@@ -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'})` : ''}\n`,
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
  }