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