@statorjs/stator 2.3.0 → 2.5.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/README.md CHANGED
@@ -1,15 +1,8 @@
1
1
  # @statorjs/stator
2
2
 
3
- A server-canonical web framework: business logic lives in composable state
4
- machines that have no awareness of the UI, and the UI is a thin renderer
5
- binding machine outputs to DOM positions. Templates are `.stator` single-file
6
- components; interactions POST typed events; the server diffs affected bindings
7
- into small JSON patches. Client islands run the same machine engine in the
8
- browser as custom elements.
3
+ A server-canonical web framework: business logic lives in composable state machines that have no awareness of the UI, and the UI is a thin renderer binding machine outputs to DOM positions. Templates are `.stator` single-file components; interactions POST typed events; the server diffs affected bindings into small JSON patches. Client islands run the same machine engine in the browser as custom elements.
9
4
 
10
- Full documentation — tutorial, concepts, guides — lives in the repo's
11
- [`apps/docs`](https://github.com/statorjs/stator/tree/main/apps/docs) site;
12
- the repo [README](https://github.com/statorjs/stator#readme) has the tour.
5
+ Full documentation — tutorial, concepts, guides — lives in the repo's [`apps/docs`](https://github.com/statorjs/stator/tree/main/apps/docs) site; the repo [README](https://github.com/statorjs/stator#readme) has the tour.
13
6
 
14
7
  ## Install
15
8
 
@@ -36,8 +29,7 @@ pnpm add @statorjs/stator hono
36
29
  | `@statorjs/stator/build` | `buildApp`, `loadProductionHead`, `syncTypes` |
37
30
  | `@statorjs/stator/components` | built-ins (`<JsonLd>`) |
38
31
 
39
- `compiler` and `vite` subpaths exist but are internal — their shape may change
40
- in minor releases.
32
+ `compiler` and `vite` subpaths exist but are internal — their shape may change in minor releases.
41
33
 
42
34
  ## Minimal app
43
35
 
@@ -96,8 +88,7 @@ const [counter] = Stator.reads([Counter])
96
88
  </html>
97
89
  ```
98
90
 
99
- Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring
100
- (type sync, production build, deploy).
91
+ Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring (type sync, production build, deploy).
101
92
 
102
93
  ## License
103
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.3.0",
3
+ "version": "2.5.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",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.3.0"
98
+ "@statorjs/stator": "2.5.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
2
3
  import { join, relative, resolve, sep } from 'node:path'
3
4
  import { compile } from '../compiler/index.ts'
@@ -47,8 +48,11 @@ export interface BuildResult {
47
48
  islands: number
48
49
  }
49
50
 
50
- /** Shape of `dist/stator-manifest.json` (only written when islands exist). */
51
+ /** Shape of `dist/stator-manifest.json` (always written — carries `buildId`). */
51
52
  export interface StatorManifest {
53
+ /** Per-build identifier — `stator start` serves it into live pages for the
54
+ * reload handshake (a client on an older build reloads on reconnect). */
55
+ buildId: string
52
56
  /** Island component (dist-relative `.stator` path) → its script URL. */
53
57
  islands: Record<string, string>
54
58
  /** Route file (dist-relative) → script URLs for every island it reaches. */
@@ -86,6 +90,9 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
86
90
  // (raw TS, run under tsx/native in prod like the rest of the server).
87
91
  const middlewareSrc = join(root, 'middleware.ts')
88
92
  if (await exists(middlewareSrc)) await cp(middlewareSrc, join(outDir, 'middleware.ts'))
93
+ // Same for a root-level boot.ts (the once-at-startup hook).
94
+ const bootSrc = join(root, 'boot.ts')
95
+ if (await exists(bootSrc)) await cp(bootSrc, join(outDir, 'boot.ts'))
89
96
 
90
97
  // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
91
98
  const statorFiles = await walk(outDir, (f) => f.endsWith('.stator'))
@@ -120,10 +127,13 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
120
127
  await writeFile(join(outDir, 'static', 'components.css'), css)
121
128
  }
122
129
 
123
- if (islands.length > 0) {
124
- const manifest = await buildClientAssets(outDir, islands)
125
- await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
126
- }
130
+ // Always write the manifest — it carries the build-id even for an app with no
131
+ // islands (a live route without islands still needs the reload handshake).
132
+ const manifest: StatorManifest =
133
+ islands.length > 0
134
+ ? { buildId: randomUUID(), ...(await buildClientAssets(outDir, islands)) }
135
+ : { buildId: randomUUID(), islands: {}, routes: {} }
136
+ await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
127
137
 
128
138
  return { outDir, compiled: statorFiles.length, hasCss: Boolean(css), islands: islands.length }
129
139
  }
@@ -135,7 +145,7 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
135
145
  async function buildClientAssets(
136
146
  outDir: string,
137
147
  islands: Array<{ rel: string; entry: string }>,
138
- ): Promise<StatorManifest> {
148
+ ): Promise<Omit<StatorManifest, 'buildId'>> {
139
149
  const [{ build: viteBuild }, { machineStub }] = await Promise.all([
140
150
  import('vite'),
141
151
  import('../vite/stub.ts'),
package/src/build/head.ts CHANGED
@@ -7,12 +7,15 @@ import type { StatorManifest } from './build.ts'
7
7
  * build produced one, and injects each route's island `<script type="module">`
8
8
  * tags from `stator-manifest.json`. Pass the result to `createApp`:
9
9
  *
10
- * const app = await createApp({ ..., headExtras: await loadProductionHead(dist) })
10
+ * const { headExtras, buildId } = await loadProductionHead(dist)
11
+ * const app = await createApp({ ..., headExtras, buildId })
11
12
  *
12
- * Both artifacts are optional — a server-only app without styles gets an
13
- * empty hook.
13
+ * Everything is optional — a server-only app without styles gets an empty hook,
14
+ * and a build with no `buildId` in its manifest just skips the reload handshake.
14
15
  */
15
- export async function loadProductionHead(distDir: string): Promise<(filePath: string) => string> {
16
+ export async function loadProductionHead(
17
+ distDir: string,
18
+ ): Promise<{ headExtras: (filePath: string) => string; buildId?: string }> {
16
19
  const dist = resolve(distDir)
17
20
 
18
21
  let cssTag = ''
@@ -24,20 +27,23 @@ export async function loadProductionHead(distDir: string): Promise<(filePath: st
24
27
  }
25
28
 
26
29
  let routes: StatorManifest['routes'] = {}
30
+ let buildId: string | undefined
27
31
  try {
28
32
  const manifest = JSON.parse(
29
33
  await readFile(join(dist, 'stator-manifest.json'), 'utf8'),
30
34
  ) as StatorManifest
31
35
  routes = manifest.routes ?? {}
36
+ buildId = manifest.buildId
32
37
  } catch {
33
- // no islands
38
+ // no manifest
34
39
  }
35
40
 
36
- return (filePath: string): string => {
41
+ const headExtras = (filePath: string): string => {
37
42
  const rel = relative(dist, resolve(filePath)).replace(/\\/g, '/')
38
43
  const scripts = routes[rel] ?? []
39
44
  return [cssTag, ...scripts.map((url) => `<script type="module" src="${url}"></script>`)]
40
45
  .filter(Boolean)
41
46
  .join('\n')
42
47
  }
48
+ return { headExtras, buildId }
43
49
  }
@@ -16,6 +16,7 @@ export async function run(ctx: CliContext): Promise<void> {
16
16
  }
17
17
 
18
18
  const config = await loadConfig(ctx.root)
19
+ const { headExtras, buildId } = await loadProductionHead(dist)
19
20
  const app = await createApp({
20
21
  machinesDir: resolve(dist, 'machines'),
21
22
  routesDir: resolve(dist, 'routes'),
@@ -26,9 +27,12 @@ export async function run(ctx: CliContext): Promise<void> {
26
27
  trustedOrigins: config.trustedOrigins,
27
28
  origin: config.origin,
28
29
  host: config.host,
30
+ secret: config.secret,
29
31
  cors: config.cors,
30
- headExtras: await loadProductionHead(dist),
32
+ headExtras,
33
+ buildId,
31
34
  middlewareFile: resolve(dist, 'middleware.ts'),
35
+ bootFile: resolve(dist, 'boot.ts'),
32
36
  })
33
37
 
34
38
  await app.listen(resolvePort(ctx.portFlag, config.port))
package/src/cli/run.ts CHANGED
@@ -59,6 +59,12 @@ const ctx: CliContext = {
59
59
  rest: positionals.slice(1),
60
60
  }
61
61
 
62
+ // Load .env before importing the command — so `stator.config.ts` (imported by
63
+ // loadConfig inside the command) can read `process.env.*`. createApp/createDevApp
64
+ // load it again for the direct-`server.ts` path; idempotent.
65
+ const { loadDotenv } = await import('../server/env.ts')
66
+ loadDotenv(ctx.root)
67
+
62
68
  // Dynamic import per command so `check` never loads the dev server's Vite, etc.
63
69
  const { run } = (await import(`./commands/${command}.ts`)) as {
64
70
  run: (ctx: CliContext) => Promise<void>
@@ -72,7 +72,12 @@ function initLiveChannel(): { close(): void } | undefined {
72
72
  if (!meta) return
73
73
 
74
74
  const routeKey = `GET ${location.pathname}${location.search}`
75
- const url = `/__sse?route=${encodeURIComponent(routeKey)}&client=${encodeURIComponent(clientId)}`
75
+ // Echo the build this page was rendered against (if any) so the server can
76
+ // reload us when it's serving a newer build (dev restart / deploy). Baked
77
+ // into the URL at load time, so it rides EventSource's native reconnect too.
78
+ const build = document.querySelector('meta[name="stator-build"]')?.getAttribute('content')
79
+ const buildParam = build ? `&build=${encodeURIComponent(build)}` : ''
80
+ const url = `/__sse?route=${encodeURIComponent(routeKey)}&client=${encodeURIComponent(clientId)}${buildParam}`
76
81
 
77
82
  // Connection-state signal: `data-stator-connection` on the root element
78
83
  // (CSS hook for offline banners etc.) plus a `stator:connection-state`
package/src/config.ts CHANGED
@@ -30,6 +30,12 @@ export interface StatorConfig {
30
30
  * (redirects, SSE reconnect, OG tags) and as a spoof-proof same-origin
31
31
  * anchor. Exposed to middleware via `stator(c).origin`. */
32
32
  origin?: string
33
+ /** Signing secret for signed cookies (`cookies.setSigned`/`getSigned`) — the
34
+ * sealed short-lived-state primitive (OAuth `state`/PKCE, a magic-link token,
35
+ * a WebAuthn challenge). Falls back to `process.env.STATOR_SECRET`. Use a long
36
+ * random string (≥32 chars); keep it out of source — set it in `.env.local` or
37
+ * a platform secret. Absent → the signed-cookie methods throw. */
38
+ secret?: string
33
39
  /** The swappable persistence adapters, grouped by concern. Both optional —
34
40
  * default to in-memory (restart-wipe). Neither is a machine-graph entry
35
41
  * point (machines are file-discovered). */
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { pathToFileURL } from 'node:url'
3
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
4
+ import type { StatorConfigData } from './context.ts'
5
+ import type { ModuleLoader } from './discovery.ts'
6
+
7
+ // A global-registry symbol so the brand survives the dev dual-instance: the app's
8
+ // `boot.ts` is loaded through Vite while the framework checks it from the native
9
+ // module instance — `Symbol.for` resolves to the same symbol in both.
10
+ const BOOT_BRAND = Symbol.for('stator.boot.definition')
11
+
12
+ /**
13
+ * What a boot hook receives. Deliberately narrow: boot is a *source*, not a
14
+ * controller. It supplies events to the app-machine graph and reads config —
15
+ * policy (when/whether/how to act) belongs in the machine chart (a guard can
16
+ * debounce a `TICK`), not in this closure.
17
+ *
18
+ * Not exposed on purpose: `listen`/`fetch` (self-request foot-guns), the raw
19
+ * Hono app (that's `StatorApp.hono`'s break-glass), and the store (reaching
20
+ * around machines). Env is ambient — read `process.env` directly (`.env` is
21
+ * loaded before boot runs).
22
+ */
23
+ export interface BootContext {
24
+ /** Feed an event into an APP-lifecycle machine — the bound `dispatchToApp`.
25
+ * Boot has no session, so it operates on the app plane only. */
26
+ dispatchToApp<D extends AnyMachineDef>(
27
+ machine: D,
28
+ event: EventOf<D>,
29
+ ): Promise<{ committed: boolean }>
30
+ /** Curated framework config (the same view middleware sees via `stator(c)`):
31
+ * `origin`, `trustedOrigins`, `sameSite`, `cors`. No adapters or secret. */
32
+ readonly config: StatorConfigData
33
+ }
34
+
35
+ /** Cleanup run on graceful shutdown — clear a poll interval, unsubscribe a
36
+ * source. Return it from the boot function; the framework composes it into the
37
+ * SIGTERM/SIGINT handler. */
38
+ export type BootTeardown = () => void | Promise<void>
39
+
40
+ /** A boot function: runs once when the server starts serving. May be async, and
41
+ * may return a teardown. The `void` union is the React-`useEffect` shape — a
42
+ * hook that MAY return a cleanup — and is required so `defineBoot(() => {…})`
43
+ * with no return typechecks (`() => void` isn't assignable to `undefined`). */
44
+ // biome-ignore lint/suspicious/noConfusingVoidType: intentional useEffect-style optional-teardown return
45
+ export type BootFn = (ctx: BootContext) => void | BootTeardown | Promise<void | BootTeardown>
46
+
47
+ /** The value an app's `boot.ts` exports (default). Opaque — build it with
48
+ * `defineBoot`. */
49
+ export interface BootDefinition {
50
+ readonly [BOOT_BRAND]: true
51
+ readonly run: BootFn
52
+ }
53
+
54
+ /** Type guard for a discovered `boot.ts` default export. */
55
+ export function isBootDefinition(value: unknown): value is BootDefinition {
56
+ return typeof value === 'object' && value !== null && BOOT_BRAND in value
57
+ }
58
+
59
+ /**
60
+ * Define code that runs once when the server starts serving — the home for a
61
+ * long-lived inbound source: query config at boot, start a poll or subscription
62
+ * that dispatches events into the app-machine graph. Export the result as the
63
+ * default from `boot.ts` at the app root.
64
+ *
65
+ * Runs once per process (a dev restart re-runs it; an in-process rebuild does
66
+ * not). Return a teardown to clean up on shutdown.
67
+ *
68
+ * ```ts
69
+ * // boot.ts
70
+ * export default defineBoot(async ({ dispatchToApp }) => {
71
+ * const timer = setInterval(
72
+ * () => dispatchToApp(FleetMachine, { type: 'TICK', data: await poll() }),
73
+ * 30_000,
74
+ * )
75
+ * return () => clearInterval(timer)
76
+ * })
77
+ * ```
78
+ */
79
+ export function defineBoot(run: BootFn): BootDefinition {
80
+ return { [BOOT_BRAND]: true, run }
81
+ }
82
+
83
+ const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
84
+
85
+ /**
86
+ * Load an app's `boot.ts` (if present) and validate its default export. Returns
87
+ * `undefined` when there's no file. `loader` is injected so dev goes through
88
+ * Vite; prod/native uses `import` — the same single toolchain touch-point as
89
+ * `discoverMiddleware`.
90
+ */
91
+ export async function discoverBoot(
92
+ file: string,
93
+ loader: ModuleLoader = nativeLoader,
94
+ ): Promise<BootDefinition | undefined> {
95
+ if (!existsSync(file)) return undefined
96
+ const mod = await loader(file)
97
+ const def = (mod as { default?: unknown }).default
98
+ if (!isBootDefinition(def)) {
99
+ throw new Error(`${file} must \`export default defineBoot((ctx) => { ... })\``)
100
+ }
101
+ return def
102
+ }
103
+
104
+ /**
105
+ * Run a discovered boot definition and return its teardown (if any). Called from
106
+ * `listen()` — once the server is up — so boot never fires during tests that use
107
+ * `app.fetch` without listening.
108
+ */
109
+ export async function runBoot(
110
+ def: BootDefinition | undefined,
111
+ ctx: BootContext,
112
+ ): Promise<BootTeardown | undefined> {
113
+ if (!def) return undefined
114
+ const result = await def.run(ctx)
115
+ return typeof result === 'function' ? result : undefined
116
+ }
@@ -32,6 +32,7 @@ interface NestedReads {
32
32
  trustedOrigins?: readonly string[]
33
33
  origin?: string
34
34
  host?: string
35
+ secret?: string
35
36
  cors?: { origins?: readonly string[]; credentials?: boolean }
36
37
  }
