@junoflow/cli 0.6.0 → 0.9.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 +189 -73
- package/app-client.js +306 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +4 -2
- package/package.json +9 -4
- package/src/api.js +182 -39
- package/src/app-dev/build.js +134 -0
- package/src/app-dev/harness.js +108 -0
- package/src/app-dev/panel.html +336 -0
- package/src/app-dev/panel.js +290 -0
- package/src/app-dev/server.js +347 -0
- package/src/app-dev/stream.js +224 -0
- package/src/app-dev/watch.js +47 -0
- package/src/app-project.js +133 -0
- package/src/authoring-skills.js +2 -1
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -0
- package/src/commands/app-dev.js +141 -0
- package/src/commands/app-gen-types.js +37 -0
- package/src/commands/app-init.js +46 -0
- package/src/commands/app-pull.js +106 -0
- package/src/commands/check.js +31 -15
- package/src/commands/deploy.js +57 -4
- package/src/commands/flow-check.js +4 -22
- package/src/commands/flow-deploy.js +11 -8
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +12 -5
- package/src/commands/flow-pull.js +69 -40
- package/src/commands/pull.js +96 -102
- package/src/errors.js +3 -3
- package/src/exit.js +2 -2
- package/src/flow-project.js +47 -97
- package/src/index.js +62 -1
- package/src/project.js +76 -8
- package/src/watch.js +48 -11
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +51 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>juno app dev</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
--bg: #f6f6f4;
|
|
11
|
+
--fg: #1d1d1b;
|
|
12
|
+
--muted: #6b6b66;
|
|
13
|
+
--line: #d9d9d4;
|
|
14
|
+
--field: #ffffff;
|
|
15
|
+
--error-bg: #fbe9e7;
|
|
16
|
+
--error-fg: #8a1c0c;
|
|
17
|
+
}
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--bg: #1c1c1a;
|
|
21
|
+
--fg: #e8e8e4;
|
|
22
|
+
--muted: #9a9a94;
|
|
23
|
+
--line: #3a3a36;
|
|
24
|
+
--field: #262624;
|
|
25
|
+
--error-bg: #3b1a14;
|
|
26
|
+
--error-fg: #ffb4a6;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
* {
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
}
|
|
32
|
+
html,
|
|
33
|
+
body {
|
|
34
|
+
margin: 0;
|
|
35
|
+
height: 100%;
|
|
36
|
+
}
|
|
37
|
+
body {
|
|
38
|
+
display: flex;
|
|
39
|
+
background: var(--bg);
|
|
40
|
+
color: var(--fg);
|
|
41
|
+
font:
|
|
42
|
+
13px/1.4 system-ui,
|
|
43
|
+
sans-serif;
|
|
44
|
+
}
|
|
45
|
+
aside {
|
|
46
|
+
width: 320px;
|
|
47
|
+
flex: none;
|
|
48
|
+
overflow-y: auto;
|
|
49
|
+
padding: 12px;
|
|
50
|
+
border-right: 1px solid var(--line);
|
|
51
|
+
}
|
|
52
|
+
main {
|
|
53
|
+
flex: 1;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
}
|
|
56
|
+
iframe {
|
|
57
|
+
border: 0;
|
|
58
|
+
width: 100%;
|
|
59
|
+
height: 100%;
|
|
60
|
+
background: #fff;
|
|
61
|
+
}
|
|
62
|
+
h1 {
|
|
63
|
+
font-size: 14px;
|
|
64
|
+
margin: 0 0 4px;
|
|
65
|
+
}
|
|
66
|
+
h2 {
|
|
67
|
+
font-size: 11px;
|
|
68
|
+
text-transform: uppercase;
|
|
69
|
+
letter-spacing: 0.05em;
|
|
70
|
+
color: var(--muted);
|
|
71
|
+
margin: 16px 0 6px;
|
|
72
|
+
}
|
|
73
|
+
#status {
|
|
74
|
+
color: var(--muted);
|
|
75
|
+
}
|
|
76
|
+
#errors {
|
|
77
|
+
background: var(--error-bg);
|
|
78
|
+
color: var(--error-fg);
|
|
79
|
+
padding: 8px;
|
|
80
|
+
margin: 8px 0 0;
|
|
81
|
+
border-radius: 4px;
|
|
82
|
+
white-space: pre-wrap;
|
|
83
|
+
font:
|
|
84
|
+
12px/1.4 ui-monospace,
|
|
85
|
+
monospace;
|
|
86
|
+
}
|
|
87
|
+
#errors:empty {
|
|
88
|
+
display: none;
|
|
89
|
+
}
|
|
90
|
+
select,
|
|
91
|
+
input,
|
|
92
|
+
button {
|
|
93
|
+
font: inherit;
|
|
94
|
+
color: inherit;
|
|
95
|
+
}
|
|
96
|
+
select,
|
|
97
|
+
input {
|
|
98
|
+
background: var(--field);
|
|
99
|
+
border: 1px solid var(--line);
|
|
100
|
+
border-radius: 4px;
|
|
101
|
+
padding: 3px 6px;
|
|
102
|
+
}
|
|
103
|
+
select {
|
|
104
|
+
width: 100%;
|
|
105
|
+
}
|
|
106
|
+
button {
|
|
107
|
+
background: var(--field);
|
|
108
|
+
border: 1px solid var(--line);
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
padding: 3px 8px;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
113
|
+
.binding {
|
|
114
|
+
border-top: 1px solid var(--line);
|
|
115
|
+
padding: 6px 0;
|
|
116
|
+
}
|
|
117
|
+
.binding .name {
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
}
|
|
120
|
+
.binding .kind {
|
|
121
|
+
color: var(--muted);
|
|
122
|
+
font-size: 11px;
|
|
123
|
+
margin-left: 4px;
|
|
124
|
+
}
|
|
125
|
+
.binding code {
|
|
126
|
+
display: block;
|
|
127
|
+
font:
|
|
128
|
+
12px/1.4 ui-monospace,
|
|
129
|
+
monospace;
|
|
130
|
+
color: var(--muted);
|
|
131
|
+
overflow-wrap: anywhere;
|
|
132
|
+
margin: 2px 0 4px;
|
|
133
|
+
}
|
|
134
|
+
.binding form {
|
|
135
|
+
display: flex;
|
|
136
|
+
gap: 4px;
|
|
137
|
+
}
|
|
138
|
+
.binding input {
|
|
139
|
+
flex: 1;
|
|
140
|
+
min-width: 0;
|
|
141
|
+
}
|
|
142
|
+
.empty,
|
|
143
|
+
.hint {
|
|
144
|
+
color: var(--muted);
|
|
145
|
+
}
|
|
146
|
+
.hint {
|
|
147
|
+
font-size: 11px;
|
|
148
|
+
margin-top: 4px;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<aside>
|
|
154
|
+
<h1 id="title">juno app dev</h1>
|
|
155
|
+
<div id="status">connecting…</div>
|
|
156
|
+
<pre id="errors"></pre>
|
|
157
|
+
|
|
158
|
+
<h2>Scenario</h2>
|
|
159
|
+
<select id="scenario"></select>
|
|
160
|
+
|
|
161
|
+
<h2>States</h2>
|
|
162
|
+
<div id="states"></div>
|
|
163
|
+
<div class="hint">
|
|
164
|
+
A value is JSON; anything else is sent as a string.
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<h2>Controls</h2>
|
|
168
|
+
<div id="controls"></div>
|
|
169
|
+
<div class="hint">
|
|
170
|
+
A value is <code>{"value", "active", "expires_at"}</code>. Press sends a
|
|
171
|
+
button press.
|
|
172
|
+
</div>
|
|
173
|
+
</aside>
|
|
174
|
+
<main><iframe id="page" title="the app"></iframe></main>
|
|
175
|
+
|
|
176
|
+
<script>
|
|
177
|
+
// The panel for `juno app dev` (tools/npm/cli/src/app-dev/panel.js):
|
|
178
|
+
// it draws the server's `state` events and posts what the author does.
|
|
179
|
+
// It never touches the page in the frame beyond reloading it.
|
|
180
|
+
'use strict';
|
|
181
|
+
|
|
182
|
+
var $ = function (id) {
|
|
183
|
+
return document.getElementById(id);
|
|
184
|
+
};
|
|
185
|
+
var frame = $('page');
|
|
186
|
+
var shape = null; // the bindings drawn, as JSON
|
|
187
|
+
|
|
188
|
+
function post(path, body) {
|
|
189
|
+
return fetch('/_dev/' + path, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify(body),
|
|
193
|
+
})
|
|
194
|
+
.then(function (res) {
|
|
195
|
+
return res.json().then(function (data) {
|
|
196
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
197
|
+
return data;
|
|
198
|
+
});
|
|
199
|
+
})
|
|
200
|
+
.catch(function (err) {
|
|
201
|
+
alert(err.message);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Typed text is JSON when it parses, and a string when it does not, so
|
|
206
|
+
// `on` needs no quotes.
|
|
207
|
+
function parse(text) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(text);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return text;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function el(tag, cls, text) {
|
|
216
|
+
var n = document.createElement(tag);
|
|
217
|
+
if (cls) n.className = cls;
|
|
218
|
+
if (text != null) n.textContent = text;
|
|
219
|
+
return n;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function row(section, name, kind) {
|
|
223
|
+
var div = el('div', 'binding');
|
|
224
|
+
div.dataset.name = name;
|
|
225
|
+
var head = el('div');
|
|
226
|
+
head.appendChild(el('span', 'name', name));
|
|
227
|
+
if (kind) head.appendChild(el('span', 'kind', kind));
|
|
228
|
+
div.appendChild(head);
|
|
229
|
+
div.appendChild(el('code', 'value'));
|
|
230
|
+
var form = el('form');
|
|
231
|
+
var input = el('input');
|
|
232
|
+
input.placeholder = 'new value';
|
|
233
|
+
form.appendChild(input);
|
|
234
|
+
form.appendChild(el('button', null, 'set'));
|
|
235
|
+
if (section === 'controls') {
|
|
236
|
+
var press = el('button', null, 'press');
|
|
237
|
+
press.type = 'button';
|
|
238
|
+
press.onclick = function () {
|
|
239
|
+
post('press', { binding: name });
|
|
240
|
+
};
|
|
241
|
+
form.appendChild(press);
|
|
242
|
+
}
|
|
243
|
+
form.onsubmit = function (e) {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
var body = {};
|
|
246
|
+
body[section] = {};
|
|
247
|
+
body[section][name] = parse(input.value);
|
|
248
|
+
post('set', body).then(function (ok) {
|
|
249
|
+
if (ok) input.value = '';
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
div.appendChild(form);
|
|
253
|
+
return div;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function drawBindings(s) {
|
|
257
|
+
var next = JSON.stringify(s.bindings);
|
|
258
|
+
if (next === shape) return;
|
|
259
|
+
shape = next;
|
|
260
|
+
var states = $('states');
|
|
261
|
+
var controls = $('controls');
|
|
262
|
+
states.textContent = '';
|
|
263
|
+
controls.textContent = '';
|
|
264
|
+
s.bindings.states.forEach(function (b) {
|
|
265
|
+
states.appendChild(row('states', b.name, b.kind));
|
|
266
|
+
});
|
|
267
|
+
s.bindings.controls.forEach(function (name) {
|
|
268
|
+
controls.appendChild(row('controls', name));
|
|
269
|
+
});
|
|
270
|
+
if (!s.bindings.states.length) {
|
|
271
|
+
states.appendChild(el('div', 'empty', 'none declared'));
|
|
272
|
+
}
|
|
273
|
+
if (!s.bindings.controls.length) {
|
|
274
|
+
controls.appendChild(el('div', 'empty', 'none declared'));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function drawValues(values) {
|
|
279
|
+
['states', 'controls'].forEach(function (section) {
|
|
280
|
+
$(section)
|
|
281
|
+
.querySelectorAll('.binding')
|
|
282
|
+
.forEach(function (div) {
|
|
283
|
+
var v = values[section][div.dataset.name];
|
|
284
|
+
div.querySelector('.value').textContent = JSON.stringify(v);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function drawScenarios(s) {
|
|
290
|
+
var sel = $('scenario');
|
|
291
|
+
sel.textContent = '';
|
|
292
|
+
var none = el('option', null, '(none: an empty world)');
|
|
293
|
+
none.value = '';
|
|
294
|
+
sel.appendChild(none);
|
|
295
|
+
var paths = s.scenarios.slice();
|
|
296
|
+
// A --scenario outside test/fixtures/ is still the one in use.
|
|
297
|
+
if (s.scenario && paths.indexOf(s.scenario) < 0) paths.push(s.scenario);
|
|
298
|
+
paths.forEach(function (p) {
|
|
299
|
+
var o = el('option', null, p);
|
|
300
|
+
o.value = p;
|
|
301
|
+
if (s.scenarios.indexOf(p) < 0) o.disabled = true;
|
|
302
|
+
sel.appendChild(o);
|
|
303
|
+
});
|
|
304
|
+
sel.value = s.scenario || '';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
$('scenario').onchange = function (e) {
|
|
308
|
+
post('scenario', { path: e.target.value || null });
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
function draw(s) {
|
|
312
|
+
document.title = s.id + ' — juno app dev';
|
|
313
|
+
$('title').textContent = s.id;
|
|
314
|
+
if (!frame.getAttribute('src')) frame.src = '/' + s.id + '/';
|
|
315
|
+
$('errors').textContent = s.errors.join('\n\n');
|
|
316
|
+
drawScenarios(s);
|
|
317
|
+
drawBindings(s);
|
|
318
|
+
drawValues(s.values);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
var events = new EventSource('/_dev/events');
|
|
322
|
+
events.addEventListener('open', function () {
|
|
323
|
+
$('status').textContent = 'connected';
|
|
324
|
+
});
|
|
325
|
+
events.addEventListener('error', function () {
|
|
326
|
+
$('status').textContent = 'disconnected: is juno app dev running?';
|
|
327
|
+
});
|
|
328
|
+
events.addEventListener('state', function (e) {
|
|
329
|
+
draw(JSON.parse(e.data));
|
|
330
|
+
});
|
|
331
|
+
events.addEventListener('reload', function () {
|
|
332
|
+
frame.contentWindow.location.reload();
|
|
333
|
+
});
|
|
334
|
+
</script>
|
|
335
|
+
</body>
|
|
336
|
+
</html>
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readManifestFile } from '../working-copy.js';
|
|
5
|
+
import { openStream, PING_MS } from './stream.js';
|
|
6
|
+
|
|
7
|
+
/** The panel page, served as written (one static file, no framework). */
|
|
8
|
+
const PANEL = readFile(new URL('./panel.html', import.meta.url));
|
|
9
|
+
|
|
10
|
+
/** Where the panel's scenario picker looks. */
|
|
11
|
+
const FIXTURES = 'test/fixtures';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* createPanel is the dev panel's side of `juno app dev` (plan-local-dev-6
|
|
15
|
+
* decisions 7–9): the shell page at `/`, its channel under `/_dev/`, and
|
|
16
|
+
* what a save does.
|
|
17
|
+
*
|
|
18
|
+
* | Request | Body | Answer |
|
|
19
|
+
* | --------------------- | ---------------------------- | ----------------------- |
|
|
20
|
+
* | `GET /` | | panel.html |
|
|
21
|
+
* | `GET /_dev/events` | | SSE: `state`, `reload` |
|
|
22
|
+
* | `POST /_dev/set` | `{states?, controls?}` | 200 `{values}` |
|
|
23
|
+
* | `POST /_dev/press` | `{binding}` | 200 `{binding, at}` |
|
|
24
|
+
* | `POST /_dev/scenario` | `{path}`: a listed one, null | 200 the `state` data |
|
|
25
|
+
*
|
|
26
|
+
* A POST must say `Content-Type: application/json`, which no page on another
|
|
27
|
+
* origin can send without a preflight this server never answers; a body the
|
|
28
|
+
* panel could not have sent is a 400 `{error}`.
|
|
29
|
+
*
|
|
30
|
+
* `state` carries everything the panel draws — `{id, scenario, scenarios,
|
|
31
|
+
* bindings, values, errors}` — and is sent on connect and after anything in
|
|
32
|
+
* it moves. `reload` tells the panel to reload the page's frame.
|
|
33
|
+
*
|
|
34
|
+
* Saves (watchApp's handlers): `src/` rebuilds the handlers at once, so a
|
|
35
|
+
* build error reaches the panel without a call, and reloads only after a
|
|
36
|
+
* good build — the last good one keeps serving. `page/` and `public/`
|
|
37
|
+
* reload. `manifest.json`, when its text changed, rebuilds the harness,
|
|
38
|
+
* keeps the overrides still declared, and reloads. A save under
|
|
39
|
+
* `test/fixtures/` relists the scenarios, and re-applies the one in use,
|
|
40
|
+
* from scratch, when its file is among them.
|
|
41
|
+
*/
|
|
42
|
+
export function createPanel({
|
|
43
|
+
cwd,
|
|
44
|
+
dev,
|
|
45
|
+
builder,
|
|
46
|
+
live,
|
|
47
|
+
pingMs = PING_MS,
|
|
48
|
+
log = () => {},
|
|
49
|
+
}) {
|
|
50
|
+
const clients = new Set();
|
|
51
|
+
// What failed: the handlers build is builder.error; these are the others.
|
|
52
|
+
const problems = { manifest: null, scenario: null };
|
|
53
|
+
let manifestRaw = JSON.stringify(dev.manifest);
|
|
54
|
+
let scenarioRaw = null;
|
|
55
|
+
// Saves are applied one at a time, in order.
|
|
56
|
+
let queue = Promise.resolve();
|
|
57
|
+
|
|
58
|
+
const panel = {
|
|
59
|
+
/** The shell: the panel and the page's frame. */
|
|
60
|
+
async shell(req, res) {
|
|
61
|
+
const body = await PANEL;
|
|
62
|
+
res.writeHead(200, {
|
|
63
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
64
|
+
'Content-Length': body.length,
|
|
65
|
+
});
|
|
66
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/** Everything under /_dev/. */
|
|
70
|
+
async route(req, res, url) {
|
|
71
|
+
const name = url.pathname.slice('/_dev/'.length);
|
|
72
|
+
if (name === 'events') {
|
|
73
|
+
if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
|
|
74
|
+
const client = openStream(res, pingMs, () => clients.delete(client));
|
|
75
|
+
clients.add(client);
|
|
76
|
+
client.send('state', await state());
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const action = Object.hasOwn(ACTIONS, name) ? ACTIONS[name] : null;
|
|
80
|
+
if (!action) return sendJSON(res, 404, { error: 'not found' });
|
|
81
|
+
if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
|
|
82
|
+
if (!/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {
|
|
83
|
+
return sendJSON(res, 415, {
|
|
84
|
+
error: 'the panel posts Content-Type: application/json',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = JSON.parse((await readBody(req)) || '{}');
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return sendJSON(res, 400, {
|
|
92
|
+
error: `body is not valid JSON: ${err.message}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
96
|
+
return sendJSON(res, 400, { error: 'body must be a JSON object' });
|
|
97
|
+
}
|
|
98
|
+
let answer;
|
|
99
|
+
try {
|
|
100
|
+
answer = await action(body);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return sendJSON(res, 400, { error: err.message });
|
|
103
|
+
}
|
|
104
|
+
sendJSON(res, 200, answer);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/** Sends every panel the current state. */
|
|
108
|
+
async changed() {
|
|
109
|
+
if (clients.size === 0) return;
|
|
110
|
+
broadcast('state', await state());
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/** watchApp's handlers. */
|
|
114
|
+
watch: {
|
|
115
|
+
src: () => serially(onSource),
|
|
116
|
+
page: () => serially(async () => broadcast('reload', {})),
|
|
117
|
+
manifest: () => serially(onManifest),
|
|
118
|
+
fixtures: () => serially(onFixtures),
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/** Resolves once every save so far has been applied. */
|
|
122
|
+
settled() {
|
|
123
|
+
return queue;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** Records the scenario file in use, so an unchanged save is ignored. */
|
|
127
|
+
async noteScenario() {
|
|
128
|
+
scenarioRaw = dev.scenario
|
|
129
|
+
? await readFile(join(cwd, dev.scenario.path), 'utf8').catch(() => null)
|
|
130
|
+
: null;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/** Ends every panel connection. */
|
|
134
|
+
close() {
|
|
135
|
+
for (const c of clients) c.end();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const ACTIONS = {
|
|
140
|
+
async set(body) {
|
|
141
|
+
live.set({ states: body.states, controls: body.controls });
|
|
142
|
+
await panel.changed();
|
|
143
|
+
return { values: live.values() };
|
|
144
|
+
},
|
|
145
|
+
async press(body) {
|
|
146
|
+
return live.press(body.binding);
|
|
147
|
+
},
|
|
148
|
+
async scenario(body) {
|
|
149
|
+
const path = body.path ?? null;
|
|
150
|
+
if (path !== null && !(await listScenarios()).includes(path)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${JSON.stringify(path)} is not a scenario under ${FIXTURES}/`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
await switchScenario(path);
|
|
156
|
+
return state();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Applies the scenario at path to a fresh harness; drops the overrides. */
|
|
161
|
+
async function switchScenario(path) {
|
|
162
|
+
await dev.useScenario(path);
|
|
163
|
+
problems.scenario = null;
|
|
164
|
+
await panel.noteScenario();
|
|
165
|
+
live.reset({ clear: true });
|
|
166
|
+
await panel.changed();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function onSource() {
|
|
170
|
+
builder.invalidate();
|
|
171
|
+
try {
|
|
172
|
+
await builder.handlers();
|
|
173
|
+
} catch {
|
|
174
|
+
// Nothing has built yet: builder.error says why.
|
|
175
|
+
}
|
|
176
|
+
if (builder.error) {
|
|
177
|
+
log(`build src/app.ts: ${builder.error.message}`);
|
|
178
|
+
return panel.changed();
|
|
179
|
+
}
|
|
180
|
+
await panel.changed();
|
|
181
|
+
broadcast('reload', {});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function onManifest() {
|
|
185
|
+
let read;
|
|
186
|
+
try {
|
|
187
|
+
read = await readManifestFile(cwd);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
problems.manifest = err.message;
|
|
190
|
+
return panel.changed();
|
|
191
|
+
}
|
|
192
|
+
problems.manifest = null;
|
|
193
|
+
const raw = JSON.stringify(read.manifest);
|
|
194
|
+
if (raw === manifestRaw) return panel.changed();
|
|
195
|
+
manifestRaw = raw;
|
|
196
|
+
dev.setManifest(read.manifest);
|
|
197
|
+
live.reset({ clear: false });
|
|
198
|
+
await panel.changed();
|
|
199
|
+
broadcast('reload', {});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function onFixtures() {
|
|
203
|
+
const path = dev.scenario?.path;
|
|
204
|
+
if (path && (await listScenarios()).includes(path)) {
|
|
205
|
+
const raw = await readFile(join(cwd, path), 'utf8').catch(() => null);
|
|
206
|
+
if (raw !== scenarioRaw) {
|
|
207
|
+
try {
|
|
208
|
+
await switchScenario(path);
|
|
209
|
+
return;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// The scenario in use stays, and says why on the panel.
|
|
212
|
+
problems.scenario = err.message;
|
|
213
|
+
scenarioRaw = raw;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await panel.changed();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function serially(fn) {
|
|
221
|
+
queue = queue.then(fn).catch((err) => log(`app dev: ${err.message}`));
|
|
222
|
+
return queue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function listScenarios() {
|
|
226
|
+
let names;
|
|
227
|
+
try {
|
|
228
|
+
names = await readdir(join(cwd, FIXTURES));
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return names
|
|
233
|
+
.filter((n) => n.endsWith('.json'))
|
|
234
|
+
.sort()
|
|
235
|
+
.map((n) => `${FIXTURES}/${n}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function state() {
|
|
239
|
+
const states = dev.manifest.states ?? {};
|
|
240
|
+
return {
|
|
241
|
+
id: dev.manifest.id,
|
|
242
|
+
scenario: dev.scenario?.path ?? null,
|
|
243
|
+
scenarios: await listScenarios(),
|
|
244
|
+
bindings: {
|
|
245
|
+
states: Object.keys(states)
|
|
246
|
+
.sort()
|
|
247
|
+
.map((name) => ({ name, kind: bindingKind(states[name]) })),
|
|
248
|
+
controls: Object.keys(dev.manifest.controls ?? {}).sort(),
|
|
249
|
+
},
|
|
250
|
+
values: live.values(),
|
|
251
|
+
errors: [
|
|
252
|
+
builder.error && `src/app.ts: ${builder.error.message}`,
|
|
253
|
+
problems.manifest,
|
|
254
|
+
problems.scenario,
|
|
255
|
+
].filter(Boolean),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function broadcast(event, data) {
|
|
260
|
+
for (const c of clients) c.send(event, data);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return panel;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A state binding's kind, as the harness names it. */
|
|
267
|
+
function bindingKind(b) {
|
|
268
|
+
if (b?.slot) return 'slot';
|
|
269
|
+
if (b?.signal) return 'signal';
|
|
270
|
+
return b?.access === 'read_write' ? 'read_write' : 'read';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function methodNotAllowed(res, allow) {
|
|
274
|
+
res.setHeader('Allow', allow);
|
|
275
|
+
sendJSON(res, 405, { error: 'method not allowed' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sendJSON(res, status, body) {
|
|
279
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
280
|
+
res.end(JSON.stringify(body));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readBody(req) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
const chunks = [];
|
|
286
|
+
req.on('data', (c) => chunks.push(c));
|
|
287
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
288
|
+
req.on('error', reject);
|
|
289
|
+
});
|
|
290
|
+
}
|