@statorjs/stator 2.0.2 → 2.2.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.0.2",
3
+ "version": "2.2.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.0.2"
98
+ "@statorjs/stator": "2.2.0"
92
99
  },
93
100
  "scripts": {
94
101
  "typecheck": "tsc --noEmit",
@@ -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,21 @@
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
+ })
20
+ await app.listen(resolvePort(ctx.portFlag, config.port))
21
+ }
@@ -0,0 +1,30 @@
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
+ headExtras: await loadProductionHead(dist),
27
+ })
28
+
29
+ await app.listen(resolvePort(ctx.portFlag, config.port))
30
+ }
@@ -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')
@@ -1,6 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { elementMarkerSelector } from '../wire/island-markers.ts'
3
3
  import type { ClientDirective, ClientElement } from './client-script.ts'
4
+ import { CLIENT_AUTHOR_GLOBALS, CLIENT_LOWERING_TARGETS, importLine } from './emit-names.ts'
4
5
 
5
6
  /**
6
7
  * Phase 3b stage 5 — emit the client entry module for a client component: the
@@ -13,8 +14,10 @@ import type { ClientDirective, ClientElement } from './client-script.ts'
13
14
  * class as written; the subclass overrides `setup()`.
14
15
  */
15
16
 
16
- const PRIMITIVES =
17
- "import { StatorElement, defineElement, use, machine, bind, bindSlot, effect, dispatch, attrValue, setAttr } from '@statorjs/stator/client'"
17
+ const PRIMITIVES = importLine(
18
+ [...CLIENT_AUTHOR_GLOBALS, ...CLIENT_LOWERING_TARGETS],
19
+ '@statorjs/stator/client',
20
+ )
18
21
 
