@statorjs/stator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- package/src/wire/index.ts +67 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AnyMachineDef, EventOf } from '../engine/index.ts'
|
|
2
|
+
import { applyDirectives, applyPatches } from '../wire/apply.ts'
|
|
3
|
+
import type { WireEnvelope } from '../wire/index.ts'
|
|
4
|
+
import { clientId } from './client-id.ts'
|
|
5
|
+
|
|
6
|
+
export interface DispatchResult {
|
|
7
|
+
/** The POST reached the server and returned 200. */
|
|
8
|
+
ok: boolean
|
|
9
|
+
/** The event committed a transition. `ok && !committed` means a guard
|
|
10
|
+
* dropped it — the UI should not pretend something happened. */
|
|
11
|
+
committed: boolean
|
|
12
|
+
/** Patches applied to THIS page (a committed event may patch zero slots
|
|
13
|
+
* here if the touched machines aren't bound on the current route). */
|
|
14
|
+
patchCount: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Commit an event to a SERVER machine over the existing `/__events` wire — the
|
|
19
|
+
* one visible boundary crossing from a client island. Addressed by the imported
|
|
20
|
+
* machine def (not a magic string); the event is typed against that machine's
|
|
21
|
+
* event union. Applies the returned patches and reports what actually
|
|
22
|
+
* happened: transport success, commit, and patch count are three different
|
|
23
|
+
* facts, and buttons that say "done" should be looking at `committed`.
|
|
24
|
+
*
|
|
25
|
+
* This is the client half of [[typed-events-and-machine-mediated-dispatch]].
|
|
26
|
+
* The compiler resolves a server-machine identity import into a stub carrying
|
|
27
|
+
* `{ name }`; this helper reads the name and posts.
|
|
28
|
+
*/
|
|
29
|
+
export async function dispatch<D extends AnyMachineDef>(
|
|
30
|
+
machine: D,
|
|
31
|
+
event: EventOf<D>,
|
|
32
|
+
): Promise<DispatchResult> {
|
|
33
|
+
let res: Response
|
|
34
|
+
try {
|
|
35
|
+
res = await fetch('/__events', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
Accept: 'application/json',
|
|
40
|
+
'X-Stator-Route': `GET ${location.pathname}${location.search}`,
|
|
41
|
+
'X-Stator-Client': clientId,
|
|
42
|
+
},
|
|
43
|
+
credentials: 'same-origin',
|
|
44
|
+
body: JSON.stringify({ machine: machine.name, event }),
|
|
45
|
+
})
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('stator: dispatch network error', err)
|
|
48
|
+
return { ok: false, committed: false, patchCount: 0 }
|
|
49
|
+
}
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
console.error('stator: dispatch failed', res.status)
|
|
52
|
+
return { ok: false, committed: false, patchCount: 0 }
|
|
53
|
+
}
|
|
54
|
+
const data = (await res.json()) as WireEnvelope
|
|
55
|
+
applyPatches(data.patches ?? [])
|
|
56
|
+
applyDirectives(data.directives ?? [])
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
committed: data.committed ?? true,
|
|
60
|
+
patchCount: data.patches?.length ?? 0,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { type CollectedActor, popCollector, pushCollector } from './use.ts'
|
|
2
|
+
|
|
3
|
+
const ACTORS = Symbol('stator.actors')
|
|
4
|
+
const DISPOSERS = Symbol('stator.disposers')
|
|
5
|
+
|
|
6
|
+
/** camelCase author name → kebab DOM attribute (`unitPrice` → `unit-price`). */
|
|
7
|
+
function camelToKebab(name: string): string {
|
|
8
|
+
return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** kebab DOM attribute → camelCase author name (`unit-price` → `unitPrice`). */
|
|
12
|
+
function kebabToCamel(name: string): string {
|
|
13
|
+
return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base class for a client island. The author writes
|
|
18
|
+
* `export class QuantityStepper extends StatorElement { ... }`; the compiler
|
|
19
|
+
* registers it (via `defineElement`) against the kebab-case tag.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle: on connect, start the actors created by `use()` during
|
|
22
|
+
* construction and run the element's `setup()` (where the compiler emits the
|
|
23
|
+
* `bind()` / `on:` wiring); on disconnect, dispose bindings and stop actors.
|
|
24
|
+
* Element lifetime owns actor lifetime — full-page navigation resets client
|
|
25
|
+
* state, which is the intended default for ephemeral UI.
|
|
26
|
+
*/
|
|
27
|
+
export class StatorElement extends HTMLElement {
|
|
28
|
+
/** @internal — actors (+ deferred seed thunks) collected during construction
|
|
29
|
+
* (set by `defineElement`). */
|
|
30
|
+
[ACTORS]: CollectedActor[] = [];
|
|
31
|
+
/** @internal — binding disposers registered during setup. */
|
|
32
|
+
[DISPOSERS]: Array<() => void> = []
|
|
33
|
+
|
|
34
|
+
/** Named handles to `ref:`-marked elements within this island, resolved
|
|
35
|
+
* lazily by `data-ref`. `this.refs.btn` → the nearest `[data-ref="btn"]`. */
|
|
36
|
+
get refs(): Record<string, HTMLElement> {
|
|
37
|
+
const self = this
|
|
38
|
+
return new Proxy(
|
|
39
|
+
{},
|
|
40
|
+
{
|
|
41
|
+
get(_t, name: string) {
|
|
42
|
+
return self.querySelector(`[data-ref="${name}"]`) as HTMLElement | null
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
) as Record<string, HTMLElement>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read + coerce a single attribute by its literal name (raw escape hatch for
|
|
49
|
+
* dynamic / undeclared attributes). */
|
|
50
|
+
attr<T = string>(name: string, coerce?: (raw: string) => T): T | undefined {
|
|
51
|
+
const raw = this.getAttribute(name)
|
|
52
|
+
if (raw === null) return undefined
|
|
53
|
+
return coerce ? coerce(raw) : (raw as unknown as T)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Typed, coerced view of the element's declared attributes.
|
|
57
|
+
*
|
|
58
|
+
* `static attrs = { unitPrice: Number, selected: Boolean }` declares the
|
|
59
|
+
* surface; `this.attrs.unitPrice` reads the kebab DOM attribute (`unit-price`)
|
|
60
|
+
* and coerces it. `Number`/parse coercers run on the string; `Boolean` is
|
|
61
|
+
* treated as a presence flag (attribute present → true). camelCase author name
|
|
62
|
+
* ↔ kebab DOM attribute is framework-managed. */
|
|
63
|
+
get attrs(): Record<string, unknown> {
|
|
64
|
+
const decl = (this.constructor as { attrs?: Record<string, (raw: string) => unknown> }).attrs
|
|
65
|
+
const self = this
|
|
66
|
+
return new Proxy(
|
|
67
|
+
{},
|
|
68
|
+
{
|
|
69
|
+
get(_t, prop: string) {
|
|
70
|
+
const coerce = decl?.[prop]
|
|
71
|
+
const attrName = camelToKebab(prop)
|
|
72
|
+
if (coerce === (Boolean as unknown)) {
|
|
73
|
+
return self.hasAttribute(attrName)
|
|
74
|
+
}
|
|
75
|
+
const raw = self.getAttribute(attrName)
|
|
76
|
+
if (raw === null) return undefined
|
|
77
|
+
return coerce ? coerce(raw) : raw
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Register a disposer to run on disconnect (used by generated bind wiring). */
|
|
84
|
+
protected track(dispose: () => void): void {
|
|
85
|
+
this[DISPOSERS].push(dispose)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Overridden by the compiler-generated subclass: wires bindings + listeners.
|
|
89
|
+
* Runs after actors start, on connect. */
|
|
90
|
+
protected setup(): void {}
|
|
91
|
+
|
|
92
|
+
connectedCallback(): void {
|
|
93
|
+
// Apply deferred seeds now that attributes are available, then start.
|
|
94
|
+
for (const { actor, seedThunk } of this[ACTORS]) {
|
|
95
|
+
if (seedThunk) actor.seed(seedThunk())
|
|
96
|
+
actor.start()
|
|
97
|
+
}
|
|
98
|
+
this.setup()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
disconnectedCallback(): void {
|
|
102
|
+
for (const dispose of this[DISPOSERS]) dispose()
|
|
103
|
+
this[DISPOSERS] = []
|
|
104
|
+
for (const { actor } of this[ACTORS]) actor.stop()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Register a client-island class against its custom-element tag. Brackets
|
|
110
|
+
* construction so `use()` calls (which run as field initializers during
|
|
111
|
+
* construction) are collected onto the instance for lifecycle management.
|
|
112
|
+
*
|
|
113
|
+
* The compiler emits `defineElement(QuantityStepper, 'quantity-stepper')`.
|
|
114
|
+
*/
|
|
115
|
+
export function defineElement(UserClass: typeof StatorElement, tag: string): void {
|
|
116
|
+
const decl = (UserClass as unknown as { attrs?: Record<string, (raw: string) => unknown> }).attrs
|
|
117
|
+
const observed = decl ? Object.keys(decl).map(camelToKebab) : []
|
|
118
|
+
|
|
119
|
+
const Wrapped = class extends UserClass {
|
|
120
|
+
constructor() {
|
|
121
|
+
const bucket = pushCollector()
|
|
122
|
+
try {
|
|
123
|
+
super()
|
|
124
|
+
} finally {
|
|
125
|
+
popCollector()
|
|
126
|
+
}
|
|
127
|
+
this[ACTORS] = bucket
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Declared attrs are observed; a change invokes the author's
|
|
131
|
+
* `${key}Changed(next)` method (coerced per the attrs declaration).
|
|
132
|
+
* This is how live server state flows INTO an island: bind an attr on
|
|
133
|
+
* the island's tag to a read(), implement `${key}Changed`. */
|
|
134
|
+
static get observedAttributes(): string[] {
|
|
135
|
+
return observed
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
attributeChangedCallback(name: string, oldRaw: string | null, newRaw: string | null): void {
|
|
139
|
+
if (oldRaw === newRaw || !this.isConnected) return
|
|
140
|
+
const key = kebabToCamel(name)
|
|
141
|
+
const handler = (this as unknown as Record<string, unknown>)[`${key}Changed`]
|
|
142
|
+
if (typeof handler !== 'function') return
|
|
143
|
+
const coerce = decl?.[key]
|
|
144
|
+
const value =
|
|
145
|
+
coerce === (Boolean as unknown)
|
|
146
|
+
? newRaw !== null
|
|
147
|
+
: newRaw === null
|
|
148
|
+
? undefined
|
|
149
|
+
: coerce
|
|
150
|
+
? coerce(newRaw)
|
|
151
|
+
: newRaw
|
|
152
|
+
;(handler as (v: unknown) => void).call(this, value)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!customElements.get(tag)) customElements.define(tag, Wrapped)
|
|
156
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client authoring API for `.stator` `<script>` islands — `@statorjs/stator/client`.
|
|
3
|
+
* Browser-safe (no server imports). The compiler auto-injects what a generated
|
|
4
|
+
* island needs; these are also the symbols an author references directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { bind, effect } from './bind.ts'
|
|
8
|
+
export { dispatch } from './dispatch.ts'
|
|
9
|
+
export { defineElement, StatorElement } from './element.ts'
|
|
10
|
+
export type { MachineConfig } from './machine.ts'
|
|
11
|
+
export { machine } from './machine.ts'
|
|
12
|
+
export type { ClientInstance } from './use.ts'
|
|
13
|
+
export { use } from './use.ts'
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stator dev inspector — a framework-owned, dev-only observability toolbar.
|
|
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.
|
|
8
|
+
*
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const STYLES = `
|
|
14
|
+
.stator-inspector { position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483000; pointer-events: none; }
|
|
15
|
+
.stator-inspector > * { pointer-events: auto; }
|
|
16
|
+
.stator-inspector-drawer[hidden], .stator-inspector-toggle[hidden] { display: none; }
|
|
17
|
+
.stator-inspector-toggle {
|
|
18
|
+
position: fixed; bottom: 1rem; right: 1rem; background: #1a1a1a; color: #d0d0d0;
|
|
19
|
+
border: 1px solid #2a2a2a; padding: 0.45rem 0.85rem; border-radius: 999px;
|
|
20
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem;
|
|
21
|
+
cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.18); display: inline-flex;
|
|
22
|
+
align-items: center; gap: 0.4rem;
|
|
23
|
+
}
|
|
24
|
+
.stator-inspector-toggle:hover { background: #232323; color: #f0f0f0; }
|
|
25
|
+
.stator-inspector-drawer {
|
|
26
|
+
background: #141414; color: #d0d0d0; border-top: 1px solid #2a2a2a; max-height: 240px;
|
|
27
|
+
display: flex; flex-direction: column; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
28
|
+
font-size: 0.78rem; box-shadow: 0 -2px 12px rgba(0,0,0,0.25);
|
|
29
|
+
}
|
|
30
|
+
.stator-inspector-header {
|
|
31
|
+
display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0.9rem;
|
|
32
|
+
background: #1a1a1a; border-bottom: 1px solid #262626; flex-shrink: 0;
|
|
33
|
+
}
|
|
34
|
+
.stator-inspector-title { display: inline-flex; align-items: center; gap: 0.5rem; color: #f5f5f5; font-weight: 600; letter-spacing: 0.02em; }
|
|
35
|
+
.stator-inspector-dot { width: 8px; height: 8px; border-radius: 50%; background: #6a9955; box-shadow: 0 0 0 2px rgba(106,153,85,0.18); }
|
|
36
|
+
.stator-inspector-legend { display: inline-flex; gap: 0.75rem; color: #888; }
|
|
37
|
+
.stator-inspector-key--up { color: #dcdcaa; }
|
|
38
|
+
.stator-inspector-key--down { color: #9cdcfe; }
|
|
39
|
+
.stator-inspector-header button {
|
|
40
|
+
background: transparent; border: 1px solid #333; color: #aaa; padding: 0.15rem 0.55rem;
|
|
41
|
+
font-family: inherit; font-size: 0.75rem; border-radius: 4px; cursor: pointer;
|
|
42
|
+
}
|
|
43
|
+
.stator-inspector-header button:hover { background: #232323; color: #f0f0f0; }
|
|
44
|
+
.stator-inspector-close { margin-left: auto; font-size: 1rem !important; line-height: 1; padding: 0.05rem 0.5rem !important; }
|
|
45
|
+
.stator-inspector-body { flex: 1; overflow-y: auto; padding: 0; }
|
|
46
|
+
.stator-inspector-empty { margin: 0; padding: 1rem 0.9rem; color: #6a6a6a; font-style: italic; }
|
|
47
|
+
.stator-inspector-row { border-bottom: 1px solid #1f1f1f; }
|
|
48
|
+
.stator-inspector-row:last-child { border-bottom: none; }
|
|
49
|
+
.stator-inspector-summary {
|
|
50
|
+
display: grid; grid-template-columns: 90px 16px 130px 1fr auto; align-items: baseline;
|
|
51
|
+
gap: 0.75rem; padding: 0.35rem 0.9rem; cursor: pointer; user-select: none;
|
|
52
|
+
}
|
|
53
|
+
.stator-inspector-summary:hover { background: #1c1c1c; }
|
|
54
|
+
.stator-inspector-time { color: #6a6a6a; font-variant-numeric: tabular-nums; }
|
|
55
|
+
.stator-inspector-arrow { text-align: center; font-weight: 700; }
|
|
56
|
+
.stator-inspector-row--up .stator-inspector-arrow { color: #dcdcaa; }
|
|
57
|
+
.stator-inspector-row--down .stator-inspector-arrow { color: #9cdcfe; }
|
|
58
|
+
.stator-inspector-machine { color: #c586c0; font-weight: 500; }
|
|
59
|
+
.stator-inspector-row--down .stator-inspector-machine { color: #9cdcfe; }
|
|
60
|
+
.stator-inspector-event-type { color: #4ec9b0; font-weight: 500; }
|
|
61
|
+
.stator-inspector-params { color: #888; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
62
|
+
.stator-inspector-detail {
|
|
63
|
+
margin: 0; padding: 0.5rem 0.9rem 0.75rem 2.5rem; background: #0e0e0e; color: #cfcfcf;
|
|
64
|
+
font-size: 0.74rem; white-space: pre-wrap; word-break: break-word; border-top: 1px dashed #2a2a2a;
|
|
65
|
+
}
|
|
66
|
+
.stator-flash { outline-style: solid; outline-offset: 3px; animation: stator-flash 1200ms ease-out forwards; border-radius: 2px; }
|
|
67
|
+
@keyframes stator-flash {
|
|
68
|
+
0% { outline-width: 3px; outline-color: var(--flash-color, dodgerblue); background-color: var(--flash-bg, rgba(30,144,255,0.16)); }
|
|
69
|
+
30% { outline-width: 3px; outline-color: var(--flash-color, dodgerblue); background-color: var(--flash-bg, rgba(30,144,255,0.16)); }
|
|
70
|
+
100% { outline-width: 0; outline-color: transparent; background-color: transparent; }
|
|
71
|
+
}
|
|
72
|
+
.stator-flash--text { --flash-color: #3b82f6; --flash-bg: rgba(59,130,246,0.12); }
|
|
73
|
+
.stator-flash--attr { --flash-color: #a855f7; --flash-bg: rgba(168,85,247,0.12); }
|
|
74
|
+
.stator-flash--html { --flash-color: #14b8a6; --flash-bg: rgba(20,184,166,0.10); }
|
|
75
|
+
@media (max-height: 600px) { .stator-inspector-drawer { max-height: 50vh; } }
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const STORAGE_KEY = 'stator:inspector:open'
|
|
79
|
+
const MAX_ENTRIES = 40
|
|
80
|
+
const FLASH_MS = 1200
|
|
81
|
+
|
|
82
|
+
const w = window as unknown as { __statorInspectorMounted?: boolean }
|
|
83
|
+
|
|
84
|
+
function escapeHtml(s: unknown): string {
|
|
85
|
+
if (s == null) return ''
|
|
86
|
+
return String(s)
|
|
87
|
+
.replace(/&/g, '&')
|
|
88
|
+
.replace(/</g, '<')
|
|
89
|
+
.replace(/>/g, '>')
|
|
90
|
+
.replace(/"/g, '"')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fmtTime(t: number): string {
|
|
94
|
+
const d = new Date(t)
|
|
95
|
+
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
|
96
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function summarizePatches(patches: Array<{ op: string }>): string {
|
|
100
|
+
const counts: Record<string, number> = {}
|
|
101
|
+
for (const p of patches) counts[p.op] = (counts[p.op] || 0) + 1
|
|
102
|
+
return Object.keys(counts)
|
|
103
|
+
.sort()
|
|
104
|
+
.map((op) => `${op}·${counts[op]}`)
|
|
105
|
+
.join(' ')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatEventParams(event: Record<string, unknown>): string {
|
|
109
|
+
const { type: _type, ...rest } = event
|
|
110
|
+
const keys = Object.keys(rest)
|
|
111
|
+
if (keys.length === 0) return ''
|
|
112
|
+
return keys.map((k) => `${k}=${JSON.stringify(rest[k])}`).join(' ')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function mount(): void {
|
|
116
|
+
const style = document.createElement('style')
|
|
117
|
+
style.textContent = STYLES
|
|
118
|
+
document.head.appendChild(style)
|
|
119
|
+
|
|
120
|
+
const root = document.createElement('div')
|
|
121
|
+
root.className = 'stator-inspector'
|
|
122
|
+
root.innerHTML = `
|
|
123
|
+
<button class="stator-inspector-toggle" type="button" aria-label="Show stator inspector">
|
|
124
|
+
<span aria-hidden="true">{ }</span> Inspect
|
|
125
|
+
</button>
|
|
126
|
+
<section class="stator-inspector-drawer" aria-label="Stator inspector" hidden>
|
|
127
|
+
<header class="stator-inspector-header">
|
|
128
|
+
<span class="stator-inspector-title"><span class="stator-inspector-dot" aria-hidden="true"></span> Stator inspector</span>
|
|
129
|
+
<span class="stator-inspector-legend">
|
|
130
|
+
<span class="stator-inspector-key stator-inspector-key--up">↑ event</span>
|
|
131
|
+
<span class="stator-inspector-key stator-inspector-key--down">↓ patches</span>
|
|
132
|
+
</span>
|
|
133
|
+
<button class="stator-inspector-clear" type="button" title="Clear log">clear</button>
|
|
134
|
+
<button class="stator-inspector-close" type="button" aria-label="Close inspector">×</button>
|
|
135
|
+
</header>
|
|
136
|
+
<div class="stator-inspector-body">
|
|
137
|
+
<p class="stator-inspector-empty">Interact with the page to see events and patches.</p>
|
|
138
|
+
</div>
|
|
139
|
+
</section>`
|
|
140
|
+
document.body.appendChild(root)
|
|
141
|
+
|
|
142
|
+
const q = (sel: string) => root.querySelector(sel) as HTMLElement
|
|
143
|
+
const drawer = q('.stator-inspector-drawer')
|
|
144
|
+
const body = q('.stator-inspector-body')
|
|
145
|
+
const toggle = q('.stator-inspector-toggle')
|
|
146
|
+
|
|
147
|
+
const setOpen = (open: boolean) => {
|
|
148
|
+
try {
|
|
149
|
+
localStorage.setItem(STORAGE_KEY, open ? 'true' : 'false')
|
|
150
|
+
} catch {}
|
|
151
|
+
;(drawer as HTMLElement).hidden = !open
|
|
152
|
+
;(toggle as HTMLElement).hidden = open
|
|
153
|
+
}
|
|
154
|
+
let initiallyOpen = true
|
|
155
|
+
try {
|
|
156
|
+
initiallyOpen = localStorage.getItem(STORAGE_KEY) !== 'false'
|
|
157
|
+
} catch {}
|
|
158
|
+
setOpen(initiallyOpen)
|
|
159
|
+
|
|
160
|
+
toggle.addEventListener('click', () => setOpen(true))
|
|
161
|
+
q('.stator-inspector-close').addEventListener('click', () => setOpen(false))
|
|
162
|
+
q('.stator-inspector-clear').addEventListener('click', () => {
|
|
163
|
+
body.innerHTML = '<p class="stator-inspector-empty">Log cleared.</p>'
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const addEntry = (kind: 'up' | 'down', html: string, detail: unknown) => {
|
|
167
|
+
const empty = body.querySelector('.stator-inspector-empty')
|
|
168
|
+
if (empty) empty.remove()
|
|
169
|
+
const row = document.createElement('div')
|
|
170
|
+
row.className = `stator-inspector-row stator-inspector-row--${kind}`
|
|
171
|
+
row.innerHTML = html
|
|
172
|
+
const expand = document.createElement('pre')
|
|
173
|
+
expand.className = 'stator-inspector-detail'
|
|
174
|
+
expand.hidden = true
|
|
175
|
+
expand.textContent = JSON.stringify(detail, null, 2)
|
|
176
|
+
row.appendChild(expand)
|
|
177
|
+
;(row.querySelector('.stator-inspector-summary') as HTMLElement).addEventListener(
|
|
178
|
+
'click',
|
|
179
|
+
() => {
|
|
180
|
+
expand.hidden = !expand.hidden
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
body.insertBefore(row, body.firstChild)
|
|
184
|
+
while (body.children.length > MAX_ENTRIES) body.removeChild(body.lastChild as Node)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
window.addEventListener('stator:event-sent', (e: Event) => {
|
|
188
|
+
const { machine, event, timestamp } = (e as CustomEvent).detail
|
|
189
|
+
addEntry(
|
|
190
|
+
'up',
|
|
191
|
+
`<div class="stator-inspector-summary">
|
|
192
|
+
<span class="stator-inspector-time">${fmtTime(timestamp)}</span>
|
|
193
|
+
<span class="stator-inspector-arrow">↑</span>
|
|
194
|
+
<span class="stator-inspector-machine">${escapeHtml(machine)}</span>
|
|
195
|
+
<span class="stator-inspector-event-type">${escapeHtml(event.type)}</span>
|
|
196
|
+
<span class="stator-inspector-params">${escapeHtml(formatEventParams(event))}</span>
|
|
197
|
+
</div>`,
|
|
198
|
+
(e as CustomEvent).detail,
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
window.addEventListener('stator:patches-received', (e: Event) => {
|
|
203
|
+
const { patches, source, durationMs, timestamp } = (e as CustomEvent).detail
|
|
204
|
+
const timing = durationMs != null ? `${durationMs}ms` : ''
|
|
205
|
+
const sourceLabel = source === 'sse' ? '(sse push)' : '(post)'
|
|
206
|
+
addEntry(
|
|
207
|
+
'down',
|
|
208
|
+
`<div class="stator-inspector-summary">
|
|
209
|
+
<span class="stator-inspector-time">${fmtTime(timestamp)}</span>
|
|
210
|
+
<span class="stator-inspector-arrow">↓</span>
|
|
211
|
+
<span class="stator-inspector-machine">${patches.length} patch${patches.length === 1 ? '' : 'es'}</span>
|
|
212
|
+
<span class="stator-inspector-event-type">${escapeHtml(summarizePatches(patches))}</span>
|
|
213
|
+
<span class="stator-inspector-params">${sourceLabel} ${timing}</span>
|
|
214
|
+
</div>`,
|
|
215
|
+
(e as CustomEvent).detail,
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
window.addEventListener('stator:patch-applied', (e: Event) => {
|
|
220
|
+
const { patch, element } = (e as CustomEvent).detail
|
|
221
|
+
if (!element) return
|
|
222
|
+
const opClass = `stator-flash--${patch.op}`
|
|
223
|
+
;(element as HTMLElement).classList.add('stator-flash', opClass)
|
|
224
|
+
window.setTimeout(() => {
|
|
225
|
+
;(element as HTMLElement).classList.remove('stator-flash', opClass)
|
|
226
|
+
}, FLASH_MS)
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!w.__statorInspectorMounted) {
|
|
231
|
+
w.__statorInspectorMounted = true
|
|
232
|
+
if (document.readyState === 'loading') {
|
|
233
|
+
document.addEventListener('DOMContentLoaded', mount)
|
|
234
|
+
} else {
|
|
235
|
+
mount()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineMachine, type EventObject, type MachineDef } from '../engine/index.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Terse machine form for component-local client state. Desugars to a single-state
|
|
5
|
+
* `defineMachine` — context is every top-level key except the reserved `on` /
|
|
6
|
+
* `select` / `name`, transitions go under one implicit `active` state, and
|
|
7
|
+
* `select` becomes selectors.
|
|
8
|
+
*
|
|
9
|
+
* machine({ count: 1, on: { INC: s => s.count++ }, select: { atMax: s => s.count >= 99 } })
|
|
10
|
+
*
|
|
11
|
+
* Client machines run only via `createActor` (never the Store), so the name is
|
|
12
|
+
* just a label and need not be unique.
|
|
13
|
+
*/
|
|
14
|
+
export interface MachineConfig {
|
|
15
|
+
/** Optional label (defaults to "ClientMachine"). */
|
|
16
|
+
name?: string
|
|
17
|
+
/** Transition map for the single implicit state. A bare function is an action;
|
|
18
|
+
* an object is a full `{ to?, when?, do?, emit? }` transition. */
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: the terse form is dynamically shaped — context/event types aren't statically threaded (it desugars through defineMachine)
|
|
20
|
+
on?: Record<string, any>
|
|
21
|
+
/** Derived values, exposed as selectors on the instance. */
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: same — ctx is the inferred context of the desugared machine
|
|
23
|
+
select?: Record<string, (ctx: any) => unknown>
|
|
24
|
+
/** Everything else is initial context. */
|
|
25
|
+
[key: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function machine(config: MachineConfig): MachineDef {
|
|
29
|
+
const { name, on = {}, select = {}, ...context } = config
|
|
30
|
+
return defineMachine({
|
|
31
|
+
name: name ?? 'ClientMachine',
|
|
32
|
+
lifecycle: 'session',
|
|
33
|
+
events: {} as EventObject,
|
|
34
|
+
context: context as object,
|
|
35
|
+
initial: 'active',
|
|
36
|
+
states: { active: { on } },
|
|
37
|
+
selectors: select,
|
|
38
|
+
}) as MachineDef
|
|
39
|
+
}
|