37
38
 
@@ -47,6 +48,8 @@ export interface ResolvedAppConfig {
47
48
  sameSite?: 'Lax' | 'Strict'
48
49
  origin?: string
49
50
  host?: string
51
+ /** Signed-cookie signing key. `config.secret` ?? `STATOR_SECRET`. */
52
+ secret?: string
50
53
  cors?: { origins: readonly string[]; credentials: boolean }
51
54
  }
52
55
 
@@ -82,6 +85,8 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
82
85
  sameSite: config.sessions?.cookie?.sameSite,
83
86
  origin: config.origin,
84
87
  host: config.host,
88
+ // The signing key: explicit config wins, else the env (loadable via .env).
89
+ secret: config.secret ?? process.env.STATOR_SECRET,
85
90
  cors: config.cors
86
91
  ? {
87
92
  origins: config.cors.origins ?? config.trustedOrigins ?? [],
@@ -7,7 +7,7 @@ import type { Store } from './store.ts'
7
7
  * The resolved config data `buildHonoApp` stashes on the context (early), for
8
8
  * the security defaults, `cors()`, and app middleware to read.
9
9
  */
10
- interface StatorConfigData {
10
+ export interface StatorConfigData {
11
11
  readonly origin?: string
12
12
  readonly trustedOrigins: readonly string[]
13
13
  readonly sameSite: 'Lax' | 'Strict'
@@ -20,6 +20,8 @@ declare module 'hono' {
20
20
  interface ContextVariableMap {
21
21
  stator: StatorConfigData
22
22
  statorStore: { persistence: Store }
23
+ /** Signed-cookie signing key, stashed by the bridge when configured. */
24
+ statorSecret: string
23
25
  }
24
26
  }
25
27
 
@@ -1,5 +1,5 @@
1
1
  import type { Context } from 'hono'
2
- import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
2
+ import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from 'hono/cookie'
3
3
  import type { RouteCookieOptions } from './routing.ts'
4
4
 
5
5
  /**
@@ -17,8 +17,31 @@ export interface CookieJar {
17
17
  /** Write a cookie on the response. Defaults are hono's (not HttpOnly/Secure);
18
18
  * pass options for auth-shaped cookies. */
19
19
  set(name: string, value: string, options?: RouteCookieOptions): void
20
- /** Expire a cookie. `path`/`domain` must match how it was set. */
20
+ /** Expire a cookie. `path`/`domain` must match how it was set. Works for
21
+ * signed cookies too — deletion needs no signature. */
21
22
  delete(name: string, options?: Pick<RouteCookieOptions, 'path' | 'domain'>): void
23
+ /** Read a signed cookie, verifying its signature against the app secret.
24
+ * `undefined` if absent OR the signature is invalid (tampered, or signed
25
+ * with a since-rotated secret) — an untrustworthy value is treated as absent.
26
+ * Throws if no secret is configured. The sealed short-lived-state read
27
+ * (OAuth `state`/PKCE, a magic-link token, a WebAuthn challenge). */
28
+ getSigned(name: string): Promise<string | undefined>
29
+ /** Write a signed cookie — the value is tamper-evident, not encrypted (still
30
+ * readable by the client, so don't put secrets *in* it; put a nonce it can't
31
+ * forge). Throws if no secret is configured. */
32
+ setSigned(name: string, value: string, options?: RouteCookieOptions): Promise<void>
33
+ }
34
+
35
+ /** Read the configured signing secret, or throw a fix-it error. */
36
+ function requireSecret(c: Context): string {
37
+ const secret = c.get('statorSecret')
38
+ if (!secret) {
39
+ throw new Error(
40
+ 'stator: signed cookies require a secret — set `secret` in stator.config.ts ' +
41
+ 'or `STATOR_SECRET` in the environment (e.g. .env.local).',
42
+ )
43
+ }
44
+ return secret
22
45
  }
23
46
 
24
47
  /** Build the cookie jar bound to a request context. Shared by `stator(c)`
@@ -33,5 +56,14 @@ export function cookieJar(c: Context): CookieJar {
33
56
  delete: (name, options) => {
34
57
  deleteCookie(c, name, options as never)
35
58
  },
59
+ getSigned: async (name) => {
60
+ // hono returns `false` for a present-but-invalid signature; collapse it to
61
+ // undefined so callers never trust (or even see) a tampered value.
62
+ const value = await getSignedCookie(c, requireSecret(c), name)
63
+ return value === false ? undefined : value
64
+ },
65
+ setSigned: async (name, value, options) => {
66
+ await setSignedCookie(c, name, value, requireSecret(c), options as never)
67
+ },
36
68
  }
37
69
  }
@@ -6,9 +6,11 @@ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
6
6
  import { dispatchToApp } from './app-dispatch.ts'
7
7
  import type { AppStore } from './app-store.ts'
8
8
  import { installGracefulShutdown, printStartupNotice } from './banner.ts'
9
+ import { type BootTeardown, discoverBoot, runBoot } from './boot.ts'
9
10
  import { type DeprecatedFlatConfig, resolveAppConfig } from './config-compat.ts'
10
11
  import { discoverMachines } from './discovery.ts'
11
12
  import { wireAppEffects } from './effects.ts'
13
+ import { loadDotenv } from './env.ts'
12
14
  import { buildHonoApp } from './http.ts'
13
15
  import { logger, setLogLevel } from './logger.ts'
14
16
  import { MachineStore } from './machine-store.ts'
@@ -63,6 +65,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
63
65
  trustedOrigins?: readonly string[]
64
66
  /** Canonical app URL, exposed via `stator(c).origin`. */
65
67
  origin?: string
68
+ /** Signed-cookie signing key. Falls back to `STATOR_SECRET`. Mirrors
69
+ * `StatorConfig.secret`. */
70
+ secret?: string
66
71
  /** Listen host / bind address (used by `listen`). Default: all interfaces. */
67
72
  host?: string
68
73
  /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
@@ -73,6 +78,12 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
73
78
  /** Path to the app's `middleware.ts` (if any). Loaded and validated; its
74
79
  * default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
75
80
  middlewareFile?: string
81
+ /** Path to the app's `boot.ts` (if any). Its default export must be
82
+ * `defineBoot(...)`; it runs once when the app starts listening. */
83
+ bootFile?: string
84
+ /** Build identifier for the deploy-aware reload handshake. `stator start`
85
+ * passes the id baked into the build manifest; absent → no handshake. */
86
+ buildId?: string
76
87
  }
77
88
 
78
89
  export interface StatorApp {
@@ -96,6 +107,9 @@ export interface StatorApp {
96
107
  }
97
108
 
98
109
  export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
110
+ // Load .env before anything reads process.env (LOG_LEVEL below, app secrets).
111
+ // No-op if the CLI already loaded it; covers the direct `server.ts` entry.
112
+ loadDotenv()
99
113
  const machinesDir = resolve(config.machinesDir)
100
114
  const routesDir = resolve(config.routesDir)
101
115
  const staticDir = config.staticDir ? resolve(config.staticDir) : undefined
@@ -121,6 +135,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
121
135
  const middleware = config.middlewareFile
122
136
  ? await discoverMiddleware(config.middlewareFile)
123
137
  : undefined
138
+ const bootDef = config.bootFile ? await discoverBoot(config.bootFile) : undefined
124
139
  const inspector = resolved.inspector
125
140
  const app = await buildHonoApp({
126
141
  routes,
@@ -137,6 +152,8 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
137
152
  trustedOrigins: resolved.trustedOrigins,
138
153
  sameSite: resolved.sameSite,
139
154
  origin: resolved.origin,
155
+ secret: resolved.secret,
156
+ buildId: config.buildId,
140
157
  cors: resolved.cors,
141
158
  middleware,
142
159
  })
@@ -144,9 +161,27 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
144
161
  return {
145
162
  listen(port: number): Promise<void> {
146
163
  return new Promise((resolveFn) => {
147
- const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, () => {
164
+ const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, async () => {
148
165
  // Always-on (level-independent) so `warn` prod still confirms boot.
149
166
  printStartupNotice({ port, machines: defs.length, routes: routes.length })
167
+ // Run boot.ts once the server is up — its long-lived work (a poll, a
168
+ // subscription) starts here, not during createApp, so tests that only
169
+ // `app.fetch` never trigger it.
170
+ const teardown: BootTeardown | undefined = await runBoot(bootDef, {
171
+ dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
172
+ config: {
173
+ origin: resolved.origin,
174
+ trustedOrigins: resolved.trustedOrigins ?? [],
175
+ sameSite: resolved.sameSite ?? 'Lax',
176
+ cors: resolved.cors,
177
+ },
178
+ })
179
+ // Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in prod.
180
+ // Boot teardown runs first (unsubscribe/clear timers), then the server.
181
+ installGracefulShutdown(async () => {
182
+ if (teardown) await teardown()
183
+ await new Promise<void>((done) => server.close(() => done()))
184
+ }, true)
150
185
  resolveFn()
151
186
  })
152
187
  // Production is strict about its port (a collision is a deploy
@@ -161,9 +196,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
161
196
  }
162
197
  throw err
163
198
  })
164
- // Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in
165
- // prod: the structured logs are the record.
166
- installGracefulShutdown(() => new Promise<void>((done) => server.close(() => done())), true)
167
199
  })
168
200
  },
