@statorjs/stator 2.4.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 +4 -13
- package/package.json +2 -2
- package/src/build/build.ts +16 -6
- package/src/build/head.ts +12 -6
- package/src/cli/commands/start.ts +5 -1
- package/src/client/runtime.ts +6 -1
- package/src/server/boot.ts +116 -0
- package/src/server/context.ts +1 -1
- package/src/server/create-app.ts +28 -4
- package/src/server/dev.ts +28 -5
- package/src/server/http.ts +34 -2
- package/src/server/index.ts +2 -0
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
|
+
"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.
|
|
98
|
+
"@statorjs/stator": "2.5.0"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
package/src/build/build.ts
CHANGED
|
@@ -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` (
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
10
|
+
* const { headExtras, buildId } = await loadProductionHead(dist)
|
|
11
|
+
* const app = await createApp({ ..., headExtras, buildId })
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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(
|
|
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
|
|
38
|
+
// no manifest
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
|
|
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
|
|
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/client/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
@@ -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
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -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'
|
package/src/server/create-app.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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'
|
|
@@ -77,6 +78,12 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
|
|
|
77
78
|
/** Path to the app's `middleware.ts` (if any). Loaded and validated; its
|
|
78
79
|
* default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
|
|
79
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
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
export interface StatorApp {
|
|
@@ -128,6 +135,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
128
135
|
const middleware = config.middlewareFile
|
|
129
136
|
? await discoverMiddleware(config.middlewareFile)
|
|
130
137
|
: undefined
|
|
138
|
+
const bootDef = config.bootFile ? await discoverBoot(config.bootFile) : undefined
|
|
131
139
|
const inspector = resolved.inspector
|
|
132
140
|
const app = await buildHonoApp({
|
|
133
141
|
routes,
|
|
@@ -145,6 +153,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
145
153
|
sameSite: resolved.sameSite,
|
|
146
154
|
origin: resolved.origin,
|
|
147
155
|
secret: resolved.secret,
|
|
156
|
+
buildId: config.buildId,
|
|
148
157
|
cors: resolved.cors,
|
|
149
158
|
middleware,
|
|
150
159
|
})
|
|
@@ -152,9 +161,27 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
152
161
|
return {
|
|
153
162
|
listen(port: number): Promise<void> {
|
|
154
163
|
return new Promise((resolveFn) => {
|
|
155
|
-
const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, () => {
|
|
164
|
+
const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, async () => {
|
|
156
165
|
// Always-on (level-independent) so `warn` prod still confirms boot.
|
|
157
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)
|
|
158
185
|
resolveFn()
|
|
159
186
|
})
|
|
160
187
|
// Production is strict about its port (a collision is a deploy
|
|
@@ -169,9 +196,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
169
196
|
}
|
|
170
197
|
throw err
|
|
171
198
|
})
|
|
172
|
-
// Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in
|
|
173
|
-
// prod: the structured logs are the record.
|
|
174
|
-
installGracefulShutdown(() => new Promise<void>((done) => server.close(() => done())), true)
|
|
175
199
|
})
|
|
176
200
|
},
|
|
177
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'
|
|
@@ -115,6 +116,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
115
116
|
// Load .env before anything reads process.env. No-op if the CLI already
|
|
116
117
|
// loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
|
|
117
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()
|
|
118
123
|
// Vite's HMR websocket defaults to 24678 for EVERY dev server — two
|
|
119
124
|
// stator apps side by side would fight over it (the loser's live reload
|
|
120
125
|
// silently dies). Probe a free one instead.
|
|
@@ -254,6 +259,7 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
254
259
|
sameSite: resolved.sameSite,
|
|
255
260
|
origin: resolved.origin,
|
|
256
261
|
secret: resolved.secret,
|
|
262
|
+
buildId: devBuildId,
|
|
257
263
|
cors: resolved.cors,
|
|
258
264
|
middleware,
|
|
259
265
|
})
|
|
@@ -263,6 +269,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
263
269
|
await rebuildRoutes()
|
|
264
270
|
await rebuildServer()
|
|
265
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
|
+
|
|
266
276
|
// Live reload: on a relevant source change, re-discover and rebuild the app,
|
|
267
277
|
// then tell the browser to reload. A template/route edit keeps the store (and
|
|
268
278
|
// your session — cart contents and all) intact; only a machine edit resets it,
|
|
@@ -313,16 +323,12 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
313
323
|
const server = createHttpServer((req, res) => {
|
|
314
324
|
vite.middlewares(req, res, () => honoListener(req, res))
|
|
315
325
|
})
|
|
316
|
-
installGracefulShutdown(async () => {
|
|
317
|
-
await vite.close()
|
|
318
|
-
await new Promise<void>((done) => server.close(() => done()))
|
|
319
|
-
})
|
|
320
326
|
// Busy port? Shift up like every modern dev server (the requested
|
|
321
327
|
// port is someone's other project, not an error).
|
|
322
328
|
return findFreePort(port).then(
|
|
323
329
|
(freePort) =>
|
|
324
330
|
new Promise((resolveFn) => {
|
|
325
|
-
server.listen(freePort, () => {
|
|
331
|
+
server.listen(freePort, async () => {
|
|
326
332
|
printDevBanner({
|
|
327
333
|
port: freePort,
|
|
328
334
|
requestedPort: port,
|
|
@@ -330,6 +336,23 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
330
336
|
routes: routes.length,
|
|
331
337
|
inspector: true,
|
|
332
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
|
+
})
|
|
333
356
|
resolveFn()
|
|
334
357
|
})
|
|
335
358
|
}),
|
package/src/server/http.ts
CHANGED
|
@@ -49,6 +49,10 @@ export interface HttpConfig {
|
|
|
49
49
|
/** Signed-cookie signing key (`config.secret` ?? `STATOR_SECRET`). Stashed on
|
|
50
50
|
* the request context so `cookies.setSigned`/`getSigned` can reach it. */
|
|
51
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
|
|
52
56
|
/** Resolved CORS read policy, exposed via `stator(c).cors`. */
|
|
53
57
|
cors?: { origins: readonly string[]; credentials: boolean }
|
|
54
58
|
/** The app's discovered `middleware.ts` definition (if any). `withDefaults`
|
|
@@ -304,6 +308,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
304
308
|
c.header('X-Accel-Buffering', 'no')
|
|
305
309
|
|
|
306
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
|
+
|
|
307
322
|
const runtime = new SessionRuntime(sessionId, config.store)
|
|
308
323
|
await runtime.loadGraph(route.reads)
|
|
309
324
|
const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
|
|
@@ -497,7 +512,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
497
512
|
if (isStatorQueryRoute(getRoute)) {
|
|
498
513
|
return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
|
|
499
514
|
}
|
|
500
|
-
return handleGet(
|
|
515
|
+
return handleGet(
|
|
516
|
+
c,
|
|
517
|
+
matched.route,
|
|
518
|
+
getRoute,
|
|
519
|
+
matched.params,
|
|
520
|
+
config.store,
|
|
521
|
+
config.headExtras,
|
|
522
|
+
config.buildId,
|
|
523
|
+
)
|
|
501
524
|
})
|
|
502
525
|
|
|
503
526
|
return app
|
|
@@ -568,6 +591,7 @@ async function handleGet(
|
|
|
568
591
|
params: Record<string, string>,
|
|
569
592
|
store: MachineStore,
|
|
570
593
|
headExtras?: (filePath: string) => string | Promise<string>,
|
|
594
|
+
buildId?: string,
|
|
571
595
|
): Promise<Response> {
|
|
572
596
|
{
|
|
573
597
|
const { sessionId } = getOrCreateSessionId(c)
|
|
@@ -586,7 +610,15 @@ async function handleGet(
|
|
|
586
610
|
const extra = await headExtras(discovered.filePath)
|
|
587
611
|
if (extra) headHtml.push(extra)
|
|
588
612
|
}
|
|
589
|
-
if (route.live)
|
|
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
|
+
}
|
|
590
622
|
|
|
591
623
|
// Auto-inject the client runtime (delegated events + patch application).
|
|
592
624
|
// Apps never hand-include it — a forgotten <script> is a silently dead
|
package/src/server/index.ts
CHANGED
|
@@ -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'
|