@statorjs/stator 2.6.0 → 2.7.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/package.json +2 -2
- package/src/client/inspector.css +198 -0
- package/src/client/inspector.ts +330 -7
- package/src/engine/describe.ts +129 -0
- package/src/engine/index.ts +6 -0
- package/src/server/dev-native.ts +1 -0
- package/src/server/dev-vite.ts +1 -0
- package/src/server/http.ts +22 -0
- package/src/server/index.ts +2 -0
- package/src/server/inspect.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"pino-pretty": "^13.1.3",
|
|
96
96
|
"vite": "^6.0.0",
|
|
97
97
|
"vitest": "^2.1.0",
|
|
98
|
-
"@statorjs/stator": "2.
|
|
98
|
+
"@statorjs/stator": "2.7.0"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
package/src/client/inspector.css
CHANGED
|
@@ -189,6 +189,204 @@
|
|
|
189
189
|
border-top: 1px dashed #2a2a2a;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
.stator-inspector-tabs {
|
|
193
|
+
display: inline-flex;
|
|
194
|
+
gap: 0.25rem;
|
|
195
|
+
}
|
|
196
|
+
.stator-inspector-header button.stator-inspector-tab {
|
|
197
|
+
border-color: transparent;
|
|
198
|
+
}
|
|
199
|
+
.stator-inspector-header button.stator-inspector-tab--active {
|
|
200
|
+
border-color: #333;
|
|
201
|
+
background: #232323;
|
|
202
|
+
color: #f0f0f0;
|
|
203
|
+
}
|
|
204
|
+
/* Resize grip: the drawer's draggable top edge. */
|
|
205
|
+
.stator-inspector-grip {
|
|
206
|
+
flex-shrink: 0;
|
|
207
|
+
height: 5px;
|
|
208
|
+
cursor: ns-resize;
|
|
209
|
+
touch-action: none;
|
|
210
|
+
background: transparent;
|
|
211
|
+
}
|
|
212
|
+
.stator-inspector-grip:hover,
|
|
213
|
+
.stator-inspector-grip:active {
|
|
214
|
+
background: #333;
|
|
215
|
+
}
|
|
216
|
+
/* Machines tab: master-detail — nav of machines (live state inline), detail pane. */
|
|
217
|
+
.stator-inspector-machines {
|
|
218
|
+
flex: 1;
|
|
219
|
+
min-height: 0;
|
|
220
|
+
display: flex;
|
|
221
|
+
align-items: stretch;
|
|
222
|
+
}
|
|
223
|
+
.stator-inspector-machines[hidden],
|
|
224
|
+
.stator-inspector-body[hidden],
|
|
225
|
+
.stator-inspector-legend[hidden],
|
|
226
|
+
.stator-inspector-header button[hidden] {
|
|
227
|
+
display: none;
|
|
228
|
+
}
|
|
229
|
+
.stator-inspector-mnav {
|
|
230
|
+
flex: 0 0 230px;
|
|
231
|
+
min-width: 0;
|
|
232
|
+
overflow-y: auto;
|
|
233
|
+
border-right: 1px solid #1f1f1f;
|
|
234
|
+
padding-bottom: 0.4rem;
|
|
235
|
+
}
|
|
236
|
+
.stator-inspector-mnav-head {
|
|
237
|
+
padding: 0.45rem 0.9rem 0.2rem;
|
|
238
|
+
color: #6a6a6a;
|
|
239
|
+
text-transform: uppercase;
|
|
240
|
+
letter-spacing: 0.06em;
|
|
241
|
+
font-size: 0.68rem;
|
|
242
|
+
}
|
|
243
|
+
.stator-inspector-mnav-row {
|
|
244
|
+
display: flex;
|
|
245
|
+
justify-content: space-between;
|
|
246
|
+
align-items: baseline;
|
|
247
|
+
gap: 0.5rem;
|
|
248
|
+
width: 100%;
|
|
249
|
+
text-align: left;
|
|
250
|
+
background: transparent;
|
|
251
|
+
border: none;
|
|
252
|
+
color: #d0d0d0;
|
|
253
|
+
padding: 0.3rem 0.9rem;
|
|
254
|
+
font: inherit;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
}
|
|
257
|
+
.stator-inspector-mnav-row:hover {
|
|
258
|
+
background: #1c1c1c;
|
|
259
|
+
}
|
|
260
|
+
.stator-inspector-mnav-row--active {
|
|
261
|
+
background: #232323;
|
|
262
|
+
color: #f5f5f5;
|
|
263
|
+
}
|
|
264
|
+
.stator-inspector-mnav-name {
|
|
265
|
+
color: #c586c0;
|
|
266
|
+
font-weight: 500;
|
|
267
|
+
overflow: hidden;
|
|
268
|
+
text-overflow: ellipsis;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
}
|
|
271
|
+
.stator-inspector-mnav-state {
|
|
272
|
+
color: #4ec9b0;
|
|
273
|
+
white-space: nowrap;
|
|
274
|
+
}
|
|
275
|
+
.stator-inspector-mdetail {
|
|
276
|
+
flex: 1;
|
|
277
|
+
min-width: 0;
|
|
278
|
+
overflow-y: auto;
|
|
279
|
+
padding: 0.5rem 0.9rem 0.75rem;
|
|
280
|
+
}
|
|
281
|
+
.stator-inspector-detail-head {
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: baseline;
|
|
284
|
+
gap: 0.75rem;
|
|
285
|
+
padding-bottom: 0.4rem;
|
|
286
|
+
}
|
|
287
|
+
.stator-inspector-badge {
|
|
288
|
+
color: #6a6a6a;
|
|
289
|
+
border: 1px solid #2a2a2a;
|
|
290
|
+
border-radius: 4px;
|
|
291
|
+
padding: 0 0.35rem;
|
|
292
|
+
font-size: 0.7rem;
|
|
293
|
+
}
|
|
294
|
+
.stator-inspector-state {
|
|
295
|
+
color: #4ec9b0;
|
|
296
|
+
font-weight: 500;
|
|
297
|
+
}
|
|
298
|
+
.stator-inspector-dim {
|
|
299
|
+
color: #6a6a6a;
|
|
300
|
+
font-weight: 400;
|
|
301
|
+
font-style: italic;
|
|
302
|
+
}
|
|
303
|
+
.stator-inspector-stale {
|
|
304
|
+
color: #d16969;
|
|
305
|
+
border: 1px solid rgba(209, 105, 105, 0.4);
|
|
306
|
+
border-radius: 4px;
|
|
307
|
+
padding: 0 0.35rem;
|
|
308
|
+
font-size: 0.7rem;
|
|
309
|
+
margin-left: 0.3rem;
|
|
310
|
+
}
|
|
311
|
+
.stator-inspector-accepts {
|
|
312
|
+
color: #888;
|
|
313
|
+
display: flex;
|
|
314
|
+
flex-wrap: wrap;
|
|
315
|
+
gap: 0.3rem;
|
|
316
|
+
align-items: baseline;
|
|
317
|
+
padding-bottom: 0.45rem;
|
|
318
|
+
}
|
|
319
|
+
.stator-inspector-chip {
|
|
320
|
+
color: #dcdcaa;
|
|
321
|
+
border: 1px solid #2f2f2f;
|
|
322
|
+
border-radius: 4px;
|
|
323
|
+
padding: 0 0.3rem;
|
|
324
|
+
font-size: 0.72rem;
|
|
325
|
+
white-space: nowrap;
|
|
326
|
+
}
|
|
327
|
+
.stator-inspector-chip--server {
|
|
328
|
+
color: #6a6a6a;
|
|
329
|
+
border-style: dashed;
|
|
330
|
+
}
|
|
331
|
+
/* Cross-link chips: machine selectors (buttons) and route links (anchors). */
|
|
332
|
+
button.stator-inspector-chip {
|
|
333
|
+
font: inherit;
|
|
334
|
+
font-size: 0.72rem;
|
|
335
|
+
background: transparent;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
}
|
|
338
|
+
a.stator-inspector-chip {
|
|
339
|
+
text-decoration: none;
|
|
340
|
+
}
|
|
341
|
+
.stator-inspector-chip--link {
|
|
342
|
+
color: #c586c0;
|
|
343
|
+
}
|
|
344
|
+
.stator-inspector-chip--link:hover {
|
|
345
|
+
background: #1c1c1c;
|
|
346
|
+
text-decoration: underline;
|
|
347
|
+
}
|
|
348
|
+
.stator-inspector-chip-guard {
|
|
349
|
+
color: #9cdcfe;
|
|
350
|
+
margin-left: 0.15rem;
|
|
351
|
+
}
|
|
352
|
+
.stator-inspector-context {
|
|
353
|
+
margin: 0;
|
|
354
|
+
padding: 0.4rem 0.6rem;
|
|
355
|
+
background: #0e0e0e;
|
|
356
|
+
color: #cfcfcf;
|
|
357
|
+
font-size: 0.74rem;
|
|
358
|
+
white-space: pre-wrap;
|
|
359
|
+
word-break: break-word;
|
|
360
|
+
border: 1px solid #1f1f1f;
|
|
361
|
+
border-radius: 4px;
|
|
362
|
+
}
|
|
363
|
+
.stator-inspector-context--initial {
|
|
364
|
+
color: #7a7a7a;
|
|
365
|
+
}
|
|
366
|
+
.stator-inspector-route {
|
|
367
|
+
display: grid;
|
|
368
|
+
grid-template-columns: minmax(120px, max-content) 1fr;
|
|
369
|
+
gap: 0.75rem;
|
|
370
|
+
padding: 0.25rem 0;
|
|
371
|
+
border-bottom: 1px solid #1a1a1a;
|
|
372
|
+
}
|
|
373
|
+
.stator-inspector-route:last-child {
|
|
374
|
+
border-bottom: none;
|
|
375
|
+
}
|
|
376
|
+
.stator-inspector-route-path {
|
|
377
|
+
color: #c586c0;
|
|
378
|
+
}
|
|
379
|
+
a.stator-inspector-route-path {
|
|
380
|
+
text-decoration: none;
|
|
381
|
+
}
|
|
382
|
+
a.stator-inspector-route-path:hover {
|
|
383
|
+
text-decoration: underline;
|
|
384
|
+
}
|
|
385
|
+
.stator-inspector-route-method {
|
|
386
|
+
color: #888;
|
|
387
|
+
font-size: 0.72rem;
|
|
388
|
+
}
|
|
389
|
+
|
|
192
390
|
@media (max-height: 600px) {
|
|
193
391
|
.stator-inspector-drawer {
|
|
194
392
|
max-height: 50vh;
|
package/src/client/inspector.ts
CHANGED
|
@@ -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).
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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,
|
package/src/server/dev-native.ts
CHANGED
|
@@ -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,
|
package/src/server/dev-vite.ts
CHANGED
|
@@ -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,
|
package/src/server/http.ts
CHANGED
|
@@ -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) => {
|
package/src/server/index.ts
CHANGED
|
@@ -52,6 +52,8 @@ export {
|
|
|
52
52
|
export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
|
|
53
53
|
export type { HttpConfig } from './http.ts'
|
|
54
54
|
export { buildHonoApp } from './http.ts'
|
|
55
|
+
export type { InspectPayload, InspectRoute, InspectRouteMethod } from './inspect.ts'
|
|
56
|
+
export { buildInspectPayload } from './inspect.ts'
|
|
55
57
|
export type { InstanceHandle } from './instance-proxy.ts'
|
|
56
58
|
export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
|
|
57
59
|
export { logger, scopedLogger, setLogLevel } from './logger.ts'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev state inspection — the data feed for the inspector's Machines view.
|
|
3
|
+
*
|
|
4
|
+
* Serves the caller's OWN session: the payload is scoped to the session the
|
|
5
|
+
* request's cookie addresses (app-lifecycle machines are process-global and
|
|
6
|
+
* labeled as such by their lifecycle). Registered only by the dev servers —
|
|
7
|
+
* machine context is working state and may hold anything, so this endpoint
|
|
8
|
+
* never exists in production.
|
|
9
|
+
*
|
|
10
|
+
* Read-only by construction: no actors are instantiated, no session lock is
|
|
11
|
+
* taken, nothing is dispatched. Session snapshots come straight from the
|
|
12
|
+
* persistence store — a machine never touched by this session reads as null
|
|
13
|
+
* (it would start from the def's initial context on first dispatch).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describeMachine, type MachineDescription } from '../engine/describe.ts'
|
|
17
|
+
import type { Snapshot } from '../engine/types.ts'
|
|
18
|
+
import { codeHashOf } from './machine-hash.ts'
|
|
19
|
+
import type { MachineStore } from './machine-store.ts'
|
|
20
|
+
import { type DiscoveredRoute, HTTP_METHODS } from './route-discovery.ts'
|
|
21
|
+
import { isStatorQueryRoute, isStatorRoute } from './routing.ts'
|
|
22
|
+
|
|
23
|
+
export interface InspectRouteMethod {
|
|
24
|
+
/** `page` = defineRoute, `data` = defineApiRoute({method:'GET'}), `api` = the rest. */
|
|
25
|
+
kind: 'page' | 'data' | 'api'
|
|
26
|
+
reads: string[]
|
|
27
|
+
/** Page routes only: whether the route opened an SSE live channel. */
|
|
28
|
+
live?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InspectRoute {
|
|
32
|
+
urlPath: string
|
|
33
|
+
methods: Record<string, InspectRouteMethod>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface InspectPayload {
|
|
37
|
+
buildId?: string
|
|
38
|
+
/** All registered machines, described + their running code hash. */
|
|
39
|
+
machines: Array<MachineDescription & { hash?: string }>
|
|
40
|
+
/** The caller's session's persisted snapshots (null = never touched). */
|
|
41
|
+
session: Record<string, Snapshot<unknown> | null>
|
|
42
|
+
/** App-lifecycle live snapshots — process-global, same for every caller. */
|
|
43
|
+
app: Record<string, Snapshot<unknown>>
|
|
44
|
+
routes: InspectRoute[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function buildInspectPayload(opts: {
|
|
48
|
+
store: MachineStore
|
|
49
|
+
routes: DiscoveredRoute[]
|
|
50
|
+
sessionId?: string
|
|
51
|
+
buildId?: string
|
|
52
|
+
}): Promise<InspectPayload> {
|
|
53
|
+
const defs = [...opts.store.allDefs()].sort((a, b) => a.name.localeCompare(b.name))
|
|
54
|
+
|
|
55
|
+
const machines = defs.map((def) => {
|
|
56
|
+
const hash = codeHashOf(def)
|
|
57
|
+
return { ...describeMachine(def), ...(hash !== undefined ? { hash } : {}) }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const session: Record<string, Snapshot<unknown> | null> = {}
|
|
61
|
+
if (opts.sessionId !== undefined) {
|
|
62
|
+
for (const def of defs) {
|
|
63
|
+
if (def.lifecycle !== 'session') continue
|
|
64
|
+
session[def.name] =
|
|
65
|
+
((await opts.store.persistence.get(
|
|
66
|
+
opts.sessionId,
|
|
67
|
+
def.name,
|
|
68
|
+
)) as Snapshot<unknown> | null) ?? null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const app: Record<string, Snapshot<unknown>> = {}
|
|
73
|
+
for (const def of defs) {
|
|
74
|
+
if (def.lifecycle !== 'app') continue
|
|
75
|
+
const handle = opts.store.appInstance(def.name)
|
|
76
|
+
if (handle) app[def.name] = handle.actor.getSnapshot()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const routes = opts.routes.map((route): InspectRoute => {
|
|
80
|
+
const methods: Record<string, InspectRouteMethod> = {}
|
|
81
|
+
for (const method of HTTP_METHODS) {
|
|
82
|
+
const def = route[method]
|
|
83
|
+
if (!def) continue
|
|
84
|
+
const reads = def.reads.map((d) => d.name)
|
|
85
|
+
if (isStatorRoute(def)) {
|
|
86
|
+
methods[method] = { kind: 'page', reads, live: def.live }
|
|
87
|
+
} else {
|
|
88
|
+
methods[method] = { kind: isStatorQueryRoute(def) ? 'data' : 'api', reads }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { urlPath: route.urlPath, methods }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...(opts.buildId !== undefined ? { buildId: opts.buildId } : {}),
|
|
96
|
+
machines,
|
|
97
|
+
session,
|
|
98
|
+
app,
|
|
99
|
+
routes,
|
|
100
|
+
}
|
|
101
|
+
}
|