169
201
  fetch: (request: Request) => app.fetch(request),
package/src/server/dev.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { existsSync } from 'node:fs'
2
3
  import { readFile } from 'node:fs/promises'
3
4
  import { createServer as createHttpServer } from 'node:http'
@@ -12,6 +13,7 @@ import { machineStub, stator } from '../vite/index.ts'
12
13
  import type { AppStore } from './app-store.ts'
13
14
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
14
15
  import { resolveAppConfig } from './config-compat.ts'
16
+ import { loadDotenv } from './env.ts'
15
17
  import { logger, setLogLevel } from './logger.ts'
16
18
  import type { MachineStore } from './machine-store.ts'
17
19
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -73,6 +75,9 @@ export interface DevAppConfig {
73
75
  trustedOrigins?: readonly string[]
74
76
  /** Canonical app URL, exposed via `stator(c).origin`. */
75
77
  origin?: string
78
+ /** Signed-cookie signing key. Falls back to `STATOR_SECRET`. Mirrors
79
+ * `StatorConfig.secret`. */
80
+ secret?: string
76
81
  /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
77
82
  cors?: { origins?: string[]; credentials?: boolean }
78
83
  // Deprecated flat keys — accepted (typed) so 2.1.0 callers don't break; nested
@@ -108,6 +113,13 @@ export interface DevApp {
108
113
  }
109
114
 
110
115
  export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
116
+ // Load .env before anything reads process.env. No-op if the CLI already
117
+ // loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
118
+ loadDotenv(resolve(config.root))
119
+ // One build-id per dev boot — a `tsx` restart is a new process → new id → the
120
+ // client reloads on reconnect (the restart-without-reload gap). An inner Vite
121
+ // rebuild keeps the same id (no full reload; islands hot-reload via Vite).
122
+ const devBuildId = randomUUID()
111
123
  // Vite's HMR websocket defaults to 24678 for EVERY dev server — two
112
124
  // stator apps side by side would fight over it (the loser's live reload
113
125
  // silently dies). Probe a free one instead.
@@ -246,6 +258,8 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
246
258
  trustedOrigins: resolved.trustedOrigins,
247
259
  sameSite: resolved.sameSite,
248
260
  origin: resolved.origin,
261
+ secret: resolved.secret,
262
+ buildId: devBuildId,
249
263
  cors: resolved.cors,
250
264
  middleware,
251
265
  })
