@statorjs/stator 2.9.0 → 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.0",
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.0"
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