@statorjs/stator 2.6.0 → 2.8.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.
@@ -1,13 +1,25 @@
1
1
  /**
2
2
  * Stator dev inspector — a framework-owned, dev-only observability toolbar.
3
3
  *
4
- * Auto-injected by `createDevApp` (never in production). It subscribes to the
5
- * public `stator:*` CustomEvent contract the client runtime dispatches on
6
- * `window` and renders a bottom drawer with one row per outgoing event (↑) and
7
- * per incoming patch batch (↓), plus a brief flash on each patched element.
4
+ * Auto-injected by `createDevApp` (never in production). Two tabs in a
5
+ * height-adjustable drawer (drag the top edge; the size persists):
8
6
  *
9
- * It depends on nothing but that event contract — the same surface any external
10
- * devtool would use. Self-contained: it injects its own styles.
7
+ * - **Wire** subscribes to the public `stator:*` CustomEvent contract the
8
+ * client runtime dispatches on `window` and renders one row per outgoing
9
+ * event (↑) and per incoming patch batch (↓), plus a brief flash on each
10
+ * patched element.
11
+ * - **Machines** fetches `/@stator/inspect` — the dev server's cookie-scoped
12
+ * state endpoint — into a master-detail view: a nav listing every machine
13
+ * with its live state (your session's machines first, then app-lifecycle
14
+ * machines, which are process-global and labeled as such — their context is
15
+ * whatever the app aggregates there), and a detail pane with the selected
16
+ * machine's context and accepted events. The endpoint exists only on the
17
+ * dev server, so on a production page that opted into the toolbar the tab
18
+ * degrades to a notice.
19
+ *
20
+ * The wire tab depends on nothing but the public event contract — the same
21
+ * surface any external devtool would use. Self-contained: it injects its own
22
+ * styles.
11
23
  *
12
24
  * The widget is a `<stator-inspector>` custom element with a shadow root
13
25
  * (the Astro-dev-toolbar / vite-error-overlay pattern): the inspected app's
@@ -21,11 +33,60 @@ import inspectorCss from './inspector.css'
21
33
  import flashCss from './inspector-flash.css'
22
34
 
23
35
  const STORAGE_KEY = 'stator:inspector:open'
36
+ const TAB_KEY = 'stator:inspector:tab'
37
+ const HEIGHT_KEY = 'stator:inspector:height'
38
+ const SELECTED_KEY = 'stator:inspector:machine'
39
+ const ROUTES_SELECTION = '@routes' // storage sentinel; machine names are identifiers
24
40
  const MAX_ENTRIES = 40
25
41
  const FLASH_MS = 1200
42
+ const REFRESH_DEBOUNCE_MS = 250
43
+ const MIN_DRAWER_PX = 140
26
44
 
27
45
  const w = window as unknown as { __statorInspectorMounted?: boolean }
28
46
 
47
+ /** Structural mirror of the server's InspectPayload — the toolbar depends on
48
+ * the wire shape, not the server module. */
49
+ interface TransitionDesc {
50
+ to?: string
51
+ guarded: boolean
52
+ action: boolean
53
+ emits: string[]
54
+ effect: boolean
55
+ }
56
+ interface StateDesc {
57
+ on: Record<string, TransitionDesc[]>
58
+ entry: boolean
59
+ after: Array<{ delay: number | 'dynamic'; send: string }>
60
+ }
61
+ interface MachineDesc {
62
+ name: string
63
+ lifecycle: 'app' | 'session'
64
+ persist: boolean
65
+ initial: string
66
+ states: Record<string, StateDesc>
67
+ on: Record<string, TransitionDesc[]>
68
+ events: string[]
69
+ serverOnly: string[]
70
+ selectors: string[]
71
+ reads: string[]
72
+ context: unknown
73
+ hash?: string
74
+ }
75
+ interface SnapshotDesc {
76
+ value: string[]
77
+ context: unknown
78
+ code?: string
79
+ }
80
+ interface InspectPayloadDesc {
81
+ machines: MachineDesc[]
82
+ session: Record<string, SnapshotDesc | null>
83
+ app: Record<string, SnapshotDesc>
84
+ routes: Array<{
85
+ urlPath: string
86
+ methods: Record<string, { kind: string; reads: string[]; live?: boolean }>
87
+ }>
88
+ }
89
+
29
90
  function escapeHtml(s: unknown): string {
30
91
  if (s == null) return ''
31
92
  return String(s)
@@ -57,6 +118,116 @@ function formatEventParams(event: Record<string, unknown>): string {
57
118
  return keys.map((k) => `${k}=${JSON.stringify(rest[k])}`).join(' ')
58
119
  }
59
120
 
121
+ const snapshotOf = (p: InspectPayloadDesc, m: MachineDesc): SnapshotDesc | null | undefined =>
122
+ m.lifecycle === 'session' ? p.session[m.name] : p.app[m.name]
123
+
124
+ const leafOf = (m: MachineDesc, snap: SnapshotDesc | null | undefined): string =>
125
+ snap ? (snap.value[snap.value.length - 1] ?? m.initial) : m.initial
126
+
127
+ /** Stale = a PERSISTED snapshot stamped by different machine code (absent
128
+ * stamp included — a pre-policy snapshot also resets on hydration). Session
129
+ * machines only: an app machine's snapshot is the live actor's, which never
130
+ * carries the persist-time `code` stamp and can't be stale — it is, by
131
+ * construction, running the current code. */
132
+ const isStale = (m: MachineDesc, snap: SnapshotDesc | null | undefined): boolean =>
133
+ m.lifecycle === 'session' && Boolean(snap && m.hash && snap.code !== m.hash)
134
+
135
+ /** Events the machine accepts in `leaf`: the state's own handlers plus the
136
+ * machine-level fallbacks the state doesn't shadow — actor.send's lookup. */
137
+ function acceptedEvents(
138
+ m: MachineDesc,
139
+ leaf: string,
140
+ ): Array<{ type: string; serverOnly: boolean; guarded: boolean }> {
141
+ const stateOn = m.states[leaf]?.on ?? {}
142
+ const merged: Record<string, TransitionDesc[]> = { ...m.on, ...stateOn }
143
+ return Object.keys(merged)
144
+ .sort()
145
+ .map((type) => ({
146
+ type,
147
+ serverOnly: m.serverOnly.includes(type),
148
+ guarded: (merged[type] ?? []).every((t) => t.guarded),
149
+ }))
150
+ }
151
+
152
+ const STALE_TITLE =
153
+ 'the persisted snapshot was written by different machine code — this session resets on its next request'
154
+
155
+ function navRow(m: MachineDesc, p: InspectPayloadDesc, active: boolean): string {
156
+ const snap = snapshotOf(p, m)
157
+ const leaf = leafOf(m, snap)
158
+ return `<button type="button" class="stator-inspector-mnav-row${active ? ' stator-inspector-mnav-row--active' : ''}" data-name="${escapeHtml(m.name)}">
159
+ <span class="stator-inspector-mnav-name">${escapeHtml(m.name)}</span>
160
+ <span class="stator-inspector-mnav-state">${escapeHtml(leaf)}${isStale(m, snap) ? `<span class="stator-inspector-stale" title="${STALE_TITLE}">!</span>` : ''}</span>
161
+ </button>`
162
+ }
163
+
164
+ /** A cross-link chip selecting a machine's detail — wired up after render. */
165
+ const machineChip = (name: string): string =>
166
+ `<button type="button" class="stator-inspector-chip stator-inspector-chip--link" data-machine="${escapeHtml(name)}">${escapeHtml(name)}</button>`
167
+
168
+ const isNavigable = (r: InspectPayloadDesc['routes'][number]): boolean =>
169
+ r.methods.GET !== undefined && !/[:*]/.test(r.urlPath)
170
+
171
+ function machineDetail(
172
+ m: MachineDesc,
173
+ snap: SnapshotDesc | null | undefined,
174
+ payload: InspectPayloadDesc,
175
+ ): string {
176
+ const leaf = leafOf(m, snap)
177
+ const context = snap ? snap.context : m.context
178
+ const chips = acceptedEvents(m, leaf)
179
+ .map(
180
+ (e) =>
181
+ `<span class="stator-inspector-chip${e.serverOnly ? ' stator-inspector-chip--server' : ''}" title="${e.serverOnly ? 'server-only — clients may not dispatch this' : 'dispatchable'}${e.guarded ? '; guarded' : ''}">${escapeHtml(e.type)}${e.guarded ? '<span class="stator-inspector-chip-guard">?</span>' : ''}</span>`,
182
+ )
183
+ .join('')
184
+ // The graph glue: which machines this one reads, and which routes read it.
185
+ // Navigable routes render as real links (go look at the page showing this
186
+ // machine); parameterized ones stay text.
187
+ const reads = m.reads.map(machineChip).join('')
188
+ const readBy = payload.routes
189
+ .filter((r) => Object.values(r.methods).some((meth) => meth.reads.includes(m.name)))
190
+ .map((r) =>
191
+ isNavigable(r)
192
+ ? `<a class="stator-inspector-chip stator-inspector-chip--link" href="${escapeHtml(r.urlPath)}">${escapeHtml(r.urlPath)}</a>`
193
+ : `<span class="stator-inspector-chip">${escapeHtml(r.urlPath)}</span>`,
194
+ )
195
+ .join('')
196
+ return `<div class="stator-inspector-detail-head">
197
+ <span class="stator-inspector-machine">${escapeHtml(m.name)}</span>
198
+ <span class="stator-inspector-badge">${m.lifecycle}${m.persist ? ' · persist' : ''}</span>
199
+ <span class="stator-inspector-state">${escapeHtml(leaf)}${snap ? '' : ' <span class="stator-inspector-dim">(initial — not touched this session)</span>'}</span>
200
+ ${isStale(m, snap) ? `<span class="stator-inspector-stale" title="${STALE_TITLE}">stale</span>` : ''}
201
+ </div>
202
+ <div class="stator-inspector-accepts">accepts ${chips || '<span class="stator-inspector-dim">nothing in this state</span>'}</div>
203
+ ${reads ? `<div class="stator-inspector-accepts">reads ${reads}</div>` : ''}
204
+ <div class="stator-inspector-accepts">read by ${readBy || '<span class="stator-inspector-dim">no routes</span>'}</div>
205
+ <pre class="stator-inspector-context${snap ? '' : ' stator-inspector-context--initial'}">${escapeHtml(JSON.stringify(context, null, 2))}</pre>`
206
+ }
207
+
208
+ function routesDetail(payload: InspectPayloadDesc): string {
209
+ const rows = payload.routes
210
+ .map((r) => {
211
+ const methods = Object.entries(r.methods)
212
+ .map(([method, m]) => {
213
+ const reads = m.reads.length
214
+ ? m.reads.map(machineChip).join('')
215
+ : '<span class="stator-inspector-dim">no reads</span>'
216
+ return `<span class="stator-inspector-route-method">${escapeHtml(method)}·${escapeHtml(m.kind)}${m.live ? '·live' : ''}</span> ${reads}`
217
+ })
218
+ .join('<br>')
219
+ // A static GET route is navigable — render it as a real link (cmd-click
220
+ // works; the toolbar's persisted state survives the navigation). A
221
+ // parameterized path has no concrete URL to offer.
222
+ const path = isNavigable(r)
223
+ ? `<a class="stator-inspector-route-path" href="${escapeHtml(r.urlPath)}">${escapeHtml(r.urlPath)}</a>`
224
+ : `<span class="stator-inspector-route-path">${escapeHtml(r.urlPath)}</span>`
225
+ return `<div class="stator-inspector-route">${path}<span>${methods}</span></div>`
226
+ })
227
+ .join('')
228
+ return `<div class="stator-inspector-detail-head"><span class="stator-inspector-machine">Routes</span><span class="stator-inspector-badge">url → reads</span></div>${rows || '<p class="stator-inspector-empty">No routes discovered.</p>'}`
229
+ }
230
+
60
231
  function mount(): void {
61
232
  // Flash styles decorate APP elements, so they can't live in the widget's
62
233
  // shadow — they're document-level, in `@layer stator-inspector` (see
@@ -80,25 +251,168 @@ function mount(): void {
80
251
  <span aria-hidden="true">{ }</span> Inspect
81
252
  </button>
82
253
  <section class="stator-inspector-drawer" aria-label="Stator inspector" hidden>
254
+ <div class="stator-inspector-grip" title="Drag to resize"></div>
83
255
  <header class="stator-inspector-header">
84
256
  <span class="stator-inspector-title"><span class="stator-inspector-dot" aria-hidden="true"></span> Stator inspector</span>
257
+ <nav class="stator-inspector-tabs" aria-label="Inspector tabs">
258
+ <button class="stator-inspector-tab" type="button" data-tab="wire">Wire</button>
259
+ <button class="stator-inspector-tab" type="button" data-tab="machines">Machines</button>
260
+ </nav>
85
261
  <span class="stator-inspector-legend">
86
262
  <span class="stator-inspector-key stator-inspector-key--up">↑ event</span>
87
263
  <span class="stator-inspector-key stator-inspector-key--down">↓ patches</span>
88
264
  </span>
89
265
  <button class="stator-inspector-clear" type="button" title="Clear log">clear</button>
266
+ <button class="stator-inspector-refresh" type="button" title="Refresh machine state" hidden>refresh</button>
90
267
  <button class="stator-inspector-close" type="button" aria-label="Close inspector">×</button>
91
268
  </header>
92
269
  <div class="stator-inspector-body">
93
270
  <p class="stator-inspector-empty">Interact with the page to see events and patches.</p>
94
271
  </div>
272
+ <div class="stator-inspector-machines" hidden>
273
+ <p class="stator-inspector-empty">Loading machine state…</p>
274
+ </div>
95
275
  </section>`
96
276
  document.body.appendChild(root)
97
277
 
98
278
  const q = (sel: string) => shadow.querySelector(sel) as HTMLElement
99
279
  const drawer = q('.stator-inspector-drawer')
100
280
  const body = q('.stator-inspector-body')
281
+ const machinesEl = q('.stator-inspector-machines')
101
282
  const toggle = q('.stator-inspector-toggle')
283
+ const legend = q('.stator-inspector-legend')
284
+ const clearBtn = q('.stator-inspector-clear')
285
+ const refreshBtn = q('.stator-inspector-refresh')
286
+ const grip = q('.stator-inspector-grip')
287
+
288
+ // ── Drawer resize: drag the top edge; height persists ────────────────────
289
+ const clampHeight = (h: number): number =>
290
+ Math.min(Math.max(h, MIN_DRAWER_PX), Math.round(window.innerHeight * 0.85))
291
+ const applyHeight = (h: number): void => {
292
+ drawer.style.height = `${clampHeight(h)}px`
293
+ drawer.style.maxHeight = 'none'
294
+ }
295
+ try {
296
+ const stored = Number.parseInt(localStorage.getItem(HEIGHT_KEY) ?? '', 10)
297
+ if (Number.isFinite(stored)) applyHeight(stored)
298
+ } catch {}
299
+ grip.addEventListener('pointerdown', (e: PointerEvent) => {
300
+ e.preventDefault()
301
+ grip.setPointerCapture(e.pointerId)
302
+ const move = (ev: PointerEvent) => applyHeight(window.innerHeight - ev.clientY)
303
+ const up = () => {
304
+ grip.removeEventListener('pointermove', move)
305
+ grip.removeEventListener('pointerup', up)
306
+ try {
307
+ localStorage.setItem(HEIGHT_KEY, String(Number.parseInt(drawer.style.height, 10)))
308
+ } catch {}
309
+ }
310
+ grip.addEventListener('pointermove', move)
311
+ grip.addEventListener('pointerup', up)
312
+ })
313
+
314
+ // ── Machines tab: fetch + master-detail render ───────────────────────────
315
+ let refreshTimer: number | undefined
316
+ let lastPayload: InspectPayloadDesc | undefined
317
+ let selected: string = ((): string => {
318
+ try {
319
+ return localStorage.getItem(SELECTED_KEY) ?? ''
320
+ } catch {
321
+ return ''
322
+ }
323
+ })()
324
+
325
+ const renderMachines = (payload: InspectPayloadDesc): void => {
326
+ lastPayload = payload
327
+ // Your session's machines first — they're what you came to see.
328
+ const session = payload.machines.filter((m) => m.lifecycle === 'session')
329
+ const appMachines = payload.machines.filter((m) => m.lifecycle === 'app')
330
+ // A stored selection may name a machine that no longer exists.
331
+ const names = new Set(payload.machines.map((m) => m.name))
332
+ if (selected !== ROUTES_SELECTION && !names.has(selected)) {
333
+ selected = session[0]?.name ?? appMachines[0]?.name ?? ROUTES_SELECTION
334
+ }
335
+ const section = (label: string, machines: MachineDesc[]): string =>
336
+ machines.length
337
+ ? `<div class="stator-inspector-mnav-head">${label}</div>${machines.map((m) => navRow(m, payload, selected === m.name)).join('')}`
338
+ : ''
339
+ const nav = `<nav class="stator-inspector-mnav">
340
+ ${section('your session', session)}
341
+ ${section('app · process-global', appMachines)}
342
+ <div class="stator-inspector-mnav-head">pages</div>
343
+ <button type="button" class="stator-inspector-mnav-row${selected === ROUTES_SELECTION ? ' stator-inspector-mnav-row--active' : ''}" data-routes>
344
+ <span class="stator-inspector-mnav-name">Routes</span>
345
+ <span class="stator-inspector-mnav-state">${payload.routes.length}</span>
346
+ </button>
347
+ </nav>`
348
+ const selectedMachine = payload.machines.find((m) => m.name === selected)
349
+ const detail =
350
+ selected === ROUTES_SELECTION || !selectedMachine
351
+ ? routesDetail(payload)
352
+ : machineDetail(selectedMachine, snapshotOf(payload, selectedMachine), payload)
353
+ // Re-render in place, keeping each pane's scroll position (live refreshes
354
+ // arrive on every patch batch — a jumping pane would be unusable).
355
+ const prevNav = machinesEl.querySelector('.stator-inspector-mnav')
356
+ const prevDetail = machinesEl.querySelector('.stator-inspector-mdetail')
357
+ const navScroll = prevNav?.scrollTop ?? 0
358
+ const detailScroll = prevDetail?.scrollTop ?? 0
359
+ machinesEl.innerHTML = `${nav}<div class="stator-inspector-mdetail">${detail}</div>`
360
+ const navEl = machinesEl.querySelector('.stator-inspector-mnav')
361
+ const detailEl = machinesEl.querySelector('.stator-inspector-mdetail')
362
+ if (navEl) navEl.scrollTop = navScroll
363
+ if (detailEl) detailEl.scrollTop = detailScroll
364
+ const select = (name: string): void => {
365
+ selected = name
366
+ try {
367
+ localStorage.setItem(SELECTED_KEY, selected)
368
+ } catch {}
369
+ if (lastPayload) renderMachines(lastPayload)
370
+ }
371
+ for (const row of Array.from(
372
+ machinesEl.querySelectorAll<HTMLButtonElement>('.stator-inspector-mnav-row'),
373
+ )) {
374
+ row.addEventListener('click', () =>
375
+ select('routes' in row.dataset ? ROUTES_SELECTION : (row.dataset.name ?? '')),
376
+ )
377
+ }
378
+ // Cross-link chips (route reads, a machine's own reads) select that machine.
379
+ for (const chip of Array.from(
380
+ machinesEl.querySelectorAll<HTMLButtonElement>('button[data-machine]'),
381
+ )) {
382
+ chip.addEventListener('click', () => select(chip.dataset.machine ?? ''))
383
+ }
384
+ }
385
+ const refreshMachines = (): void => {
386
+ fetch('/@stator/inspect', { headers: { Accept: 'application/json' } })
387
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
388
+ .then((payload: InspectPayloadDesc) => renderMachines(payload))
389
+ .catch(() => {
390
+ machinesEl.innerHTML =
391
+ '<p class="stator-inspector-empty">State inspection is served by the dev server only — this page has the wire toolbar without it.</p>'
392
+ })
393
+ }
394
+ const scheduleRefresh = (): void => {
395
+ if (drawer.hidden || machinesEl.hidden) return
396
+ window.clearTimeout(refreshTimer)
397
+ refreshTimer = window.setTimeout(refreshMachines, REFRESH_DEBOUNCE_MS)
398
+ }
399
+
400
+ // ── Tabs ─────────────────────────────────────────────────────────────────
401
+ const tabs = Array.from(shadow.querySelectorAll<HTMLButtonElement>('.stator-inspector-tab'))
402
+ const setTab = (tab: string) => {
403
+ try {
404
+ localStorage.setItem(TAB_KEY, tab)
405
+ } catch {}
406
+ const machines = tab === 'machines'
407
+ body.hidden = machines
408
+ machinesEl.hidden = !machines
409
+ legend.hidden = machines
410
+ clearBtn.hidden = machines
411
+ refreshBtn.hidden = !machines
412
+ for (const t of tabs) t.classList.toggle('stator-inspector-tab--active', t.dataset.tab === tab)
413
+ if (machines) refreshMachines()
414
+ }
415
+ for (const t of tabs) t.addEventListener('click', () => setTab(t.dataset.tab ?? 'wire'))
102
416
 
103
417
  const setOpen = (open: boolean) => {
104
418
  try {
@@ -106,16 +420,23 @@ function mount(): void {
106
420
  } catch {}
107
421
  ;(drawer as HTMLElement).hidden = !open
108
422
  ;(toggle as HTMLElement).hidden = open
423
+ if (open && !machinesEl.hidden) refreshMachines()
109
424
  }
110
425
  let initiallyOpen = true
111
426
  try {
112
427
  initiallyOpen = localStorage.getItem(STORAGE_KEY) !== 'false'
113
428
  } catch {}
429
+ let initialTab = 'wire'
430
+ try {
431
+ initialTab = localStorage.getItem(TAB_KEY) === 'machines' ? 'machines' : 'wire'
432
+ } catch {}
433
+ setTab(initialTab)
114
434
  setOpen(initiallyOpen)
115
435
 
116
436
  toggle.addEventListener('click', () => setOpen(true))
117
437
  q('.stator-inspector-close').addEventListener('click', () => setOpen(false))
118
- q('.stator-inspector-clear').addEventListener('click', () => {
438
+ refreshBtn.addEventListener('click', refreshMachines)
439
+ clearBtn.addEventListener('click', () => {
119
440
  body.innerHTML = '<p class="stator-inspector-empty">Log cleared.</p>'
120
441
  })
121
442
 
@@ -185,6 +506,8 @@ function mount(): void {
185
506
  </div>`,
186
507
  (e as CustomEvent).detail,
187
508
  )
509
+ // Patches mean state moved — keep the Machines tab current while visible.
510
+ scheduleRefresh()
188
511
  })
