@statorjs/stator 2.9.1 → 2.10.0-next.1

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.
@@ -0,0 +1,390 @@
1
+ import { type Dirent, realpathSync } from 'node:fs'
2
+ import { readdir, readFile, stat } from 'node:fs/promises'
3
+ import { builtinModules } from 'node:module'
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
5
+ import { build } from 'esbuild'
6
+ import ts from 'typescript'
7
+ import { compile, regionResolverFor } from '../compiler/index.ts'
8
+
9
+ /**
10
+ * What `stator build` copies into `dist/` — derived from the app's own module
11
+ * graph, not from a list of directory names.
12
+ *
13
+ * The build used to copy every top-level directory that wasn't on a denylist,
14
+ * which meant runtime data (an uploads dir, a JSON cache a deploy script
15
+ * maintains) landed in the artifact, while a root-level file a module actually
16
+ * reads — `new URL('../app.db', import.meta.url)` — did not. Both failures come
17
+ * from the same place: guessing at the app's shape instead of asking the code.
18
+ *
19
+ * So one esbuild pass walks the graph from the entry points the FRAMEWORK
20
+ * itself loads — the route and machine files, plus the root single-file hooks —
21
+ * and everything else is copied because it was reached. `templates/`, `lib/`,
22
+ * `components/` are not names this module knows; they are consequences. The
23
+ * pass parses nothing itself: resolution is esbuild's, so tsconfig `paths`,
24
+ * extensionless specifiers, index files and `exports` maps behave exactly as
25
+ * they do at runtime, and `.stator` files are compiled on load so frontmatter
26
+ * imports are in the graph like any other import.
27
+ *
28
+ * Copying is DIRECTORY-granular on purpose. A module's data files — a template
29
+ * read with `readFile`, a fixture JSON — are invisible to any import graph, so
30
+ * a reached module brings its whole top-level directory along. The cost is
31
+ * copying a few unreferenced siblings; the alternative is a dist that is
32
+ * missing files nothing declared.
33
+ *
34
+ * Nothing from `node_modules` is traced or copied. A bare specifier is recorded
35
+ * as a direct dependency and left external, so the walk stops there and never
36
+ * descends into a dependency's own imports — `dist/` holds no `node_modules`,
37
+ * and the recorded list is the app's own dependency set, which is exactly what
38
+ * a manifest should declare.
39
+ */
40
+
41
+ /** Directories whose files are entry points, by framework convention. */
42
+ const ENTRY_DIRS = ['routes', 'machines']
43
+ /** Root-level single-file entry points. Each is optional. */
44
+ const ENTRY_FILES = [
45
+ 'middleware.ts',
46
+ 'boot.ts',
47
+ 'stator.config.ts',
48
+ 'stator.config.mts',
49
+ 'stator.config.js',
50
+ 'stator.config.mjs',
51
+ ]
52
+ /** Directories the framework serves by path rather than importing. */
53
+ const SERVED_DIRS = ['static']
54
+ /** Never app source, so never worth reporting as an uncopied candidate. */
55
+ const NEVER_SOURCE = new Set(['node_modules', 'dist', 'tests', 'test', '__tests__'])
56
+
57
+ const CODE = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx|stator)$/
58
+ const BUILTIN = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
59
+ /** A `node_modules` path segment, either separator. Anything under one is a
60
+ * dependency, never app source — and a real app's `node_modules` sits INSIDE
61
+ * its root, so root containment alone cannot tell them apart. */
62
+ const NODE_MODULES = /[\\/]node_modules[\\/]/
63
+ const norm = (p: string): string => p.replace(/\\/g, '/')
64
+
65
+ export interface UntracedImport {
66
+ /** App-relative file. */
67
+ file: string
68
+ line: number
69
+ /** The call as written, truncated. */
70
+ source: string
71
+ }
72
+
73
+ export interface CopySetOptions {
74
+ /** App source root. */
75
+ root: string
76
+ /** Extra app-relative paths (directories or files) to copy verbatim — the
77
+ * escape hatch for what no import graph can see, e.g. a directory read
78
+ * through a runtime-built path. */
79
+ include?: string[]
80
+ }
81
+
82
+ export interface CopySet {
83
+ /** Top-level directories to copy whole. */
84
+ dirs: string[]
85
+ /** Root-level files to copy. */
86
+ files: string[]
87
+ /** Top-level directories present in the root that nothing reached. */
88
+ unused: string[]
89
+ /** Direct dependencies: the package name behind every bare specifier the
90
+ * app's own code reached. NOT transitive — a dependency is external, so it
91
+ * is never loaded and its own imports are never resolved. That is the right
92
+ * granularity for a generated manifest (a package manager resolves
93
+ * transitives from each dependency's own package.json) and for checking
94
+ * runtime imports against `dependencies` rather than `devDependencies`:
95
+ * a type-only import is elided before resolution, so it never lands here. */
96
+ packages: string[]
97
+ /** Reached files outside the app root — impossible to place in `dist/`. */
98
+ external: string[]
99
+ /** Specifiers esbuild could not resolve. Not fatal here: `stator check`
100
+ * typechecks the same graph and is the gate for a genuinely broken import. */
101
+ unresolved: string[]
102
+ /** `import()` calls no static analysis can follow. */
103
+ untraced: UntracedImport[]
104
+ /** App-local files the graph reached, app-relative. */
105
+ reached: string[]
106
+ ms: number
107
+ }
108
+
109
+ const exists = (p: string): Promise<boolean> =>
110
+ stat(p).then(
111
+ () => true,
112
+ () => false,
113
+ )
114
+
115
+ async function walk(dir: string, match: (f: string) => boolean): Promise<string[]> {
116
+ const out: string[] = []
117
+ let entries: Dirent[]
118
+ try {
119
+ entries = await readdir(dir, { withFileTypes: true })
120
+ } catch {
121
+ return out
122
+ }
123
+ for (const e of entries) {
124
+ const full = join(dir, e.name)
125
+ if (e.isDirectory()) out.push(...(await walk(full, match)))
126
+ else if (match(full)) out.push(full)
127
+ }
128
+ return out
129
+ }
130
+
131
+ /**
132
+ * Walk the app's server graph and report what `dist/` needs. Writes nothing and
133
+ * runs no app code — `.stator` is compiled, everything else is only parsed.
134
+ */
135
+ export async function resolveCopySet(opts: CopySetOptions): Promise<CopySet> {
136
+ const root = resolve(opts.root)
137
+ // esbuild reports REAL paths (on macOS /var is a symlink to /private/var), so
138
+ // classify against the real root or every reached file looks external and
139
+ // nothing gets copied. Same lesson as `hashMachines`.
140
+ const realRoot = ((): string => {
141
+ try {
142
+ return realpathSync(root)
143
+ } catch {
144
+ return root
145
+ }
146
+ })()
147
+ /**
148
+ * Is this absolute path inside the app root? `path.relative` is the only
149
+ * correct test on Windows: paths there are case-insensitive and esbuild's
150
+ * spelling of a drive letter need not match ours, so comparing normalized
151
+ * strings with `startsWith` reports a perfectly good in-root file as
152
+ * external — and across drives `relative` returns an ABSOLUTE path, which a
153
+ * `..` check alone would miss. (win32's `relative` lowercases before
154
+ * comparing; posix is unaffected.)
155
+ */
156
+ const insideRoot = (abs: string): boolean => {
157
+ const rel = relative(realRoot, abs)
158
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
159
+ }
160
+
161
+ const t0 = performance.now()
162
+
163
+ const entries: string[] = []
164
+ for (const dir of ENTRY_DIRS) entries.push(...(await walk(join(root, dir), (f) => CODE.test(f))))
165
+ for (const name of ENTRY_FILES) {
166
+ const p = join(root, name)
167
+ if (await exists(p)) entries.push(p)
168
+ }
169
+ if (entries.length === 0) {
170
+ throw new Error(
171
+ `stator build: no entry points found — expected ${ENTRY_DIRS.map((d) => `${d}/`).join(' or ')} under ${root}`,
172
+ )
173
+ }
174
+
175
+ const reached = new Set<string>()
176
+ const assets = new Set<string>()
177
+ const packages = new Set<string>()
178
+ const external = new Set<string>()
179
+ const unresolved = new Set<string>()
180
+ const untraced: UntracedImport[] = []
181
+
182
+ /**
183
+ * The two dependencies a module carries that the bundler's own graph misses,
184
+ * read from the SYNTAX rather than the text.
185
+ *
186
+ * Both used to be regex scans over raw source, which cannot tell code from
187
+ * prose: a comment explaining why the app avoids `import(name)`, or a string
188
+ * documenting the pattern, was picked up as a real untraceable import and
189
+ * failed the build. Found by dogfooding, and the reason this parses. Comments
190
+ * and string contents are simply not part of the tree.
191
+ */
192
+ const scan = (text: string, file: string): string => {
193
+ const sourceFile = ts.createSourceFile(
194
+ file,
195
+ text,
196
+ ts.ScriptTarget.Latest,
197
+ false,
198
+ /\.(tsx|jsx)$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
199
+ )
200
+
201
+ /** `import.meta.url`. */
202
+ const isImportMetaUrl = (node: ts.Node): boolean =>
203
+ ts.isPropertyAccessExpression(node) &&
204
+ node.name.text === 'url' &&
205
+ ts.isMetaProperty(node.expression) &&
206
+ node.expression.keywordToken === ts.SyntaxKind.ImportKeyword
207
+
208
+ /** A specifier the bundler can follow: a string literal, or a template —
209
+ * esbuild glob-expands one with a fixed prefix, pulling in every match. */
210
+ const analysable = (node: ts.Node): boolean =>
211
+ ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)
212
+
213
+ const visit = (node: ts.Node): void => {
214
+ // `new URL('./x', import.meta.url)` — an asset reference no import graph
215
+ // sees. String-literal specifiers only, the same limit as the client seam.
216
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) {
217
+ if (node.expression.text === 'URL') {
218
+ const [spec, meta] = node.arguments ?? []
219
+ if (spec && ts.isStringLiteralLike(spec) && spec.text.startsWith('.')) {
220
+ if (meta && isImportMetaUrl(meta)) assets.add(resolve(dirname(file), spec.text))
221
+ }
222
+ }
223
+ }
224
+ // `import(expr)` the bundler cannot follow. It says nothing about these,
225
+ // so this is the only place they are caught.
226
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
227
+ const arg = node.arguments[0]
228
+ if (!arg || !analysable(arg)) {
229
+ const start = node.getStart(sourceFile)
230
+ untraced.push({
231
+ file: norm(relative(realRoot, file)),
232
+ line: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
233
+ source: `${text
234
+ .slice(start, start + 60)
235
+ .split('\n')[0]!
236
+ .trim()}…`,
237
+ })
238
+ }
239
+ }
240
+ ts.forEachChild(node, visit)
241
+ }
242
+ ts.forEachChild(sourceFile, visit)
243
+ return text
244
+ }
245
+
246
+ try {
247
+ await build({
248
+ // Unique synthetic output name per entry: a `routes/x.stator` +
249
+ // `routes/x.ts` pair (the GET/POST route merge) otherwise collides on one
250
+ // output path and fails the whole pass.
251
+ entryPoints: entries.map((f) => ({
252
+ in: f,
253
+ out: norm(relative(root, f)).replace(/[\\/]/g, '_').replace(/\./g, '-'),
254
+ })),
255
+ absWorkingDir: root,
256
+ bundle: true,
257
+ write: false,
258
+ format: 'esm',
259
+ platform: 'node',
260
+ target: 'node20',
261
+ outdir: join(root, '.stator-copy-set'), // virtual — write: false
262
+ logLevel: 'silent',
263
+ plugins: [
264
+ {
265
+ name: 'stator-copy-set',
266
+ setup(b) {
267
+ b.onResolve({ filter: /.*/ }, async (args) => {
268
+ if (args.kind === 'entry-point' || args.pluginData?.resolved) return null
269
+ if (BUILTIN.has(args.path)) return { path: args.path, external: true }
270
+
271
+ const r = await b.resolve(args.path, {
272
+ kind: args.kind,
273
+ importer: args.importer,
274
+ resolveDir: args.resolveDir,
275
+ pluginData: { resolved: true },
276
+ })
277
+ if (r.errors.length > 0) {
278
+ unresolved.add(`${args.path} (from ${norm(relative(realRoot, args.importer))})`)
279
+ return { path: args.path, external: true }
280
+ }
281
+ const abs = r.path
282
+ // Package or app file? Neither the path nor the specifier settles
283
+ // it alone, so both are consulted. A dependency installed the
284
+ // ordinary way lives UNDER the app root in `node_modules`, so
285
+ // containment cannot exclude it; a dependency reached through a
286
+ // workspace link resolves to a real path with no `node_modules`
287
+ // segment, so the path cannot exclude it either. Meanwhile a
288
+ // tsconfig `paths` alias looks exactly like a bare specifier and
289
+ // is app code. So: under node_modules is always a package, and
290
+ // outside the root a bare specifier is a package while a relative
291
+ // one is an app file `dist` cannot contain.
292
+ const bare = !args.path.startsWith('.') && !isAbsolute(args.path)
293
+ const inRoot = isAbsolute(abs) && insideRoot(abs)
294
+ if (NODE_MODULES.test(abs) || (!inRoot && bare)) {
295
+ if (bare) {
296
+ packages.add(
297
+ args.path.startsWith('@')
298
+ ? args.path.split('/').slice(0, 2).join('/')
299
+ : args.path.split('/')[0]!,
300
+ )
301
+ }
302
+ return { path: abs, external: true }
303
+ }
304
+ if (!inRoot) {
305
+ external.add(norm(abs))
306
+ return { path: abs, external: true }
307
+ }
308
+ if (!CODE.test(abs)) {
309
+ assets.add(abs) // data file: recorded, never parsed
310
+ return { path: abs, external: true }
311
+ }
312
+ return { path: abs, pluginData: { resolved: true } }
313
+ })
314
+
315
+ b.onLoad({ filter: /\.stator$/ }, async (args) => {
316
+ reached.add(args.path)
317
+ const source = await readFile(args.path, 'utf8')
318
+ const kind = /[\\/]routes[\\/].*\.stator$/.test(args.path) ? 'route' : 'component'
319
+ const out = compile(source, {
320
+ id: args.path,
321
+ kind,
322
+ resolveRegions: regionResolverFor(args.path, source),
323
+ })
324
+ return {
325
+ contents: scan(out.serverCode, args.path),
326
+ loader: 'ts',
327
+ resolveDir: dirname(args.path),
328
+ }
329
+ })
330
+
331
+ b.onLoad({ filter: CODE }, async (args) => {
332
+ reached.add(args.path)
333
+ const source = await readFile(args.path, 'utf8')
334
+ return {
335
+ contents: scan(source, args.path),
336
+ loader: /\.(tsx|jsx)$/.test(args.path)
337
+ ? 'tsx'
338
+ : /\.(js|mjs|cjs)$/.test(args.path)
339
+ ? 'js'
340
+ : 'ts',
341
+ resolveDir: dirname(args.path),
342
+ }
343
+ })
344
+ },
345
+ },
346
+ ],
347
+ })
348
+ } catch (err) {
349
+ const errors = (err as { errors?: Array<{ text: string }> }).errors ?? []
350
+ throw new Error(
351
+ `stator build: could not walk the app's module graph — ${
352
+ errors.length > 0 ? errors.map((e) => e.text).join('; ') : (err as Error).message
353
+ }`,
354
+ )
355
+ }
356
+
357
+ const dirs = new Set<string>()
358
+ const files = new Set<string>()
359
+ for (const abs of [...reached, ...assets]) {
360
+ // Assets come from the `new URL` scan rather than resolution, so they get
361
+ // the same containment test the resolver hook applies.
362
+ if (!insideRoot(abs)) continue
363
+ const rel = norm(relative(realRoot, abs))
364
+ if (rel.includes('/')) dirs.add(rel.split('/')[0]!)
365
+ else files.add(rel)
366
+ }
367
+ for (const dir of SERVED_DIRS) if (await exists(join(root, dir))) dirs.add(dir)
368
+ for (const extra of opts.include ?? []) {
369
+ const rel = norm(extra).replace(/^\.\//, '').replace(/\/+$/, '')
370
+ if (!(await exists(join(root, rel)))) continue
371
+ if ((await stat(join(root, rel))).isDirectory() || !rel.includes('/')) dirs.add(rel)
372
+ else files.add(rel)
373
+ }
374
+
375
+ const topLevel = (await readdir(root, { withFileTypes: true }))
376
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !NEVER_SOURCE.has(e.name))
377
+ .map((e) => e.name)
378
+
379
+ return {
380
+ dirs: [...dirs].sort(),
381
+ files: [...files].sort(),
382
+ unused: topLevel.filter((d) => !dirs.has(d) && d !== 'dist').sort(),
383
+ packages: [...packages].sort(),
384
+ external: [...external].sort(),
385
+ unresolved: [...unresolved].sort(),
386
+ untraced,
387
+ reached: [...reached].map((f) => norm(relative(realRoot, f))).sort(),
388
+ ms: Math.round(performance.now() - t0),
389
+ }
390
+ }
package/src/build/head.ts CHANGED
@@ -20,6 +20,13 @@ export async function loadProductionHead(distDir: string): Promise<{
20
20
  * `machineHashes` so hydration compares against what was built. Absent on
21
21
  * a dist built before hashes existed — `createApp` then hashes live. */
22
22
  machines?: Record<string, string>
23
+ /** The config file the artifact carries, `null` when the app has none, and
24
+ * `undefined` when the manifest predates the field — which `stator start`
25
+ * reads as "rebuild", since it cannot otherwise tell a config-less app from
26
+ * a config that never made the trip. */
27
+ config?: string | null
28
+ /** The `@statorjs/stator` version that produced the artifact, if recorded. */
29
+ statorVersion?: string
23
30
  }> {
24
31
  const dist = resolve(distDir)
25
32
 
@@ -34,6 +41,8 @@ export async function loadProductionHead(distDir: string): Promise<{
34
41
  let routes: StatorManifest['routes'] = {}
35
42
  let buildId: string | undefined
36
43
  let machines: Record<string, string> | undefined
44
+ let config: string | null | undefined
45
+ let statorVersion: string | undefined
37
46
  try {
38
47
  const manifest = JSON.parse(
39
48
  await readFile(join(dist, 'stator-manifest.json'), 'utf8'),
@@ -41,6 +50,8 @@ export async function loadProductionHead(distDir: string): Promise<{
41
50
  routes = manifest.routes ?? {}
42
51
  buildId = manifest.buildId
43
52
  machines = manifest.machines
53
+ config = manifest.config
54
+ statorVersion = manifest.statorVersion
44
55
  } catch {
45
56
  // no manifest
46
57
  }
@@ -52,5 +63,11 @@ export async function loadProductionHead(distDir: string): Promise<{
52
63
  .filter(Boolean)
53
64
  .join('\n')
54
65
  }
55
- return { headExtras, buildId, ...(machines ? { machines } : {}) }
66
+ return {
67
+ headExtras,
68
+ buildId,
69
+ ...(machines ? { machines } : {}),
70
+ ...(config !== undefined ? { config } : {}),
71
+ ...(statorVersion ? { statorVersion } : {}),
72
+ }
56
73
  }
@@ -3,8 +3,12 @@
3
3
  * plain `.ts` the runtime serves with no Vite. See `buildApp`.
4
4
  */
5
5
 
6
+ export type { ArtifactDeps } from './artifact.ts'
7
+ export { writeArtifactDeps } from './artifact.ts'
6
8
  export type { BuildConfig, BuildResult, StatorManifest } from './build.ts'
7
9
  export { buildApp } from './build.ts'
10
+ export type { CopySet, CopySetOptions, UntracedImport } from './copy-set.ts'
11
+ export { resolveCopySet } from './copy-set.ts'
8
12
  export { loadProductionHead } from './head.ts'
9
13
  export type {
10
14
  BundleIslandsOptions,
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { buildApp } from '../../build/index.ts'
3
+ import { loadConfig } from '../config.ts'
3
4
  import type { CliContext } from '../run.ts'
4
5
  import { checkStack } from './check.ts'
5
6
 
@@ -7,12 +8,81 @@ import { checkStack } from './check.ts'
7
8
  * server file fails the build instead of shipping silently. */
8
9
  export async function run(ctx: CliContext): Promise<void> {
9
10
  await checkStack(ctx.root)
10
- const result = await buildApp({ root: ctx.root, outDir: resolve(ctx.root, 'dist') })
11
+ const config = await loadConfig(ctx.root)
12
+ const result = await buildApp({
13
+ root: ctx.root,
14
+ outDir: resolve(ctx.root, 'dist'),
15
+ include: config.build?.include,
16
+ untracedImports: config.build?.untracedImports,
17
+ })
18
+ const { copySet } = result
11
19
  process.stdout.write(
12
20
  `stator build: ${result.compiled} components → ${result.outDir}` +
13
21
  `${result.islands ? ` (${result.islands} island${result.islands === 1 ? '' : 's'})` : ''}` +
14
22
  ` · ${result.machines} machine${result.machines === 1 ? '' : 's'} hashed in ${result.machineHashMs} ms\n`,
15
23
  )
24
+ // What the module graph decided to copy. Printed because a copy set derived
25
+ // from code should be visible, not inferred from what turns up in dist/.
26
+ process.stdout.write(
27
+ ` copied: ${copySet.dirs.join(', ')}` +
28
+ `${copySet.files.length ? ` · ${copySet.files.length} root file${copySet.files.length === 1 ? '' : 's'} (${copySet.files.join(', ')})` : ''}` +
29
+ ` · graph walked in ${copySet.ms} ms\n`,
30
+ )
31
+ // How the artifact declares its dependencies — and, for a workspace member,
32
+ // that its transitives are not locked.
33
+ const { deps } = result
34
+ if (deps.kind === 'copied') {
35
+ process.stdout.write(
36
+ ` deps: ${deps.files.join(' + ')} copied · on the target run \`${deps.install}\`\n`,
37
+ )
38
+ } else if (deps.kind === 'generated') {
39
+ const n = Object.keys(deps.pinned ?? {}).length
40
+ process.stdout.write(
41
+ ` deps: no lockfile beside package.json (workspace app) — generated a package.json pinning ${n} direct dependenc${n === 1 ? 'y' : 'ies'}\n` +
42
+ ` on the target run \`${deps.install}\`; transitives are NOT locked, so prefer resolving at build time (\`pnpm deploy --prod\`, or build in the image)\n`,
43
+ )
44
+ if (deps.unpinned?.length) {
45
+ process.stdout.write(
46
+ ` warning: could not read an installed version for ${deps.unpinned.join(', ')} — used the declared range\n`,
47
+ )
48
+ }
49
+ }
50
+ // Layer 2 of the persistence story: whether the config DECLARES a session
51
+ // store is env-independent, so the build can say this soundly. Which store a
52
+ // declared one resolves to is not — that depends on the production
53
+ // environment, which a build machine does not have — so this is a notice
54
+ // about a shape, never a verdict about a value, and never an error: an app
55
+ // that wants in-memory made a real choice.
56
+ if (
57
+ result.copySet.files.some((f) => f.startsWith('stator.config.')) &&
58
+ !config.persistence?.session
59
+ ) {
60
+ process.stdout.write(
61
+ ' note: no session store configured — session state will not survive a restart' +
62
+ ' (see `persistence.session`, or `sessionStore({ redisUrl: process.env.REDIS_URL })`)\n',
63
+ )
64
+ }
65
+ if (copySet.unused.length > 0) {
66
+ process.stdout.write(
67
+ ` not copied: ${copySet.unused.join(', ')} — nothing in the app imports or opens them` +
68
+ ` (add to \`build.include\` if they're read at runtime)\n`,
69
+ )
70
+ }
71
+ if (copySet.untraced.length > 0) {
72
+ // Only reachable with `untracedImports: 'warn'` — the default throws.
73
+ process.stdout.write(
74
+ ` warning: ${copySet.untraced.length} untraceable dynamic import${copySet.untraced.length === 1 ? '' : 's'}` +
75
+ ` — dist may be missing what they load:\n${copySet.untraced
76
+ .map((u) => ` ${u.file}:${u.line} ${u.source}\n`)
77
+ .join('')}`,
78
+ )
79
+ }
80
+ if (copySet.external.length > 0) {
81
+ process.stdout.write(
82
+ ` warning: ${copySet.external.length} file${copySet.external.length === 1 ? '' : 's'} reached outside the app root` +
83
+ ` — not copied, so dist is not self-contained:\n${copySet.external.map((f) => ` ${f}\n`).join('')}`,
84
+ )
85
+ }
16
86
  // Deploy awareness: which machines' sessions this build resets (see the
17
87
  // snapshot hydration policy — sessions never outlive the code that made them).
18
88
  if (result.resetMachines) {
@@ -1,22 +1,88 @@
1
1
  import { stat } from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
2
3
  import { resolve } from 'node:path'
3
4
  import { loadProductionHead } from '../../build/index.ts'
4
5
  import { createApp } from '../../server/index.ts'
5
6
  import { loadConfig, resolvePort } from '../config.ts'
6
7
  import type { CliContext } from '../run.ts'
7
8
 
8
- /** Serve a built `dist/` in production mode (no Vite). Same `stator.config.ts`
9
- * as `dev` supplies the store/session/port. */
9
+ const running: string = (
10
+ createRequire(import.meta.url)('../../../package.json') as { version: string }
11
+ ).version
12
+
13
+ /** Same major.minor. A patch cannot change what the compiler emitted; a minor
14
+ * can, and the artifact was emitted by one specific compiler. */
15
+ const emitCompatible = (built: string, current: string): boolean =>
16
+ built.split('.').slice(0, 2).join('.') === current.split('.').slice(0, 2).join('.')
17
+
18
+ const exists = (p: string): Promise<boolean> =>
19
+ stat(p).then(
20
+ () => true,
21
+ () => false,
22
+ )
23
+
24
+ /**
25
+ * Serve a built artifact in production (no Vite).
26
+ *
27
+ * Everything comes from the artifact, `stator.config.ts` included. There is
28
+ * deliberately no fall back to the source tree: a config read from outside the
29
+ * artifact makes `dist/` a lie — the same build would then behave one way from
30
+ * a repo checkout and another way from a copied `dist/`, and a deploy that
31
+ * shipped only `dist/` would find no config at all and start on in-memory
32
+ * persistence without saying a word. It is also how two copies of a module end
33
+ * up live in one process: a root config importing `./lib/db.ts` while the
34
+ * machines import `dist/lib/db.ts` means two connections, two caches, and two
35
+ * versions of the same code.
36
+ */
10
37
  export async function run(ctx: CliContext): Promise<void> {
11
- const dist = resolve(ctx.root, 'dist')
12
- try {
13
- await stat(resolve(dist, 'routes'))
14
- } catch {
38
+ // The artifact can be the deploy root itself: point `--root` at a directory
39
+ // holding the manifest beside `routes/` and it is served in place, so `dist/`
40
+ // can be the only directory copied to a server.
41
+ const inPlace =
42
+ (await exists(resolve(ctx.root, 'stator-manifest.json'))) &&
43
+ (await exists(resolve(ctx.root, 'routes')))
44
+ const dist = inPlace ? resolve(ctx.root) : resolve(ctx.root, 'dist')
45
+
46
+ if (!(await exists(resolve(dist, 'routes')))) {
15
47
  throw new Error('no dist/ found — run `stator build` first')
16
48
  }
17
49
 
18
- const config = await loadConfig(ctx.root)
19
- const { headExtras, buildId, machines } = await loadProductionHead(dist)
50
+ const {
51
+ headExtras,
52
+ buildId,
53
+ machines,
54
+ config: configFile,
55
+ statorVersion: builtWith,
56
+ } = await loadProductionHead(dist)
57
+
58
+ // The artifact holds output emitted by one specific compiler. Running it
59
+ // against a different framework minor means the runtime and the emit
60
+ // disagree, and the failures are obscure — a template read that reports it
61
+ // was called outside a render, because two copies of the framework hold
62
+ // separate render state. An app whose lockfile pins an older version than
63
+ // the machine that built it produces exactly this, silently.
64
+ if (builtWith && !emitCompatible(builtWith, running)) {
65
+ throw new Error(
66
+ `this dist was compiled by @statorjs/stator ${builtWith} but ${running} is running — ` +
67
+ `rebuild with the installed version, or install ${builtWith} in the artifact`,
68
+ )
69
+ }
70
+
71
+ // An artifact built before the manifest recorded its config cannot be trusted
72
+ // to be complete: "no config" and "the config didn't travel" look identical,
73
+ // and guessing wrong silently downgrades persistence.
74
+ if (configFile === undefined) {
75
+ throw new Error(
76
+ 'this dist was built by an older stator and does not record its config — run `stator build` again',
77
+ )
78
+ }
79
+ if (configFile !== null && !(await exists(resolve(dist, configFile)))) {
80
+ throw new Error(
81
+ `dist is incomplete: the build recorded ${configFile} but it is not in the artifact — copy the whole directory, or rebuild`,
82
+ )
83
+ }
84
+
85
+ const config = await loadConfig(dist)
20
86
  const app = await createApp({
21
87
  machinesDir: resolve(dist, 'machines'),
22
88
  routesDir: resolve(dist, 'routes'),
package/src/config.ts CHANGED
@@ -73,6 +73,20 @@ export interface StatorConfig {
73
73
  /** Dev inspector toolbar (`dev` only). Default: on. */
74
74
  inspector?: boolean
75
75
  }
76
+ /** Production build policy (`build` only). What lands in `dist/` is derived
77
+ * from the app's own module graph, so neither field is normally needed. */
78
+ build?: {
79
+ /** Extra app-relative paths (directories or files) to copy into `dist/`.
80
+ * The escape hatch for what no import graph can see — a directory reached
81
+ * through a runtime-built path, say `readFile('data/' + name)`. Anything
82
+ * imported, or opened via a literal `new URL(..., import.meta.url)`, is
83
+ * already found. */
84
+ include?: string[]
85
+ /** What to do about an `import()` whose specifier no static analysis can
86
+ * follow. `error` (default) fails the build naming each one, because the
87
+ * build cannot know what such a call needs; `warn` ships anyway. */
88
+ untracedImports?: 'error' | 'warn'
89
+ }
76
90
  /** Origins allowed to make cross-site writes despite the CSRF guard — exact
77
91
  * (`https://app.example.com`) or wildcard-subdomain (`https://*.example.com`).
78
92
  * Same-origin and same-site writes are already allowed; this is for decoupled
@@ -77,11 +77,18 @@ export function printDevBanner(info: {
77
77
  * "did it start, and where" is the one thing an operator always wants, distinct
78
78
  * from the per-request logs that `warn` intentionally suppresses.
79
79
  */
80
- export function printStartupNotice(info: { port: number; machines: number; routes: number }): void {
80
+ export function printStartupNotice(info: {
81
+ port: number
82
+ machines: number
83
+ routes: number
84
+ /** Whether state survives a restart. Always shown: a deploy log should not
85
+ * have to infer the persistence posture from the absence of a warning. */
86
+ persistence?: string
87
+ }): void {
81
88
  const v = statorVersion()
82
- const inv = `${info.machines} machine${info.machines === 1 ? '' : 's'} · ${info.routes} route${
83
- info.routes === 1 ? '' : 's'
84
- }`
89
+ const inv = `${info.machines} machine${info.machines === 1 ? '' : 's'} · ${
90
+ info.routes
91
+ } route${info.routes === 1 ? '' : 's'}${info.persistence ? ` · ${info.persistence}` : ''}`
85
92
  process.stdout.write(
86
93
  ` ${c.copper(c.bold('stator'))}${v ? c.dim(` v${v}`) : ''} ${c.dim('·')} ${c.cyan(
87
94
  `http://localhost:${info.port}/`,