@statorjs/stator 2.1.0 → 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 +9 -2
- package/src/cli/commands/build.ts +15 -0
- package/src/cli/commands/check.ts +72 -0
- package/src/cli/commands/dev.ts +21 -0
- package/src/cli/commands/start.ts +30 -0
- package/src/cli/commands/test.ts +31 -0
- package/src/cli/config.ts +40 -0
- package/src/cli/loader.js +40 -0
- package/src/cli/run.ts +72 -0
- package/src/cli/stator.js +27 -0
- package/src/config.ts +68 -0
- package/src/server/banner.ts +18 -0
- package/src/server/config-compat.ts +73 -0
- package/src/server/create-app.ts +54 -26
- package/src/server/dev.ts +44 -7
- package/src/server/http.ts +3 -1
- package/src/server/index.ts +1 -1
- package/src/server/logger.ts +24 -4
- package/src/server/sse.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "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.
|
|
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')
|
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
|
+
}
|
package/src/server/banner.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
62
|
-
const store = new MachineStore(defs,
|
|
63
|
-
sessionTtlSeconds:
|
|
64
|
-
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:
|
|
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
|
|
83
|
-
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
72
|
+
/** @deprecated use `persistence.app` */
|
|
44
73
|
appStore?: AppStore
|
|
74
|
+
/** @deprecated use `sessions.ttlSeconds` */
|
|
45
75
|
sessionTtlSeconds?: number
|
|
46
|
-
/**
|
|
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
|
|
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,
|
|
148
|
-
sessionTtlSeconds:
|
|
149
|
-
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.
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/server/index.ts
CHANGED
|
@@ -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'
|
package/src/server/logger.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
162
|
-
// correlate against client-side inspector entries.
|
|
163
|
-
sseLog.
|
|
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,
|