@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 +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 +27 -13
- package/src/server/create-app.ts +21 -6
- package/src/server/dev-native.ts +5 -4
- package/src/server/dev-vite.ts +5 -4
- package/src/server/index.ts +2 -0
- package/src/server/shutdown.ts +127 -0
- package/src/server/sse.ts +15 -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
|
|