@@ -255,6 +269,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
255
269
  await rebuildRoutes()
256
270
  await rebuildServer()
257
271
 
272
+ // Discover boot.ts ONCE per process (not per rebuild) — a long-lived source
273
+ // shouldn't restart on every edit. Runs in listen() below.
274
+ const bootDef = await runtime.discoverBoot(resolve(root, 'boot.ts'), loader)
275
+
258
276
  // Live reload: on a relevant source change, re-discover and rebuild the app,
259
277
  // then tell the browser to reload. A template/route edit keeps the store (and
260
278
  // your session — cart contents and all) intact; only a machine edit resets it,
@@ -305,16 +323,12 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
305
323
  const server = createHttpServer((req, res) => {
306
324
  vite.middlewares(req, res, () => honoListener(req, res))
307
325
  })
308
- installGracefulShutdown(async () => {
309
- await vite.close()
310
- await new Promise<void>((done) => server.close(() => done()))
311
- })
312
326
  // Busy port? Shift up like every modern dev server (the requested
313
327
  // port is someone's other project, not an error).
314
328
  return findFreePort(port).then(
315
329
  (freePort) =>
316
330
  new Promise((resolveFn) => {
317
- server.listen(freePort, () => {
331
+ server.listen(freePort, async () => {
318
332
  printDevBanner({
319
333
  port: freePort,
320
334
  requestedPort: port,
@@ -322,6 +336,23 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
322
336
  routes: routes.length,
323
337
  inspector: true,
324
338
  })
339
+ // Boot runs once the dev server is up (store is read at call time,
340
+ // so a later rebuild's store is used). Teardown composes into the
341
+ // shutdown below.
342
+ const teardown = await runtime.runBoot(bootDef, {
343
+ dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
344
+ config: {
345
+ origin: resolved.origin,
346
+ trustedOrigins: resolved.trustedOrigins ?? [],
347
+ sameSite: resolved.sameSite ?? 'Lax',
348
+ cors: resolved.cors,
349
+ },
350
+ })
351
+ installGracefulShutdown(async () => {
352
+ if (teardown) await teardown()
353
+ await vite.close()
354
+ await new Promise<void>((done) => server.close(() => done()))
355
+ })
325
356
  resolveFn()
326
357
  })
327
358
  }),
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ /**
5
+ * Load `.env` files into `process.env` — the home for server config/secrets
6
+ * that's uniform across dev and prod (unlike Vite's transform-time
7
+ * `import.meta.env`, absent in prod). Precedence, highest first:
8
+ *
9
+ * real shell env > .env.local > .env
10
+ *
11
+ * `process.loadEnvFile` (native, Node ≥20.12 — no dependency) never overrides
12
+ * an already-set key, so real env always wins, and `.env.local` is loaded
13
+ * *before* `.env`: the earlier load claims a key the later one can't reclaim.
14
+ * Absent files are skipped. Idempotent-safe — a second call adds nothing new,
15
+ * so the CLI (which loads before importing `stator.config.ts`) and
16
+ * `createApp`/`createDevApp` (which load for the direct `server.ts` path) can
17
+ * both call it without conflict.
18
+ *
19
+ * `.env.local` is for machine-local secrets and belongs in `.gitignore`; `.env`
20
+ * holds committed defaults.
21
+ */
22
+ export function loadDotenv(root: string = process.cwd()): void {
23
+ for (const name of ['.env.local', '.env']) {
24
+ const path = resolve(root, name)
25
+ if (existsSync(path)) process.loadEnvFile(path)
26
+ }
27
+ }
@@ -46,6 +46,13 @@ export interface HttpConfig {
46
46
  sameSite?: 'Lax' | 'Strict'
47
47
  /** Canonical app origin, exposed to middleware via `stator(c).origin`. */
48
48
  origin?: string
49
+ /** Signed-cookie signing key (`config.secret` ?? `STATOR_SECRET`). Stashed on
50
+ * the request context so `cookies.setSigned`/`getSigned` can reach it. */
51
+ secret?: string
52
+ /** Build identifier — per-boot in dev, per-build in prod. Emitted into live
53
+ * pages as `<meta name="stator-build">`; the client echoes it on /__sse
54
+ * connect, and a mismatch reloads the page (its slot map may be stale). */
55
+ buildId?: string
49
56
  /** Resolved CORS read policy, exposed via `stator(c).cors`. */
50
57
  cors?: { origins: readonly string[]; credentials: boolean }
51
58
  /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
@@ -179,6 +186,8 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
179
186
  })
180
187
  // Stash the store so middleware session ops (rotate/clear) can act now.
181
188
  c.set('statorStore', config.store)
189
+ // Stash the signing key so the cookie jar's signed methods can reach it.
190
+ if (config.secret !== undefined) c.set('statorSecret', config.secret)
182
191
  const { sessionId, isNew } = getOrCreateSessionId(c)
183
192
  const session = getSessionState(c)
184
193
  if (!isNew && session) {
@@ -299,6 +308,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
299
308
  c.header('X-Accel-Buffering', 'no')
300
309
 
301
310
  return streamSSE(c, async (stream) => {
311
+ // Build-id handshake: this page was rendered against a server build
312
+ // (`build` param, from its `stator-build` meta). If ours differs — a dev
313
+ // restart or a deploy since page load — its DOM↔slot-ID map may be stale,
314
+ // so reload to fetch a fresh page instead of resyncing onto it. Checked
315
+ // before any render/registration: a doomed connection does no work.
316
+ const pageBuild = c.req.query('build')
317
+ if (config.buildId && pageBuild && pageBuild !== config.buildId) {
318
+ await stream.writeSSE({ data: JSON.stringify({ directives: [{ type: 'reload' }] }) })
319
+ return
320
+ }
321
+
302
322
  const runtime = new SessionRuntime(sessionId, config.store)
303
323
  await runtime.loadGraph(route.reads)
304
324
  const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
@@ -492,7 +512,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
492
512
  if (isStatorQueryRoute(getRoute)) {
493
513
  return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
494
514
  }
495
- return handleGet(c, matched.route, getRoute, matched.params, config.store, config.headExtras)
515
+ return handleGet(
516
+ c,
517
+ matched.route,
518
+ getRoute,
519
+ matched.params,
520
+ config.store,
521
+ config.headExtras,
522
+ config.buildId,
523
+ )
496
524
  })
497
525
 
498
526
  return app
@@ -563,6 +591,7 @@ async function handleGet(
563
591
  params: Record<string, string>,
564
592
  store: MachineStore,
565
593
  headExtras?: (filePath: string) => string | Promise<string>,
594
+ buildId?: string,
566
595
  ): Promise<Response> {
567
596
  {
568
597
  const { sessionId } = getOrCreateSessionId(c)
@@ -581,7 +610,15 @@ async function handleGet(
581
610
  const extra = await headExtras(discovered.filePath)
582
611
  if (extra) headHtml.push(extra)
583
612
  }
584
- if (route.live) headHtml.push('<meta name="stator-live" content="true">')
613
+ if (route.live) {
614
+ headHtml.push('<meta name="stator-live" content="true">')
615
+ // The build this page was rendered against — the client echoes it on
616
+ // /__sse connect so the server can reload a page from a stale build.
617
+ // `buildId` is a framework-generated UUID (no HTML-special chars).
618
+ if (buildId) {
619
+ headHtml.push(`<meta name="stator-build" content="${buildId}">`)
620
+ }
621
+ }
585
622
 
586
623
  // Auto-inject the client runtime (delegated events + patch application).
587
624
  // Apps never hand-include it — a forgotten <script> is a silently dead
@@ -18,6 +18,8 @@ export type {
18
18
  export { dispatchToApp } from './app-dispatch.ts'
19
19
  export type { AppStore } from './app-store.ts'
20
20
  export { InMemoryAppStore } from './app-store.ts'
21
+ export type { BootContext, BootDefinition, BootFn, BootTeardown } from './boot.ts'
22
+ export { defineBoot, discoverBoot, isBootDefinition, runBoot } from './boot.ts'
21
23
  export type { CachedStoreOptions } from './cached-store.ts'
22
24
  export { CachedStore } from './cached-store.ts'
23
25
  export type { StatorContext } from './context.ts'