@statorjs/stator 2.5.1 → 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/README.md +1 -1
- package/package.json +5 -5
- package/src/build/build.ts +93 -115
- package/src/build/head.ts +11 -4
- package/src/build/index.ts +9 -0
- package/src/build/islands.ts +228 -0
- package/src/build/source-id.ts +15 -0
- package/src/cli/commands/build.ts +11 -1
- package/src/cli/commands/dev.ts +7 -3
- package/src/cli/commands/start.ts +2 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/stator.js +9 -0
- package/src/client/inspector.css +198 -0
- package/src/client/inspector.ts +330 -7
- package/src/compiler/diagnostics.ts +10 -0
- package/src/compiler/index.ts +2 -1
- package/src/compiler/regions.ts +30 -0
- package/src/engine/actor.ts +10 -3
- package/src/engine/describe.ts +129 -0
- package/src/engine/index.ts +7 -0
- package/src/engine/types.ts +24 -1
- package/src/server/create-app.ts +7 -1
- package/src/server/dev-loader.mjs +124 -0
- package/src/server/dev-native.ts +562 -0
- package/src/server/dev-vite.ts +362 -0
- package/src/server/dev.ts +44 -318
- package/src/server/discovery.ts +45 -1
- package/src/server/effects.ts +10 -1
- package/src/server/http.ts +29 -1
- package/src/server/index.ts +3 -0
- package/src/server/inspect.ts +101 -0
- package/src/server/machine-hash.ts +195 -0
- package/src/server/machine-store.ts +9 -2
- package/src/server/session-runtime.ts +9 -4
- package/src/server/snapshot-policy.ts +110 -0
- package/src/vite/index.ts +10 -4
- package/src/vite/plugin.ts +12 -32
- package/src/vite/stub.ts +4 -1
package/src/cli/commands/dev.ts
CHANGED
|
@@ -3,9 +3,13 @@ import { createDevApp } from '../../server/dev.ts'
|
|
|
3
3
|
import { loadConfig, resolvePort } from '../config.ts'
|
|
4
4
|
import type { CliContext } from '../run.ts'
|
|
5
5
|
|
|
6
|
-
/** Run the dev server
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
/** Run the dev server. Live reload, inspector, conventions for machines/routes/
|
|
7
|
+
* static; `stator.config.ts` for the store/session/port an app previously
|
|
8
|
+
* hand-wrote a `server.ts` for.
|
|
9
|
+
*
|
|
10
|
+
* The app runs natively from its source tree, exactly as `stator start` runs a
|
|
11
|
+
* build. `STATOR_VITE_DEV=1` keeps the previous Vite-embedded dev server for
|
|
12
|
+
* one minor as an escape hatch. */
|
|
9
13
|
export async function run(ctx: CliContext): Promise<void> {
|
|
10
14
|
const config = await loadConfig(ctx.root)
|
|
11
15
|
const app = await createDevApp({
|
|
@@ -16,7 +16,7 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const config = await loadConfig(ctx.root)
|
|
19
|
-
const { headExtras, buildId } = await loadProductionHead(dist)
|
|
19
|
+
const { headExtras, buildId, machines } = await loadProductionHead(dist)
|
|
20
20
|
const app = await createApp({
|
|
21
21
|
machinesDir: resolve(dist, 'machines'),
|
|
22
22
|
routesDir: resolve(dist, 'routes'),
|
|
@@ -31,6 +31,7 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
31
31
|
cors: config.cors,
|
|
32
32
|
headExtras,
|
|
33
33
|
buildId,
|
|
34
|
+
machineHashes: machines,
|
|
34
35
|
middlewareFile: resolve(dist, 'middleware.ts'),
|
|
35
36
|
bootFile: resolve(dist, 'boot.ts'),
|
|
36
37
|
})
|
package/src/cli/run.ts
CHANGED
|
@@ -65,7 +65,7 @@ const ctx: CliContext = {
|
|
|
65
65
|
const { loadDotenv } = await import('../server/env.ts')
|
|
66
66
|
loadDotenv(ctx.root)
|
|
67
67
|
|
|
68
|
-
// Dynamic import per command so `check` never loads the dev server's
|
|
68
|
+
// Dynamic import per command so `check` never loads the dev server's watcher, etc.
|
|
69
69
|
const { run } = (await import(`./commands/${command}.ts`)) as {
|
|
70
70
|
run: (ctx: CliContext) => Promise<void>
|
|
71
71
|
}
|
package/src/cli/stator.js
CHANGED
|
@@ -23,5 +23,14 @@ if (typeof register !== 'function') {
|
|
|
23
23
|
|
|
24
24
|
register('./loader.js', import.meta.url)
|
|
25
25
|
|
|
26
|
+
// Apply sourcemaps to stack traces. Every transform in the owned pipeline (the
|
|
27
|
+
// TS loader above, the dev loader's `.stator` compile) already EMITS an inline
|
|
28
|
+
// map, but V8 ignores maps when capturing stacks unless the process opts in —
|
|
29
|
+
// this is the runtime equivalent of `--enable-source-maps`, which a bin script
|
|
30
|
+
// can't put on its own invocation. Covers dev and `stator start` alike.
|
|
31
|
+
// (`.stator` traces resolve to the compiled server module — right file,
|
|
32
|
+
// generated lines — until the compiler emits its own map to compose.)
|
|
33
|
+
process.setSourceMapsEnabled?.(true)
|
|
34
|
+
|
|
26
35
|
// The real CLI is TypeScript, transformed on import by the loader just registered.
|
|
27
36
|
await import('./run.ts')
|
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) => {
|