@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 +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/compiler/client-emit.ts +5 -2
- package/src/compiler/compile.ts +112 -20
- package/src/compiler/emit-names.ts +48 -0
- package/src/compiler/virtual-code.ts +29 -16
- 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.0
|
|
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
|
|
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
|
-
|
|
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. */
|
package/src/compiler/compile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
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,
|