@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 +2 -2
- package/src/build/artifact.ts +182 -0
- package/src/build/build.ts +87 -31
- package/src/build/copy-set.ts +349 -0
- package/src/build/head.ts +18 -1
- package/src/build/index.ts +4 -0
- package/src/cli/commands/build.ts +71 -1
- package/src/cli/commands/start.ts +74 -8
- package/src/config.ts +14 -0
- package/src/server/banner.ts +11 -4
- package/src/server/create-app.ts +18 -1
- package/src/server/index.ts +2 -0
- package/src/server/store-intent.ts +175 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
+
}
|
package/src/build/build.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { cp, mkdir,
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
38
|
-
*
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
? {
|
|
177
|
-
|
|
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 {
|
|
66
|
+
return {
|
|
67
|
+
headExtras,
|
|
68
|
+
buildId,
|
|
69
|
+
...(machines ? { machines } : {}),
|
|
70
|
+
...(config !== undefined ? { config } : {}),
|
|
71
|
+
...(statorVersion ? { statorVersion } : {}),
|
|
72
|
+
}
|
|
56
73
|
}
|
package/src/build/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
19
|
-
|
|
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
|
package/src/server/banner.ts
CHANGED
|
@@ -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: {
|
|
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'} · ${
|
|
83
|
-
info.routes
|
|
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}/`,
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|