@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.
@@ -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 (Vite-backed today: live reload, compile-error overlay,
7
- * inspector). Conventions for machines/routes/static; `stator.config.ts` for
8
- * the store/session/port an app previously hand-wrote a `server.ts` for. */
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 Vite, etc.
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
+ }
@@ -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'
@@ -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
+ }
@@ -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) => Promise.resolve(entry(ctxSnapshot, { effectId, 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(effect(ctxSnapshot, evSnapshot as never, { effectId, signal })),
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)
@@ -19,6 +19,7 @@ export type {
19
19
  Effect,
20
20
  EffectInvocation,
21
21
  EffectMeta,
22
+ EffectSession,
22
23
  EmitDeclaration,
23
24
  EmitsConfig,
24
25
  EntryEffect,
@@ -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
- run: (signal?: AbortSignal) => Promise<EventObject | null>
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'
@@ -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
+ }