@statorjs/stator 2.9.1 → 2.10.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.9.1",
3
+ "version": "2.10.0-next.0",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.9.1"
98
+ "@statorjs/stator": "2.10.0-next.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,182 @@
1
+ import { cp, readFile, stat, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, resolve } from 'node:path'
3
+
4
+ /**
5
+ * The dependency half of the deploy artifact: what `dist/` needs beside its code
6
+ * so the target can materialize `node_modules` itself.
7
+ *
8
+ * The framework never copies `node_modules` into `dist/`. Doing so would bake
9
+ * the build machine's platform into the artifact — `sharp` resolves to
10
+ * `@img/sharp-darwin-arm64` on a Mac and `@img/sharp-linux-x64` in a container,
11
+ * so a traced tree ships a binary that cannot load. Installing on the target is
12
+ * the correct move; the artifact's job is to make that install REPRODUCIBLE.
13
+ *
14
+ * Which means the lockfile is the thing that matters. Resolving dependencies at
15
+ * deploy time is how a deploy picks up a transitive version nobody tested:
16
+ * `npm install` in production is the bug, and `npm ci` (or a frozen `pnpm
17
+ * install`) against a real lockfile is the fix. So:
18
+ *
19
+ * - **A lockfile beside the app's `package.json`** means the app is
20
+ * self-contained. Both files are copied VERBATIM, and the target runs
21
+ * `npm ci --omit=dev` (or `pnpm install --frozen-lockfile --prod`) against
22
+ * exactly what was locked. A copied manifest also keeps a frozen `pnpm
23
+ * install` happy, which compares the lockfile's importer manifest against
24
+ * `package.json` and rejects a synthesized one.
25
+ *
26
+ * - **No lockfile beside it** means a workspace member: the lockfile lives at
27
+ * the monorepo root and covers every package, and the manifest may declare
28
+ * `workspace:*` specifiers that no registry can install. Neither file can
29
+ * travel, so a manifest is SYNTHESIZED instead — every reached dependency
30
+ * pinned to the version actually installed at build time, `workspace:*`
31
+ * included. Direct dependencies are then exact; transitives resolve at
32
+ * install. That gap is real and is reported, not hidden: for full
33
+ * reproducibility a workspace app should resolve at build time instead
34
+ * (`pnpm deploy --prod`, or build inside the image).
35
+ *
36
+ * Pins come from the INSTALLED tree, never from declared ranges. `npm ci`
37
+ * validates the lockfile against the manifest and refuses a mismatch
38
+ * (`Invalid: lock file's debug@4.4.3 does not satisfy debug@4.3.4`), so a pin
39
+ * taken from a range would break the very install this exists to enable.
40
+ */
41
+
42
+ /** Lockfiles, in the order a package manager would be inferred from them. */
43
+ const LOCKFILES = [
44
+ 'pnpm-lock.yaml',
45
+ 'package-lock.json',
46
+ 'npm-shrinkwrap.json',
47
+ 'yarn.lock',
48
+ 'bun.lock',
49
+ 'bun.lockb',
50
+ ]
51
+
52
+ /** How the target should install, per lockfile — a frozen install in each case. */
53
+ const INSTALL_COMMAND: Record<string, string> = {
54
+ 'pnpm-lock.yaml': 'pnpm install --frozen-lockfile --prod',
55
+ 'package-lock.json': 'npm ci --omit=dev',
56
+ 'npm-shrinkwrap.json': 'npm ci --omit=dev',
57
+ 'yarn.lock': 'yarn install --immutable',
58
+ 'bun.lock': 'bun install --frozen-lockfile --production',
59
+ 'bun.lockb': 'bun install --frozen-lockfile --production',
60
+ }
61
+
62
+ export interface ArtifactDeps {
63
+ /** `copied` — the app's own manifest and lockfile travelled verbatim, so the
64
+ * install on the target is fully locked. `generated` — a workspace member,
65
+ * so a pinned manifest was synthesized and transitives resolve at install.
66
+ * `none` — no `package.json` at the app root. */
67
+ kind: 'copied' | 'generated' | 'none'
68
+ /** Files written into dist, app-relative. */
69
+ files: string[]
70
+ /** The install to run in the artifact, ready to print. */
71
+ install?: string
72
+ /** Dependencies written into a generated manifest, name → exact version. */
73
+ pinned?: Record<string, string>
74
+ /** Reached packages whose installed version could not be read; the declared
75
+ * range was used instead, so their install is not exactly reproducible. */
76
+ unpinned?: string[]
77
+ }
78
+
79
+ const exists = (p: string): Promise<boolean> =>
80
+ stat(p).then(
81
+ () => true,
82
+ () => false,
83
+ )
84
+
85
+ /**
86
+ * The version actually installed, by walking `node_modules` up from the app
87
+ * root. Deliberately not `require.resolve`: a package with strict `exports` can
88
+ * refuse to expose its own `package.json`, and this needs to work for every
89
+ * dependency, not the well-behaved ones.
90
+ */
91
+ async function installedVersion(root: string, name: string): Promise<string | undefined> {
92
+ let dir = root
93
+ for (;;) {
94
+ const manifest = join(dir, 'node_modules', name, 'package.json')
95
+ try {
96
+ const parsed = JSON.parse(await readFile(manifest, 'utf8')) as { version?: unknown }
97
+ if (typeof parsed.version === 'string') return parsed.version
98
+ } catch {
99
+ // not here — keep walking up
100
+ }
101
+ const parent = dirname(dir)
102
+ if (parent === dir) return undefined
103
+ dir = parent
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Write the artifact's dependency manifest into `outDir`. `packages` is the set
109
+ * of direct dependencies the app's code reached (`CopySet.packages`).
110
+ */
111
+ export async function writeArtifactDeps(opts: {
112
+ root: string
113
+ outDir: string
114
+ packages: readonly string[]
115
+ }): Promise<ArtifactDeps> {
116
+ const root = resolve(opts.root)
117
+ const outDir = resolve(opts.outDir)
118
+ const manifestPath = join(root, 'package.json')
119
+ if (!(await exists(manifestPath))) return { kind: 'none', files: [] }
120
+
121
+ const lockfile = await (async () => {
122
+ for (const name of LOCKFILES) if (await exists(join(root, name))) return name
123
+ return undefined
124
+ })()
125
+
126
+ // Self-contained app: both files travel as they are, and the install on the
127
+ // target is locked to exactly what was tested.
128
+ if (lockfile) {
129
+ await cp(manifestPath, join(outDir, 'package.json'))
130
+ await cp(join(root, lockfile), join(outDir, lockfile))
131
+ return {
132
+ kind: 'copied',
133
+ files: ['package.json', lockfile],
134
+ install: INSTALL_COMMAND[lockfile],
135
+ }
136
+ }
137
+
138
+ // Workspace member: synthesize a manifest whose pins come from the installed
139
+ // tree, so `workspace:*` becomes a real version and nothing is left to
140
+ // resolve for the direct dependencies.
141
+ const app = JSON.parse(await readFile(manifestPath, 'utf8')) as {
142
+ name?: string
143
+ version?: string
144
+ dependencies?: Record<string, string>
145
+ devDependencies?: Record<string, string>
146
+ }
147
+ const pinned: Record<string, string> = {}
148
+ const unpinned: string[] = []
149
+ for (const name of [...opts.packages].sort()) {
150
+ const version = await installedVersion(root, name)
151
+ if (version) {
152
+ pinned[name] = version
153
+ continue
154
+ }
155
+ // Nothing installed to read: fall back to whatever the app declared, and
156
+ // say so — this dependency's install is not exactly reproducible.
157
+ const declared = app.dependencies?.[name] ?? app.devDependencies?.[name]
158
+ if (declared && !declared.startsWith('workspace:')) pinned[name] = declared
159
+ else pinned[name] = '*'
160
+ unpinned.push(name)
161
+ }
162
+
163
+ const generated = {
164
+ name: app.name ?? 'stator-app',
165
+ version: app.version ?? '0.0.0',
166
+ private: true,
167
+ // dist has no package.json of its own today, so Node walks up and inherits
168
+ // the app's `type`. Once the artifact is the deploy root there is nothing
169
+ // above it to inherit from.
170
+ type: 'module',
171
+ dependencies: pinned,
172
+ scripts: { start: 'stator start' },
173
+ }
174
+ await writeFile(join(outDir, 'package.json'), `${JSON.stringify(generated, null, 2)}\n`)
175
+ return {
176
+ kind: 'generated',
177
+ files: ['package.json'],
178
+ install: 'npm install --omit=dev',
179
+ pinned,
180
+ ...(unpinned.length > 0 ? { unpinned } : {}),
181
+ }
182
+ }
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
- import { dirname, join, relative, resolve, sep } from 'node:path'
2
+ import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join, relative, resolve } from 'node:path'
4
5
  import { compile, regionResolverFor } from '../compiler/index.ts'
5
6
  import { hashMachines } from '../server/machine-hash.ts'
7
+ import { type ArtifactDeps, writeArtifactDeps } from './artifact.ts'
8
+ import { type CopySet, resolveCopySet } from './copy-set.ts'
6
9
  import { bundleIslands, routeIslandMap, walkFiles } from './islands.ts'
7
10
  import { sourceId } from './source-id.ts'
8
11
 
@@ -10,7 +13,9 @@ import { sourceId } from './source-id.ts'
10
13
  * Production build: compile a `.stator` app to a `dist/` of plain `.ts` that the
11
14
  * existing `createApp` + tsx runtime serves with no Vite.
12
15
  *
13
- * 1. copy machines / routes / templates / static into dist
16
+ * 1. copy what the app's module graph reaches (see `resolveCopySet`) — the
17
+ * directories its routes/machines/hooks import from, the root-level files
18
+ * they open, and `static/`
14
19
  * 2. compile each `*.stator` → a sibling `*.stator.ts`, delete the `.stator`,
15
20
  * accumulate scoped CSS; for client components also write the generated
16
21
  * client entry as a sibling `*.stator.client.ts`
@@ -34,11 +39,16 @@ export interface BuildConfig {
34
39
  root: string
35
40
  /** Output directory. Wiped and recreated. */
36
41
  outDir: string
37
- /** Subdirectories to copy into dist. Defaults to every top-level directory
38
- * in the app root except node_modules, tests, hidden dirs, and the outDir
39
- * itself — machines and routes import freely from sibling dirs (lib/,
40
- * data/), so dist must mirror the app's source shape. */
42
+ /** Override the copied directories entirely. Normally omitted: the copy set
43
+ * is derived from the app's own module graph — see `resolveCopySet`. */
41
44
  dirs?: string[]
45
+ /** Extra app-relative paths to copy verbatim — the escape hatch for what no
46
+ * import graph can see (a directory reached through a runtime-built path). */
47
+ include?: string[]
48
+ /** What to do about an `import()` no static analysis can follow. `error`
49
+ * (the default) fails the build naming each one: a copy set that silently
50
+ * omits a lazily-imported module is a production 500. */
51
+ untracedImports?: 'error' | 'warn'
42
52
  }
43
53
 
44
54
  export interface BuildResult {
@@ -56,6 +66,12 @@ export interface BuildResult {
56
66
  * previous build's manifest — their sessions reset on deploy. `undefined`
57
67
  * when there was no previous manifest to compare against. */
58
68
  resetMachines?: string[]
69
+ /** What the module graph said `dist/` needs, and what it left behind. The
70
+ * CLI prints this — a copy set derived from code should be visible, not
71
+ * inferred from what shows up in `dist/`. */
72
+ copySet: CopySet
73
+ /** How the artifact declares its dependencies, and how to install them. */
74
+ deps: ArtifactDeps
59
75
  }
60
76
 
61
77
  /** Shape of `dist/stator-manifest.json` (always written — carries `buildId`). */
@@ -71,27 +87,38 @@ export interface StatorManifest {
71
87
  * `stator start` for the snapshot hydration policy; the build fails if a
72
88
  * machine's closure cannot be bundled, so this lands in CI, not at boot. */
73
89
  machines: Record<string, string>
90
+ /** The config file copied into the artifact, or `null` when the app has none.
91
+ * `stator start` reads config from the artifact and nowhere else, so it needs
92
+ * to tell "this app has no config" from "the config didn't make the trip" —
93
+ * the second is a partial copy and must fail loudly rather than silently
94
+ * falling back to in-memory persistence. Absent on a dist built before this
95
+ * existed, which is itself the signal to rebuild. */
96
+ config: string | null
97
+ /** The `@statorjs/stator` version that produced this artifact. */
98
+ statorVersion: string
74
99
  }
75
100
 
76
- const NEVER_COPY = new Set(['node_modules', 'tests', 'test', '__tests__'])
77
-
78
- /** Every top-level directory that can hold app source. Machines/routes
79
- * import from arbitrary sibling dirs, so dist mirrors the source tree. */
80
- async function discoverSourceDirs(root: string, outDir: string): Promise<string[]> {
81
- const outBase = relative(root, outDir).split(sep)[0]
82
- const entries = await readdir(root, { withFileTypes: true })
83
- return entries
84
- .filter(
85
- (e) =>
86
- e.isDirectory() && !e.name.startsWith('.') && !NEVER_COPY.has(e.name) && e.name !== outBase,
87
- )
88
- .map((e) => e.name)
89
- }
101
+ const statorVersion: string = (
102
+ createRequire(import.meta.url)('../../package.json') as { version: string }
103
+ ).version
90
104
 
91
105
  export async function buildApp(config: BuildConfig): Promise<BuildResult> {
92
106
  const root = resolve(config.root)
93
107
  const outDir = resolve(config.outDir)
94
- const dirs = config.dirs ?? (await discoverSourceDirs(root, outDir))
108
+
109
+ // What dist needs, from the app's own graph rather than a directory denylist.
110
+ const copySet = await resolveCopySet({ root, include: config.include })
111
+ if (copySet.untraced.length > 0 && (config.untracedImports ?? 'error') === 'error') {
112
+ const where = copySet.untraced.map((u) => ` ${u.file}:${u.line} ${u.source}`).join('\n')
113
+ throw new Error(
114
+ `stator build: ${copySet.untraced.length} dynamic import${
115
+ copySet.untraced.length === 1 ? '' : 's'
116
+ } cannot be traced, so the build cannot know what to copy:\n${where}\n` +
117
+ `Use a string literal (or a template literal with a fixed prefix, which is expanded), ` +
118
+ `list the directories it reaches in \`build.include\`, or set \`build.untracedImports: 'warn'\` to ship anyway.`,
119
+ )
120
+ }
121
+ const dirs = config.dirs ?? copySet.dirs
95
122
 
96
123
  // Remember the previous build's machine hashes (if any) so the build can
97
124
  // report which machines' sessions this deploy resets.
@@ -106,13 +133,19 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
106
133
  if (await exists(src)) await cp(src, join(outDir, d), { recursive: true })
107
134
  }
108
135
 
109
- // A root-level middleware.ts is a single file, not a source dir — copy it too
110
- // (raw TS, run under tsx/native in prod like the rest of the server).
111
- const middlewareSrc = join(root, 'middleware.ts')
112
- if (await exists(middlewareSrc)) await cp(middlewareSrc, join(outDir, 'middleware.ts'))
113
- // Same for a root-level boot.ts (the once-at-startup hook).
114
- const bootSrc = join(root, 'boot.ts')
115
- if (await exists(bootSrc)) await cp(bootSrc, join(outDir, 'boot.ts'))
136
+ // Root-level files the graph reached: the single-file hooks (middleware.ts,
137
+ // boot.ts, stator.config.*) and any data file a module opens by path — an
138
+ // `import.meta.url`-relative SQLite file being the case that used to be
139
+ // missed entirely, since the old copy step only ever handled directories.
140
+ for (const file of copySet.files) {
141
+ const src = join(root, file)
142
+ if (!(await exists(src))) continue
143
+ const dest = join(outDir, file)
144
+ // `build.include` may name a nested file, and cp() will not create its
145
+ // parent for us.
146
+ if (file.includes('/')) await mkdir(dirname(dest), { recursive: true })
147
+ await cp(src, dest)
148
+ }
116
149
 
117
150
  // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
118
151
  // The sources are deleted only after the whole set compiles — cross-file
@@ -169,12 +202,33 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
169
202
  ? Object.keys(machines).filter((k) => previous[k] !== machines[k])
170
203
  : undefined
171
204
 
205
+ // The dependency half of the artifact: the app's own manifest + lockfile when
206
+ // it has one, else a pinned manifest. Written after the tree so a generated
207
+ // package.json cannot be clobbered by the copy step.
208
+ const deps = await writeArtifactDeps({ root, outDir, packages: copySet.packages })
209
+
172
210
  // Always write the manifest — it carries the build-id even for an app with no
173
211
  // islands (a live route without islands still needs the reload handshake).
212
+ // The config file the artifact carries, by name — `copySet.files` holds it if
213
+ // the app has one, since the graph walk treats it as an entry point.
214
+ const configFile = copySet.files.find((f) => /^stator\.config\.(ts|mts|js|mjs)$/.test(f)) ?? null
174
215
  const manifest: StatorManifest =
175
216
  islands.length > 0
176
- ? { buildId: randomUUID(), ...(await buildClientAssets(outDir, islands)), machines }
177
- : { buildId: randomUUID(), islands: {}, routes: {}, machines }
217
+ ? {
218
+ buildId: randomUUID(),
219
+ ...(await buildClientAssets(outDir, islands)),
220
+ machines,
221
+ config: configFile,
222
+ statorVersion,
223
+ }
224
+ : {
225
+ buildId: randomUUID(),
226
+ islands: {},
227
+ routes: {},
228
+ machines,
229
+ config: configFile,
230
+ statorVersion,
231
+ }
178
232
  await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
179
233
 
180
234
  return {
@@ -185,6 +239,8 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
185
239
  machines: machineFiles.length,
186
240
  machineHashMs,
187
241
  ...(resetMachines ? { resetMachines } : {}),
242
+ copySet,
243
+ deps,
188
244
  }
189
245
  }
190
246
 
@@ -0,0 +1,349 @@
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 { compile, regionResolverFor } from '../compiler/index.ts'
7
+
8
+ /**
9
+ * What `stator build` copies into `dist/` — derived from the app's own module
10
+ * graph, not from a list of directory names.
11
+ *
12
+ * The build used to copy every top-level directory that wasn't on a denylist,
13
+ * which meant runtime data (an uploads dir, a JSON cache a deploy script
14
+ * maintains) landed in the artifact, while a root-level file a module actually
15
+ * reads — `new URL('../app.db', import.meta.url)` — did not. Both failures come
16
+ * from the same place: guessing at the app's shape instead of asking the code.
17
+ *
18
+ * So one esbuild pass walks the graph from the entry points the FRAMEWORK
19
+ * itself loads — the route and machine files, plus the root single-file hooks —
20
+ * and everything else is copied because it was reached. `templates/`, `lib/`,
21
+ * `components/` are not names this module knows; they are consequences. The
22
+ * pass parses nothing itself: resolution is esbuild's, so tsconfig `paths`,
23
+ * extensionless specifiers, index files and `exports` maps behave exactly as
24
+ * they do at runtime, and `.stator` files are compiled on load so frontmatter
25
+ * imports are in the graph like any other import.
26
+ *
27
+ * Copying is DIRECTORY-granular on purpose. A module's data files — a template
28
+ * read with `readFile`, a fixture JSON — are invisible to any import graph, so
29
+ * a reached module brings its whole top-level directory along. The cost is
30
+ * copying a few unreferenced siblings; the alternative is a dist that is
31
+ * missing files nothing declared.
32
+ *
33
+ * Nothing from `node_modules` is traced or copied. A bare specifier is recorded
34
+ * as a direct dependency and left external, so the walk stops there and never
35
+ * descends into a dependency's own imports — `dist/` holds no `node_modules`,
36
+ * and the recorded list is the app's own dependency set, which is exactly what
37
+ * a manifest should declare.
38
+ */
39
+
40
+ /** Directories whose files are entry points, by framework convention. */
41
+ const ENTRY_DIRS = ['routes', 'machines']
42
+ /** Root-level single-file entry points. Each is optional. */
43
+ const ENTRY_FILES = [
44
+ 'middleware.ts',
45
+ 'boot.ts',
46
+ 'stator.config.ts',
47
+ 'stator.config.mts',
48
+ 'stator.config.js',
49
+ 'stator.config.mjs',
50
+ ]
51
+ /** Directories the framework serves by path rather than importing. */
52
+ const SERVED_DIRS = ['static']
53
+ /** Never app source, so never worth reporting as an uncopied candidate. */
54
+ const NEVER_SOURCE = new Set(['node_modules', 'dist', 'tests', 'test', '__tests__'])
55
+
56
+ const CODE = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx|stator)$/
57
+ const BUILTIN = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
58
+ /** `new URL('./x', import.meta.url)` — invisible to the bundler, so read the
59
+ * literal directly. Same limit as the client seam: relative string literals.
60
+ * (Injecting an import instead does NOT work: esbuild's TS loader elides an
61
+ * import whose binding is unused, so the injected one disappears.) */
62
+ const URL_ASSET_RE = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"\n]+)\1\s*,\s*import\.meta\.url\s*\)/g
63
+ /** A `node_modules` path segment, either separator. Anything under one is a
64
+ * dependency, never app source — and a real app's `node_modules` sits INSIDE
65
+ * its root, so root containment alone cannot tell them apart. */
66
+ const NODE_MODULES = /[\\/]node_modules[\\/]/
67
+ /** `import(` not followed by a string or template literal. */
68
+ const OPAQUE_IMPORT_RE = /(?<![.\w$])import\s*\(\s*([^'"`)\s])/g
69
+
70
+ const norm = (p: string): string => p.replace(/\\/g, '/')
71
+
72
+ export interface UntracedImport {
73
+ /** App-relative file. */
74
+ file: string
75
+ line: number
76
+ /** The call as written, truncated. */
77
+ source: string
78
+ }
79
+
80
+ export interface CopySetOptions {
81
+ /** App source root. */
82
+ root: string
83
+ /** Extra app-relative paths (directories or files) to copy verbatim — the
84
+ * escape hatch for what no import graph can see, e.g. a directory read
85
+ * through a runtime-built path. */
86
+ include?: string[]
87
+ }
88
+
89
+ export interface CopySet {
90
+ /** Top-level directories to copy whole. */
91
+ dirs: string[]
92
+ /** Root-level files to copy. */
93
+ files: string[]
94
+ /** Top-level directories present in the root that nothing reached. */
95
+ unused: string[]
96
+ /** Direct dependencies: the package name behind every bare specifier the
97
+ * app's own code reached. NOT transitive — a dependency is external, so it
98
+ * is never loaded and its own imports are never resolved. That is the right
99
+ * granularity for a generated manifest (a package manager resolves
100
+ * transitives from each dependency's own package.json) and for checking
101
+ * runtime imports against `dependencies` rather than `devDependencies`:
102
+ * a type-only import is elided before resolution, so it never lands here. */
103
+ packages: string[]
104
+ /** Reached files outside the app root — impossible to place in `dist/`. */
105
+ external: string[]
106
+ /** Specifiers esbuild could not resolve. Not fatal here: `stator check`
107
+ * typechecks the same graph and is the gate for a genuinely broken import. */
108
+ unresolved: string[]
109
+ /** `import()` calls no static analysis can follow. */
110
+ untraced: UntracedImport[]
111
+ /** App-local files the graph reached, app-relative. */
112
+ reached: string[]
113
+ ms: number
114
+ }
115
+
116
+ const exists = (p: string): Promise<boolean> =>
117
+ stat(p).then(
118
+ () => true,
119
+ () => false,
120
+ )
121
+
122
+ async function walk(dir: string, match: (f: string) => boolean): Promise<string[]> {
123
+ const out: string[] = []
124
+ let entries: Dirent[]
125
+ try {
126
+ entries = await readdir(dir, { withFileTypes: true })
127
+ } catch {
128
+ return out
129
+ }
130
+ for (const e of entries) {
131
+ const full = join(dir, e.name)
132
+ if (e.isDirectory()) out.push(...(await walk(full, match)))
133
+ else if (match(full)) out.push(full)
134
+ }
135
+ return out
136
+ }
137
+
138
+ /**
139
+ * Walk the app's server graph and report what `dist/` needs. Writes nothing and
140
+ * runs no app code — `.stator` is compiled, everything else is only parsed.
141
+ */
142
+ export async function resolveCopySet(opts: CopySetOptions): Promise<CopySet> {
143
+ const root = resolve(opts.root)
144
+ // esbuild reports REAL paths (on macOS /var is a symlink to /private/var), so
145
+ // classify against the real root or every reached file looks external and
146
+ // nothing gets copied. Same lesson as `hashMachines`.
147
+ const realRoot = ((): string => {
148
+ try {
149
+ return realpathSync(root)
150
+ } catch {
151
+ return root
152
+ }
153
+ })()
154
+ /**
155
+ * Is this absolute path inside the app root? `path.relative` is the only
156
+ * correct test on Windows: paths there are case-insensitive and esbuild's
157
+ * spelling of a drive letter need not match ours, so comparing normalized
158
+ * strings with `startsWith` reports a perfectly good in-root file as
159
+ * external — and across drives `relative` returns an ABSOLUTE path, which a
160
+ * `..` check alone would miss. (win32's `relative` lowercases before
161
+ * comparing; posix is unaffected.)
162
+ */
163
+ const insideRoot = (abs: string): boolean => {
164
+ const rel = relative(realRoot, abs)
165
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
166
+ }
167
+
168
+ const t0 = performance.now()
169
+
170
+ const entries: string[] = []
171
+ for (const dir of ENTRY_DIRS) entries.push(...(await walk(join(root, dir), (f) => CODE.test(f))))
172
+ for (const name of ENTRY_FILES) {
173
+ const p = join(root, name)
174
+ if (await exists(p)) entries.push(p)
175
+ }
176
+ if (entries.length === 0) {
177
+ throw new Error(
178
+ `stator build: no entry points found — expected ${ENTRY_DIRS.map((d) => `${d}/`).join(' or ')} under ${root}`,
179
+ )
180
+ }
181
+
182
+ const reached = new Set<string>()
183
+ const assets = new Set<string>()
184
+ const packages = new Set<string>()
185
+ const external = new Set<string>()
186
+ const unresolved = new Set<string>()
187
+ const untraced: UntracedImport[] = []
188
+
189
+ /** Record the non-import dependencies a source text carries. */
190
+ const scan = (text: string, file: string): string => {
191
+ for (const m of text.matchAll(URL_ASSET_RE)) assets.add(resolve(dirname(file), m[2]!))
192
+ for (const m of text.matchAll(OPAQUE_IMPORT_RE)) {
193
+ untraced.push({
194
+ file: norm(relative(realRoot, file)),
195
+ line: text.slice(0, m.index).split('\n').length,
196
+ source: `${text
197
+ .slice(m.index, m.index + 60)
198
+ .split('\n')[0]!
199
+ .trim()}…`,
200
+ })
201
+ }
202
+ return text
203
+ }
204
+
205
+ try {
206
+ await build({
207
+ // Unique synthetic output name per entry: a `routes/x.stator` +
208
+ // `routes/x.ts` pair (the GET/POST route merge) otherwise collides on one
209
+ // output path and fails the whole pass.
210
+ entryPoints: entries.map((f) => ({
211
+ in: f,
212
+ out: norm(relative(root, f)).replace(/[\\/]/g, '_').replace(/\./g, '-'),
213
+ })),
214
+ absWorkingDir: root,
215
+ bundle: true,
216
+ write: false,
217
+ format: 'esm',
218
+ platform: 'node',
219
+ target: 'node20',
220
+ outdir: join(root, '.stator-copy-set'), // virtual — write: false
221
+ logLevel: 'silent',
222
+ plugins: [
223
+ {
224
+ name: 'stator-copy-set',
225
+ setup(b) {
226
+ b.onResolve({ filter: /.*/ }, async (args) => {
227
+ if (args.kind === 'entry-point' || args.pluginData?.resolved) return null
228
+ if (BUILTIN.has(args.path)) return { path: args.path, external: true }
229
+
230
+ const r = await b.resolve(args.path, {
231
+ kind: args.kind,
232
+ importer: args.importer,
233
+ resolveDir: args.resolveDir,
234
+ pluginData: { resolved: true },
235
+ })
236
+ if (r.errors.length > 0) {
237
+ unresolved.add(`${args.path} (from ${norm(relative(realRoot, args.importer))})`)
238
+ return { path: args.path, external: true }
239
+ }
240
+ const abs = r.path
241
+ // Package or app file? Neither the path nor the specifier settles
242
+ // it alone, so both are consulted. A dependency installed the
243
+ // ordinary way lives UNDER the app root in `node_modules`, so
244
+ // containment cannot exclude it; a dependency reached through a
245
+ // workspace link resolves to a real path with no `node_modules`
246
+ // segment, so the path cannot exclude it either. Meanwhile a
247
+ // tsconfig `paths` alias looks exactly like a bare specifier and
248
+ // is app code. So: under node_modules is always a package, and
249
+ // outside the root a bare specifier is a package while a relative
250
+ // one is an app file `dist` cannot contain.
251
+ const bare = !args.path.startsWith('.') && !isAbsolute(args.path)
252
+ const inRoot = isAbsolute(abs) && insideRoot(abs)
253
+ if (NODE_MODULES.test(abs) || (!inRoot && bare)) {
254
+ if (bare) {
255
+ packages.add(
256
+ args.path.startsWith('@')
257
+ ? args.path.split('/').slice(0, 2).join('/')
258
+ : args.path.split('/')[0]!,
259
+ )
260
+ }
261
+ return { path: abs, external: true }
262
+ }
263
+ if (!inRoot) {
264
+ external.add(norm(abs))
265
+ return { path: abs, external: true }
266
+ }
267
+ if (!CODE.test(abs)) {
268
+ assets.add(abs) // data file: recorded, never parsed
269
+ return { path: abs, external: true }
270
+ }
271
+ return { path: abs, pluginData: { resolved: true } }
272
+ })
273
+
274
+ b.onLoad({ filter: /\.stator$/ }, async (args) => {
275
+ reached.add(args.path)
276
+ const source = await readFile(args.path, 'utf8')
277
+ const kind = /[\\/]routes[\\/].*\.stator$/.test(args.path) ? 'route' : 'component'
278
+ const out = compile(source, {
279
+ id: args.path,
280
+ kind,
281
+ resolveRegions: regionResolverFor(args.path, source),
282
+ })
283
+ return {
284
+ contents: scan(out.serverCode, args.path),
285
+ loader: 'ts',
286
+ resolveDir: dirname(args.path),
287
+ }
288
+ })
289
+
290
+ b.onLoad({ filter: CODE }, async (args) => {
291
+ reached.add(args.path)
292
+ const source = await readFile(args.path, 'utf8')
293
+ return {
294
+ contents: scan(source, args.path),
295
+ loader: /\.(tsx|jsx)$/.test(args.path)
296
+ ? 'tsx'
297
+ : /\.(js|mjs|cjs)$/.test(args.path)
298
+ ? 'js'
299
+ : 'ts',
300
+ resolveDir: dirname(args.path),
301
+ }
302
+ })
303
+ },
304
+ },
305
+ ],
306
+ })
307
+ } catch (err) {
308
+ const errors = (err as { errors?: Array<{ text: string }> }).errors ?? []
309
+ throw new Error(
310
+ `stator build: could not walk the app's module graph — ${
311
+ errors.length > 0 ? errors.map((e) => e.text).join('; ') : (err as Error).message
312
+ }`,
313
+ )
314
+ }
315
+
316
+ const dirs = new Set<string>()
317
+ const files = new Set<string>()
318
+ for (const abs of [...reached, ...assets]) {
319
+ // Assets come from the `new URL` scan rather than resolution, so they get
320
+ // the same containment test the resolver hook applies.
321
+ if (!insideRoot(abs)) continue
322
+ const rel = norm(relative(realRoot, abs))
323
+ if (rel.includes('/')) dirs.add(rel.split('/')[0]!)
324
+ else files.add(rel)
325
+ }
326
+ for (const dir of SERVED_DIRS) if (await exists(join(root, dir))) dirs.add(dir)
327
+ for (const extra of opts.include ?? []) {
328
+ const rel = norm(extra).replace(/^\.\//, '').replace(/\/+$/, '')
329
+ if (!(await exists(join(root, rel)))) continue
330
+ if ((await stat(join(root, rel))).isDirectory() || !rel.includes('/')) dirs.add(rel)
331
+ else files.add(rel)
332
+ }
333
+
334
+ const topLevel = (await readdir(root, { withFileTypes: true }))
335
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !NEVER_SOURCE.has(e.name))
336
+ .map((e) => e.name)
337
+
338
+ return {
339
+ dirs: [...dirs].sort(),
340
+ files: [...files].sort(),
341
+ unused: topLevel.filter((d) => !dirs.has(d) && d !== 'dist').sort(),
342
+ packages: [...packages].sort(),
343
+ external: [...external].sort(),
344
+ unresolved: [...unresolved].sort(),
345
+ untraced,
346
+ reached: [...reached].map((f) => norm(relative(realRoot, f))).sort(),
347
+ ms: Math.round(performance.now() - t0),
348
+ }
349
+ }
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}/`,
@@ -18,6 +18,7 @@ import { discoverMiddleware } from './middleware.ts'
18
18
  import { discoverRoutes } from './route-discovery.ts'
19
19
  import { setSessionSameSite } from './session.ts'
20
20
  import { InMemoryStore, type Store } from './store.ts'
21
+ import { persistencePosture } from './store-intent.ts'
21
22
 
22
23
  export interface CreateAppConfig extends DeprecatedFlatConfig {
23
24
  machinesDir: string
@@ -169,7 +170,23 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
169
170
  return new Promise((resolveFn) => {
170
171
  const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, async () => {
171
172
  // Always-on (level-independent) so `warn` prod still confirms boot.
172
- printStartupNotice({ port, machines: defs.length, routes: routes.length })
173
+ // Persistence posture in the notice, and a warning for anything
174
+ // actually at risk. Silence about in-memory storage is the defect
175
+ // this closes: the app runs, looks healthy, and loses every session
176
+ // on restart.
177
+ const posture = persistencePosture({
178
+ session: sessionStore,
179
+ app: store.appStore,
180
+ machines: defs,
181
+ production: process.env.NODE_ENV === 'production',
182
+ })
183
+ printStartupNotice({
184
+ port,
185
+ machines: defs.length,
186
+ routes: routes.length,
187
+ persistence: posture.label,
188
+ })
189
+ for (const warning of posture.warnings) logger.warn({}, `stator: ${warning}`)
173
190
  // Run boot.ts once the server is up — its long-lived work (a poll, a
174
191
  // subscription) starts here, not during createApp, so tests that only
175
192
  // `app.fetch` never trigger it.
@@ -136,3 +136,5 @@ export {
136
136
  } from './sse.ts'
137
137
  export type { Store } from './store.ts'
138
138
  export { InMemoryStore } from './store.ts'
139
+ export type { StoreFromEnvOptions } from './store-intent.ts'
140
+ export { appStore, sessionStore } from './store-intent.ts'
@@ -0,0 +1,175 @@
1
+ import type { AppStore } from './app-store.ts'
2
+ import { InMemoryAppStore } from './app-store.ts'
3
+ import { CachedStore, type CachedStoreOptions } from './cached-store.ts'
4
+ import { RedisAppStore, RedisStore } from './redis-store.ts'
5
+ import { InMemoryStore, type Store } from './store.ts'
6
+
7
+ /**
8
+ * Picking a store from the environment, without the silent downgrade.
9
+ *
10
+ * Every app that keeps state in Redis writes the same conditional: a URL from
11
+ * the environment, a durable store when it's there, in-memory when it isn't.
12
+ * That fallback is right for dev and for CI — a build machine has no business
13
+ * holding production credentials — and wrong in exactly one place, production,
14
+ * where it means the app runs, looks healthy, and quietly loses every session
15
+ * on restart. Written in userland the framework cannot tell that outcome from a
16
+ * deliberate choice, so it cannot say anything about it.
17
+ *
18
+ * Moving the conditional in here changes only that: the returned store carries
19
+ * whether in-memory was *chosen* or *fallen back to*, so startup can be silent
20
+ * in development and loud in production, naming the empty variable instead of
21
+ * reporting a generic posture.
22
+ *
23
+ * The environment stays in charge. A URL present means durable, wherever it
24
+ * comes from — so pointing CI at a test Redis is nothing special, it is just
25
+ * the variable being set. An empty or whitespace-only value counts as absent,
26
+ * which is what a CI system that defines every variable as `""` produces.
27
+ */
28
+
29
+ /** Recorded on a fallback store so startup can tell it from a deliberate one.
30
+ * Non-enumerable and internal: the value still satisfies `Store` exactly. */
31
+ const INTENT = Symbol.for('stator.store.intent')
32
+
33
+ interface StoreIntent {
34
+ /** What the app asked for. */
35
+ wanted: 'durable'
36
+ /** The config field that was empty, for the message. */
37
+ from: string
38
+ }
39
+
40
+ function mark<T extends object>(store: T, intent: StoreIntent): T {
41
+ Object.defineProperty(store, INTENT, { value: intent, enumerable: false })
42
+ return store
43
+ }
44
+
45
+ /** The recorded intent of a store, if it was built by these helpers. */
46
+ export function storeIntent(store: unknown): StoreIntent | undefined {
47
+ if (store === null || typeof store !== 'object') return undefined
48
+ return (store as Record<symbol, StoreIntent | undefined>)[INTENT]
49
+ }
50
+
51
+ /** True for the framework's own in-memory adapters — the ones that do not
52
+ * survive a restart. A store the app supplied is assumed durable; the
53
+ * framework has no way to ask, and no business guessing. */
54
+ export function isEphemeral(store: unknown): boolean {
55
+ return store instanceof InMemoryStore || store instanceof InMemoryAppStore
56
+ }
57
+
58
+ const blank = (url: string | undefined): boolean => url === undefined || url.trim() === ''
59
+
60
+ export interface StoreFromEnvOptions {
61
+ /** Redis connection string, typically `process.env.REDIS_URL`. Absent, empty
62
+ * or whitespace-only selects in-memory — normal in dev and CI, reported at
63
+ * startup in production. */
64
+ redisUrl?: string
65
+ /** Wrap Redis in a write-through memory cache. Off by default: caching is a
66
+ * performance decision, and this helper's job is the durable-or-not one. */
67
+ cache?: CachedStoreOptions | true
68
+ /** Key prefix, passed through to the Redis adapter. */
69
+ keyPrefix?: string
70
+ /** Name used in the startup report when the URL is empty. Default `REDIS_URL`. */
71
+ envName?: string
72
+ }
73
+
74
+ /**
75
+ * Session-machine storage from the environment: Redis when a URL is set,
76
+ * in-memory otherwise.
77
+ *
78
+ * persistence: { session: sessionStore({ redisUrl: process.env.REDIS_URL }) }
79
+ *
80
+ * Call it with nothing (or omit `persistence.session` entirely) to choose
81
+ * in-memory deliberately — that is a real choice and is never reported.
82
+ */
83
+ export function sessionStore(opts: StoreFromEnvOptions = {}): Store {
84
+ if (blank(opts.redisUrl)) {
85
+ // In-memory either way, but for two different reasons. Passing the key at
86
+ // all — `redisUrl: process.env.REDIS_URL` — says the app wants durability
87
+ // from the environment, so an empty value is worth reporting in production.
88
+ // Passing no key says in-memory was the choice, which is never reported.
89
+ return 'redisUrl' in opts
90
+ ? mark(new InMemoryStore(), { wanted: 'durable', from: opts.envName ?? 'REDIS_URL' })
91
+ : new InMemoryStore()
92
+ }
93
+ const redis = opts.keyPrefix
94
+ ? new RedisStore(opts.redisUrl as string, opts.keyPrefix)
95
+ : new RedisStore(opts.redisUrl as string)
96
+ if (!opts.cache) return redis
97
+ return new CachedStore(redis, opts.cache === true ? {} : opts.cache)
98
+ }
99
+
100
+ /**
101
+ * App-machine storage (`persist: true`) from the environment. Same rule as
102
+ * `sessionStore`.
103
+ */
104
+ export function appStore(opts: StoreFromEnvOptions = {}): AppStore {
105
+ if (blank(opts.redisUrl)) {
106
+ return 'redisUrl' in opts
107
+ ? mark(new InMemoryAppStore(), { wanted: 'durable', from: opts.envName ?? 'REDIS_URL' })
108
+ : new InMemoryAppStore()
109
+ }
110
+ return opts.keyPrefix
111
+ ? new RedisAppStore(opts.redisUrl as string, opts.keyPrefix)
112
+ : new RedisAppStore(opts.redisUrl as string)
113
+ }
114
+
115
+ /** One machine's persistence-relevant shape. */
116
+ export interface MachinePersistence {
117
+ lifecycle: string
118
+ persist: boolean
119
+ }
120
+
121
+ /**
122
+ * What to say about persistence at startup. The posture goes in the notice —
123
+ * printed at every level, so a deploy log always states whether state survives
124
+ * a restart — and anything worth alerting on comes back as a warning.
125
+ *
126
+ * Warnings fire in production only, and only when something is actually at
127
+ * risk: an app with no session machines has no session state to lose, and an
128
+ * app that deliberately chose in-memory made a real choice. Nothing here ever
129
+ * refuses to start; persistent storage is assumed to be wanted, never required.
130
+ */
131
+ export function persistencePosture(input: {
132
+ session: unknown
133
+ app: unknown
134
+ machines: readonly MachinePersistence[]
135
+ production: boolean
136
+ }): { label: string; warnings: string[] } {
137
+ const name = (store: unknown): string =>
138
+ isEphemeral(store)
139
+ ? 'in-memory'
140
+ : ((store as { constructor?: { name?: string } })?.constructor?.name ?? 'custom')
141
+
142
+ const sessionMachines = input.machines.filter((m) => m.lifecycle === 'session').length
143
+ const persistedApp = input.machines.filter((m) => m.lifecycle === 'app' && m.persist).length
144
+
145
+ let label = `sessions ${name(input.session)}`
146
+ if (persistedApp > 0) label += ` · app ${name(input.app)}`
147
+
148
+ const warnings: string[] = []
149
+ if (input.production) {
150
+ const sessionIntent = storeIntent(input.session)
151
+ if (isEphemeral(input.session) && sessionIntent) {
152
+ // The app configured a durable store and the variable was empty — the
153
+ // one case the framework can name precisely.
154
+ warnings.push(
155
+ `${sessionIntent.from} is empty, so session state is in memory and will not survive a restart`,
156
+ )
157
+ } else if (isEphemeral(input.session) && sessionMachines > 0) {
158
+ warnings.push(
159
+ `no durable session store is configured, so the state of ${sessionMachines} session machine${
160
+ sessionMachines === 1 ? '' : 's'
161
+ } will not survive a restart`,
162
+ )
163
+ }
164
+
165
+ const appIntent = storeIntent(input.app)
166
+ if (isEphemeral(input.app) && persistedApp > 0) {
167
+ warnings.push(
168
+ appIntent
169
+ ? `${appIntent.from} is empty, so ${persistedApp} persisted app machine${persistedApp === 1 ? '' : 's'} will not survive a restart`
170
+ : `no durable app store is configured, so ${persistedApp} persisted app machine${persistedApp === 1 ? '' : 's'} will not survive a restart`,
171
+ )
172
+ }
173
+ }
174
+ return { label, warnings }
175
+ }