@statorjs/stator 1.0.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,475 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { dirname, extname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { build } from 'esbuild'
5
+ import { type Context, Hono } from 'hono'
6
+ import { streamSSE } from 'hono/streaming'
7
+ import { z } from 'zod'
8
+ import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
+ import { scheduleSessionEffects } from './effects.ts'
10
+ import { scopedLogger } from './logger.ts'
11
+ import type { MachineStore } from './machine-store.ts'
12
+ import { initialSyncPatches, recompute } from './recompute.ts'
13
+ import { renderRoute } from './render.ts'
14
+ import type { DiscoveredRoute } from './route-discovery.ts'
15
+ import { buildRouteRequest } from './route-request.ts'
16
+ import type { RouteDefinition } from './routing.ts'
17
+ import { getOrCreateSessionId } from './session.ts'
18
+ import { withSessionLock } from './session-lock.ts'
19
+ import { SessionRuntime } from './session-runtime.ts'
20
+ import { fanOut, registerConnection, unregisterConnection } from './sse.ts'
21
+
22
+ const httpLog = scopedLogger('http')
23
+
24
+ export interface HttpConfig {
25
+ routes: DiscoveredRoute[]
26
+ store: MachineStore
27
+ staticDir?: string
28
+ /** Optional hook to inject extra `<head>` HTML for a GET route, keyed by the
29
+ * route's file path. The dev server uses this to inline collected scoped CSS
30
+ * (SSR head injection). Inserted at the `</head>` boundary. */
31
+ headExtras?: (filePath: string) => string | Promise<string>
32
+ /** Serve the dev inspector asset at `/@stator/inspector.js`. The dev server
33
+ * sets this and injects the script tag; production leaves it off. */
34
+ inspector?: boolean
35
+ }
36
+
37
+ const eventSchema = z.object({
38
+ machine: z.string(),
39
+ event: z
40
+ .object({
41
+ type: z.string(),
42
+ })
43
+ .passthrough(),
44
+ })
45
+
46
+ /** Compiled matcher: turns `/p/:id` into a regex that captures params. */
47
+ interface RouteMatcher {
48
+ route: DiscoveredRoute
49
+ regex: RegExp
50
+ }
51
+
52
+ function compileMatcher(route: DiscoveredRoute): RouteMatcher {
53
+ // Translate Hono-pattern (`/p/:id`) into a regex that matches a literal
54
+ // URL path and captures each param value.
55
+ // A rest segment (`*name`) consumes the remainder including its leading slash,
56
+ // so it can match zero segments (`/files` for `/files/[...path]`).
57
+ const parts = route.urlPath.split('/')
58
+ let pattern = ''
59
+ for (let i = 0; i < parts.length; i++) {
60
+ const seg = parts[i]!
61
+ if (seg.startsWith('*')) {
62
+ // Absorb the preceding `/` and match the (possibly empty) remainder.
63
+ pattern = `${pattern.replace(/\/$/, '')}(?:/(.*))?`
64
+ } else if (seg.startsWith(':')) {
65
+ pattern += '([^/]+)'
66
+ if (i < parts.length - 1) pattern += '/'
67
+ } else {
68
+ pattern += seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
69
+ if (i < parts.length - 1) pattern += '/'
70
+ }
71
+ }
72
+ return { route, regex: new RegExp(`^${pattern}$`) }
73
+ }
74
+
75
+ /**
76
+ * Match a literal URL path against compiled matchers. Returns the matched
77
+ * route + extracted params, or null if nothing matches.
78
+ */
79
+ function matchPath(
80
+ matchers: RouteMatcher[],
81
+ literalPath: string,
82
+ ): { route: DiscoveredRoute; params: Record<string, string> } | null {
83
+ for (const m of matchers) {
84
+ const result = m.regex.exec(literalPath)
85
+ if (!result) continue
86
+ const params: Record<string, string> = {}
87
+ m.route.paramNames.forEach((name, i) => {
88
+ params[name] = decodeURIComponent(result[i + 1] ?? '')
89
+ })
90
+ return { route: m.route, params }
91
+ }
92
+ return null
93
+ }
94
+
95
+ /** Parse a route key like "GET /p/abc-123" into method + literal path. */
96
+ function parseRouteKey(
97
+ routeKey: string,
98
+ ): { method: string; path: string; query: Record<string, string | undefined> } | null {
99
+ const space = routeKey.indexOf(' ')
100
+ if (space < 0) return null
101
+ const target = routeKey.slice(space + 1)
102
+ // The page's ?query travels IN the route key — a baseline re-render for a
103
+ // query-dependent page (facets, pagination) must see the same request the
104
+ // GET did, or its diffs describe a page the browser isn't showing.
105
+ const q = target.indexOf('?')
106
+ const path = q === -1 ? target : target.slice(0, q)
107
+ const query: Record<string, string | undefined> = {}
108
+ if (q !== -1) {
109
+ for (const [k, v] of new URLSearchParams(target.slice(q + 1))) query[k] = v
110
+ }
111
+ return { method: routeKey.slice(0, space), path, query }
112
+ }
113
+
114
+ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
115
+ const app = new Hono()
116
+ const clientJs = await bundleClient()
117
+
118
+ // Request logger: one line per request with method, path, status, duration.
119
+ // SSE endpoints stay open indefinitely; we log on entry, not on close.
120
+ app.use('*', async (c, next) => {
121
+ const start = performance.now()
122
+ await next()
123
+ const ms = Math.round(performance.now() - start)
124
+ const status = c.res.status
125
+ const isLive = c.req.path === '/__sse'
126
+ httpLog[status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'](
127
+ {
128
+ method: c.req.method,
129
+ path: c.req.path,
130
+ status,
131
+ ms,
132
+ sse: isLive || undefined,
133
+ },
134
+ isLive ? 'sse open' : 'request',
135
+ )
136
+ })
137
+
138
+ // Compile matchers for every route, in discovery's specificity order. Our own
139
+ // matcher (not Hono's router) is the routing authority: GET/API dispatch and
140
+ // SSE/POST resolution all go through `matchPath`, so rest params (`*name`) and
141
+ // specificity ordering behave identically everywhere. Hono only routes the
142
+ // fixed framework endpoints (static exact paths it prioritizes over `*`).
143
+ const matchers: RouteMatcher[] = config.routes.map(compileMatcher)
144
+ const getMatchers = matchers // SSE/POST filter by `.GET` after matching
145
+
146
+ app.get('/static/client.js', (c) => {
147
+ c.header('Content-Type', 'application/javascript; charset=utf-8')
148
+ c.header('Cache-Control', 'no-cache')
149
+ return c.body(clientJs)
150
+ })
151
+
152
+ // Dev inspector asset — served only when enabled (the dev server injects the
153
+ // matching script tag). Bundled lazily on first build of the app.
154
+ if (config.inspector) {
155
+ const inspectorJs = await bundleInspector()
156
+ app.get('/@stator/inspector.js', (c) => {
157
+ c.header('Content-Type', 'application/javascript; charset=utf-8')
158
+ c.header('Cache-Control', 'no-cache')
159
+ return c.body(inspectorJs)
160
+ })
161
+ }
162
+
163
+ if (config.staticDir) {
164
+ const staticDir = config.staticDir
165
+ app.get('/static/*', async (c) => {
166
+ const rel = c.req.path.replace(/^\/static\//, '')
167
+ if (rel.includes('..')) return c.text('forbidden', 403)
168
+ try {
169
+ const buf = await readFile(resolve(staticDir, rel))
170
+ c.header('Content-Type', contentTypeFor(rel))
171
+ return c.body(buf)
172
+ } catch {
173
+ return c.text('not found', 404)
174
+ }
175
+ })
176
+ }
177
+
178
+ // SSE endpoint. The connection's runtime + renderState stay alive for
179
+ // the connection's lifetime — this is the one place per-session state
180
+ // outlives a request, because the connection *is* one (very long) request.
181
+ app.get('/__sse', async (c) => {
182
+ const routeKey = c.req.query('route')
183
+ if (!routeKey) return c.text('missing route param', 400)
184
+ const parsed = parseRouteKey(routeKey)
185
+ if (parsed?.method !== 'GET') {
186
+ return c.text(`malformed route key "${routeKey}"`, 400)
187
+ }
188
+ const matched = matchPath(getMatchers, parsed.path)
189
+ if (!matched?.route.GET) {
190
+ return c.text(`unknown route "${routeKey}"`, 404)
191
+ }
192
+ const route = matched.route.GET
193
+ if (!route.live) {
194
+ return c.text(`route "${routeKey}" is not declared live: true`, 400)
195
+ }
196
+ // The SSE endpoint's own Request becomes the connection's request
197
+ // object for fan-out renders. params come from the matched literal path;
198
+ // the page's ?query rides in the route key.
199
+ const request = {
200
+ ...buildRouteRequest(c, matched.route.paramNames),
201
+ params: matched.params,
202
+ query: parsed.query,
203
+ }
204
+
205
+ const { sessionId } = getOrCreateSessionId(c)
206
+
207
+ // Tell intermediate proxies (Fly edge, nginx, others) not to buffer the
208
+ // response. Without this, small SSE messages can accumulate in a proxy
209
+ // buffer waiting for a fill threshold, producing batched / dropped-
210
+ // looking delivery on the client.
211
+ c.header('X-Accel-Buffering', 'no')
212
+
213
+ return streamSSE(c, async (stream) => {
214
+ const runtime = new SessionRuntime(sessionId, config.store)
215
+ await runtime.loadGraph(route.reads)
216
+ const { renderState } = renderRoute(route, routeKey, sessionId, runtime, request)
217
+ const conn = registerConnection({
218
+ sessionId,
219
+ clientId: c.req.query('client'),
220
+ routeKey,
221
+ route,
222
+ request,
223
+ runtime,
224
+ renderState,
225
+ send: async (data: string) => {
226
+ await stream.writeSSE({ data })
227
+ },
228
+ })
229
+
230
+ // Force an immediate flush so edge proxies commit response headers
231
+ // and consider the stream "alive" before any fan-out arrives.
232
+ await stream.write(': open\n\n')
233
+
234
+ // Converge the page onto this connection's baseline: the DOM was
235
+ // rendered at page-GET time, the baseline at connect time, and any
236
+ // state change in between (an effect settling mid-navigation) would
237
+ // otherwise never reach this page.
238
+ const sync = initialSyncPatches(renderState, runtime)
239
+ if (sync.length > 0) {
240
+ await conn.send(JSON.stringify({ patches: sync }))
241
+ }
242
+
243
+ // Keep-alive every 25s so proxy idle timeouts don't close the
244
+ // connection between real events.
245
+ const keepAlive = setInterval(() => {
246
+ stream.write(': keep-alive\n\n').catch(() => {
247
+ // Stream closed; will be cleaned up by abort handler.
248
+ })
249
+ }, 25_000)
250
+
251
+ try {
252
+ await new Promise<void>((resolveFn) => {
253
+ stream.onAbort(() => resolveFn())
254
+ })
255
+ } finally {
256
+ clearInterval(keepAlive)
257
+ unregisterConnection(conn.id)
258
+ }
259
+ })
260
+ })
261
+
262
+ app.post('/__events', async (c) => {
263
+ const { sessionId } = getOrCreateSessionId(c)
264
+ const routeKey = c.req.header('X-Stator-Route')
265
+ if (!routeKey) {
266
+ return c.json({ error: 'missing X-Stator-Route header' }, 400)
267
+ }
268
+ const parsed = parseRouteKey(routeKey)
269
+ if (parsed?.method !== 'GET') {
270
+ return c.json({ error: `malformed route key "${routeKey}"` }, 400)
271
+ }
272
+ const matched = matchPath(getMatchers, parsed.path)
273
+ if (!matched?.route.GET) {
274
+ return c.json({ error: `unknown route "${routeKey}"` }, 404)
275
+ }
276
+ const route = matched.route.GET
277
+ const request = {
278
+ ...buildRouteRequest(c, matched.route.paramNames),
279
+ params: matched.params,
280
+ query: parsed.query,
281
+ }
282
+
283
+ let body: z.infer<typeof eventSchema>
284
+ try {
285
+ body = eventSchema.parse(await c.req.json())
286
+ } catch (e) {
287
+ return c.json({ error: 'invalid event payload', detail: String(e) }, 400)
288
+ }
289
+
290
+ const originDef = config.store.getDef(body.machine)
291
+ if (!originDef) {
292
+ return c.json({ error: `unknown machine "${body.machine}"` }, 404)
293
+ }
294
+
295
+ return withSessionLock(sessionId, async () => {
296
+ const runtime = new SessionRuntime(sessionId, config.store)
297
+ try {
298
+ await runtime.loadGraph([...route.reads, originDef])
299
+ runtime.wireSubscriptions()
300
+
301
+ const { renderState } = renderRoute(route, routeKey, sessionId, runtime, request)
302
+
303
+ const touched = runtime.processEvent(body.machine, body.event)
304
+
305
+ // Reads-aware selectors: bindings of machines whose selectors READ a
306
+ // touched machine must re-diff too. Persistence stays direct-only —
307
+ // derived machines' own state didn't move.
308
+ const { all: recomputeSet } = config.store.expandTouchedForRecompute(touched)
309
+ const patches = []
310
+ for (const name of recomputeSet) {
311
+ patches.push(...recompute(renderState, name, runtime))
312
+ }
313
+
314
+ await runtime.persistTouched(touched)
315
+
316
+ await fanOut(touched, {
317
+ sessionId,
318
+ originClientId: c.req.header('X-Stator-Client'),
319
+ })
320
+
321
+ // Fire-and-forget: the effects' I/O runs after this callback returns
322
+ // (the lock is never held across it); completions re-enter via the
323
+ // normal event path in server/effects.ts.
324
+ scheduleSessionEffects(runtime, config.store, sessionId)
325
+
326
+ return c.json({ patches, directives: [], committed: touched.size > 0 })
327
+ } finally {
328
+ runtime.dispose()
329
+ }
330
+ })
331
+ })
332
+
333
+ // User-route dispatch: catch-alls resolved by our matcher, registered LAST so
334
+ // the fixed framework endpoints (/__events, /__sse, /static/*) — all registered
335
+ // above — take their requests first. A request that matches no user route falls
336
+ // through to Hono's default (so framework paths handled above are untouched).
337
+ for (const method of ['POST', 'PUT', 'PATCH', 'DELETE'] as const) {
338
+ app.on(method, '*', async (c, next) => {
339
+ const matched = matchPath(matchers, c.req.path)
340
+ const apiRoute = matched?.route[method]
341
+ if (!matched || !apiRoute) return next()
342
+ return runApiRoute(c, matched.route, apiRoute, config.store, matched.params)
343
+ })
344
+ }
345
+
346
+ app.get('*', async (c, next) => {
347
+ const matched = matchPath(matchers, c.req.path)
348
+ if (!matched?.route.GET) return next()
349
+ return handleGet(
350
+ c,
351
+ matched.route,
352
+ matched.route.GET,
353
+ matched.params,
354
+ config.store,
355
+ config.headExtras,
356
+ )
357
+ })
358
+
359
+ return app
360
+ }
361
+
362
+ let cachedClientJs: string | null = null
363
+
364
+ async function bundleClient(): Promise<string> {
365
+ if (cachedClientJs) return cachedClientJs
366
+ const here = dirname(fileURLToPath(import.meta.url))
367
+ const entry = resolve(here, '../client/runtime.ts')
368
+ const result = await build({
369
+ entryPoints: [entry],
370
+ bundle: true,
371
+ format: 'iife',
372
+ target: 'es2020',
373
+ write: false,
374
+ minify: false,
375
+ logLevel: 'silent',
376
+ })
377
+ cachedClientJs = result.outputFiles[0]!.text
378
+ return cachedClientJs
379
+ }
380
+
381
+ let cachedInspectorJs: string | null = null
382
+
383
+ async function bundleInspector(): Promise<string> {
384
+ if (cachedInspectorJs) return cachedInspectorJs
385
+ const here = dirname(fileURLToPath(import.meta.url))
386
+ const entry = resolve(here, '../client/inspector.ts')
387
+ const result = await build({
388
+ entryPoints: [entry],
389
+ bundle: true,
390
+ format: 'iife',
391
+ target: 'es2020',
392
+ write: false,
393
+ minify: false,
394
+ logLevel: 'silent',
395
+ })
396
+ cachedInspectorJs = result.outputFiles[0]!.text
397
+ return cachedInspectorJs
398
+ }
399
+
400
+ /**
401
+ * Insert framework HTML at the document's `<head>` and end-of-`<body>`
402
+ * boundaries — each a no-op when its boundary is absent (e.g. a route that
403
+ * renders a bare fragment, which can't host the runtime anyway). One
404
+ * consolidated injector rather than stacked `.replace()` calls.
405
+ */
406
+ function injectIntoDocument(html: string, parts: { head?: string; bodyEnd?: string }): string {
407
+ let out = html
408
+ if (parts.head && out.includes('</head>')) {
409
+ out = out.replace('</head>', `${parts.head}</head>`)
410
+ }
411
+ if (parts.bodyEnd && out.includes('</body>')) {
412
+ out = out.replace('</body>', `${parts.bodyEnd}</body>`)
413
+ }
414
+ return out
415
+ }
416
+
417
+ async function handleGet(
418
+ c: Context,
419
+ discovered: DiscoveredRoute,
420
+ route: RouteDefinition,
421
+ params: Record<string, string>,
422
+ store: MachineStore,
423
+ headExtras?: (filePath: string) => string | Promise<string>,
424
+ ): Promise<Response> {
425
+ {
426
+ const { sessionId } = getOrCreateSessionId(c)
427
+ const literalPath = c.req.path
428
+ const routeKey = `GET ${literalPath}`
429
+ const request = { ...buildRouteRequest(c, discovered.paramNames), params }
430
+
431
+ const runtime = new SessionRuntime(sessionId, store)
432
+ try {
433
+ await runtime.loadGraph(route.reads)
434
+ const result = renderRoute(route, routeKey, sessionId, runtime, request)
435
+ let html = result.html
436
+
437
+ const headHtml: string[] = []
438
+ if (headExtras) {
439
+ const extra = await headExtras(discovered.filePath)
440
+ if (extra) headHtml.push(extra)
441
+ }
442
+ if (route.live) headHtml.push('<meta name="stator-live" content="true">')
443
+
444
+ // Auto-inject the client runtime (delegated events + patch application).
445
+ // Apps never hand-include it — a forgotten <script> is a silently dead
446
+ // page (events fire nothing, no patches apply). Idempotent: skipped if the
447
+ // document already references it, so a layout that still carries the tag
448
+ // (or two passes sharing a doc) never loads it twice.
449
+ const bodyHtml: string[] = []
450
+ if (!html.includes('/static/client.js')) {
451
+ bodyHtml.push('<script src="/static/client.js"></script>')
452
+ }
453
+
454
+ html = injectIntoDocument(html, {
455
+ head: headHtml.join(''),
456
+ bodyEnd: bodyHtml.join(''),
457
+ })
458
+ applyRenderedEffects(c, result.response)
459
+ return c.html(html)
460
+ } finally {
461
+ runtime.dispose()
462
+ }
463
+ }
464
+ }
465
+
466
+ function contentTypeFor(path: string): string {
467
+ const ext = extname(path).toLowerCase()
468
+ if (ext === '.css') return 'text/css; charset=utf-8'
469
+ if (ext === '.js' || ext === '.mjs') return 'application/javascript; charset=utf-8'
470
+ if (ext === '.json') return 'application/json; charset=utf-8'
471
+ if (ext === '.svg') return 'image/svg+xml'
472
+ if (ext === '.png') return 'image/png'
473
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
474
+ return 'application/octet-stream'
475
+ }
@@ -0,0 +1,101 @@
1
+ export type {
2
+ ElementTarget,
3
+ Patch,
4
+ PatchTarget,
5
+ SlotTarget,
6
+ WireEnvelope,
7
+ } from '../wire/index.ts'
8
+ export { dispatchToApp } from './app-dispatch.ts'
9
+ export type { AppStore } from './app-store.ts'
10
+ export { InMemoryAppStore } from './app-store.ts'
11
+ export type { CachedStoreOptions } from './cached-store.ts'
12
+ export { CachedStore } from './cached-store.ts'
13
+ export type { CreateAppConfig, StatorApp } from './create-app.ts'
14
+ export { createApp } from './create-app.ts'
15
+ export type {
16
+ ActionHelpers,
17
+ DefineMachineConfig,
18
+ Lifecycle,
19
+ MachineDef,
20
+ SelectorMap,
21
+ SubscribeEntry,
22
+ SubscribeEvent,
23
+ } from './define-machine.ts'
24
+ export { defineMachine, isStatorMachine } from './define-machine.ts'
25
+ export type { DiscoveryResult } from './discovery.ts'
26
+ export { discoverMachines } from './discovery.ts'
27
+ export type { DispatchContext } from './dispatch-context.ts'
28
+ export {
29
+ getDispatchContext,
30
+ recordTouch,
31
+ withDispatchContext,
32
+ } from './dispatch-context.ts'
33
+ export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
34
+ export type { HttpConfig } from './http.ts'
35
+ export { buildHonoApp } from './http.ts'
36
+ export type { InstanceHandle } from './instance-proxy.ts'
37
+ export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
38
+ export { logger, scopedLogger } from './logger.ts'
39
+ export { MachineStore } from './machine-store.ts'
40
+ export { recompute } from './recompute.ts'
41
+ export { RedisAppStore, RedisStore } from './redis-store.ts'
42
+ export type { RenderResult } from './render.ts'
43
+ export { renderRoute } from './render.ts'
44
+ export type {
45
+ Binding,
46
+ BindingKind,
47
+ ElementId,
48
+ EventDescriptor,
49
+ MachineName,
50
+ RenderState,
51
+ SessionId,
52
+ SlotId,
53
+ } from './render-context.ts'
54
+ export {
55
+ allocElementId,
56
+ allocSlotId,
57
+ createEventDescriptor,
58
+ createRenderState,
59
+ getCurrentRenderState,
60
+ isEventDescriptor,
61
+ popListScope,
62
+ pushListScope,
63
+ registerBinding,
64
+ requireCurrentRenderState,
65
+ runInRender,
66
+ unregisterBindingsForScope,
67
+ } from './render-context.ts'
68
+ export type { DiscoveredRoute } from './route-discovery.ts'
69
+ export { discoverRoutes } from './route-discovery.ts'
70
+ export type {
71
+ ApiRouteDefinition,
72
+ ApiRouteEnvelope,
73
+ ApiRouteHelpers,
74
+ ApiRouteResult,
75
+ DefineApiRouteConfig,
76
+ DefineRouteConfig,
77
+ Directive,
78
+ RouteContext,
79
+ RouteCookieOptions,
80
+ RouteDefinition,
81
+ RouteRenderContext,
82
+ RouteRequest,
83
+ RouteResponseContext,
84
+ } from './routing.ts'
85
+ export {
86
+ defineApiRoute,
87
+ defineRoute,
88
+ isStatorApiRoute,
89
+ isStatorRoute,
90
+ } from './routing.ts'
91
+ export { getOrCreateSessionId, SESSION_COOKIE } from './session.ts'
92
+ export { SessionRuntime } from './session-runtime.ts'
93
+ export type { Connection } from './sse.ts'
94
+ export {
95
+ activeConnectionCount,
96
+ fanOut,
97
+ registerConnection,
98
+ unregisterConnection,
99
+ } from './sse.ts'
100
+ export type { Store } from './store.ts'
101
+ export { InMemoryStore } from './store.ts'
@@ -0,0 +1,95 @@
1
+ import type { ActionHelpers, AnyActor, AnyMachineDef, MachineDef } from '../engine/index.ts'
2
+ import type { InstanceOf } from '../template/types.ts'
3
+ import {
4
+ createEventDescriptor,
5
+ type EventDescriptor,
6
+ getCurrentRenderState,
7
+ } from './render-context.ts'
8
+
9
+ export interface InstanceHandle<TDef extends MachineDef = MachineDef> {
10
+ readonly def: TDef
11
+ readonly actor: AnyActor
12
+ readonly proxy: InstanceOf<TDef>
13
+ }
14
+
15
+ const proxyToDef = new WeakMap<object, AnyMachineDef>()
16
+
17
+ export function defForProxy(proxy: object): AnyMachineDef | undefined {
18
+ return proxyToDef.get(proxy)
19
+ }
20
+
21
+ export function createInstanceProxy<TDef extends MachineDef>(
22
+ def: TDef,
23
+ actor: AnyActor,
24
+ /** Resolve a sibling machine's proxy for reads-aware selectors. Lazy —
25
+ * called at selector-access time, so graph load order doesn't matter.
26
+ * Omitted (tests, contexts without a graph): reads access throws. */
27
+ resolveRead?: (name: string) => unknown,
28
+ ): InstanceHandle<TDef> {
29
+ const proxy = Object.create(null) as Record<string, unknown>
30
+
31
+ // Selectors receive the same helpers shape actions/guards get. The reads
32
+ // views are the sibling proxies themselves, so a read machine's own
33
+ // reads-aware selectors recurse naturally. (A reads-cycle between
34
+ // selectors is an author error and shows up as a stack overflow.)
35
+ const helpers: ActionHelpers = {
36
+ reads: new Proxy({} as Record<string, unknown>, {
37
+ get(_t, prop: string) {
38
+ const sibling = resolveRead?.(prop)
39
+ if (sibling === undefined) {
40
+ throw new Error(
41
+ `stator: selector on "${def.name}" accessed reads.${prop}, but "${prop}" ` +
42
+ `is not resolvable here — declare it in reads: and evaluate selectors ` +
43
+ `through a runtime/store-backed instance.`,
44
+ )
45
+ }
46
+ return sibling
47
+ },
48
+ }),
49
+ }
50
+
51
+ for (const [name, selector] of Object.entries(def.selectors)) {
52
+ Object.defineProperty(proxy, name, {
53
+ enumerable: true,
54
+ configurable: false,
55
+ get: () => selector(actor.getSnapshot().context, helpers),
56
+ })
57
+ }
58
+
59
+ Object.defineProperty(proxy, 'send', {
60
+ enumerable: false,
61
+ configurable: false,
62
+ value: (event: { type: string; [k: string]: unknown }): EventDescriptor | undefined => {
63
+ if (getCurrentRenderState()) {
64
+ return createEventDescriptor(def.name, event)
65
+ }
66
+ actor.send(event as never)
67
+ return undefined
68
+ },
69
+ })
70
+
71
+ Object.defineProperty(proxy, 'state', {
72
+ enumerable: true,
73
+ configurable: false,
74
+ // Engine state value is a path array (`['idle']`); expose the leaf name as
75
+ // a string so templates/selectors see the current state key as before.
76
+ get: () => {
77
+ const v = actor.getSnapshot().value
78
+ return v[v.length - 1]
79
+ },
80
+ })
81
+
82
+ Object.defineProperty(proxy, 'snapshot', {
83
+ enumerable: true,
84
+ configurable: false,
85
+ get: () => actor.getSnapshot(),
86
+ })
87
+
88
+ proxyToDef.set(proxy, def)
89
+
90
+ return {
91
+ def,
92
+ actor,
93
+ proxy: proxy as unknown as InstanceOf<TDef>,
94
+ }
95
+ }