189
512
 
190
513
  window.addEventListener('stator:patch-applied', (e: Event) => {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * describeMachine — a machine def serialized to plain data.
3
+ *
4
+ * The def already IS the statechart: states, per-state `on` maps, guards,
5
+ * effects, emits, selectors and `serverOnly` are all present at runtime (only
6
+ * the event-union *type* is erased). This walks that structure into a
7
+ * JSON-able description — the SHAPE half of introspection. Consumers: the dev
8
+ * inspector's machine view; later the manifest, `stator check`, and viz.
9
+ *
10
+ * Closures stay opaque by design: a guard/action/effect reports presence,
11
+ * never its body. The walk normalizes the three authored transition forms
12
+ * (bare action fn, config object, ordered candidate array) exactly as
13
+ * `actor.send` does.
14
+ */
15
+
16
+ import type {
17
+ AnyMachineDef,
18
+ EventObject,
19
+ Lifecycle,
20
+ OnMap,
21
+ StateNode,
22
+ Transition,
23
+ TransitionConfig,
24
+ } from './types.ts'
25
+
26
+ export interface TransitionDescription {
27
+ /** Target state; absent = self-transition (action only, no state change). */
28
+ to?: string
29
+ guarded: boolean
30
+ action: boolean
31
+ emits: string[]
32
+ effect: boolean
33
+ }
34
+
35
+ export interface StateDescription {
36
+ /** Event type → ordered guarded candidates (first whose guard passes wins). */
37
+ on: Record<string, TransitionDescription[]>
38
+ entry: boolean
39
+ after: Array<{ delay: number | 'dynamic'; send: string }>
40
+ }
41
+
42
+ export interface MachineDescription {
43
+ name: string
44
+ lifecycle: Lifecycle
45
+ persist: boolean
46
+ initial: string
47
+ states: Record<string, StateDescription>
48
+ /** Machine-level fallback handlers (consulted when the state declares none). */
49
+ on: Record<string, TransitionDescription[]>
50
+ /** Every event type the chart handles somewhere (state ∪ machine level), sorted. */
51
+ events: string[]
52
+ serverOnly: string[]
53
+ emits: string[]
54
+ selectors: string[]
55
+ reads: string[]
56
+ subscribes: Array<{ from: string; event: string; dispatch: string }>
57
+ /** The def's initial context (the shape a fresh instance starts from). */
58
+ context: unknown
59
+ }
60
+
61
+ type AnyTransition = Transition<unknown, EventObject, string>
62
+ type AnyOnMap = OnMap<unknown, EventObject, string>
63
+
64
+ function describeTransitions(value: AnyTransition | AnyTransition[]): TransitionDescription[] {
65
+ const candidates = Array.isArray(value) ? value : [value]
66
+ return candidates.map((entry) => {
67
+ // A bare function is sugar for `{ do: fn }` — same normalization as actor.send.
68
+ const config: TransitionConfig<unknown, EventObject, string> =
69
+ typeof entry === 'function' ? { do: entry } : entry
70
+ const emits =
71
+ config.emit === undefined ? [] : Array.isArray(config.emit) ? [...config.emit] : [config.emit]
72
+ return {
73
+ ...(config.to !== undefined ? { to: config.to } : {}),
74
+ guarded: config.when !== undefined,
75
+ action: config.do !== undefined,
76
+ emits,
77
+ effect: config.effect !== undefined,
78
+ }
79
+ })
80
+ }
81
+
82
+ function describeOn(on: AnyOnMap | undefined): Record<string, TransitionDescription[]> {
83
+ const out: Record<string, TransitionDescription[]> = {}
84
+ for (const [type, value] of Object.entries(on ?? {})) {
85
+ if (value !== undefined) out[type] = describeTransitions(value as AnyTransition)
86
+ }
87
+ return out
88
+ }
89
+
90
+ /** Serialize a def to plain JSON-able data. Pure; never invokes app code. */
91
+ export function describeMachine(def: AnyMachineDef): MachineDescription {
92
+ const states: Record<string, StateDescription> = {}
93
+ for (const [name, node] of Object.entries(
94
+ def.states as Record<string, StateNode<unknown, EventObject, string>>,
95
+ )) {
96
+ states[name] = {
97
+ on: describeOn(node.on),
98
+ entry: node.entry !== undefined,
99
+ after: (node.after ?? []).map((a) => ({
100
+ delay: typeof a.delay === 'number' ? a.delay : 'dynamic',
101
+ send: a.send.type,
102
+ })),
103
+ }
104
+ }
105
+ const on = describeOn(def.on)
106
+ const events = new Set<string>(Object.keys(on))
107
+ for (const state of Object.values(states)) {
108
+ for (const type of Object.keys(state.on)) events.add(type)
109
+ }
110
+ return {
111
+ name: def.name,
112
+ lifecycle: def.lifecycle,
113
+ persist: def.persist,
114
+ initial: def.initial,
115
+ states,
116
+ on,
117
+ events: [...events].sort(),
118
+ serverOnly: [...def.serverOnly],
119
+ emits: Object.keys(def.emits),
120
+ selectors: Object.keys(def.selectors),
121
+ reads: def.reads.map((d) => d.name),
122
+ subscribes: def.subscribes.map((s) => ({
123
+ from: s.from.name,
124
+ event: s.event,
125
+ dispatch: typeof s.dispatch === 'string' ? s.dispatch : s.dispatch.type,
126
+ })),
127
+ context: def.context,
128
+ }
129
+ }
@@ -10,6 +10,12 @@ export type { Actor, AnyActor, CreateActorOptions } from './actor.ts'
10
10
  export { createActor } from './actor.ts'
11
11
  export type { DefineMachineConfig } from './define-machine.ts'
12
12
  export { defineMachine } from './define-machine.ts'
13
+ export type {
14
+ MachineDescription,
15
+ StateDescription,
16
+ TransitionDescription,
17
+ } from './describe.ts'
18
+ export { describeMachine } from './describe.ts'
13
19
  export type {
14
20
  Action,
15
21
  ActionHelpers,
@@ -382,6 +382,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
382
382
  staticDir,
383
383
  headExtras,
384
384
  inspector: inspectorOn,
385
+ inspect: true,
385
386
  trustedOrigins: resolved.trustedOrigins,
386
387
  sameSite: resolved.sameSite,
387
388
  origin: resolved.origin,
@@ -203,6 +203,7 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
203
203
  staticDir: config.staticDir,
204
204
  headExtras,
205
205
  inspector: inspectorOn,
206
+ inspect: true,
206
207
  trustedOrigins: resolved.trustedOrigins,
207
208
  sameSite: resolved.sameSite,
208
209
  origin: resolved.origin,
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { readFile, stat } from 'node:fs/promises'
2
2
  import { dirname, extname, resolve, sep } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { build } from 'esbuild'
@@ -9,6 +9,7 @@ import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
9
  import { crossSiteGuard } from './csrf.ts'
10
10
  import { scheduleSessionEffects } from './effects.ts'
11
11
  import { record, replayFor } from './event-dedupe.ts'
12
+ import { buildInspectPayload } from './inspect.ts'
12
13
  import { scopedLogger } from './logger.ts'
13
14
  import type { MachineStore } from './machine-store.ts'
14
15
  import type { MiddlewareDefinition } from './middleware.ts'
@@ -36,6 +37,11 @@ export interface HttpConfig {
36
37
  /** Serve the dev inspector asset at `/@stator/inspector.js`. The dev server
37
38
  * sets this and injects the script tag; production leaves it off. */
38
39
  inspector?: boolean
40
+ /** Serve the state-inspection endpoint at `/@stator/inspect` — the caller's
41
+ * own session's machine snapshots plus the machine/route catalog. Only the
42
+ * dev servers set this; production never does (machine context is working
43
+ * state and may hold anything), even when it opts into the wire toolbar. */
44
+ inspect?: boolean
39
45
  /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
40
46
  ssePingMs?: number
41
47
  /** Origins allowed to make cross-site writes despite the guard (exact or
@@ -246,6 +252,22 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
246
252
  })
247
253
  }
248
254
 
255
+ // Dev state inspection — cookie-scoped to the caller's own session (the
256
+ // request bridge above established it). See server/inspect.ts for the
257
+ // privacy stance; the flag is dev-server-only by construction.
258
+ if (config.inspect) {
259
+ app.get('/@stator/inspect', async (c) => {
260
+ const payload = await buildInspectPayload({
261
+ store: config.store,
262
+ routes: config.routes,
263
+ sessionId: getSessionState(c)?.sid,
264
+ buildId: config.buildId,
265
+ })
266
+ c.header('Cache-Control', 'no-cache')
267
+ return c.json(payload)
268
+ })
269
+ }
270
+
249
271
  if (config.staticDir) {
250
272
  const staticDir = resolve(config.staticDir)
251
273
  app.get('/static/*', async (c) => {
@@ -260,6 +282,38 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
260
282
  return c.text('forbidden', 403)
261
283
  }
262
284
  try {
285
+ const st = await stat(full)
286
+ if (!st.isFile()) return c.text('not found', 404)
287
+
288
+ // Caching contract: `/static/assets/*` is the framework's hashed-output
289
+ // namespace (island bundles, emitted URL assets) — content-addressed by
290
+ // construction, so a year of `immutable` is correct. Everything else
291
+ // under `/static/` is user files with stable names: always revalidate,
292
+ // but revalidation is a bodyless 304 (ETag from size+mtime, plus
293
+ // Last-Modified for validators that only speak dates).
294
+ const etag = `"${st.size.toString(16)}-${Math.trunc(st.mtimeMs).toString(16)}"`
295
+ c.header(
296
+ 'Cache-Control',
297
+ rel.startsWith('assets/')
298
+ ? 'public, max-age=31536000, immutable'
299
+ : 'public, max-age=0, must-revalidate',
300
+ )
301
+ c.header('ETag', etag)
302
+ c.header('Last-Modified', st.mtime.toUTCString())
303
+
304
+ const inm = c.req.header('if-none-match')
305
+ if (inm) {
306
+ if (inm.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) {
307
+ return c.body(null, 304)
308
+ }
309
+ } else {
310
+ const ims = Date.parse(c.req.header('if-modified-since') ?? '')
311
+ // HTTP dates have second precision; compare mtime truncated to match.
312
+ if (!Number.isNaN(ims) && Math.trunc(st.mtimeMs / 1000) * 1000 <= ims) {
313
+ return c.body(null, 304)
314
+ }
315
+ }
316
+
263
317
  const buf = await readFile(full)
264
318
  c.header('Content-Type', contentTypeFor(rel))
265
319
  return c.body(buf)
@@ -662,10 +716,13 @@ export function contentTypeFor(path: string): string {
662
716
  if (ext === '.png') return 'image/png'
663
717
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
664
718
  if (ext === '.webp') return 'image/webp'
719
+ if (ext === '.avif') return 'image/avif'
665
720
  if (ext === '.gif') return 'image/gif'
666
721
  if (ext === '.ico') return 'image/x-icon'
667
722
  if (ext === '.wasm') return 'application/wasm'
668
723
  if (ext === '.woff2') return 'font/woff2'
669
724
  if (ext === '.woff') return 'font/woff'
725
+ if (ext === '.ttf') return 'font/ttf'
726
+ if (ext === '.otf') return 'font/otf'
670
727
  return 'application/octet-stream'
671
728
  }