@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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- 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
|
+
}
|