@statorjs/stator 1.2.2 → 1.4.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/css.d.ts +6 -0
- package/src/client/inspector-flash.css +50 -0
- package/src/client/inspector.css +196 -0
- package/src/client/inspector.ts +31 -72
- package/src/client/runtime.ts +5 -1
- package/src/compiler/compile.ts +35 -8
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/engine/actor.ts +59 -0
- package/src/engine/types.ts +33 -0
- package/src/server/api-route.ts +7 -1
- package/src/server/create-app.ts +3 -1
- package/src/server/dev.ts +3 -1
- package/src/server/effects.ts +36 -17
- package/src/server/http.ts +25 -4
- package/src/server/machine-store.ts +7 -0
- package/src/server/recompute.ts +97 -10
- package/src/server/render-context.ts +123 -6
- package/src/server/render.ts +77 -4
- package/src/server/session-runtime.ts +14 -0
- package/src/server/timers.ts +77 -0
- package/src/template/client-shell.ts +35 -9
- package/src/template/conditional.ts +2 -1
- package/src/template/defer.ts +72 -0
- package/src/template/directives/list-attr.ts +16 -0
- package/src/template/each.ts +89 -9
- package/src/template/html.ts +65 -1
- package/src/template/index.ts +3 -1
- package/src/template/read.ts +28 -4
- package/src/template/resource.ts +70 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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 (Vite/tsx-native by design).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "1.
|
|
91
|
+
"@statorjs/stator": "1.4.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* Stator dev inspector — element-flash styles.
|
|
2
|
+
*
|
|
3
|
+
* These decorate APP elements (the patched nodes in the page), so they cannot
|
|
4
|
+
* live in the widget's shadow root — they're injected document-level as a
|
|
5
|
+
* constructable stylesheet in `@layer stator-inspector`, deliberately the
|
|
6
|
+
* lowest-priority author layer. An app's own styles are unlayered, and
|
|
7
|
+
* unlayered author styles beat every layer, so the app ALWAYS wins: the flash
|
|
8
|
+
* can never override the page it is inspecting.
|
|
9
|
+
*
|
|
10
|
+
* Bundled into the inspector asset as text by `bundleInspector()`'s esbuild
|
|
11
|
+
* `.css: 'text'` loader; there is no separate stylesheet request.
|
|
12
|
+
*/
|
|
13
|
+
@layer stator-inspector {
|
|
14
|
+
/* Highlight the changed element with an OUTLINE only — never animate its
|
|
15
|
+
background-color. A CSS animation's background-color overrides the element's
|
|
16
|
+
own background (even an .active state) for the animation's full duration, so
|
|
17
|
+
flashing background would MASK the very change it's meant to highlight: a
|
|
18
|
+
patch that lights up a toggle button's background wouldn't visibly settle
|
|
19
|
+
until the 1.2s flash cleared. Outline is painted outside the box and masks
|
|
20
|
+
nothing — and unlike background, no app is likely to animate it. */
|
|
21
|
+
.stator-flash {
|
|
22
|
+
outline-style: solid;
|
|
23
|
+
outline-offset: 3px;
|
|
24
|
+
animation: stator-flash 1200ms ease-out forwards;
|
|
25
|
+
border-radius: 2px;
|
|
26
|
+
}
|
|
27
|
+
@keyframes stator-flash {
|
|
28
|
+
0% {
|
|
29
|
+
outline-width: 3px;
|
|
30
|
+
outline-color: var(--flash-color, dodgerblue);
|
|
31
|
+
}
|
|
32
|
+
30% {
|
|
33
|
+
outline-width: 3px;
|
|
34
|
+
outline-color: var(--flash-color, dodgerblue);
|
|
35
|
+
}
|
|
36
|
+
100% {
|
|
37
|
+
outline-width: 0;
|
|
38
|
+
outline-color: transparent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
.stator-flash--text {
|
|
42
|
+
--flash-color: #3b82f6;
|
|
43
|
+
}
|
|
44
|
+
.stator-flash--attr {
|
|
45
|
+
--flash-color: #a855f7;
|
|
46
|
+
}
|
|
47
|
+
.stator-flash--html {
|
|
48
|
+
--flash-color: #14b8a6;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/* Stator dev inspector — widget (toolbar + drawer) styles.
|
|
2
|
+
*
|
|
3
|
+
* Attached by inspector.ts to the <stator-inspector> custom element's SHADOW
|
|
4
|
+
* ROOT via adoptedStyleSheets. Shadow isolation cuts both ways: the app's
|
|
5
|
+
* global element selectors (todomvc's bare `button` reset was the shipped
|
|
6
|
+
* case) can't restyle the widget, and the widget's styles provably can't
|
|
7
|
+
* touch the page. No `@layer` needed in here — nothing else competes inside
|
|
8
|
+
* the shadow tree. The element flash decorates APP nodes and therefore lives
|
|
9
|
+
* document-level instead — see inspector-flash.css.
|
|
10
|
+
*
|
|
11
|
+
* Bundled into the inspector asset as text by `bundleInspector()`'s esbuild
|
|
12
|
+
* `.css: 'text'` loader; there is no separate stylesheet request.
|
|
13
|
+
*/
|
|
14
|
+
:host {
|
|
15
|
+
display: block;
|
|
16
|
+
position: fixed;
|
|
17
|
+
bottom: 0;
|
|
18
|
+
left: 0;
|
|
19
|
+
right: 0;
|
|
20
|
+
z-index: 2147483000;
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
.stator-inspector-toggle,
|
|
24
|
+
.stator-inspector-drawer {
|
|
25
|
+
pointer-events: auto;
|
|
26
|
+
}
|
|
27
|
+
.stator-inspector-drawer[hidden],
|
|
28
|
+
.stator-inspector-toggle[hidden] {
|
|
29
|
+
display: none;
|
|
30
|
+
}
|
|
31
|
+
.stator-inspector-toggle {
|
|
32
|
+
position: fixed;
|
|
33
|
+
bottom: 1rem;
|
|
34
|
+
right: 1rem;
|
|
35
|
+
background: #1a1a1a;
|
|
36
|
+
color: #d0d0d0;
|
|
37
|
+
border: 1px solid #2a2a2a;
|
|
38
|
+
padding: 0.45rem 0.85rem;
|
|
39
|
+
border-radius: 999px;
|
|
40
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
41
|
+
font-size: 0.82rem;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 0.4rem;
|
|
47
|
+
}
|
|
48
|
+
.stator-inspector-toggle:hover {
|
|
49
|
+
background: #232323;
|
|
50
|
+
color: #f0f0f0;
|
|
51
|
+
}
|
|
52
|
+
.stator-inspector-drawer {
|
|
53
|
+
background: #141414;
|
|
54
|
+
color: #d0d0d0;
|
|
55
|
+
border-top: 1px solid #2a2a2a;
|
|
56
|
+
max-height: 240px;
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
60
|
+
font-size: 0.78rem;
|
|
61
|
+
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.25);
|
|
62
|
+
}
|
|
63
|
+
.stator-inspector-header {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 1rem;
|
|
67
|
+
padding: 0.5rem 0.9rem;
|
|
68
|
+
background: #1a1a1a;
|
|
69
|
+
border-bottom: 1px solid #262626;
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
}
|
|
72
|
+
.stator-inspector-title {
|
|
73
|
+
display: inline-flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 0.5rem;
|
|
76
|
+
color: #f5f5f5;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
letter-spacing: 0.02em;
|
|
79
|
+
}
|
|
80
|
+
.stator-inspector-dot {
|
|
81
|
+
width: 8px;
|
|
82
|
+
height: 8px;
|
|
83
|
+
border-radius: 50%;
|
|
84
|
+
background: #6a9955;
|
|
85
|
+
box-shadow: 0 0 0 2px rgba(106, 153, 85, 0.18);
|
|
86
|
+
}
|
|
87
|
+
.stator-inspector-legend {
|
|
88
|
+
display: inline-flex;
|
|
89
|
+
gap: 0.75rem;
|
|
90
|
+
color: #888;
|
|
91
|
+
}
|
|
92
|
+
.stator-inspector-key--up {
|
|
93
|
+
color: #dcdcaa;
|
|
94
|
+
}
|
|
95
|
+
.stator-inspector-key--down {
|
|
96
|
+
color: #9cdcfe;
|
|
97
|
+
}
|
|
98
|
+
.stator-inspector-header button {
|
|
99
|
+
background: transparent;
|
|
100
|
+
border: 1px solid #333;
|
|
101
|
+
color: #aaa;
|
|
102
|
+
padding: 0.15rem 0.55rem;
|
|
103
|
+
font-family: inherit;
|
|
104
|
+
font-size: 0.75rem;
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
.stator-inspector-header button:hover {
|
|
109
|
+
background: #232323;
|
|
110
|
+
color: #f0f0f0;
|
|
111
|
+
}
|
|
112
|
+
/* higher specificity than `.stator-inspector-header button` so it wins without !important */
|
|
113
|
+
.stator-inspector-header button.stator-inspector-close {
|
|
114
|
+
margin-left: auto;
|
|
115
|
+
font-size: 1rem;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
padding: 0.05rem 0.5rem;
|
|
118
|
+
}
|
|
119
|
+
.stator-inspector-body {
|
|
120
|
+
flex: 1;
|
|
121
|
+
overflow-y: auto;
|
|
122
|
+
padding: 0;
|
|
123
|
+
}
|
|
124
|
+
.stator-inspector-empty {
|
|
125
|
+
margin: 0;
|
|
126
|
+
padding: 1rem 0.9rem;
|
|
127
|
+
color: #6a6a6a;
|
|
128
|
+
font-style: italic;
|
|
129
|
+
}
|
|
130
|
+
.stator-inspector-row {
|
|
131
|
+
border-bottom: 1px solid #1f1f1f;
|
|
132
|
+
}
|
|
133
|
+
.stator-inspector-row:last-child {
|
|
134
|
+
border-bottom: none;
|
|
135
|
+
}
|
|
136
|
+
.stator-inspector-summary {
|
|
137
|
+
display: grid;
|
|
138
|
+
grid-template-columns: 90px 16px 130px 1fr auto;
|
|
139
|
+
align-items: baseline;
|
|
140
|
+
gap: 0.75rem;
|
|
141
|
+
padding: 0.35rem 0.9rem;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
user-select: none;
|
|
144
|
+
}
|
|
145
|
+
.stator-inspector-summary:hover {
|
|
146
|
+
background: #1c1c1c;
|
|
147
|
+
}
|
|
148
|
+
.stator-inspector-time {
|
|
149
|
+
color: #6a6a6a;
|
|
150
|
+
font-variant-numeric: tabular-nums;
|
|
151
|
+
}
|
|
152
|
+
.stator-inspector-arrow {
|
|
153
|
+
text-align: center;
|
|
154
|
+
font-weight: 700;
|
|
155
|
+
}
|
|
156
|
+
.stator-inspector-row--up .stator-inspector-arrow {
|
|
157
|
+
color: #dcdcaa;
|
|
158
|
+
}
|
|
159
|
+
.stator-inspector-row--down .stator-inspector-arrow {
|
|
160
|
+
color: #9cdcfe;
|
|
161
|
+
}
|
|
162
|
+
.stator-inspector-machine {
|
|
163
|
+
color: #c586c0;
|
|
164
|
+
font-weight: 500;
|
|
165
|
+
}
|
|
166
|
+
.stator-inspector-row--down .stator-inspector-machine {
|
|
167
|
+
color: #9cdcfe;
|
|
168
|
+
}
|
|
169
|
+
.stator-inspector-event-type {
|
|
170
|
+
color: #4ec9b0;
|
|
171
|
+
font-weight: 500;
|
|
172
|
+
}
|
|
173
|
+
.stator-inspector-params {
|
|
174
|
+
color: #888;
|
|
175
|
+
text-align: right;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
text-overflow: ellipsis;
|
|
178
|
+
white-space: nowrap;
|
|
179
|
+
font-variant-numeric: tabular-nums;
|
|
180
|
+
}
|
|
181
|
+
.stator-inspector-detail {
|
|
182
|
+
margin: 0;
|
|
183
|
+
padding: 0.5rem 0.9rem 0.75rem 2.5rem;
|
|
184
|
+
background: #0e0e0e;
|
|
185
|
+
color: #cfcfcf;
|
|
186
|
+
font-size: 0.74rem;
|
|
187
|
+
white-space: pre-wrap;
|
|
188
|
+
word-break: break-word;
|
|
189
|
+
border-top: 1px dashed #2a2a2a;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@media (max-height: 600px) {
|
|
193
|
+
.stator-inspector-drawer {
|
|
194
|
+
max-height: 50vh;
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/client/inspector.ts
CHANGED
|
@@ -8,72 +8,17 @@
|
|
|
8
8
|
*
|
|
9
9
|
* It depends on nothing but that event contract — the same surface any external
|
|
10
10
|
* devtool would use. Self-contained: it injects its own styles.
|
|
11
|
+
*
|
|
12
|
+
* The widget is a `<stator-inspector>` custom element with a shadow root
|
|
13
|
+
* (the Astro-dev-toolbar / vite-error-overlay pattern): the inspected app's
|
|
14
|
+
* global element selectors can't restyle the toolbar, and the toolbar's styles
|
|
15
|
+
* provably can't touch the page. Only the element-flash styles live at
|
|
16
|
+
* document level (they decorate app nodes), in the lowest-priority
|
|
17
|
+
* `@layer stator-inspector` so the app always wins over them.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
`
|
|
20
|
+
import inspectorCss from './inspector.css'
|
|
21
|
+
import flashCss from './inspector-flash.css'
|
|
77
22
|
|
|
78
23
|
const STORAGE_KEY = 'stator:inspector:open'
|
|
79
24
|
const MAX_ENTRIES = 40
|
|
@@ -113,13 +58,24 @@ function formatEventParams(event: Record<string, unknown>): string {
|
|
|
113
58
|
}
|
|
114
59
|
|
|
115
60
|
function mount(): void {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
61
|
+
// Flash styles decorate APP elements, so they can't live in the widget's
|
|
62
|
+
// shadow — they're document-level, in `@layer stator-inspector` (see
|
|
63
|
+
// inspector-flash.css) so the app's own (unlayered) styles always win.
|
|
64
|
+
const flashSheet = new CSSStyleSheet()
|
|
65
|
+
flashSheet.replaceSync(flashCss)
|
|
66
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, flashSheet]
|
|
67
|
+
|
|
68
|
+
// The widget itself: a custom element whose shadow root carries the markup
|
|
69
|
+
// and styles — isolated from the page's cascade in both directions.
|
|
70
|
+
if (!customElements.get('stator-inspector')) {
|
|
71
|
+
customElements.define('stator-inspector', class extends HTMLElement {})
|
|
72
|
+
}
|
|
73
|
+
const root = document.createElement('stator-inspector')
|
|
74
|
+
const shadow = root.attachShadow({ mode: 'open' })
|
|
75
|
+
const widgetSheet = new CSSStyleSheet()
|
|
76
|
+
widgetSheet.replaceSync(inspectorCss)
|
|
77
|
+
shadow.adoptedStyleSheets = [widgetSheet]
|
|
78
|
+
shadow.innerHTML = `
|
|
123
79
|
<button class="stator-inspector-toggle" type="button" aria-label="Show stator inspector">
|
|
124
80
|
<span aria-hidden="true">{ }</span> Inspect
|
|
125
81
|
</button>
|
|
@@ -139,7 +95,7 @@ function mount(): void {
|
|
|
139
95
|
</section>`
|
|
140
96
|
document.body.appendChild(root)
|
|
141
97
|
|
|
142
|
-
const q = (sel: string) =>
|
|
98
|
+
const q = (sel: string) => shadow.querySelector(sel) as HTMLElement
|
|
143
99
|
const drawer = q('.stator-inspector-drawer')
|
|
144
100
|
const body = q('.stator-inspector-body')
|
|
145
101
|
const toggle = q('.stator-inspector-toggle')
|
|
@@ -219,6 +175,9 @@ function mount(): void {
|
|
|
219
175
|
window.addEventListener('stator:patch-applied', (e: Event) => {
|
|
220
176
|
const { patch, element } = (e as CustomEvent).detail
|
|
221
177
|
if (!element) return
|
|
178
|
+
// Only flash while the drawer is open — the flash is an inspection aid, not
|
|
179
|
+
// ambient page decoration; a closed inspector shouldn't touch the app.
|
|
180
|
+
if (drawer.hidden) return
|
|
222
181
|
const opClass = `stator-flash--${patch.op}`
|
|
223
182
|
;(element as HTMLElement).classList.add('stator-flash', opClass)
|
|
224
183
|
window.setTimeout(() => {
|
package/src/client/runtime.ts
CHANGED
|
@@ -69,12 +69,16 @@ function initLiveChannel(): void {
|
|
|
69
69
|
return
|
|
70
70
|
}
|
|
71
71
|
if (data.patches) {
|
|
72
|
+
// SSE is server-pushed — no client round-trip to time — so report the
|
|
73
|
+
// apply duration (still the useful "how expensive was this update?").
|
|
74
|
+
const startedAt = performance.now()
|
|
75
|
+
applyPatches(data.patches)
|
|
72
76
|
emit('stator:patches-received', {
|
|
73
77
|
patches: data.patches,
|
|
74
78
|
source: 'sse',
|
|
79
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
75
80
|
timestamp: Date.now(),
|
|
76
81
|
})
|
|
77
|
-
applyPatches(data.patches)
|
|
78
82
|
}
|
|
79
83
|
if (data.directives && data.directives.length > 0) {
|
|
80
84
|
applyDirectives(data.directives)
|
package/src/compiler/compile.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface CompileResult {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const PRIMITIVES_IMPORT =
|
|
51
|
-
"import { html, read, each, when, match, on, classList, styleList } from '@statorjs/stator/template'"
|
|
51
|
+
"import { html, read, each, itemBind, when, match, defer, on, classList, styleList } from '@statorjs/stator/template'"
|
|
52
52
|
|
|
53
53
|
export interface CompileOptions {
|
|
54
54
|
/** Stable id for the component (file path). Used for the scope hash so the
|
|
@@ -186,11 +186,11 @@ function compileClient(
|
|
|
186
186
|
const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
|
|
187
187
|
const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
|
|
188
188
|
const serverCode = [
|
|
189
|
-
"import { html, read, each, when, match, on, classList, styleList, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
|
|
189
|
+
"import { html, read, each, when, match, defer, on, classList, styleList, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
|
|
190
190
|
'',
|
|
191
191
|
`export default function (props = {}) {`,
|
|
192
192
|
` const __inner = ${innerExpr}`,
|
|
193
|
-
` const __attrs = clientShellAttrs(props, ${attrDecl})`,
|
|
193
|
+
` const __attrs = clientShellAttrs(props, ${attrDecl}, ${JSON.stringify(root.rootAttrs)})`,
|
|
194
194
|
` return createHtmlFragment(\`<${root.tag}\${__attrs}${rootScope}>\` + __inner.html + \`</${root.tag}>\`)`,
|
|
195
195
|
'}',
|
|
196
196
|
'',
|
|
@@ -214,9 +214,36 @@ function compileClient(
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
/**
|
|
218
|
-
*
|
|
219
|
-
|
|
217
|
+
/** Static attributes authored on a client component's ROOT element — its own
|
|
218
|
+
* base `class`, `hidden`, ARIA, `data-*`. These are carried across the
|
|
219
|
+
* split-and-reassemble so a component can style/flag its own root; the use site
|
|
220
|
+
* merges them under its props (FINDINGS #4). Namespaced directives (class:list,
|
|
221
|
+
* on:) and expression-valued attrs are not static and are skipped here. */
|
|
222
|
+
function staticRootAttrs(
|
|
223
|
+
attrs: ts.JsxAttributes,
|
|
224
|
+
sf: ts.SourceFile,
|
|
225
|
+
): Record<string, string | boolean> {
|
|
226
|
+
const out: Record<string, string | boolean> = {}
|
|
227
|
+
for (const attr of attrs.properties) {
|
|
228
|
+
if (!ts.isJsxAttribute(attr) || ts.isJsxNamespacedName(attr.name)) continue
|
|
229
|
+
const name = attr.name.getText(sf)
|
|
230
|
+
const init = attr.initializer
|
|
231
|
+
if (init === undefined) {
|
|
232
|
+
out[name] = true // valueless boolean attribute
|
|
233
|
+
} else if (ts.isStringLiteral(init)) {
|
|
234
|
+
out[name] = init.text
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Parse a client template, returning the custom-element root tag, its inner
|
|
241
|
+
* source (its children), and the root's own static attributes. Enforces "root
|
|
242
|
+
* must be the custom element". */
|
|
243
|
+
function extractClientRoot(
|
|
244
|
+
template: string,
|
|
245
|
+
file?: string,
|
|
246
|
+
): { tag: string; inner: string; rootAttrs: Record<string, string | boolean> } {
|
|
220
247
|
const wrapped = `const __t = (<>${template}</>);`
|
|
221
248
|
const sf = ts.createSourceFile('t.tsx', wrapped, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
222
249
|
let fragment: ts.JsxFragment | undefined
|
|
@@ -241,13 +268,13 @@ function extractClientRoot(template: string, file?: string): { tag: string; inne
|
|
|
241
268
|
if (ts.isJsxSelfClosingElement(rootEl)) {
|
|
242
269
|
const tag = rootEl.tagName.getText(sf)
|
|
243
270
|
requireCustomRoot(tag, file)
|
|
244
|
-
return { tag, inner: '' }
|
|
271
|
+
return { tag, inner: '', rootAttrs: staticRootAttrs(rootEl.attributes, sf) }
|
|
245
272
|
}
|
|
246
273
|
const el = rootEl as ts.JsxElement
|
|
247
274
|
const tag = el.openingElement.tagName.getText(sf)
|
|
248
275
|
requireCustomRoot(tag, file)
|
|
249
276
|
const inner = wrapped.slice(el.openingElement.getEnd(), el.closingElement.getStart())
|
|
250
|
-
return { tag, inner }
|
|
277
|
+
return { tag, inner, rootAttrs: staticRootAttrs(el.openingElement.attributes, sf) }
|
|
251
278
|
}
|
|
252
279
|
|
|
253
280
|
function requireCustomRoot(tag: string, file?: string): void {
|
package/src/compiler/lower.ts
CHANGED
|
Binary file
|
|
@@ -49,7 +49,7 @@ export interface VirtualCodeResult {
|
|
|
49
49
|
// runtime hides a missing-import bug; a name in both collides with the
|
|
50
50
|
// author's legitimate import (`raw` is NOT a runtime global — authors
|
|
51
51
|
// import it — which is why it must not be in this list).
|
|
52
|
-
const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'on', 'classList', 'styleList']
|
|
52
|
+
const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'defer', 'on', 'classList', 'styleList']
|
|
53
53
|
const CLIENT_GLOBALS = [
|
|
54
54
|
'StatorElement',
|
|
55
55
|
'use',
|
package/src/engine/actor.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface CreateActorOptions<C> {
|
|
|
63
63
|
* they take events straight from the untrusted wire (`/__events`), where a
|
|
64
64
|
* `@set` would be an arbitrary-context-write that bypasses every guard. */
|
|
65
65
|
internalEvents?: boolean
|
|
66
|
+
/** Host hook: a state was ENTERED (fresh start or a value-changing transition —
|
|
67
|
+
* never hydration). The server uses it to arm `after` timers from the state's
|
|
68
|
+
* config; `ctx` is the current context (for context-dependent delays). */
|
|
69
|
+
onStateEnter?: (stateKey: string, ctx: C) => void
|
|
70
|
+
/** Host hook: a state was LEFT (a value-changing transition). The server uses
|
|
71
|
+
* it to cancel that state's `after` timers. */
|
|
72
|
+
onStateExit?: (stateKey: string) => void
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/** Unique per-invocation effect id — usable as an idempotency key, so it must
|
|
@@ -96,6 +103,10 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
96
103
|
let context: C = opts.snapshot
|
|
97
104
|
? (structuredClone(opts.snapshot.context) as C)
|
|
98
105
|
: (structuredClone(def.context) as C)
|
|
106
|
+
// Hydrated actors have already lived: their current state's entry effect fired
|
|
107
|
+
// when it was entered, so `start()` must NOT re-fire it. Fresh actors (no
|
|
108
|
+
// snapshot) enter their initial state for the first time.
|
|
109
|
+
const hydrated = opts.snapshot !== undefined
|
|
99
110
|
|
|
100
111
|
const subscribers = new Set<(s: Snapshot<C>) => void>()
|
|
101
112
|
const emitListeners = new Map<string, Set<(e: EmittedEvent) => void>>()
|
|
@@ -112,6 +123,39 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
112
123
|
for (const fn of subscribers) fn(snap)
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// Schedule a state's entry effect (if declared) — the same host-scheduled,
|
|
127
|
+
// commit-time-snapshot, at-most-once path as a transition effect, minus the
|
|
128
|
+
// event. Firing does NOT bump `commits` (an entry is not a committed
|
|
129
|
+
// transition); the server persists an entry-firing machine via the host's
|
|
130
|
+
// pending-effects queue instead (see server/session-runtime.ts).
|
|
131
|
+
const fireEntryEffect = (stateKey: string): void => {
|
|
132
|
+
const entry = def.states[stateKey]?.entry
|
|
133
|
+
if (!entry) return
|
|
134
|
+
const effectId = newEffectId()
|
|
135
|
+
const ctxSnapshot = structuredClone(context)
|
|
136
|
+
const invocation: EffectInvocation = {
|
|
137
|
+
machineName: def.name,
|
|
138
|
+
effectId,
|
|
139
|
+
run: () => Promise.resolve(entry(ctxSnapshot, { effectId })),
|
|
140
|
+
}
|
|
141
|
+
if (opts.onEffect) {
|
|
142
|
+
opts.onEffect(invocation)
|
|
143
|
+
} else {
|
|
144
|
+
void invocation
|
|
145
|
+
.run()
|
|
146
|
+
.then((completion) => {
|
|
147
|
+
if (completion) actor.send(completion as E)
|
|
148
|
+
})
|
|
149
|
+
.catch((err) => {
|
|
150
|
+
console.error(
|
|
151
|
+
`stator: entry effect ${effectId} of "${def.name}" threw — effects must catch ` +
|
|
152
|
+
`and return their failure event. Dropped.`,
|
|
153
|
+
err,
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
115
159
|
const actor: Actor<C, E> = {
|
|
116
160
|
seed(partial: Partial<C>) {
|
|
117
161
|
if (!started) context = { ...context, ...partial }
|
|
@@ -120,6 +164,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
120
164
|
if (!started) {
|
|
121
165
|
started = true
|
|
122
166
|
notify() // let subscribe-before-start consumers sync initial state
|
|
167
|
+
// Fresh initial-state entry (a hydrated actor already fired its).
|
|
168
|
+
if (!hydrated) {
|
|
169
|
+
const initial = value[value.length - 1]!
|
|
170
|
+
fireEntryEffect(initial)
|
|
171
|
+
opts.onStateEnter?.(initial, context)
|
|
172
|
+
}
|
|
123
173
|
}
|
|
124
174
|
return actor
|
|
125
175
|
},
|
|
@@ -228,6 +278,15 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
228
278
|
}
|
|
229
279
|
}
|
|
230
280
|
|
|
281
|
+
// A value-changing transition leaves the old state and enters the new one:
|
|
282
|
+
// fire the new state's entry effect and let the host arm/cancel `after`
|
|
283
|
+
// timers (self-transitions and action-only transitions do neither).
|
|
284
|
+
if (config.to && config.to !== stateKey) {
|
|
285
|
+
opts.onStateExit?.(stateKey)
|
|
286
|
+
fireEntryEffect(config.to)
|
|
287
|
+
opts.onStateEnter?.(config.to, context)
|
|
288
|
+
}
|
|
289
|
+
|
|
231
290
|
notify()
|
|
232
291
|
},
|
|
233
292
|
|
package/src/engine/types.ts
CHANGED
|
@@ -83,6 +83,31 @@ export type Effect<C, E extends EventObject, EAll extends EventObject = EventObj
|
|
|
83
83
|
meta: EffectMeta,
|
|
84
84
|
) => Promise<EAll | null>
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A state ENTRY effect: async I/O the host schedules when a state is *entered* —
|
|
88
|
+
* a fresh start at the initial state, or a transition that changes the state
|
|
89
|
+
* value; never on hydration. Same host-scheduled, off-lock, at-most-once
|
|
90
|
+
* pipeline as a transition `Effect`, minus the event argument (a state entry has
|
|
91
|
+
* no triggering event). Returns the completion event to dispatch (annotate the
|
|
92
|
+
* return with your machine's event union, exactly as for `Effect`), or null.
|
|
93
|
+
*/
|
|
94
|
+
export type EntryEffect<C, EAll extends EventObject = EventObject> = (
|
|
95
|
+
ctx: C,
|
|
96
|
+
meta: EffectMeta,
|
|
97
|
+
) => Promise<EAll | null>
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A state timeout: after `delay` ms in the state, the host dispatches `send`.
|
|
101
|
+
* Armed on entry, cancelled on exit — host-scheduled, in-memory, non-durable in
|
|
102
|
+
* v1 (a restart drops armed timers). `delay` may depend on context. The trigger
|
|
103
|
+
* is a *described* value, not a bare-ms key, so it can grow (dynamic delays now;
|
|
104
|
+
* durable/cron schedules later) without a breaking change.
|
|
105
|
+
*/
|
|
106
|
+
export interface AfterEntry<C, EAll extends EventObject = EventObject> {
|
|
107
|
+
delay: number | ((ctx: C) => number)
|
|
108
|
+
send: EAll
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
/** A scheduled effect surfaced to the host: everything needed to run it and
|
|
87
112
|
* dispatch its completion. `run` closes over the commit-time snapshots. */
|
|
88
113
|
export interface EffectInvocation {
|
|
@@ -132,6 +157,14 @@ export type OnMap<C, E extends EventObject, S extends string, R = Record<string,
|
|
|
132
157
|
|
|
133
158
|
export interface StateNode<C, E extends EventObject, S extends string, R = Record<string, any>> {
|
|
134
159
|
on?: OnMap<C, E, S, R>
|
|
160
|
+
/** Async I/O run on *entering* this state (a fresh start at the initial state,
|
|
161
|
+
* or a value-changing transition — never on hydration), host-scheduled like a
|
|
162
|
+
* transition effect. `EAll` is the machine's full event union. See
|
|
163
|
+
* `EntryEffect`. */
|
|
164
|
+
entry?: EntryEffect<C, E>
|
|
165
|
+
/** State timeouts: each fires `send` after its `delay` ms in this state
|
|
166
|
+
* (armed on entry, cancelled on exit). See `AfterEntry`. */
|
|
167
|
+
after?: AfterEntry<C, E>[]
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
/** Payload selector runs synchronously AFTER the transition's action, so it
|
package/src/server/api-route.ts
CHANGED
|
@@ -83,8 +83,14 @@ export async function runApiRoute(
|
|
|
83
83
|
return c.text('Internal Server Error', 500)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Persist committed machines plus any fresh machine that fired its initial
|
|
87
|
+
// entry effect (not in `touched` — an entry commits no transition), so it
|
|
88
|
+
// isn't re-created and re-fired next request. Fan-out stays on `touched`.
|
|
89
|
+
const toPersist = new Set([...touched, ...runtime.entryFiredMachines()])
|
|
90
|
+
if (toPersist.size > 0) {
|
|
91
|
+
await runtime.persistTouched(toPersist)
|
|
92
|
+
}
|
|
86
93
|
if (touched.size > 0) {
|
|
87
|
-
await runtime.persistTouched(touched)
|
|
88
94
|
await fanOut(touched, { sessionId })
|
|
89
95
|
}
|
|
90
96
|
|