@statorjs/stator 2.5.0 → 2.6.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 +1 -1
- package/package.json +5 -5
- package/src/build/build.ts +93 -115
- package/src/build/head.ts +11 -4
- package/src/build/index.ts +9 -0
- package/src/build/islands.ts +228 -0
- package/src/build/source-id.ts +15 -0
- package/src/cli/commands/build.ts +11 -1
- package/src/cli/commands/dev.ts +7 -3
- package/src/cli/commands/start.ts +2 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/stator.js +9 -0
- package/src/compiler/diagnostics.ts +10 -0
- package/src/compiler/index.ts +2 -1
- package/src/compiler/regions.ts +30 -0
- package/src/engine/actor.ts +10 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/types.ts +24 -1
- package/src/server/create-app.ts +7 -1
- package/src/server/dev-loader.mjs +124 -0
- package/src/server/dev-native.ts +561 -0
- package/src/server/dev-vite.ts +361 -0
- package/src/server/dev.ts +44 -313
- package/src/server/discovery.ts +45 -1
- package/src/server/effects.ts +10 -1
- package/src/server/http.ts +7 -1
- package/src/server/index.ts +1 -0
- package/src/server/machine-hash.ts +195 -0
- package/src/server/machine-store.ts +9 -2
- package/src/server/session-runtime.ts +9 -4
- package/src/server/snapshot-policy.ts +110 -0
- package/src/vite/index.ts +10 -4
- package/src/vite/plugin.ts +12 -32
- package/src/vite/stub.ts +4 -1
package/src/server/dev.ts
CHANGED
|
@@ -1,44 +1,24 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
3
|
-
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import { createServer as createHttpServer } from 'node:http'
|
|
5
|
-
import { relative, resolve } from 'node:path'
|
|
6
|
-
import { getRequestListener } from '@hono/node-server'
|
|
7
1
|
import type { Hono } from 'hono'
|
|
8
|
-
import {
|
|
9
|
-
import { compile } from '../compiler/index.ts'
|
|
2
|
+
import type { ViteDevServer } from 'vite'
|
|
10
3
|
import type { LogLevel } from '../config.ts'
|
|
11
4
|
import type { AnyMachineDef, EventOf } from '../engine/index.ts'
|
|
12
|
-
import { machineStub, stator } from '../vite/index.ts'
|
|
13
5
|
import type { AppStore } from './app-store.ts'
|
|
14
|
-
import {
|
|
15
|
-
import { resolveAppConfig } from './config-compat.ts'
|
|
16
|
-
import { loadDotenv } from './env.ts'
|
|
17
|
-
import { logger, setLogLevel } from './logger.ts'
|
|
18
|
-
import type { MachineStore } from './machine-store.ts'
|
|
19
|
-
import type { DiscoveredRoute } from './route-discovery.ts'
|
|
20
|
-
import { isStatorQueryRoute } from './routing.ts'
|
|
6
|
+
import { createNativeDevApp as createNative, type NativeDevApp } from './dev-native.ts'
|
|
21
7
|
import type { Store } from './store.ts'
|
|
22
8
|
|
|
23
9
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
10
|
+
* The dev entry. `createDevApp` boots the NATIVE dev server (`dev-native.ts`):
|
|
11
|
+
* the app runs from its source tree under Node's module loader exactly as
|
|
12
|
+
* `stator start` runs a build — no Vite in the server path, no dev/prod
|
|
13
|
+
* divergence, truthful `import.meta.url`s.
|
|
28
14
|
*
|
|
29
|
-
*
|
|
30
|
-
* (`
|
|
31
|
-
*
|
|
32
|
-
* instance. Importing the runtime natively here would create a second instance
|
|
33
|
-
* whose render-context doesn't match the one the templates resolve against, and
|
|
34
|
-
* `read()` would throw. (This mirrors how Astro/SvelteKit run SSR through Vite.)
|
|
35
|
-
*
|
|
36
|
-
* The production serve path (pre-built assets + manifest, no Vite) is a separate
|
|
37
|
-
* follow-up; this is the dev half of Phase 3a.
|
|
15
|
+
* `STATOR_VITE_DEV=1` keeps the previous Vite-embedded dev server
|
|
16
|
+
* (`dev-vite.ts`) for one minor as an escape hatch while the native loop beds
|
|
17
|
+
* in. It is loaded lazily, so the default path never imports Vite.
|
|
38
18
|
*/
|
|
39
19
|
|
|
40
20
|
export interface DevAppConfig {
|
|
41
|
-
/**
|
|
21
|
+
/** The app directory (must reach node_modules for resolution). */
|
|
42
22
|
root: string
|
|
43
23
|
machinesDir: string
|
|
44
24
|
routesDir: string
|
|
@@ -94,13 +74,15 @@ export interface DevAppConfig {
|
|
|
94
74
|
|
|
95
75
|
export interface DevApp {
|
|
96
76
|
fetch: (request: Request) => Response | Promise<Response>
|
|
97
|
-
|
|
77
|
+
/** @deprecated The dev server no longer embeds Vite — this is `undefined`
|
|
78
|
+
* (with a one-time warning) and will be removed in the next major. Under the
|
|
79
|
+
* transitional `STATOR_VITE_DEV=1` escape hatch it is still the real
|
|
80
|
+
* `ViteDevServer` for one minor. */
|
|
81
|
+
readonly vite: ViteDevServer | undefined
|
|
98
82
|
/** Server-originated dispatch to an APP-lifecycle machine (webhooks, cron)
|
|
99
83
|
* — the dev counterpart of `StatorApp.dispatchToApp`. Closes over the
|
|
100
|
-
* current store
|
|
101
|
-
*
|
|
102
|
-
* natively-imported `dispatchToApp` would be a second module instance
|
|
103
|
-
* whose connection registry is empty. */
|
|
84
|
+
* current store, surviving machine-edit rebuilds, so SSE fan-out reaches
|
|
85
|
+
* live connections. */
|
|
104
86
|
dispatchToApp<D extends AnyMachineDef>(
|
|
105
87
|
machine: D,
|
|
106
88
|
event: EventOf<D>,
|
|
@@ -112,293 +94,42 @@ export interface DevApp {
|
|
|
112
94
|
close: () => Promise<void>
|
|
113
95
|
}
|
|
114
96
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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()
|
|
123
|
-
// Vite's HMR websocket defaults to 24678 for EVERY dev server — two
|
|
124
|
-
// stator apps side by side would fight over it (the loser's live reload
|
|
125
|
-
// silently dies). Probe a free one instead.
|
|
126
|
-
const hmrPort = await findFreePort(24678)
|
|
127
|
-
const vite = await createViteServer({
|
|
128
|
-
root: resolve(config.root),
|
|
129
|
-
appType: 'custom',
|
|
130
|
-
server: { middlewareMode: true, hmr: { port: hmrPort } },
|
|
131
|
-
// machineStub keeps server machines out of browser module graphs: island
|
|
132
|
-
// imports of a machine resolve to a `{ name }` stub (SSR loads are
|
|
133
|
-
// untouched — `options.ssr` gates it).
|
|
134
|
-
plugins: [stator(), machineStub({ machinesDir: resolve(config.machinesDir) })],
|
|
135
|
-
// The framework's own source is TS that Vite must transform, not externalize.
|
|
136
|
-
ssr: { noExternal: [/@statorjs\/stator/] },
|
|
137
|
-
logLevel: 'warn',
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
// Load the runtime through Vite so it shares an instance with the templates.
|
|
141
|
-
// (Type-only: the static import type is erased, so no second instance.)
|
|
142
|
-
const runtime = (await vite.ssrLoadModule(
|
|
143
|
-
'@statorjs/stator/server',
|
|
144
|
-
)) as typeof import('./index.ts')
|
|
145
|
-
const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
|
|
146
|
-
|
|
147
|
-
const root = resolve(config.root)
|
|
148
|
-
const machinesDir = resolve(config.machinesDir)
|
|
149
|
-
const routesDir = resolve(config.routesDir)
|
|
150
|
-
const resolved = resolveAppConfig(config)
|
|
151
|
-
// Level precedence: LOG_LEVEL env > config > dev default (info). Apply to this
|
|
152
|
-
// module's logger AND the Vite-loaded runtime instance — SSE/http/effects log
|
|
153
|
-
// through the latter — via setLogLevel, which also covers the scoped children.
|
|
154
|
-
const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
|
|
155
|
-
setLogLevel(logLevel)
|
|
156
|
-
runtime.setLogLevel(logLevel)
|
|
157
|
-
// The session cookie is written by the Vite-loaded runtime, so configure that
|
|
158
|
-
// instance's SameSite (the native one would set a different module's global).
|
|
159
|
-
runtime.setSessionSameSite(resolved.sameSite ?? 'Lax')
|
|
160
|
-
const inspectorOn = resolved.inspector ?? true
|
|
161
|
-
|
|
162
|
-
const resultCache = new Map<string, ReturnType<typeof compile>>()
|
|
163
|
-
const compiledFor = async (file: string) => {
|
|
164
|
-
let r = resultCache.get(file)
|
|
165
|
-
if (r === undefined) {
|
|
166
|
-
// Match the plugin's kind detection so route-page frontmatter
|
|
167
|
-
// (Stator.reads etc.) compiles under the route capability set.
|
|
168
|
-
const kind = /[\\/]routes[\\/].*\.stator$/.test(file) ? 'route' : 'component'
|
|
169
|
-
r = compile(await readFile(file, 'utf8'), { id: file, kind })
|
|
170
|
-
resultCache.set(file, r)
|
|
171
|
-
}
|
|
172
|
-
return r
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const headExtras = async (routeFile: string) => {
|
|
176
|
-
const files = reachableStatorFiles(vite, routeFile)
|
|
177
|
-
let css = ''
|
|
178
|
-
const scripts: string[] = []
|
|
179
|
-
for (const f of files) {
|
|
180
|
-
const r = await compiledFor(f)
|
|
181
|
-
if (r.css) css += `/* ${f} */\n${r.css}\n`
|
|
182
|
-
if (r.isClient) {
|
|
183
|
-
// Vite's transform middleware compiles this URL because browsers send
|
|
184
|
-
// `Sec-Fetch-Dest: script` for module scripts (the `.stator` extension
|
|
185
|
-
// alone wouldn't match its JS-request check). curl-style probes must
|
|
186
|
-
// send that header or they'll see raw source and cry wolf.
|
|
187
|
-
const url = `/${relative(root, f).replace(/\\/g, '/')}?stator&type=client`
|
|
188
|
-
scripts.push(`<script type="module" src="${url}"></script>`)
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const head: string[] = []
|
|
192
|
-
// Vite's HMR client — pages are rendered by Hono, not Vite's
|
|
193
|
-
// transformIndexHtml, so it isn't injected for us. Without it the browser
|
|
194
|
-
// has no socket to receive the full-reload signal on a source change.
|
|
195
|
-
head.push('<script type="module" src="/@vite/client"></script>')
|
|
196
|
-
// The dev inspector toolbar (off in production — only the dev server injects it).
|
|
197
|
-
if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
|
|
198
|
-
if (css.trim()) head.push(`<style data-stator-dev>\n${css.trim()}\n</style>`)
|
|
199
|
-
head.push(...scripts)
|
|
200
|
-
return head.join('\n')
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// The app graph is rebuilt on a source change so edits don't need a restart.
|
|
204
|
-
let store: MachineStore
|
|
205
|
-
let routes: DiscoveredRoute[] = []
|
|
206
|
-
let machineCount = 0
|
|
207
|
-
let app: Hono
|
|
208
|
-
|
|
209
|
-
const rebuildStore = async (): Promise<void> => {
|
|
210
|
-
const { defs } = await runtime.discoverMachines(machinesDir, loader)
|
|
211
|
-
machineCount = defs.length
|
|
212
|
-
store = new runtime.MachineStore(defs, resolved.session ?? new runtime.InMemoryStore(), {
|
|
213
|
-
sessionTtlSeconds: resolved.sessionTtlSeconds,
|
|
214
|
-
appStore: resolved.app,
|
|
215
|
-
})
|
|
216
|
-
// Wire the effect scheduler before booting — a fresh app machine's
|
|
217
|
-
// initial-state entry effect fires during boot and needs a live scheduler.
|
|
218
|
-
runtime.wireAppEffects(store)
|
|
219
|
-
await store.bootAppMachines()
|
|
220
|
-
// Dev-only lint: a session machine whose `after` timer drives a loop with
|
|
221
|
-
// a data-loading entry effect is server-side polling that runs for
|
|
222
|
-
// sessions nobody is watching. Warn with the steer, don't block.
|
|
223
|
-
for (const f of runtime.findPollLoops(defs)) {
|
|
224
|
-
console.warn(
|
|
225
|
-
`stator: session machine "${f.machine}" self-reschedules through \`after\` ` +
|
|
226
|
-
`(${f.cycle.join(' → ')} via ${f.event}) with a data-loading entry effect on the loop. ` +
|
|
227
|
-
`This polls upstream for sessions nobody is watching. Prefer a client-owned clock ` +
|
|
228
|
-
`with a staleness guard — see the effects guide, "Who owns the clock".`,
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
const rebuildRoutes = async (): Promise<void> => {
|
|
233
|
-
routes = await runtime.discoverRoutes(routesDir, loader)
|
|
234
|
-
// Vite serves `public/` at the root ahead of Hono in dev, so a static
|
|
235
|
-
// file at a data route's URL silently wins. Warn at discovery time.
|
|
236
|
-
// (The brand is a plain property, so this native-import check works on
|
|
237
|
-
// defs the Vite-loaded runtime created.)
|
|
238
|
-
for (const r of routes) {
|
|
239
|
-
if (!r.GET || !isStatorQueryRoute(r.GET) || r.paramNames.length > 0) continue
|
|
240
|
-
if (existsSync(resolve(root, 'public', r.urlPath.slice(1)))) {
|
|
241
|
-
console.warn(
|
|
242
|
-
`stator: public/${r.urlPath.slice(1)} shadows the data route ${r.urlPath} in dev — ` +
|
|
243
|
-
`the static file wins. Rename one of them.`,
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
const rebuildServer = async (): Promise<void> => {
|
|
249
|
-
// Re-discover `middleware.ts` each rebuild so edits to it take effect. Loaded
|
|
250
|
-
// through the Vite runtime so its handlers share the runtime's instance.
|
|
251
|
-
const middleware = await runtime.discoverMiddleware(resolve(root, 'middleware.ts'), loader)
|
|
252
|
-
app = await runtime.buildHonoApp({
|
|
253
|
-
routes,
|
|
254
|
-
store,
|
|
255
|
-
staticDir: config.staticDir,
|
|
256
|
-
headExtras,
|
|
257
|
-
inspector: inspectorOn,
|
|
258
|
-
trustedOrigins: resolved.trustedOrigins,
|
|
259
|
-
sameSite: resolved.sameSite,
|
|
260
|
-
origin: resolved.origin,
|
|
261
|
-
secret: resolved.secret,
|
|
262
|
-
buildId: devBuildId,
|
|
263
|
-
cors: resolved.cors,
|
|
264
|
-
middleware,
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
await rebuildStore()
|
|
269
|
-
await rebuildRoutes()
|
|
270
|
-
await rebuildServer()
|
|
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
|
-
|
|
276
|
-
// Live reload: on a relevant source change, re-discover and rebuild the app,
|
|
277
|
-
// then tell the browser to reload. A template/route edit keeps the store (and
|
|
278
|
-
// your session — cart contents and all) intact; only a machine edit resets it,
|
|
279
|
-
// since route `reads:` bind to machine defs by identity and must re-bind as a
|
|
280
|
-
// set. Rebuilds are serialized so overlapping saves can't race.
|
|
281
|
-
const isAppFile = (file: string): boolean => {
|
|
282
|
-
if (!/\.(stator|ts|js)$/.test(file)) return false
|
|
283
|
-
if (file.includes('/node_modules/') || file.includes('/dist/')) return false
|
|
284
|
-
if (file.startsWith(machinesDir) || file.startsWith(routesDir)) return true
|
|
285
|
-
return (vite.moduleGraph.getModulesByFile(file)?.size ?? 0) > 0
|
|
286
|
-
}
|
|
287
|
-
let reloadChain: Promise<void> = Promise.resolve()
|
|
288
|
-
const onChange = (file: string): void => {
|
|
289
|
-
const abs = resolve(file)
|
|
290
|
-
if (!isAppFile(abs)) return
|
|
291
|
-
reloadChain = reloadChain.then(async () => {
|
|
292
|
-
invalidateModuleTree(vite, abs)
|
|
293
|
-
resultCache.delete(abs)
|
|
294
|
-
try {
|
|
295
|
-
if (abs.startsWith(machinesDir)) await rebuildStore()
|
|
296
|
-
await rebuildRoutes()
|
|
297
|
-
await rebuildServer()
|
|
298
|
-
vite.ws.send({ type: 'full-reload' })
|
|
299
|
-
logger.info({ file: relative(root, abs) }, 'reloaded')
|
|
300
|
-
} catch (err) {
|
|
301
|
-
logger.error({ err: (err as Error).message, file: relative(root, abs) }, 'reload failed')
|
|
302
|
-
}
|
|
303
|
-
})
|
|
304
|
-
}
|
|
305
|
-
vite.watcher.on('change', onChange)
|
|
306
|
-
vite.watcher.on('add', onChange)
|
|
307
|
-
vite.watcher.on('unlink', onChange)
|
|
308
|
-
|
|
97
|
+
let warnedVite = false
|
|
98
|
+
/** The native app in the public `DevApp` shape: `vite` is a deprecated getter
|
|
99
|
+
* that warns once and returns `undefined`. Exported for tests. */
|
|
100
|
+
export function withDeprecatedVite(app: NativeDevApp): DevApp {
|
|
309
101
|
return {
|
|
310
102
|
fetch: (request) => app.fetch(request),
|
|
311
103
|
get hono() {
|
|
312
|
-
return app
|
|
104
|
+
return app.hono
|
|
313
105
|
},
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
// Busy port? Shift up like every modern dev server (the requested
|
|
327
|
-
// port is someone's other project, not an error).
|
|
328
|
-
return findFreePort(port).then(
|
|
329
|
-
(freePort) =>
|
|
330
|
-
new Promise((resolveFn) => {
|
|
331
|
-
server.listen(freePort, async () => {
|
|
332
|
-
printDevBanner({
|
|
333
|
-
port: freePort,
|
|
334
|
-
requestedPort: port,
|
|
335
|
-
machines: machineCount,
|
|
336
|
-
routes: routes.length,
|
|
337
|
-
inspector: true,
|
|
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
|
-
})
|
|
356
|
-
resolveFn()
|
|
357
|
-
})
|
|
358
|
-
}),
|
|
359
|
-
)
|
|
106
|
+
dispatchToApp: (machine, event) => app.dispatchToApp(machine, event),
|
|
107
|
+
listen: (port) => app.listen(port),
|
|
108
|
+
close: () => app.close(),
|
|
109
|
+
get vite(): undefined {
|
|
110
|
+
if (!warnedVite) {
|
|
111
|
+
warnedVite = true
|
|
112
|
+
console.warn(
|
|
113
|
+
'stator: DevApp.vite is deprecated and returns undefined — the dev server no ' +
|
|
114
|
+
'longer embeds Vite. STATOR_VITE_DEV=1 keeps the Vite dev server for one minor.',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
360
118
|
},
|
|
361
|
-
close: () => vite.close(),
|
|
362
119
|
}
|
|
363
120
|
}
|
|
364
121
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const stack: Array<{ importers: Set<unknown> }> = [
|
|
370
|
-
...((vite.moduleGraph.getModulesByFile(file) ?? []) as Set<never>),
|
|
371
|
-
]
|
|
372
|
-
while (stack.length) {
|
|
373
|
-
const mod = stack.pop()
|
|
374
|
-
if (!mod || seen.has(mod)) continue
|
|
375
|
-
seen.add(mod)
|
|
376
|
-
vite.moduleGraph.invalidateModule(mod as never)
|
|
377
|
-
for (const imp of mod.importers) stack.push(imp as never)
|
|
122
|
+
export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
123
|
+
if (process.env.STATOR_VITE_DEV === '1') {
|
|
124
|
+
const { createViteDevApp } = await import('./dev-vite.ts')
|
|
125
|
+
return createViteDevApp(config)
|
|
378
126
|
}
|
|
127
|
+
return withDeprecatedVite(await createNative(config))
|
|
379
128
|
}
|
|
380
129
|
|
|
381
|
-
/**
|
|
382
|
-
*
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
*/
|
|
386
|
-
function reachableStatorFiles(vite: ViteDevServer, entryFile: string): string[] {
|
|
387
|
-
const seen = new Set<string>()
|
|
388
|
-
const statorFiles = new Set<string>()
|
|
389
|
-
|
|
390
|
-
const visit = (node: { id: string | null; importedModules: Set<unknown> } | undefined): void => {
|
|
391
|
-
if (!node) return
|
|
392
|
-
const id = node.id ?? ''
|
|
393
|
-
if (seen.has(id)) return
|
|
394
|
-
seen.add(id)
|
|
395
|
-
const file = id.split('?')[0]!
|
|
396
|
-
if (/\.stator$/.test(file)) statorFiles.add(file)
|
|
397
|
-
for (const dep of node.importedModules) visit(dep as never)
|
|
398
|
-
}
|
|
130
|
+
/** @deprecated `createDevApp` IS the native dev server now — use it. (This
|
|
131
|
+
* alias always boots the native server, ignoring `STATOR_VITE_DEV`.) */
|
|
132
|
+
export const createNativeDevApp = async (config: DevAppConfig): Promise<DevApp> =>
|
|
133
|
+
withDeprecatedVite(await createNative(config))
|
|
399
134
|
|
|
400
|
-
|
|
401
|
-
visit(node as never)
|
|
402
|
-
}
|
|
403
|
-
return [...statorFiles]
|
|
404
|
-
}
|
|
135
|
+
export type { NativeDevApp } from './dev-native.ts'
|
package/src/server/discovery.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises'
|
|
2
|
-
import { extname, resolve } from 'node:path'
|
|
2
|
+
import { extname, relative, resolve } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { type AnyMachineDef, isStatorMachine } from './define-machine.ts'
|
|
5
|
+
import { hashMachines, setCodeHash } from './machine-hash.ts'
|
|
5
6
|
|
|
6
7
|
export interface DiscoveryResult {
|
|
7
8
|
defs: AnyMachineDef[]
|
|
@@ -14,9 +15,25 @@ export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>
|
|
|
14
15
|
|
|
15
16
|
const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
|
|
16
17
|
|
|
18
|
+
export interface DiscoverMachinesOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Code hashes for the hydration policy (see `snapshot-policy.ts`).
|
|
21
|
+
* - omitted: computed live, one esbuild pass over the discovered files
|
|
22
|
+
* (the dev servers, and `createApp` without a build manifest);
|
|
23
|
+
* - a map keyed by machine FILE (relative to the machines dir, e.g.
|
|
24
|
+
* `cart.ts`): consumed as-is — `stator start` from the build manifest,
|
|
25
|
+
* whose hashes are per file because the build never executes a machine
|
|
26
|
+
* to learn its name. A discovered machine with no entry is an error,
|
|
27
|
+
* never a silent always-reset;
|
|
28
|
+
* - `false`: skip hashing (unit tests assembling stores from defs).
|
|
29
|
+
*/
|
|
30
|
+
hashes?: ReadonlyMap<string, string> | Readonly<Record<string, string>> | false
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
export async function discoverMachines(
|
|
18
34
|
dir: string,
|
|
19
35
|
load: ModuleLoader = nativeLoader,
|
|
36
|
+
opts: DiscoverMachinesOptions = {},
|
|
20
37
|
): Promise<DiscoveryResult> {
|
|
21
38
|
const absDir = resolve(dir)
|
|
22
39
|
// A missing conventional dir means "no machines yet" (e.g. early in a fresh
|
|
@@ -38,6 +55,19 @@ export async function discoverMachines(
|
|
|
38
55
|
|
|
39
56
|
const defs: AnyMachineDef[] = []
|
|
40
57
|
const seenNames = new Set<string>()
|
|
58
|
+
const supplied = opts.hashes
|
|
59
|
+
const byFile =
|
|
60
|
+
supplied === false || supplied === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: supplied instanceof Map
|
|
63
|
+
? supplied
|
|
64
|
+
: new Map(Object.entries(supplied))
|
|
65
|
+
// Live hashing: one esbuild pass over every machine file, before any import,
|
|
66
|
+
// so an unbundleable closure fails here with the file named.
|
|
67
|
+
const live =
|
|
68
|
+
supplied === undefined && files.length > 0
|
|
69
|
+
? await hashMachines(files, { machinesDir: absDir })
|
|
70
|
+
: undefined
|
|
41
71
|
|
|
42
72
|
for (const file of files) {
|
|
43
73
|
const mod = await load(file)
|
|
@@ -52,6 +82,20 @@ export async function discoverMachines(
|
|
|
52
82
|
throw new Error(`stator: duplicate machine name "${def.name}" in ${file}`)
|
|
53
83
|
}
|
|
54
84
|
seenNames.add(def.name)
|
|
85
|
+
if (byFile) {
|
|
86
|
+
const key = relative(absDir, file).replace(/\\/g, '/')
|
|
87
|
+
const hash = byFile.get(key)
|
|
88
|
+
if (hash === undefined) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`stator: no code hash for machine "${def.name}" (${key}) in the build manifest — ` +
|
|
91
|
+
`rebuild with \`stator build\`; a machine without a hash cannot hydrate safely.`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
setCodeHash(def, hash)
|
|
95
|
+
} else if (live) {
|
|
96
|
+
const { hash, inputs } = live.get(file)!
|
|
97
|
+
setCodeHash(def, hash, inputs)
|
|
98
|
+
}
|
|
55
99
|
defs.push(def)
|
|
56
100
|
}
|
|
57
101
|
|
package/src/server/effects.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AnyMachineDef, EffectInvocation, EventObject } from '../engine/ind
|
|
|
2
2
|
import { dispatchToApp } from './app-dispatch.ts'
|
|
3
3
|
import { scopedLogger } from './logger.ts'
|
|
4
4
|
import type { MachineStore } from './machine-store.ts'
|
|
5
|
+
import { CLAIMS_KEY } from './session.ts'
|
|
5
6
|
import { withSessionLock } from './session-lock.ts'
|
|
6
7
|
import { SessionRuntime } from './session-runtime.ts'
|
|
7
8
|
import { fanOut } from './sse.ts'
|
|
@@ -156,7 +157,15 @@ async function runSessionEffect(
|
|
|
156
157
|
try {
|
|
157
158
|
let completion: Awaited<ReturnType<EffectInvocation['run']>>
|
|
158
159
|
try {
|
|
159
|
-
|
|
160
|
+
// The session the effect runs for: its id and its claims as of now —
|
|
161
|
+
// loaded here (outside the lock, like the I/O the effect is about to do)
|
|
162
|
+
// so an entry effect can reload a durable fact by identity after a fresh
|
|
163
|
+
// start or a snapshot reset, with no client round trip.
|
|
164
|
+
const claims = (await store.persistence.get(sessionId, CLAIMS_KEY)) ?? undefined
|
|
165
|
+
completion = await invocation.run(controller.signal, {
|
|
166
|
+
id: sessionId,
|
|
167
|
+
claims: <T>() => claims as T | undefined,
|
|
168
|
+
})
|
|
160
169
|
} catch (err) {
|
|
161
170
|
// Backstop only — the type contract asks effects to catch and return
|
|
162
171
|
// their failure event. Never crashes the host.
|
package/src/server/http.ts
CHANGED
|
@@ -653,7 +653,7 @@ async function handleGet(
|
|
|
653
653
|
}
|
|
654
654
|
}
|
|
655
655
|
|
|
656
|
-
function contentTypeFor(path: string): string {
|
|
656
|
+
export function contentTypeFor(path: string): string {
|
|
657
657
|
const ext = extname(path).toLowerCase()
|
|
658
658
|
if (ext === '.css') return 'text/css; charset=utf-8'
|
|
659
659
|
if (ext === '.js' || ext === '.mjs') return 'application/javascript; charset=utf-8'
|
|
@@ -661,5 +661,11 @@ function contentTypeFor(path: string): string {
|
|
|
661
661
|
if (ext === '.svg') return 'image/svg+xml'
|
|
662
662
|
if (ext === '.png') return 'image/png'
|
|
663
663
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
|
664
|
+
if (ext === '.webp') return 'image/webp'
|
|
665
|
+
if (ext === '.gif') return 'image/gif'
|
|
666
|
+
if (ext === '.ico') return 'image/x-icon'
|
|
667
|
+
if (ext === '.wasm') return 'application/wasm'
|
|
668
|
+
if (ext === '.woff2') return 'font/woff2'
|
|
669
|
+
if (ext === '.woff') return 'font/woff'
|
|
664
670
|
return 'application/octet-stream'
|
|
665
671
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ export { buildHonoApp } from './http.ts'
|
|
|
55
55
|
export type { InstanceHandle } from './instance-proxy.ts'
|
|
56
56
|
export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
|
|
57
57
|
export { logger, scopedLogger, setLogLevel } from './logger.ts'
|
|
58
|
+
export { codeHashOf, codeInputsOf } from './machine-hash.ts'
|
|
58
59
|
export { MachineStore } from './machine-store.ts'
|
|
59
60
|
export type { MiddlewareDefinition } from './middleware.ts'
|
|
60
61
|
export {
|