@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
|
@@ -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
|