@statorjs/stator 2.5.0 → 2.6.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/README.md +1 -1
- package/package.json +5 -5
- package/src/build/build.ts +93 -115
- package/src/build/head.ts +11 -4
- package/src/build/index.ts +9 -0
- package/src/build/islands.ts +228 -0
- package/src/build/source-id.ts +15 -0
- package/src/cli/commands/build.ts +11 -1
- package/src/cli/commands/dev.ts +7 -3
- package/src/cli/commands/start.ts +2 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/stator.js +9 -0
- package/src/compiler/diagnostics.ts +10 -0
- package/src/compiler/index.ts +2 -1
- package/src/compiler/regions.ts +30 -0
- package/src/engine/actor.ts +10 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/types.ts +24 -1
- package/src/server/create-app.ts +7 -1
- package/src/server/dev-loader.mjs +124 -0
- package/src/server/dev-native.ts +561 -0
- package/src/server/dev-vite.ts +361 -0
- package/src/server/dev.ts +44 -313
- package/src/server/discovery.ts +45 -1
- package/src/server/effects.ts +10 -1
- package/src/server/http.ts +7 -1
- package/src/server/index.ts +1 -0
- package/src/server/machine-hash.ts +195 -0
- package/src/server/machine-store.ts +9 -2
- package/src/server/session-runtime.ts +9 -4
- package/src/server/snapshot-policy.ts +110 -0
- package/src/vite/index.ts +10 -4
- package/src/vite/plugin.ts +12 -32
- package/src/vite/stub.ts +4 -1
package/src/cli/commands/dev.ts
CHANGED
|
@@ -3,9 +3,13 @@ import { createDevApp } from '../../server/dev.ts'
|
|
|
3
3
|
import { loadConfig, resolvePort } from '../config.ts'
|
|
4
4
|
import type { CliContext } from '../run.ts'
|
|
5
5
|
|
|
6
|
-
/** Run the dev server
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
/** Run the dev server. Live reload, inspector, conventions for machines/routes/
|
|
7
|
+
* static; `stator.config.ts` for the store/session/port an app previously
|
|
8
|
+
* hand-wrote a `server.ts` for.
|
|
9
|
+
*
|
|
10
|
+
* The app runs natively from its source tree, exactly as `stator start` runs a
|
|
11
|
+
* build. `STATOR_VITE_DEV=1` keeps the previous Vite-embedded dev server for
|
|
12
|
+
* one minor as an escape hatch. */
|
|
9
13
|
export async function run(ctx: CliContext): Promise<void> {
|
|
10
14
|
const config = await loadConfig(ctx.root)
|
|
11
15
|
const app = await createDevApp({
|
|
@@ -16,7 +16,7 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const config = await loadConfig(ctx.root)
|
|
19
|
-
const { headExtras, buildId } = await loadProductionHead(dist)
|
|
19
|
+
const { headExtras, buildId, machines } = await loadProductionHead(dist)
|
|
20
20
|
const app = await createApp({
|
|
21
21
|
machinesDir: resolve(dist, 'machines'),
|
|
22
22
|
routesDir: resolve(dist, 'routes'),
|
|
@@ -31,6 +31,7 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
31
31
|
cors: config.cors,
|
|
32
32
|
headExtras,
|
|
33
33
|
buildId,
|
|
34
|
+
machineHashes: machines,
|
|
34
35
|
middlewareFile: resolve(dist, 'middleware.ts'),
|
|
35
36
|
bootFile: resolve(dist, 'boot.ts'),
|
|
36
37
|
})
|
package/src/cli/run.ts
CHANGED
|
@@ -65,7 +65,7 @@ const ctx: CliContext = {
|
|
|
65
65
|
const { loadDotenv } = await import('../server/env.ts')
|
|
66
66
|
loadDotenv(ctx.root)
|
|
67
67
|
|
|
68
|
-
// Dynamic import per command so `check` never loads the dev server's
|
|
68
|
+
// Dynamic import per command so `check` never loads the dev server's watcher, etc.
|
|
69
69
|
const { run } = (await import(`./commands/${command}.ts`)) as {
|
|
70
70
|
run: (ctx: CliContext) => Promise<void>
|
|
71
71
|
}
|
package/src/cli/stator.js
CHANGED
|
@@ -23,5 +23,14 @@ if (typeof register !== 'function') {
|
|
|
23
23
|
|
|
24
24
|
register('./loader.js', import.meta.url)
|
|
25
25
|
|
|
26
|
+
// Apply sourcemaps to stack traces. Every transform in the owned pipeline (the
|
|
27
|
+
// TS loader above, the dev loader's `.stator` compile) already EMITS an inline
|
|
28
|
+
// map, but V8 ignores maps when capturing stacks unless the process opts in —
|
|
29
|
+
// this is the runtime equivalent of `--enable-source-maps`, which a bin script
|
|
30
|
+
// can't put on its own invocation. Covers dev and `stator start` alike.
|
|
31
|
+
// (`.stator` traces resolve to the compiled server module — right file,
|
|
32
|
+
// generated lines — until the compiler emits its own map to compose.)
|
|
33
|
+
process.setSourceMapsEnabled?.(true)
|
|
34
|
+
|
|
26
35
|
// The real CLI is TypeScript, transformed on import by the loader just registered.
|
|
27
36
|
await import('./run.ts')
|
|
@@ -63,3 +63,13 @@ export function locAt(source: string, offset: number, file?: string): Diagnostic
|
|
|
63
63
|
const { line, column } = offsetToLineCol(source, offset)
|
|
64
64
|
return { file, line, column, frame: codeFrame(source, line, column) }
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/** Render a `CompileError` for a terminal or a dev overlay: the message, then
|
|
68
|
+
* `file:line:column` and the code frame when the error is located. Plain text
|
|
69
|
+
* on purpose — it is the one shape that survives crossing a loader/worker
|
|
70
|
+
* thread, where an Error's custom properties (`loc`) are dropped. */
|
|
71
|
+
export function formatCompileError(err: CompileError): string {
|
|
72
|
+
if (!err.loc) return err.message
|
|
73
|
+
const { file, line, column, frame } = err.loc
|
|
74
|
+
return `${err.message}\n\n${file ?? '<source>'}:${line}:${column}\n${frame}`
|
|
75
|
+
}
|
package/src/compiler/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type { DiagnosticLocation } from './diagnostics.ts'
|
|
|
29
29
|
export {
|
|
30
30
|
CompileError,
|
|
31
31
|
codeFrame,
|
|
32
|
+
formatCompileError,
|
|
32
33
|
locAt,
|
|
33
34
|
offsetToLineCol,
|
|
34
35
|
} from './diagnostics.ts'
|
|
@@ -36,7 +37,7 @@ export { generateDts } from './dts.ts'
|
|
|
36
37
|
export { scopeHash } from './hash.ts'
|
|
37
38
|
export type { LowerMeta, LowerOptions } from './lower.ts'
|
|
38
39
|
export { lowerTemplate } from './lower.ts'
|
|
39
|
-
export { componentImportSpecifier, declaredRegions } from './regions.ts'
|
|
40
|
+
export { componentImportSpecifier, declaredRegions, regionResolverFor } from './regions.ts'
|
|
40
41
|
export type { ParsedStator, ScannedRegions, SourceRegion } from './split.ts'
|
|
41
42
|
export { scanRegions, splitStator } from './split.ts'
|
|
42
43
|
export { scopeCss } from './styles.ts'
|
package/src/compiler/regions.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
1
3
|
import ts from 'typescript'
|
|
2
4
|
import { splitStator } from './split.ts'
|
|
3
5
|
|
|
@@ -77,3 +79,31 @@ export function componentImportSpecifier(
|
|
|
77
79
|
}
|
|
78
80
|
return null
|
|
79
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a region resolver for a file: maps a component identifier used in
|
|
85
|
+
* `file` to the named regions its imported `.stator` declares, so `compile()`
|
|
86
|
+
* can reject a `child="x"` the callee never declares. Reads sibling files
|
|
87
|
+
* synchronously (resolution happens mid-compile). Returns null when the
|
|
88
|
+
* identifier isn't a `.stator` default import or the file can't be read.
|
|
89
|
+
*
|
|
90
|
+
* Shared by every compile path — the Vite plugin, `buildApp`, and the native
|
|
91
|
+
* dev loader — so the check is identical in dev and prod.
|
|
92
|
+
*/
|
|
93
|
+
export function regionResolverFor(
|
|
94
|
+
file: string,
|
|
95
|
+
source: string,
|
|
96
|
+
): (componentName: string) => Set<string> | null {
|
|
97
|
+
const { frontmatter } = splitStator(source)
|
|
98
|
+
return (componentName) => {
|
|
99
|
+
const spec = componentImportSpecifier(frontmatter, componentName)
|
|
100
|
+
if (!spec) return null
|
|
101
|
+
const target = spec.startsWith('.') ? resolve(dirname(file), spec) : null
|
|
102
|
+
if (!target) return null
|
|
103
|
+
try {
|
|
104
|
+
return declaredRegions(readFileSync(target, 'utf8'))
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/engine/actor.ts
CHANGED
|
@@ -160,7 +160,8 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
160
160
|
effectId,
|
|
161
161
|
kind: 'entry',
|
|
162
162
|
stateKey,
|
|
163
|
-
run: (signal) =>
|
|
163
|
+
run: (signal, session) =>
|
|
164
|
+
Promise.resolve(entry(ctxSnapshot, { effectId, signal, ...(session ? { session } : {}) })),
|
|
164
165
|
}
|
|
165
166
|
if (opts.onEffect) {
|
|
166
167
|
opts.onEffect(invocation)
|
|
@@ -289,8 +290,14 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
289
290
|
machineName: def.name,
|
|
290
291
|
effectId,
|
|
291
292
|
kind: 'transition',
|
|
292
|
-
run: (signal) =>
|
|
293
|
-
Promise.resolve(
|
|
293
|
+
run: (signal, session) =>
|
|
294
|
+
Promise.resolve(
|
|
295
|
+
effect(ctxSnapshot, evSnapshot as never, {
|
|
296
|
+
effectId,
|
|
297
|
+
signal,
|
|
298
|
+
...(session ? { session } : {}),
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
294
301
|
}
|
|
295
302
|
if (opts.onEffect) {
|
|
296
303
|
opts.onEffect(invocation)
|
package/src/engine/index.ts
CHANGED
package/src/engine/types.ts
CHANGED
|
@@ -61,6 +61,18 @@ export type Guard<C, E extends EventObject, R = Record<string, any>> = (
|
|
|
61
61
|
export interface EffectMeta {
|
|
62
62
|
effectId: string
|
|
63
63
|
signal?: AbortSignal
|
|
64
|
+
/** The session this effect runs for — set by the server host for SESSION
|
|
65
|
+
* machines only (app machines and client islands have none). Lets an entry
|
|
66
|
+
* effect reload a durable fact by identity after a fresh start or a
|
|
67
|
+
* snapshot reset, with no client round trip: `loadCart(meta.session.claims<Me>().userId)`. */
|
|
68
|
+
session?: EffectSession
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The session an effect runs for. `claims` mirrors `stator(c).claims<T>()`:
|
|
72
|
+
* the app-defined claims as of the moment the effect started, or `undefined`. */
|
|
73
|
+
export interface EffectSession {
|
|
74
|
+
readonly id: string
|
|
75
|
+
claims<T = unknown>(): T | undefined
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/**
|
|
@@ -127,7 +139,9 @@ export interface EffectInvocation {
|
|
|
127
139
|
kind: 'entry' | 'transition'
|
|
128
140
|
/** The owning state — set for entry effects, for exit-abort targeting. */
|
|
129
141
|
stateKey?: string
|
|
130
|
-
|
|
142
|
+
/** Run the effect. The host passes the abort signal and, for session
|
|
143
|
+
* machines, the session — merged into the effect's `meta`. */
|
|
144
|
+
run: (signal?: AbortSignal, session?: EffectSession) => Promise<EventObject | null>
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
/** Object form of a transition. A bare `Action` is sugar for `{ do: fn }`.
|
|
@@ -216,6 +230,15 @@ export interface Snapshot<C> {
|
|
|
216
230
|
* effect (the load role is re-runnable by contract) instead of leaving the
|
|
217
231
|
* machine wedged waiting for a completion that died with another process. */
|
|
218
232
|
pendingEntry?: { effectId: string }
|
|
233
|
+
/** Snapshot format version, stamped by the host on persist. Absent ⇒ the
|
|
234
|
+
* original (current) format. Lets the shape itself evolve (hierarchy in
|
|
235
|
+
* `value`) with a known-old snapshot recognisable as such. */
|
|
236
|
+
format?: number
|
|
237
|
+
/** Code hash of the machine that wrote this snapshot, stamped by the host on
|
|
238
|
+
* persist. Hydration rejects a snapshot whose hash differs from the running
|
|
239
|
+
* machine's — sessions never outlive the code that made them. Absent
|
|
240
|
+
* (pre-existing snapshots) ⇒ mismatch. */
|
|
241
|
+
code?: string
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
export type Lifecycle = 'app' | 'session'
|
package/src/server/create-app.ts
CHANGED
|
@@ -84,6 +84,10 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
|
|
|
84
84
|
/** Build identifier for the deploy-aware reload handshake. `stator start`
|
|
85
85
|
* passes the id baked into the build manifest; absent → no handshake. */
|
|
86
86
|
buildId?: string
|
|
87
|
+
/** Machine code hashes from the build manifest (`loadProductionHead(dist).machines`),
|
|
88
|
+
* keyed by file relative to `machinesDir`. Omitted ⇒ hashed live at boot;
|
|
89
|
+
* a discovered machine missing from a supplied map is a boot error. */
|
|
90
|
+
machineHashes?: Readonly<Record<string, string>>
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
export interface StatorApp {
|
|
@@ -120,7 +124,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
120
124
|
// the scoped children (http/sse/…), which a bare `logger.level =` would miss.
|
|
121
125
|
setLogLevel(process.env.LOG_LEVEL ?? resolved.logLevel ?? 'warn')
|
|
122
126
|
setSessionSameSite(resolved.sameSite ?? 'Lax')
|
|
123
|
-
const { defs } = await discoverMachines(machinesDir
|
|
127
|
+
const { defs } = await discoverMachines(machinesDir, undefined, {
|
|
128
|
+
...(config.machineHashes ? { hashes: config.machineHashes } : {}),
|
|
129
|
+
})
|
|
124
130
|
const sessionStore = resolved.session ?? new InMemoryStore()
|
|
125
131
|
const store = new MachineStore(defs, sessionStore, {
|
|
126
132
|
sessionTtlSeconds: resolved.sessionTtlSeconds,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Native dev-server module loader — the Vite exit's dev half. Three jobs, all
|
|
2
|
+
// on Node's module-customization hooks thread:
|
|
3
|
+
//
|
|
4
|
+
// 1. `.stator` compile-on-load: a `.stator` import is read from the SOURCE tree,
|
|
5
|
+
// compiled by the framework compiler (server render module), and type-
|
|
6
|
+
// stripped by esbuild. No mirror, no on-disk `.stator.ts` — the server runs
|
|
7
|
+
// from the app's real files, so `import.meta.url`-relative paths (a SQLite
|
|
8
|
+
// file, a data dir) mean the same thing in dev as in the app's source.
|
|
9
|
+
// 2. TS-on-import, like the CLI's shipped `cli/loader.js`.
|
|
10
|
+
// 3. App-scoped `?v=` propagation — the one piece the prod loader lacks.
|
|
11
|
+
//
|
|
12
|
+
// Invalidation model — importer-only: every app module is imported at
|
|
13
|
+
// `?v=<its own version>`. The dev server keeps a per-file version map, bumps
|
|
14
|
+
// a changed file AND its transitive importers (a static reverse import graph),
|
|
15
|
+
// and pushes the new versions here over a MessagePort before re-importing
|
|
16
|
+
// entries. `resolve` stamps each app-local child with its current version, so
|
|
17
|
+
// a changed subtree re-evaluates fresh while everything else keeps its cached
|
|
18
|
+
// module instance — a `lib/db.ts` that opens a connection at top level runs
|
|
19
|
+
// once per session, not once per edit, and the ESM registry (which never
|
|
20
|
+
// releases a module) grows by the affected subtree instead of the whole app.
|
|
21
|
+
//
|
|
22
|
+
// The versioning boundary is CRITICAL: it applies only to files under the app
|
|
23
|
+
// root (`initialize({ appDir })`) and never inside a `node_modules` directory.
|
|
24
|
+
// Versioning framework internals would fork their singletons (render context,
|
|
25
|
+
// registries) into a second instance and break `read()` — the same dual-
|
|
26
|
+
// instance hazard as the old Vite fence, from the other side. Both checks are
|
|
27
|
+
// needed: under pnpm the framework realpaths OUT of node_modules (so the app-
|
|
28
|
+
// root allowlist catches it), under a flat install it sits INSIDE the app root
|
|
29
|
+
// (so the node_modules denylist catches it).
|
|
30
|
+
//
|
|
31
|
+
// This module imports the TypeScript compiler source, so it must be registered
|
|
32
|
+
// after a TS loader (the dev server registers `cli/loader.js` first).
|
|
33
|
+
import { readFile } from 'node:fs/promises'
|
|
34
|
+
import { sep } from 'node:path'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
import { transform } from 'esbuild'
|
|
37
|
+
import { sourceId } from '../build/source-id.ts'
|
|
38
|
+
import { CompileError, compile, formatCompileError, regionResolverFor } from '../compiler/index.ts'
|
|
39
|
+
|
|
40
|
+
const TS = /\.(?:ts|tsx|mts|cts)$/
|
|
41
|
+
const STATOR = /\.stator$/
|
|
42
|
+
/** Extensions whose app-local imports carry the build version. */
|
|
43
|
+
const VERSIONED = /\.(?:stator|ts|tsx|mts|cts|js|mjs)$/
|
|
44
|
+
|
|
45
|
+
/** Absolute (real) app root under which versioning applies. Unset ⇒ this loader
|
|
46
|
+
* behaves like the prod TS loader plus `.stator` compile (no versioning). */
|
|
47
|
+
let appDir
|
|
48
|
+
/** Absolute file path → current version, pushed by the dev server. */
|
|
49
|
+
const versions = new Map()
|
|
50
|
+
|
|
51
|
+
export async function initialize(data) {
|
|
52
|
+
appDir = data?.appDir
|
|
53
|
+
const port = data?.port
|
|
54
|
+
if (!port) return
|
|
55
|
+
port.on('message', (msg) => {
|
|
56
|
+
if (msg?.type !== 'versions') return
|
|
57
|
+
for (const [file, v] of msg.entries) versions.set(file, v)
|
|
58
|
+
port.postMessage({ type: 'ack', id: msg.id })
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const inApp = (path) =>
|
|
63
|
+
Boolean(appDir) && path.startsWith(appDir + sep) && !path.includes(`${sep}node_modules${sep}`)
|
|
64
|
+
|
|
65
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
66
|
+
const result = await nextResolve(specifier, context)
|
|
67
|
+
const url = new URL(result.url)
|
|
68
|
+
const own = TS.test(url.pathname) || STATOR.test(url.pathname)
|
|
69
|
+
const format = own ? 'module' : result.format
|
|
70
|
+
if (!VERSIONED.test(url.pathname) || format !== 'module') return result
|
|
71
|
+
|
|
72
|
+
// Stamp an app-local module with its current version (entries arrive already
|
|
73
|
+
// stamped by the dev server; children get theirs here).
|
|
74
|
+
if (url.protocol === 'file:' && !url.searchParams.has('v')) {
|
|
75
|
+
const path = fileURLToPath(url)
|
|
76
|
+
if (inApp(path)) {
|
|
77
|
+
url.searchParams.set('v', String(versions.get(path) ?? 1))
|
|
78
|
+
return { url: url.href, format, shortCircuit: true }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return own ? { ...result, format, shortCircuit: true } : result
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function load(url, context, nextLoad) {
|
|
85
|
+
if (!url.startsWith('file:')) return nextLoad(url, context)
|
|
86
|
+
// `fileURLToPath` ignores the `?v=` query, so cache-busted URLs read the real file.
|
|
87
|
+
const path = fileURLToPath(url)
|
|
88
|
+
|
|
89
|
+
if (STATOR.test(path)) {
|
|
90
|
+
const source = await readFile(path, 'utf8')
|
|
91
|
+
const { id, kind } = appDir
|
|
92
|
+
? sourceId(appDir, path)
|
|
93
|
+
: { id: path, kind: /[\\/]routes[\\/]/.test(path) ? 'route' : 'component' }
|
|
94
|
+
let result
|
|
95
|
+
try {
|
|
96
|
+
result = compile(source, { id, kind, resolveRegions: regionResolverFor(path, source) })
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Errors cross back to the main thread as plain Errors (custom props are
|
|
99
|
+
// dropped), so bake file:line:column + the code frame into the message.
|
|
100
|
+
throw err instanceof CompileError ? new Error(formatCompileError(err)) : err
|
|
101
|
+
}
|
|
102
|
+
const { code } = await transform(result.serverCode, {
|
|
103
|
+
loader: 'ts',
|
|
104
|
+
format: 'esm',
|
|
105
|
+
target: 'node20',
|
|
106
|
+
sourcemap: 'inline',
|
|
107
|
+
sourcefile: path,
|
|
108
|
+
})
|
|
109
|
+
return { format: 'module', source: code, shortCircuit: true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (TS.test(path)) {
|
|
113
|
+
const source = await readFile(path, 'utf8')
|
|
114
|
+
const { code } = await transform(source, {
|
|
115
|
+
loader: path.endsWith('x') ? 'tsx' : 'ts',
|
|
116
|
+
format: 'esm',
|
|
117
|
+
target: 'node20',
|
|
118
|
+
sourcemap: 'inline',
|
|
119
|
+
sourcefile: path,
|
|
120
|
+
})
|
|
121
|
+
return { format: 'module', source: code, shortCircuit: true }
|
|
122
|
+
}
|
|
123
|
+
return nextLoad(url, context)
|
|
124
|
+
}
|