19
22
  export interface EmitClientInput {
20
23
  /** The author's `<script>` source. */
@@ -8,6 +8,12 @@ import {
8
8
  pascalToKebab,
9
9
  } from './client-script.ts'
10
10
  import { CompileError, type DiagnosticLocation, locAt } from './diagnostics.ts'
11
+ import {
12
+ ISLAND_SHELL_EXTRAS,
13
+ importLine,
14
+ TEMPLATE_AUTHOR_GLOBALS,
15
+ TEMPLATE_LOWERING_TARGETS,
16
+ } from './emit-names.ts'
11
17
  import { scopeHash } from './hash.ts'
12
18
  import { type LowerMeta, lowerTemplate } from './lower.ts'
13
19
  import { splitStator } from './split.ts'
@@ -47,8 +53,19 @@ export interface CompileResult {
47
53
  clientTag?: string
48
54
  }
49
55
 
50
- const PRIMITIVES_IMPORT =
51
- "import { html, read, each, itemBind, when, match, defer, on, classList, styleList, spreadAttrs } from '@statorjs/stator/template'"
56
+ const PRIMITIVES_IMPORT = importLine(
57
+ [...TEMPLATE_AUTHOR_GLOBALS, ...TEMPLATE_LOWERING_TARGETS],
58
+ '@statorjs/stator/template',
59
+ )
60
+
61
+ const SHELL_PRIMITIVES_IMPORT = importLine(
62
+ [
63
+ ...TEMPLATE_AUTHOR_GLOBALS,
64
+ ...TEMPLATE_LOWERING_TARGETS.filter((n) => n !== 'itemBind'),
65
+ ...ISLAND_SHELL_EXTRAS,
66
+ ],
67
+ '@statorjs/stator/template',
68
+ )
52
69
 
53
70
  export interface CompileOptions {
54
71
  /** Stable id for the component (file path). Used for the scope hash so the
@@ -81,24 +98,17 @@ export function compile(source: string, opts: CompileOptions = {}): CompileResul
81
98
  const script = scripts.join('\n')
82
99
  if (script.trim()) {
83
100
  if (analyzeScriptClasses(script).length > 0) {
84
- // An island's shell renders from props — there is no frontmatter
85
- // execution on this path, and silently discarding the fence produced
86
- // either a dangling-identifier crash at render or dead code that
87
- // looked alive. Surface it instead.
88
- if (frontmatter.trim()) {
89
- throw new CompileError(
90
- `stator: an island file (a template with a StatorElement <script>) can't have ` +
91
- `frontmatter — the fence would never run: the island's shell renders from props. ` +
92
- `Move the server work into the route or component that renders this island and ` +
93
- `pass the results as props.`,
94
- locAt(source, 0, opts.id),
95
- )
96
- }
101
+ // The island's SERVER FENCE (2.1): runs per shell render, on the
102
+ // server only, with its bindings in template scope. The <script>
103
+ // never sees it, in either direction.
104
+ const fm = processFrontmatter(frontmatter, 'island', source, opts.id)
97
105
  return compileClient(template, script, {
98
106
  hash,
99
107
  scopeAttr,
100
108
  styles,
101
109
  file: opts.id,
110
+ fm,
111
+ source,
102
112
  })
103
113
  }
104
114
  throw new CompileError(
@@ -152,7 +162,16 @@ export function compile(source: string, opts: CompileOptions = {}): CompileResul
152
162
  function compileClient(
153
163
  template: string,
154
164
  script: string,
155
- ctx: { hash: string; scopeAttr?: string; styles: string[]; file?: string },
165
+ ctx: {
166
+ hash: string
167
+ scopeAttr?: string
168
+ styles: string[]
169
+ file?: string
170
+ /** The island's processed server fence (2.1) — empty parts when absent. */
171
+ fm?: FrontmatterParts
172
+ /** Original source, for located collision diagnostics. */
173
+ source?: string
174
+ },
156
175
  ): CompileResult {
157
176
  const classes = analyzeScriptClasses(script)
158
177
  const root = extractClientRoot(template, ctx.file)
@@ -162,6 +181,22 @@ function compileClient(
162
181
  analyzeClient(script, tags, { file: ctx.file })
163
182
 
164
183
  const cls = classes.find((c) => pascalToKebab(c.name) === root.tag)
184
+
185
+ // A fence binding sharing a name with a `use()` field is ambiguous by
186
+ // construction (client wiring keys template identifiers on the use-field
187
+ // set) — a located error, not a precedence rule.
188
+ if (ctx.fm && cls) {
189
+ const fenceNames = fenceBindingNames(ctx.fm)
190
+ for (const field of cls.useFields.keys()) {
191
+ if (fenceNames.has(field)) {
192
+ throw new CompileError(
193
+ `stator: "${field}" is both a fence binding and a use() field — the template ` +
194
+ `couldn't tell the server value from the client machine. Rename one.`,
195
+ ctx.source ? locAt(ctx.source, 0, ctx.file) : undefined,
196
+ )
197
+ }
198
+ }
199
+ }
165
200
  if (!cls) {
166
201
  // analyzeClient validated the tag↔class match just above; reaching here is a bug.
167
202
  throw new CompileError(
@@ -199,9 +234,13 @@ function compileClient(
199
234
  const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
200
235
  const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
201
236
  const serverCode = [
202
- "import { html, read, each, when, match, defer, on, classList, styleList, spreadAttrs, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
237
+ SHELL_PRIMITIVES_IMPORT,
238
+ ...(ctx.fm?.hoisted ? [ctx.fm.hoisted] : []),
203
239
  '',
204
240
  `export default function (props = {}) {`,
241
+ // The fence body runs here — per shell render, server only, ahead of
242
+ // the template so its bindings are in scope for the interpolations.
243
+ ...(ctx.fm?.body ? [ctx.fm.body] : []),
205
244
  ` const __inner = ${innerExpr}`,
206
245
  ` const __attrs = clientShellAttrs(props, ${attrDecl}, ${JSON.stringify(root.rootAttrs)})`,
207
246
  ` return createHtmlFragment(\`<${root.tag}\${__attrs}${rootScope}>\` + __inner.html + \`</${root.tag}>\`)`,
@@ -324,7 +363,7 @@ const VALID_PRAGMAS = new Set(['live'])
324
363
  */
325
364
  function processFrontmatter(
326
365
  fm: string,
327
- kind: 'route' | 'component',
366
+ kind: 'route' | 'component' | 'island',
328
367
  source: string,
329
368
  file?: string,
330
369
  ): FrontmatterParts {
@@ -355,6 +394,14 @@ function processFrontmatter(
355
394
  const repls: Array<[start: number, end: number, replacement: string]> = []
356
395
  const visit = (n: ts.Node): void => {
357
396
  if (ts.isCallExpression(n) && isStatorCall(n, 'props')) {
397
+ if (kind === 'island') {
398
+ throw new CompileError(
399
+ "stator: Stator.props() is not available in an island's fence — island props are " +
400
+ 'declared by `static attrs` on the element class, and richer values arrive ' +
401
+ 'through the hydrate pattern (the shell renders with the props of its use site).',
402
+ locInFm(n),
403
+ )
404
+ }
358
405
  if (kind === 'route') {
359
406
  throw new CompileError(
360
407
  'stator: Stator.props() is not available in a route page (a route has no parent props). ' +
@@ -387,6 +434,12 @@ function processFrontmatter(
387
434
  return
388
435
  }
389
436
  if (ts.isCallExpression(n) && isStatorCall(n, 'forwarded')) {
437
+ if (kind === 'island') {
438
+ throw new CompileError(
439
+ "stator: Stator.forwarded() is not available in an island's fence.",
440
+ locInFm(n),
441
+ )
442
+ }
390
443
  if (kind === 'route') {
391
444
  throw new CompileError(
392
445
  'stator: Stator.forwarded() is only available in a component — it reads a directive ' +
@@ -435,6 +488,41 @@ function processFrontmatter(
435
488
  }
436
489
  }
437
490
 
491
+ /** Top-level names a fence declares — imports (default/named/namespace),
492
+ * consts/lets, functions, classes — for the fence-vs-use()-field collision
493
+ * check. Parsed from the processed parts (hoisted + body). */
494
+ function fenceBindingNames(fm: FrontmatterParts): Set<string> {
495
+ const names = new Set<string>()
496
+ const sf = ts.createSourceFile(
497
+ 'fence.ts',
498
+ `${fm.hoisted}\n${fm.body}`,
499
+ ts.ScriptTarget.Latest,
500
+ true,
501
+ ts.ScriptKind.TS,
502
+ )
503
+ const bind = (n: ts.BindingName): void => {
504
+ if (ts.isIdentifier(n)) names.add(n.text)
505
+ else for (const el of n.elements) if (ts.isBindingElement(el)) bind(el.name)
506
+ }
507
+ for (const stmt of sf.statements) {
508
+ if (ts.isImportDeclaration(stmt) && stmt.importClause) {
509
+ const c = stmt.importClause
510
+ if (c.name) names.add(c.name.text)
511
+ if (c.namedBindings) {
512
+ if (ts.isNamespaceImport(c.namedBindings)) names.add(c.namedBindings.name.text)
513
+ else for (const s of c.namedBindings.elements) names.add(s.name.text)
514
+ }
515
+ } else if (ts.isVariableStatement(stmt)) {
516
+ for (const d of stmt.declarationList.declarations) bind(d.name)
517
+ } else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
518
+ names.add(stmt.name.text)
519
+ } else if (ts.isClassDeclaration(stmt) && stmt.name) {
520
+ names.add(stmt.name.text)
521
+ }
522
+ }
523
+ return names
524
+ }
525
+
438
526
  function emitComponent(fm: FrontmatterParts, htmlExpr: string, meta: LowerMeta): string {
439
527
  let param = ''
440
528
  if (fm.propsType && meta.usesChildren) param = `props: ${fm.propsType} & { children?: any }`
@@ -469,7 +557,7 @@ function emitRoute(fm: FrontmatterParts, htmlExpr: string, _meta: LowerMeta): st
469
557
  /** Parse `// @stator <flag>` comment pragmas from the frontmatter text. */
470
558
  function parsePragmas(
471
559
  fm: string,
472
- kind: 'route' | 'component',
560
+ kind: 'route' | 'component' | 'island',
473
561
  source: string,
474
562
  file?: string,
475
563
  ): Set<string> {
@@ -494,7 +582,11 @@ function parsePragmas(
494
582
  return out
495
583
  }
496
584
 
497
- function requireRoute(what: string, kind: 'route' | 'component', loc: DiagnosticLocation): void {
585
+ function requireRoute(
586
+ what: string,
587
+ kind: 'route' | 'component' | 'island',
588
+ loc: DiagnosticLocation,
589
+ ): void {
498
590
  if (kind !== 'route') {
499
591
  throw new CompileError(
500
592
  `stator: ${what} is only available in a route page, not a component. ` +
@@ -0,0 +1,48 @@
1
+ /** The names the compiler auto-injects into emitted modules — single source
2
+ * of truth for the emitters (compile.ts, client-emit.ts) AND the language
3
+ * server (virtual-code.ts), which must inject the same globals in-editor: a
4
+ * name injected in one place but not the other hides a missing-import bug,
5
+ * and a name injected that authors also legitimately import collides with
6
+ * their import (`raw` is NOT a global — authors import it — which is why it
7
+ * appears in no list here).
8
+ *
9
+ * Author globals are names template/script authors may write directly;
10
+ * lowering targets are names only the compiler's output references. */
11
+
12
+ /** Template names authors write: control flow + display + directive values. */
13
+ export const TEMPLATE_AUTHOR_GLOBALS = [
14
+ 'read',
15
+ 'each',
16
+ 'when',
17
+ 'match',
18
+ 'defer',
19
+ 'on',
20
+ 'classList',
21
+ 'styleList',
22
+ ] as const
23
+
24
+ /** Template names only lowered code references. `itemBind` needs server
25
+ * render state, so it is a SERVER-module target only — client-island shells
26
+ * never emit it (see lower.ts, keyed-row lowering). */
27
+ export const TEMPLATE_LOWERING_TARGETS = ['html', 'itemBind', 'spreadAttrs'] as const
28
+
29
+ /** Extra lowering targets for a client island's server SHELL module. */
30
+ export const ISLAND_SHELL_EXTRAS = ['createHtmlFragment', 'clientShellAttrs'] as const
31
+
32
+ /** Client-module names island authors write in a `<script>`. */
33
+ export const CLIENT_AUTHOR_GLOBALS = [
34
+ 'StatorElement',
35
+ 'use',
36
+ 'machine',
37
+ 'defineElement',
38
+ 'bind',
39
+ 'effect',
40
+ 'dispatch',
41
+ ] as const
42
+
43
+ /** Client-module names only lowered code references. */
44
+ export const CLIENT_LOWERING_TARGETS = ['bindSlot', 'attrValue', 'setAttr'] as const
45
+
46
+ export function importLine(names: readonly string[], from: string): string {
47
+ return `import { ${names.join(', ')} } from '${from}'`
48
+ }
@@ -21,6 +21,7 @@
21
21
  import ts from 'typescript'
22
22
  import { analyzeScriptClasses } from './client-script.ts'
23
23
  import { statorPropsType } from './dts.ts'
24
+ import { CLIENT_AUTHOR_GLOBALS, TEMPLATE_AUTHOR_GLOBALS } from './emit-names.ts'
24
25
  import { type ScannedRegions, scanRegions } from './split.ts'
25
26
 
26
27
  /** A contiguous run mapping generated code back to source, 1:1 over its length. */
@@ -44,21 +45,11 @@ export interface VirtualCodeResult {
44
45
  styles: VirtualFile[]
45
46
  }
46
47
 
47
- // Must mirror the RUNTIME's auto-injected globals exactly (compile.ts
48
- // PRIMITIVES_IMPORT / client-emit.ts): a name injected here but not at
49
- // runtime hides a missing-import bug; a name in both collides with the
50
- // author's legitimate import (`raw` is NOT a runtime global — authors
51
- // import it — which is why it must not be in this list).
52
- const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'defer', 'on', 'classList', 'styleList']
53
- const CLIENT_GLOBALS = [
54
- 'StatorElement',
55
- 'use',
56
- 'machine',
57
- 'defineElement',
58
- 'bind',
59
- 'effect',
60
- 'dispatch',
61
- ]
48
+ // The editor injects the AUTHOR-visible subsets only — lowering targets are
49
+ // names authors never write, so injecting them here would shadow nothing and
50
+ // pollute completions. Shared with the emitters via emit-names.ts.
51
+ const TEMPLATE_GLOBALS = TEMPLATE_AUTHOR_GLOBALS
52
+ const CLIENT_GLOBALS = CLIENT_AUTHOR_GLOBALS
62
53
 
63
54
  /** Local names the user's code already binds from `modulePath` imports —
64
55
  * the runtime strips such habit-imports; the virtual emit (which must keep
@@ -79,7 +70,7 @@ function userImportedLocals(code: string, modulePath: string): Set<string> {
79
70
  return locals
80
71
  }
81
72
 
82
- function injectImports(globals: string[], modulePath: string, userCode: string): string {
73
+ function injectImports(globals: readonly string[], modulePath: string, userCode: string): string {
83
74
  const bound = userImportedLocals(userCode, modulePath)
84
75
  const missing = globals.filter((g) => !bound.has(g))
85
76
  return missing.length > 0 ? `import { ${missing.join(', ')} } from '${modulePath}';\n` : ''
@@ -367,6 +358,28 @@ function buildClientTsx(regions: ScannedRegions): VirtualFile {
367
358
  AMBIENT_TYPE_IMPORTS +
368
359
  STATOR_AMBIENT
369
360
 
361
+ // The island's SERVER FENCE (2.1): typed here so the editor checks it, but
362
+ // wrapped so its bindings don't leak into the <script>'s scope (at runtime
363
+ // the two never meet). Hoisted imports that also appear verbatim in the
364
+ // script are skipped — same module, one binding.
365
+ const fm = regions.frontmatter?.content ?? ''
366
+ if (fm.trim()) {
367
+ const fmOffset = regions.frontmatter?.contentOffset ?? 0
368
+ const { hoisted, body } = splitFrontmatter(fm, fmOffset)
369
+ for (const seg of hoisted) {
370
+ if (userScript.includes(seg.text.trim())) continue
371
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
372
+ code += `${seg.text}\n`
373
+ }
374
+ code += 'function __statorFence(props: Record<string, unknown>) {\n'
375
+ for (const seg of body) {
376
+ code += ' '
377
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
378
+ code += `${seg.text}\n`
379
+ }
380
+ code += '}\nvoid __statorFence\n'
381
+ }
382
+
370
383
  for (const script of regions.scripts) {
371
384
  push(mappings, script.contentOffset, code.length, script.content.length)
372
385
  code += `${script.content}\n`
package/src/config.ts ADDED
@@ -0,0 +1,68 @@
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
+ /** The swappable persistence adapters, grouped by concern. Both optional —
27
+ * default to in-memory (restart-wipe). Neither is a machine-graph entry
28
+ * point (machines are file-discovered). */
29
+ persistence?: {
30
+ /** Session-lifecycle machine state. Default: `InMemoryStore`. Pass a
31
+ * `RedisStore`/`CachedStore` for durability — the adapter seam. */
32
+ session?: Store
33
+ /** App-lifecycle machine state for `persist: true` machines (no TTL, one
34
+ * blob per machine). Advanced — most apps never set it. Default:
35
+ * in-memory (restart-wipe). Pass a `RedisAppStore` for durable app state. */
36
+ app?: AppStore
37
+ }
38
+ /** Session policy (no adapter here — that lives in `persistence.session`). */
39
+ sessions?: {
40
+ /** Per-session idle TTL in seconds. Default: 86400 (24h). */
41
+ ttlSeconds?: number
42
+ // cookieName?, rotation? — reserved policy siblings (not wired yet).
43
+ }
44
+ /** Realtime / push policy. Protocol-neutral so a future WS transport doesn't
45
+ * make the key a lie. */
46
+ realtime?: {
47
+ /** SSE heartbeat interval in ms (`start` only). Default: 25000. */
48
+ pingMs?: number
49
+ // transport? — reserved INTERNAL seam (not user-facing).
50
+ }
51
+ /** Dev-only tooling. */
52
+ dev?: {
53
+ /** Dev inspector toolbar (`dev` only). Default: on. */
54
+ inspector?: boolean
55
+ }
56
+ /** Logging policy. */
57
+ logging?: {
58
+ /** Minimum level to emit. Default: `warn` in production, `info` in dev.
59
+ * Precedence: `LOG_LEVEL` env > this > default. */
60
+ level?: LogLevel
61
+ }
62
+ // observers?: Observer[] — top-level when the observability spec lands.
63
+ }
64
+
65
+ /** Identity helper for a typed config: `export default defineConfig({ … })`. */
66
+ export function defineConfig(config: StatorConfig): StatorConfig {
67
+ return config
68
+ }
@@ -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
@@ -0,0 +1,73 @@
1
+ import type { LogLevel } from '../config.ts'
2
+ import type { AppStore } from './app-store.ts'
3
+ import type { Store } from './store.ts'
4
+
5
+ /**
6
+ * The pre-nesting flat config keys. `createApp` shipped all of these at 2.1.0 (and
7
+ * `createDevApp` all but `ssePingMs`), so they stay ACCEPTED — typed and
8
+ * `@deprecated`, not removed — to keep those callers compiling and running:
9
+ * nesting the args would otherwise be a breaking change (a major). The removal is
10
+ * parked as a major-cutover (see ROADMAP surface hygiene). Nested values win when
11
+ * both a flat key and its nested home are present.
12
+ */
13
+ export interface DeprecatedFlatConfig {
14
+ /** @deprecated use `persistence.session` */
15
+ store?: Store
16
+ /** @deprecated use `persistence.app` */
17
+ appStore?: AppStore
18
+ /** @deprecated use `sessions.ttlSeconds` */
19
+ sessionTtlSeconds?: number
20
+ /** @deprecated use `realtime.pingMs` */
21
+ ssePingMs?: number
22
+ /** @deprecated use `dev.inspector` */
23
+ inspector?: boolean
24
+ }
25
+
26
+ interface NestedReads {
27
+ persistence?: { session?: Store; app?: AppStore }
28
+ sessions?: { ttlSeconds?: number }
29
+ realtime?: { pingMs?: number }
30
+ dev?: { inspector?: boolean }
31
+ logging?: { level?: LogLevel }
32
+ }
33
+
34
+ /** The flat internal values `createApp`/`createDevApp` actually consume. */
35
+ export interface ResolvedAppConfig {
36
+ session?: Store
37
+ app?: AppStore
38
+ sessionTtlSeconds?: number
39
+ ssePingMs?: number
40
+ inspector?: boolean
41
+ logLevel?: LogLevel
42
+ }
43
+
44
+ /**
45
+ * Resolve the nested config, falling back to the deprecated flat keys, and warn
46
+ * once (per app construction) if any flat key is in use. Nested wins. Exported for
47
+ * testing.
48
+ */
49
+ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): ResolvedAppConfig {
50
+ const deprecated: string[] = []
51
+ if (config.store !== undefined) deprecated.push('store → persistence.session')
52
+ if (config.appStore !== undefined) deprecated.push('appStore → persistence.app')
53
+ if (config.sessionTtlSeconds !== undefined)
54
+ deprecated.push('sessionTtlSeconds → sessions.ttlSeconds')
55
+ if (config.ssePingMs !== undefined) deprecated.push('ssePingMs → realtime.pingMs')
56
+ if (config.inspector !== undefined) deprecated.push('inspector → dev.inspector')
57
+
58
+ if (deprecated.length > 0) {
59
+ console.warn(
60
+ 'stator: these createApp/createDevApp options are deprecated and will be removed in a future major — move to the nested shape:\n' +
61
+ deprecated.map((d) => ` ${d}`).join('\n'),
62
+ )
63
+ }
64
+
65
+ return {
66
+ session: config.persistence?.session ?? config.store,
67
+ app: config.persistence?.app ?? config.appStore,
68
+ sessionTtlSeconds: config.sessions?.ttlSeconds ?? config.sessionTtlSeconds,
69
+ ssePingMs: config.realtime?.pingMs ?? config.ssePingMs,
70
+ inspector: config.dev?.inspector ?? config.inspector,
71
+ logLevel: config.logging?.level,
72
+ }
73
+ }
@@ -1,39 +1,60 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { serve } from '@hono/node-server'
3
+ import type { LogLevel } from '../config.ts'
3
4
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
4
5
  import { dispatchToApp } from './app-dispatch.ts'
5
6
  import type { AppStore } from './app-store.ts'
6
- import { installGracefulShutdown } from './banner.ts'
7
+ import { installGracefulShutdown, printStartupNotice } from './banner.ts'
8
+ import { type DeprecatedFlatConfig, resolveAppConfig } from './config-compat.ts'
7
9
  import { discoverMachines } from './discovery.ts'
8
10
  import { wireAppEffects } from './effects.ts'
9
11
  import { buildHonoApp } from './http.ts'
10
- import { logger } from './logger.ts'
12
+ import { logger, setLogLevel } from './logger.ts'
11
13
  import { MachineStore } from './machine-store.ts'
12
14
  import { discoverRoutes } from './route-discovery.ts'
13
15
  import { InMemoryStore, type Store } from './store.ts'
14
16
 
15
- export interface CreateAppConfig {
17
+ export interface CreateAppConfig extends DeprecatedFlatConfig {
16
18
  machinesDir: string
17
19
  routesDir: string
18
20
  staticDir?: string
19
- /** Persistence adapter for session-lifecycle machine state. Defaults to
20
- * InMemoryStore — fine for dev, V1 adapters swap in here. */
21
- store?: Store
22
- /** Persistence for `persist: true` app-lifecycle machines (no TTL, one
23
- * blob per machine). Defaults to in-memory (restart-wipe); pass
24
- * RedisAppStore for durable app state. */
25
- appStore?: AppStore
26
- /** Per-session TTL in seconds. Every set to any of the session's
27
- * machines refreshes this expiry. Defaults to 24h (86400). */
28
- sessionTtlSeconds?: number
29
- /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
30
- ssePingMs?: number
21
+ /** Swappable persistence adapters, grouped by concern. Both optional —
22
+ * default to in-memory (restart-wipe). Mirrors `StatorConfig.persistence`. */
23
+ persistence?: {
24
+ /** Session-lifecycle machine state. Defaults to InMemoryStore — fine for
25
+ * dev, V1 adapters swap in here. */
26
+ session?: Store
27
+ /** App-lifecycle machine state for `persist: true` machines (no TTL, one
28
+ * blob per machine). Defaults to in-memory (restart-wipe); pass
29
+ * RedisAppStore for durable app state. */
30
+ app?: AppStore
31
+ }
32
+ /** Session policy. */
33
+ sessions?: {
34
+ /** Per-session TTL in seconds. Every set to any of the session's machines
35
+ * refreshes this expiry. Defaults to 24h (86400). */
36
+ ttlSeconds?: number
37
+ }
38
+ /** Realtime / push policy. */
39
+ realtime?: {
40
+ /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
41
+ pingMs?: number
42
+ }
43
+ /** Dev-only tooling. */
44
+ dev?: {
45
+ /** Serve + inject the wire inspector toolbar (the dev server's on by
46
+ * default; production opts in — demo sites want the wire visible). */
47
+ inspector?: boolean
48
+ }
49
+ /** Logging policy. */
50
+ logging?: {
51
+ /** Minimum level to emit. Default: `warn` in production, `info` in dev.
52
+ * `LOG_LEVEL` env takes precedence over this. */
53
+ level?: LogLevel
54
+ }
31
55
  /** Extra `<head>` HTML per GET route. A production build uses this to link the
32
56
  * prebuilt `components.css`; ignored if omitted. */
33
57
  headExtras?: (filePath: string) => string | Promise<string>
34
- /** Serve + inject the wire inspector toolbar (the dev server's on by
35
- * default; production opts in — demo sites want the wire visible). */
36
- inspector?: boolean
37
58
  }
38
59
 
39
60
  export interface StatorApp {
@@ -57,11 +78,16 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
57
78
  const routesDir = resolve(config.routesDir)
58
79
  const staticDir = config.staticDir ? resolve(config.staticDir) : undefined
59
80
 
81
+ const resolved = resolveAppConfig(config)
82
+ // Level precedence: LOG_LEVEL env > config > the production default (createApp
83
+ // is the production entry point, so it defaults quiet). setLogLevel also covers
84
+ // the scoped children (http/sse/…), which a bare `logger.level =` would miss.
85
+ setLogLevel(process.env.LOG_LEVEL ?? resolved.logLevel ?? 'warn')
60
86
  const { defs } = await discoverMachines(machinesDir)
61
- const persistence = config.store ?? new InMemoryStore()
62
- const store = new MachineStore(defs, persistence, {
63
- sessionTtlSeconds: config.sessionTtlSeconds,
64
- appStore: config.appStore,
87
+ const sessionStore = resolved.session ?? new InMemoryStore()
88
+ const store = new MachineStore(defs, sessionStore, {
89
+ sessionTtlSeconds: resolved.sessionTtlSeconds,
90
+ appStore: resolved.app,
65
91
  })
66
92
  // Wire the effect scheduler BEFORE booting: a fresh app machine fires its
67
93
  // initial-state entry effect during boot, and that must have somewhere to go.
@@ -69,25 +95,27 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
69
95
  await store.bootAppMachines()
70
96
 
71
97
  const routes = await discoverRoutes(routesDir)
98
+ const inspector = resolved.inspector
72
99
  const app = await buildHonoApp({
73
100
  routes,
74
101
  store,
75
102
  staticDir,
76
- headExtras: config.inspector
103
+ headExtras: inspector
77
104
  ? async (filePath) => {
78
105
  const base = (await config.headExtras?.(filePath)) ?? ''
79
106
  return `${base}\n<script src="/@stator/inspector.js" defer></script>`
80
107
  }
81
108
  : config.headExtras,
82
- inspector: config.inspector,
83
- ssePingMs: config.ssePingMs,
109
+ inspector,
110
+ ssePingMs: resolved.ssePingMs,
84
111
  })
85
112
 
86
113
  return {
87
114
  listen(port: number): Promise<void> {
88
115
  return new Promise((resolveFn) => {
89
116
  const server = serve({ fetch: app.fetch, port }, () => {
90
- logger.info({ port, machines: defs.length, routes: routes.length }, 'listening')
117
+ // Always-on (level-independent) so `warn` prod still confirms boot.
118
+ printStartupNotice({ port, machines: defs.length, routes: routes.length })
91
119
  resolveFn()
92
120
  })
93
121
  // Production is strict about its port (a collision is a deploy
package/src/server/dev.ts CHANGED
@@ -6,11 +6,13 @@ import { getRequestListener } from '@hono/node-server'
6
6
  import type { Hono } from 'hono'
7
7
  import { createServer as createViteServer, type ViteDevServer } from 'vite'
8
8
  import { compile } from '../compiler/index.ts'
9
+ import type { LogLevel } from '../config.ts'
9
10
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
10
11
  import { machineStub, stator } from '../vite/index.ts'
11
12
  import type { AppStore } from './app-store.ts'
12
13
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
13
- import { logger } from './logger.ts'
14
+ import { resolveAppConfig } from './config-compat.ts'
15
+ import { logger, setLogLevel } from './logger.ts'
14
16
  import type { MachineStore } from './machine-store.ts'
15
17
  import type { DiscoveredRoute } from './route-discovery.ts'
16
18
  import { isStatorQueryRoute } from './routing.ts'
@@ -39,11 +41,39 @@ export interface DevAppConfig {
39
41
  machinesDir: string
40
42
  routesDir: string
41
43
  staticDir?: string
44
+ /** Swappable persistence adapters, grouped by concern. Both optional —
45
+ * default to in-memory. Mirrors `StatorConfig.persistence`. */
46
+ persistence?: {
47
+ /** Session-lifecycle machine state. Defaults to InMemoryStore. */
48
+ session?: Store
49
+ /** App-lifecycle machine state for `persist: true` machines. Defaults to
50
+ * in-memory. */
51
+ app?: AppStore
52
+ }
53
+ /** Session policy. */
54
+ sessions?: {
55
+ /** Per-session TTL in seconds. Defaults to 24h (86400). */
56
+ ttlSeconds?: number
57
+ }
58
+ /** Dev-only tooling. */
59
+ dev?: {
60
+ /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
61
+ inspector?: boolean
62
+ }
63
+ /** Logging policy. */
64
+ logging?: {
65
+ /** Minimum level to emit. Default: `info` in dev. `LOG_LEVEL` env wins. */
66
+ level?: LogLevel
67
+ }
68
+ // Deprecated flat keys — accepted (typed) so 2.1.0 callers don't break; nested
69
+ // wins. `createDevApp` never shipped `ssePingMs`, so it's not accepted here.
70
+ /** @deprecated use `persistence.session` */
42
71
  store?: Store
43
- /** Persistence for `persist: true` app machines. Defaults to in-memory. */
72
+ /** @deprecated use `persistence.app` */
44
73
  appStore?: AppStore
74
+ /** @deprecated use `sessions.ttlSeconds` */
45
75
  sessionTtlSeconds?: number
46
- /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
76
+ /** @deprecated use `dev.inspector` */
47
77
  inspector?: boolean
48
78
  }
49
79
 
@@ -92,7 +122,14 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
92
122
  const root = resolve(config.root)
93
123
  const machinesDir = resolve(config.machinesDir)
94
124
  const routesDir = resolve(config.routesDir)
95
- const inspectorOn = config.inspector ?? true
125
+ const resolved = resolveAppConfig(config)
126
+ // Level precedence: LOG_LEVEL env > config > dev default (info). Apply to this
127
+ // module's logger AND the Vite-loaded runtime instance — SSE/http/effects log
128
+ // through the latter — via setLogLevel, which also covers the scoped children.
129
+ const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
130
+ setLogLevel(logLevel)
131
+ runtime.setLogLevel(logLevel)
132
+ const inspectorOn = resolved.inspector ?? true
96
133
 
97
134
  const resultCache = new Map<string, ReturnType<typeof compile>>()
98
135
  const compiledFor = async (file: string) => {
@@ -144,9 +181,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
144
181
  const rebuildStore = async (): Promise<void> => {
145
182
  const { defs } = await runtime.discoverMachines(machinesDir, loader)
146
183
  machineCount = defs.length
147
- store = new runtime.MachineStore(defs, config.store ?? new runtime.InMemoryStore(), {
148
- sessionTtlSeconds: config.sessionTtlSeconds,
149
- appStore: config.appStore,
184
+ store = new runtime.MachineStore(defs, resolved.session ?? new runtime.InMemoryStore(), {
185
+ sessionTtlSeconds: resolved.sessionTtlSeconds,
186
+ appStore: resolved.app,
150
187
  })
151
188
  // Wire the effect scheduler before booting — a fresh app machine's
152
189
  // initial-state entry effect fires during boot and needs a live scheduler.
@@ -137,7 +137,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
137
137
  const ms = Math.round(performance.now() - start)
138
138
  const status = c.res.status
139
139
  const isLive = c.req.path === '/__sse'
140
- httpLog[status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'](
140
+ // Successful requests log at debug (one line per asset is prod noise); 4xx
141
+ // warns and 5xx errors stay visible at the default level.
142
+ httpLog[status >= 500 ? 'error' : status >= 400 ? 'warn' : 'debug'](
141
143
  {
142
144
  method: c.req.method,
143
145
  path: c.req.path,
@@ -47,7 +47,7 @@ export type { HttpConfig } from './http.ts'
47
47
  export { buildHonoApp } from './http.ts'
48
48
  export type { InstanceHandle } from './instance-proxy.ts'
49
49
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
50
- export { logger, scopedLogger } from './logger.ts'
50
+ export { logger, scopedLogger, setLogLevel } from './logger.ts'
51
51
  export { MachineStore } from './machine-store.ts'
52
52
  export { runQueryRoute } from './query-route.ts'
53
53
  export { recompute } from './recompute.ts'
@@ -3,8 +3,10 @@ import pino, { type Logger } from 'pino'
3
3
 
4
4
  /**
5
5
  * Framework logger. Pretty colored output in dev (auto-detected via
6
- * NODE_ENV), JSON in production for log aggregators. Level controlled by
7
- * LOG_LEVEL env (default 'info').
6
+ * NODE_ENV), JSON in production for log aggregators. Level defaults to `warn`
7
+ * in production (quiet — errors and warnings only) and `info` in dev; the
8
+ * `LOG_LEVEL` env overrides both, and `stator.config.ts`'s `logging.level`
9
+ * overrides the default when `LOG_LEVEL` is unset (applied at app construction).
8
10
  *
9
11
  * Application code can use this module-level logger or call `child()` for
10
12
  * scoped context. The framework uses scoped children for SSE events,
@@ -23,8 +25,8 @@ function hasPinoPretty(): boolean {
23
25
  }
24
26
 
25
27
  function buildLogger(): Logger {
26
- const level = process.env.LOG_LEVEL ?? 'info'
27
28
  const isProd = process.env.NODE_ENV === 'production'
29
+ const level = process.env.LOG_LEVEL ?? (isProd ? 'warn' : 'info')
28
30
 
29
31
  if (isProd || !hasPinoPretty()) {
30
32
  return pino({ level })
@@ -46,7 +48,25 @@ function buildLogger(): Logger {
46
48
 
47
49
  export const logger: Logger = buildLogger()
48
50
 
51
+ // Pino children capture the parent level at CREATION and do not follow later
52
+ // changes to it. The framework's scoped loggers (http, sse, effect, …) are all
53
+ // created at module load, so `logger.level = …` alone would never quiet them.
54
+ // Track them and set the level on each explicitly in `setLogLevel`.
55
+ const scopedChildren: Logger[] = []
56
+
49
57
  /** Child logger with a `scope` tag for filtering. */
50
58
  export function scopedLogger(scope: string): Logger {
51
- return logger.child({ scope })
59
+ const child = logger.child({ scope })
60
+ scopedChildren.push(child)
61
+ return child
62
+ }
63
+
64
+ /**
65
+ * Set the level on the root logger AND every scoped child. Children created
66
+ * after this call inherit the new root level, so this is complete regardless of
67
+ * import order. Called once at app construction from the resolved config.
68
+ */
69
+ export function setLogLevel(level: string): void {
70
+ logger.level = level
71
+ for (const child of scopedChildren) child.level = level
52
72
  }
package/src/server/sse.ts CHANGED
@@ -45,7 +45,7 @@ export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Con
45
45
  const id = `sse${nextId++}`
46
46
  const conn: Connection = { ...init, id, closed: false }
47
47
  connections.set(id, conn)
48
- sseLog.info(
48
+ sseLog.debug(
49
49
  { id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
50
50
  'connection opened',
51
51
  )
@@ -58,7 +58,7 @@ export function unregisterConnection(id: string): void {
58
58
  conn.closed = true
59
59
  conn.runtime.dispose()
60
60
  connections.delete(id)
61
- sseLog.info(
61
+ sseLog.debug(
62
62
  { id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
63
63
  'connection closed',
64
64
  )
@@ -158,9 +158,9 @@ export async function fanOut(
158
158
  }
159
159
  }
160
160
 
161
- // Log every fan-out at info so the user can see touched-machines flow and
162
- // correlate against client-side inspector entries.
163
- sseLog.info(
161
+ // Log every fan-out at debug so the user can see touched-machines flow and
162
+ // correlate against client-side inspector entries — noisy for prod, so debug.
163
+ sseLog.debug(
164
164
  {
165
165
  touched: [...touched],
166
166
  total: connections.size,