@statorjs/stator 2.1.0 → 2.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.1.0",
3
+ "version": "2.3.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 (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,9 @@
24
24
  "src",
25
25
  "README.md"
26
26
  ],
27
+ "bin": {
28
+ "stator": "./src/cli/stator.js"
29
+ },
27
30
  "exports": {
28
31
  "./server": {
29
32
  "types": "./src/server/index.ts",
@@ -41,6 +44,10 @@
41
44
  "types": "./src/build/index.ts",
42
45
  "default": "./src/build/index.ts"
43
46
  },
47
+ "./config": {
48
+ "types": "./src/config.ts",
49
+ "default": "./src/config.ts"
50
+ },
44
51
  "./template": {
45
52
  "types": "./src/template/index.ts",
46
53
  "default": "./src/template/index.ts"
@@ -88,7 +95,7 @@
88
95
  "pino-pretty": "^13.1.3",
89
96
  "vite": "^6.0.0",
90
97
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "2.1.0"
98
+ "@statorjs/stator": "2.3.0"
92
99
  },
93
100
  "scripts": {
94
101
  "typecheck": "tsc --noEmit",
@@ -82,6 +82,11 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
82
82
  if (await exists(src)) await cp(src, join(outDir, d), { recursive: true })
83
83
  }
84
84
 
85
+ // A root-level middleware.ts is a single file, not a source dir — copy it too
86
+ // (raw TS, run under tsx/native in prod like the rest of the server).
87
+ const middlewareSrc = join(root, 'middleware.ts')
88
+ if (await exists(middlewareSrc)) await cp(middlewareSrc, join(outDir, 'middleware.ts'))
89
+
85
90
  // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
86
91
  const statorFiles = await walk(outDir, (f) => f.endsWith('.stator'))
87
92
  let css = ''
@@ -0,0 +1,15 @@
1
+ import { resolve } from 'node:path'
2
+ import { buildApp } from '../../build/index.ts'
3
+ import type { CliContext } from '../run.ts'
4
+ import { checkStack } from './check.ts'
5
+
6
+ /** Compile the app to `dist/` — but validate the whole stack FIRST, so a broken
7
+ * server file fails the build instead of shipping silently. */
8
+ export async function run(ctx: CliContext): Promise<void> {
9
+ await checkStack(ctx.root)
10
+ const result = await buildApp({ root: ctx.root, outDir: resolve(ctx.root, 'dist') })
11
+ process.stdout.write(
12
+ `stator build: ${result.compiled} components → ${result.outDir}` +
13
+ `${result.islands ? ` (${result.islands} island${result.islands === 1 ? '' : 's'})` : ''}\n`,
14
+ )
15
+ }
@@ -0,0 +1,72 @@
1
+ import ts from 'typescript'
2
+ import { syncTypes } from '../../build/index.ts'
3
+ import type { CliContext } from '../run.ts'
4
+
5
+ /**
6
+ * Validate the WHOLE stack — server machines/routes/lib plus the generated
7
+ * `.stator` types — with no output. This is the piece production builds were
8
+ * missing: `buildApp` only ever touched client islands and copied server files
9
+ * to `dist/` unvalidated, so a broken server import shipped silently. `build`
10
+ * now runs this first.
11
+ *
12
+ * Two steps: (1) `syncTypes` regenerates each `.stator.d.ts` + the virtual TSX
13
+ * the language server typechecks, so the app's `tsconfig` sees current types;
14
+ * (2) a `tsc --noEmit`-equivalent pass via the TypeScript API over that config.
15
+ */
16
+ export async function checkStack(root: string): Promise<void> {
17
+ const { written } = await syncTypes(root)
18
+
19
+ const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json')
20
+ if (!configPath) throw new Error(`no tsconfig.json found at or above ${root}`)
21
+
22
+ const parsed = ts.getParsedCommandLineOfConfigFile(
23
+ configPath,
24
+ { noEmit: true },
25
+ {
26
+ ...ts.sys,
27
+ onUnRecoverableConfigFileDiagnostic: (d) => {
28
+ throw new Error(ts.flattenDiagnosticMessageText(d.messageText, ts.sys.newLine))
29
+ },
30
+ },
31
+ )
32
+ if (!parsed) throw new Error(`could not parse ${configPath}`)
33
+
34
+ const program = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options })
35
+ const diagnostics = [
36
+ ...program.getConfigFileParsingDiagnostics(),
37
+ ...ts.getPreEmitDiagnostics(program),
38
+ ]
39
+
40
+ if (diagnostics.length > 0) {
41
+ process.stderr.write(
42
+ ts.formatDiagnosticsWithColorAndContext(diagnostics, {
43
+ getCanonicalFileName: (f) => f,
44
+ getCurrentDirectory: ts.sys.getCurrentDirectory,
45
+ getNewLine: () => ts.sys.newLine,
46
+ }),
47
+ )
48
+ const n = diagnostics.length
49
+ throw new Error(`typecheck failed with ${n} error${n === 1 ? '' : 's'}`)
50
+ }
51
+
52
+ // Split the program into the app's own files vs. framework/lib overhead so the
53
+ // count reads honestly: most of the program is @statorjs/stator's shipped source
54
+ // (raw TS — typechecked, not `skipLibCheck`-skipped) plus TS libs, not your code.
55
+ // App files = under the app root and outside node_modules; the rest is framework
56
+ // + libs. The same split holds for an installed app (framework source lives under
57
+ // `root/node_modules`, which the node_modules test excludes).
58
+ const rootPrefix = `${root.replace(/\\/g, '/').replace(/\/+$/, '')}/`
59
+ const sourceFiles = program.getSourceFiles()
60
+ const appFiles = sourceFiles.filter(
61
+ (sf) => sf.fileName.startsWith(rootPrefix) && !sf.fileName.includes('/node_modules/'),
62
+ ).length
63
+ const frameworkFiles = sourceFiles.length - appFiles
64
+
65
+ process.stdout.write(
66
+ `stator check: ok — ${written} .stator.d.ts, ${appFiles} app files (${frameworkFiles} framework files)\n`,
67
+ )
68
+ }
69
+
70
+ export async function run(ctx: CliContext): Promise<void> {
71
+ await checkStack(ctx.root)
72
+ }
@@ -0,0 +1,24 @@
1
+ import { resolve } from 'node:path'
2
+ import { createDevApp } from '../../server/dev.ts'
3
+ import { loadConfig, resolvePort } from '../config.ts'
4
+ import type { CliContext } from '../run.ts'
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. */
9
+ export async function run(ctx: CliContext): Promise<void> {
10
+ const config = await loadConfig(ctx.root)
11
+ const app = await createDevApp({
12
+ root: ctx.root,
13
+ machinesDir: resolve(ctx.root, 'machines'),
14
+ routesDir: resolve(ctx.root, 'routes'),
15
+ staticDir: resolve(ctx.root, 'static'),
16
+ persistence: config.persistence,
17
+ sessions: config.sessions,
18
+ dev: config.dev,
19
+ trustedOrigins: config.trustedOrigins,
20
+ origin: config.origin,
21
+ cors: config.cors,
22
+ })
23
+ await app.listen(resolvePort(ctx.portFlag, config.port))
24
+ }
@@ -0,0 +1,35 @@
1
+ import { stat } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { loadProductionHead } from '../../build/index.ts'
4
+ import { createApp } from '../../server/index.ts'
5
+ import { loadConfig, resolvePort } from '../config.ts'
6
+ import type { CliContext } from '../run.ts'
7
+
8
+ /** Serve a built `dist/` in production mode (no Vite). Same `stator.config.ts`
9
+ * as `dev` supplies the store/session/port. */
10
+ export async function run(ctx: CliContext): Promise<void> {
11
+ const dist = resolve(ctx.root, 'dist')
12
+ try {
13
+ await stat(resolve(dist, 'routes'))
14
+ } catch {
15
+ throw new Error('no dist/ found — run `stator build` first')
16
+ }
17
+
18
+ const config = await loadConfig(ctx.root)
19
+ const app = await createApp({
20
+ machinesDir: resolve(dist, 'machines'),
21
+ routesDir: resolve(dist, 'routes'),
22
+ staticDir: resolve(dist, 'static'),
23
+ persistence: config.persistence,
24
+ sessions: config.sessions,
25
+ realtime: config.realtime,
26
+ trustedOrigins: config.trustedOrigins,
27
+ origin: config.origin,
28
+ host: config.host,
29
+ cors: config.cors,
30
+ headExtras: await loadProductionHead(dist),
31
+ middlewareFile: resolve(dist, 'middleware.ts'),
32
+ })
33
+
34
+ await app.listen(resolvePort(ctx.portFlag, config.port))
35
+ }
@@ -0,0 +1,31 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import type { CliContext } from '../run.ts'
5
+
6
+ /**
7
+ * Run the test suite. The runner is a deliberately swappable seam — today it
8
+ * wraps the app's local Vitest; as the platform matures this becomes
9
+ * `node --test` (with a `.stator` loader hook), invisibly to callers.
10
+ */
11
+ export async function run(ctx: CliContext): Promise<void> {
12
+ const bin = join(
13
+ ctx.root,
14
+ 'node_modules',
15
+ '.bin',
16
+ process.platform === 'win32' ? 'vitest.cmd' : 'vitest',
17
+ )
18
+ if (!existsSync(bin)) {
19
+ throw new Error(
20
+ 'vitest not found in node_modules — install it, or run your test script directly',
21
+ )
22
+ }
23
+ const args = ctx.rest.length > 0 ? ctx.rest : ['run']
24
+ await new Promise<void>((resolvePromise, reject) => {
25
+ const child = spawn(bin, args, { stdio: 'inherit', cwd: ctx.root })
26
+ child.on('error', reject)
27
+ child.on('exit', (code) =>
28
+ code === 0 ? resolvePromise() : reject(new Error(`tests failed (exit ${code})`)),
29
+ )
30
+ })
31
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import type { StatorConfig } from '../config.ts'
5
+
6
+ const CONFIG_NAMES = [
7
+ 'stator.config.ts',
8
+ 'stator.config.mts',
9
+ 'stator.config.js',
10
+ 'stator.config.mjs',
11
+ ]
12
+
13
+ /**
14
+ * Load `stator.config.{ts,mts,js,mjs}` from the app root if present. Returns `{}`
15
+ * when there is none — every field is optional. The default export may be the
16
+ * config object or a (possibly async) function returning one.
17
+ *
18
+ * No key normalization: `StatorConfig` is a brand-new, never-released surface, so
19
+ * the nested shape is the only shape — there is nothing to be backward-compatible
20
+ * with.
21
+ */
22
+ export async function loadConfig(root: string): Promise<StatorConfig> {
23
+ for (const name of CONFIG_NAMES) {
24
+ const path = join(root, name)
25
+ if (!existsSync(path)) continue
26
+ const mod = (await import(pathToFileURL(path).href)) as { default?: unknown }
27
+ const raw = mod.default ?? mod
28
+ const resolved = typeof raw === 'function' ? await (raw as () => unknown)() : raw
29
+ if (resolved && typeof resolved === 'object') return resolved as StatorConfig
30
+ throw new Error(`${name} must export a config object (or a function returning one)`)
31
+ }
32
+ return {}
33
+ }
34
+
35
+ /** Resolve the listen port: `--port` flag > `$PORT` > config > 3000. */
36
+ export function resolvePort(flag: number | undefined, configPort: number | undefined): number {
37
+ if (flag != null) return flag
38
+ if (process.env.PORT) return Number(process.env.PORT)
39
+ return configPort ?? 3000
40
+ }
@@ -0,0 +1,40 @@
1
+ // Minimal TS-on-import loader for the `stator` CLI, so the plain-`node` bin can
2
+ // import the framework's TypeScript source without a `tsx` dependency. Uses
3
+ // esbuild (already a framework dep) to strip/transform types at load time.
4
+ //
5
+ // This is the seed of Stator's own thin toolchain glue: the same shape a
6
+ // `.stator` `node:test` loader hook will take later. It intentionally handles
7
+ // only `.ts`/`.tsx` — `.stator` is compiled by the framework's own compiler on
8
+ // the dev/build path, not here.
9
+ import { readFile } from 'node:fs/promises'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { transform } from 'esbuild'
12
+
13
+ const TS = /\.(?:ts|tsx|mts|cts)$/
14
+
15
+ /** Tell Node a `.ts` URL is an ES module so it reaches the `load` hook instead
16
+ * of throwing ERR_UNKNOWN_FILE_EXTENSION. Imports carry explicit extensions
17
+ * (framework convention), so no specifier rewriting is needed. */
18
+ export async function resolve(specifier, context, nextResolve) {
19
+ const result = await nextResolve(specifier, context)
20
+ if (TS.test(new URL(result.url).pathname))
21
+ return { ...result, format: 'module', shortCircuit: true }
22
+ return result
23
+ }
24
+
25
+ /** Transform TS source to ESM JS at load time. */
26
+ export async function load(url, context, nextLoad) {
27
+ if (url.startsWith('file:') && TS.test(new URL(url).pathname)) {
28
+ const path = fileURLToPath(url)
29
+ const source = await readFile(path, 'utf8')
30
+ const { code } = await transform(source, {
31
+ loader: path.endsWith('x') ? 'tsx' : 'ts',
32
+ format: 'esm',
33
+ target: 'node20',
34
+ sourcemap: 'inline',
35
+ sourcefile: path,
36
+ })
37
+ return { format: 'module', source: code, shortCircuit: true }
38
+ }
39
+ return nextLoad(url, context)
40
+ }
package/src/cli/run.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { resolve } from 'node:path'
2
+ import { parseArgs } from 'node:util'
3
+
4
+ /** Shared context every command receives. */
5
+ export interface CliContext {
6
+ /** App root — where machines/, routes/, static/, dist/ live. Defaults to cwd. */
7
+ root: string
8
+ /** `--port` flag, if given. Final port resolves flag > $PORT > config > 3000. */
9
+ portFlag?: number
10
+ /** Extra positionals after the command (e.g. test file globs). */
11
+ rest: string[]
12
+ }
13
+
14
+ const COMMANDS = ['dev', 'build', 'start', 'check', 'test'] as const
15
+ type Command = (typeof COMMANDS)[number]
16
+
17
+ const HELP = `stator — the Stator toolchain CLI
18
+
19
+ Usage: stator <command> [options]
20
+
21
+ Commands:
22
+ dev Run the dev server (live reload, inspector)
23
+ build Compile the app to dist/ (runs \`check\` first)
24
+ start Serve a built dist/ in production mode
25
+ check Typecheck the whole stack (server + generated .stator types), no output
26
+ test Run the test suite
27
+
28
+ Options:
29
+ --root <dir> App root (default: current directory)
30
+ --port <n> Listen port for dev/start (default: $PORT or 3000)
31
+ -h, --help Show this help
32
+ `
33
+
34
+ const { values, positionals } = parseArgs({
35
+ allowPositionals: true,
36
+ strict: false,
37
+ options: {
38
+ root: { type: 'string' },
39
+ port: { type: 'string' },
40
+ help: { type: 'boolean', short: 'h' },
41
+ },
42
+ })
43
+
44
+ const command = positionals[0] as Command | undefined
45
+
46
+ if (values.help || !command) {
47
+ process.stdout.write(HELP)
48
+ process.exit(command ? 0 : values.help ? 0 : 1)
49
+ }
50
+
51
+ if (!COMMANDS.includes(command)) {
52
+ process.stderr.write(`stator: unknown command "${command}"\n\n${HELP}`)
53
+ process.exit(1)
54
+ }
55
+
56
+ const ctx: CliContext = {
57
+ root: resolve(String(values.root ?? process.cwd())),
58
+ portFlag: values.port != null ? Number(values.port) : undefined,
59
+ rest: positionals.slice(1),
60
+ }
61
+
62
+ // Dynamic import per command so `check` never loads the dev server's Vite, etc.
63
+ const { run } = (await import(`./commands/${command}.ts`)) as {
64
+ run: (ctx: CliContext) => Promise<void>
65
+ }
66
+
67
+ try {
68
+ await run(ctx)
69
+ } catch (err) {
70
+ process.stderr.write(`stator ${command}: ${err instanceof Error ? err.message : String(err)}\n`)
71
+ process.exit(1)
72
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // The `stator` CLI entry (bin). Plain JS so it runs on any Node the framework
3
+ // supports; it registers a small esbuild TS loader (see loader.js) so the rest
4
+ // of the CLI — and the framework API it drives — can be authored in TypeScript
5
+ // without a `tsx` dependency. As Node's native TS support stabilizes this loader
6
+ // becomes removable; the bin stays.
7
+ //
8
+ // The CLI's one hard runtime requirement is `module.register` — present on
9
+ // Node 18.19+ or 20.6+. The framework RUNTIME has no such floor, so we do NOT
10
+ // declare a package-wide `engines` — this opt-in tool self-detects instead,
11
+ // keeping the CLI a purely additive feature with no change to the package's
12
+ // node-compat contract.
13
+ import nodeModule from 'node:module'
14
+
15
+ const register = nodeModule?.register
16
+ if (typeof register !== 'function') {
17
+ process.stderr.write(
18
+ `stator: the CLI requires module.register — Node 18.19+ or 20.6+ (found ${process.version}).\n` +
19
+ `The framework runtime itself has no such requirement.\n`,
20
+ )
21
+ process.exit(1)
22
+ }
23
+
24
+ register('./loader.js', import.meta.url)
25
+
26
+ // The real CLI is TypeScript, transformed on import by the loader just registered.
27
+ await import('./run.ts')
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { AppStore, Store } from './server/index.ts'
2
+
3
+ /** Log verbosity, from most to least severe. Setting a level emits it and
4
+ * everything above it — `warn` shows `warn`+`error`+`fatal`, hiding
5
+ * `info`/`debug`. Pino's levels. */
6
+ export type LogLevel = 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
7
+
8
+ /**
9
+ * Stator app configuration — the home for what previously forced a hand-written
10
+ * `server.ts`/`start.ts`: the persistence adapters, session/realtime policy, and
11
+ * dev port.
12
+ *
13
+ * Loaded from `stator.config.{ts,mts,js,mjs}` at the app root by the `stator`
14
+ * CLI (`dev`/`start`). Grouped by concern so the config principle is visible in
15
+ * the shape: `persistence` holds the swappable adapters (infra); `sessions` /
16
+ * `realtime` / `dev` hold policy. Config owns *how it runs*, never *what it does*
17
+ * (behavior — e.g. a route's `live` flag — stays in code).
18
+ *
19
+ * Every field is optional; omit the file entirely to accept defaults (in-memory
20
+ * persistence, 24h session TTL, port 3000). There is no required machine-graph
21
+ * entry point — machines are file-discovered from `machines/`.
22
+ */
23
+ export interface StatorConfig {
24
+ /** Listen port for `dev`/`start`. Precedence: `--port` flag > `$PORT` > this > 3000. */
25
+ port?: number
26
+ /** Listen host / bind address. Default: all interfaces. Containers behind a
27
+ * proxy typically need `0.0.0.0`. */
28
+ host?: string
29
+ /** Canonical app URL (`https://example.com`) — for absolute-URL generation
30
+ * (redirects, SSE reconnect, OG tags) and as a spoof-proof same-origin
31
+ * anchor. Exposed to middleware via `stator(c).origin`. */
32
+ origin?: string
33
+ /** The swappable persistence adapters, grouped by concern. Both optional —
34
+ * default to in-memory (restart-wipe). Neither is a machine-graph entry
35
+ * point (machines are file-discovered). */
36
+ persistence?: {
37
+ /** Session-lifecycle machine state. Default: `InMemoryStore`. Pass a
38
+ * `RedisStore`/`CachedStore` for durability — the adapter seam. */
39
+ session?: Store
40
+ /** App-lifecycle machine state for `persist: true` machines (no TTL, one
41
+ * blob per machine). Advanced — most apps never set it. Default:
42
+ * in-memory (restart-wipe). Pass a `RedisAppStore` for durable app state. */
43
+ app?: AppStore
44
+ }
45
+ /** Session policy (no adapter here — that lives in `persistence.session`). */
46
+ sessions?: {
47
+ /** Per-session idle TTL in seconds. Default: 86400 (24h). */
48
+ ttlSeconds?: number
49
+ /** Session cookie policy. */
50
+ cookie?: {
51
+ /** `SameSite` attribute. Default `Lax` (allows same-site subdomains).
52
+ * `Strict` withholds the cookie from every cross-site request and flips
53
+ * the CSRF guard to allowlist-only — the controlled posture. */
54
+ sameSite?: 'Lax' | 'Strict'
55
+ }
56
+ // cookieName?, rotation? — reserved policy siblings (not wired yet).
57
+ }
58
+ /** Realtime / push policy. Protocol-neutral so a future WS transport doesn't
59
+ * make the key a lie. */
60
+ realtime?: {
61
+ /** SSE heartbeat interval in ms (`start` only). Default: 25000. */
62
+ pingMs?: number
63
+ // transport? — reserved INTERNAL seam (not user-facing).
64
+ }
65
+ /** Dev-only tooling. */
66
+ dev?: {
67
+ /** Dev inspector toolbar (`dev` only). Default: on. */
68
+ inspector?: boolean
69
+ }
70
+ /** Origins allowed to make cross-site writes despite the CSRF guard — exact
71
+ * (`https://app.example.com`) or wildcard-subdomain (`https://*.example.com`).
72
+ * Same-origin and same-site writes are already allowed; this is for decoupled
73
+ * frontends or partner domains. */
74
+ trustedOrigins?: string[]
75
+ /** Cross-origin READ policy (CORS) — governs which cross-origin sites may read
76
+ * responses (distinct from `trustedOrigins`, which governs cross-site writes).
77
+ * Opt in per route/app with the `cors()` middleware; `origins` defaults to
78
+ * `trustedOrigins`. */
79
+ cors?: { origins?: string[]; credentials?: boolean }
80
+ /** Logging policy. */
81
+ logging?: {
82
+ /** Minimum level to emit. Default: `warn` in production, `info` in dev.
83
+ * Precedence: `LOG_LEVEL` env > this > default. */
84
+ level?: LogLevel
85
+ }
86
+ // observers?: Observer[] — top-level when the observability spec lands.
87
+ }
88
+
89
+ /** Identity helper for a typed config: `export default defineConfig({ … })`. */
90
+ export function defineConfig(config: StatorConfig): StatorConfig {
91
+ return config
92
+ }
@@ -30,6 +30,13 @@ export interface DefineMachineConfig<
30
30
  /** Typed event surface. Pass `{} as MyEvents` — a phantom carrier the engine
31
31
  * reads only for its type. Actions/guards then narrow per transition. */
32
32
  events?: E
33
+ /** Event types NO client may dispatch to `/__events` — effect completions
34
+ * (`CHARGE_APPROVED`), timer/`after:` events, cross-machine internals: events
35
+ * only the server/engine generates. A production `stator start` rejects a
36
+ * client POST of one with 403 (dev does not enforce). Typed against the event
37
+ * union, so a name that isn't a real event is a compile error. The chart still
38
+ * handles them normally when the engine/effects raise them. */
39
+ serverOnly?: readonly E['type'][]
33
40
  emits?: EmitsConfig<C, E>
34
41
  context: C
35
42
  initial: NoInfer<S>
@@ -116,6 +123,7 @@ export function defineMachine<
116
123
  persist: config.persist ?? false,
117
124
  reads,
118
125
  subscribes: config.subscribes ?? [],
126
+ serverOnly: config.serverOnly ?? [],
119
127
  emits: normalizeEmits<C, E>(config.emits),
120
128
  selectors: (config.selectors ?? {}) as Sel,
121
129
  capabilities: computeCapabilities(reads),
@@ -243,6 +243,10 @@ export interface MachineDef<
243
243
  persist: boolean
244
244
  reads: AnyMachineDef[]
245
245
  subscribes: SubscribeEntry[]
246
+ /** Event types no client may dispatch to `/__events` (server-generated only:
247
+ * effect completions, timers, cross-machine internals). Normalized to `[]`.
248
+ * Enforced with a 403 in production; the chart handles them normally. */
249
+ serverOnly: readonly string[]
246
250
  /** Normalized: every declared emit, possibly with a payload selector. */
247
251
  emits: Record<string, EmitDeclaration<C, E>>
248
252
  selectors: Sel
@@ -1,7 +1,7 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import type { Context } from 'hono'
3
2
  import { setCookie } from 'hono/cookie'
4
3
  import { safeNavigationUrl } from '../wire/safe-url.ts'
4
+ import { cookieJar } from './cookies.ts'
5
5
  import { scheduleSessionEffects } from './effects.ts'
6
6
  import { scopedLogger } from './logger.ts'
7
7
  import type { MachineStore } from './machine-store.ts'
@@ -15,7 +15,7 @@ import type {
15
15
  Directive,
16
16
  RouteRequest,
17
17
  } from './routing.ts'
18
- import { getOrCreateSessionId, setSessionCookie } from './session.ts'
18
+ import { getOrCreateSessionId, getSessionState, rotateSessionNow } from './session.ts'
19
19
  import { withSessionLock } from './session-lock.ts'
20
20
  import { SessionRuntime } from './session-runtime.ts'
21
21
  import { fanOut } from './sse.ts'
@@ -73,6 +73,25 @@ export async function runApiRoute(
73
73
  rotateSession: (opts) => {
74
74
  rotation = { clear: opts?.clear === true }
75
75
  },
76
+ clearSession: () => {
77
+ rotation = { clear: true }
78
+ },
79
+ claims: <T = unknown>() => getSessionState(c)?.claims as T | undefined,
80
+ setClaims: (claims) => {
81
+ const s = getSessionState(c)
82
+ if (s) {
83
+ s.claims = claims
84
+ s.claimsDirty = true
85
+ }
86
+ },
87
+ clearClaims: () => {
88
+ const s = getSessionState(c)
89
+ if (s) {
90
+ s.claims = undefined
91
+ s.claimsDirty = true
92
+ }
93
+ },
94
+ cookies: cookieJar(c),
76
95
  }
77
96
 
78
97
  let result: Response | ApiRouteEnvelope
@@ -101,20 +120,11 @@ export async function runApiRoute(
101
120
  // new cookie. Effect completions must chase the NEW id.
102
121
  let effectsSessionId = sessionId
103
122
  if (rotation !== null) {
104
- const newSessionId = randomUUID()
105
- if ((rotation as { clear: boolean }).clear) {
106
- await store.persistence.deleteSession(sessionId)
107
- } else {
108
- if (!store.persistence.renameSession) {
109
- throw new Error(
110
- 'stator: rotateSession requires a store with renameSession — ' +
111
- 'the configured custom store does not implement it.',
112
- )
113
- }
114
- await store.persistence.renameSession(sessionId, newSessionId)
115
- }
116
- setSessionCookie(c, newSessionId)
117
- effectsSessionId = newSessionId
123
+ // Apply the deferred rotation now the handler has returned. The helper
124
+ // moves (or, on clear, deletes) the session, sets the new cookie, and
125
+ // updates the shared session state so the bridge persists claims to the
126
+ // new id. Effect completions must chase the NEW id.
127
+ effectsSessionId = await rotateSessionNow(c, store, rotation)
118
128
  }
119
129
  // Effects queued by dispatched events run after this callback returns
120
130
  // (never under the session lock); see server/effects.ts.
@@ -201,8 +211,10 @@ function synthesizeResponse(
201
211
  // same-origin, else fall back to '/' (no open redirect).
202
212
  return c.redirect(sameOriginReferer(request), 303)
203
213
  }
204
- // No actionable directive for a no-JS client. Send a minimal 204.
205
- return new Response(null, { status: 204 })
214
+ // No actionable directive for a no-JS client. Send a minimal 204 — via
215
+ // c.body so any cookies set this request (session rotation, the cookie jar)
216
+ // survive; a bare `new Response` would drop c's accumulated Set-Cookie.
217
+ return c.body(null, 204)
206
218
  }
207
219
 
208
220
  // JSON / client-runtime path.
@@ -70,6 +70,24 @@ export function printDevBanner(info: {
70
70
  process.stdout.write(`${lines.join('\n')}\n`)
71
71
  }
72
72
 
73
+ /**
74
+ * One-line production startup notice. Printed to stdout directly — NOT through
75
+ * the leveled logger — so it always shows, even at the quiet `warn` default:
76
+ * "did it start, and where" is the one thing an operator always wants, distinct
77
+ * from the per-request logs that `warn` intentionally suppresses.
78
+ */
79
+ export function printStartupNotice(info: { port: number; machines: number; routes: number }): void {
80
+ const v = statorVersion()
81
+ const inv = `${info.machines} machine${info.machines === 1 ? '' : 's'} · ${info.routes} route${
82
+ info.routes === 1 ? '' : 's'
83
+ }`
84
+ process.stdout.write(
85
+ ` ${c.copper(c.bold('stator'))}${v ? c.dim(` v${v}`) : ''} ${c.dim('·')} ${c.cyan(
86
+ `http://localhost:${info.port}/`,
87
+ )} ${c.dim(`· ${inv}`)}\n`,
88
+ )
89
+ }
90
+
73
91
  /**
74
92
  * Exit a server process like a well-mannered CLI: first signal closes
75
93
  * cleanly and exits 0 (Ctrl+C is a normal action, not a failure — without