@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,347 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { extname, join, posix } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The URL segment the instance answers itself under /<id>/
|
|
7
|
+
* (internal/appserve's reservedSegment); ingest refuses an asset under it, so
|
|
8
|
+
* it never shadows a file here either.
|
|
9
|
+
*/
|
|
10
|
+
const RESERVED = '_juno';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The version every response names. The dev server has one build of the app
|
|
14
|
+
* and never a second, so the client's skew check (`X-Juno-App-Version`
|
|
15
|
+
* against the stamp) never fires.
|
|
16
|
+
*/
|
|
17
|
+
export const DEV_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
/** The client the instance serves, byte for byte (TestAppClientCLICopy). */
|
|
20
|
+
const CLIENT = readFile(new URL('../../app-client.js', import.meta.url));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Content types by extension, for public/. The instance uses Go's
|
|
24
|
+
* mime.TypeByExtension; Node has no table of its own, so this is the part of
|
|
25
|
+
* it a page plausibly serves. Anything else is application/octet-stream.
|
|
26
|
+
*/
|
|
27
|
+
const CONTENT_TYPES = {
|
|
28
|
+
'.avif': 'image/avif',
|
|
29
|
+
'.css': 'text/css; charset=utf-8',
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.htm': 'text/html; charset=utf-8',
|
|
32
|
+
'.html': 'text/html; charset=utf-8',
|
|
33
|
+
'.ico': 'image/vnd.microsoft.icon',
|
|
34
|
+
'.jpeg': 'image/jpeg',
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
37
|
+
'.json': 'application/json',
|
|
38
|
+
'.map': 'application/json',
|
|
39
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
40
|
+
'.mp3': 'audio/mpeg',
|
|
41
|
+
'.mp4': 'video/mp4',
|
|
42
|
+
'.pdf': 'application/pdf',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.svg': 'image/svg+xml',
|
|
45
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
46
|
+
'.wasm': 'application/wasm',
|
|
47
|
+
'.webm': 'video/webm',
|
|
48
|
+
'.webmanifest': 'application/manifest+json',
|
|
49
|
+
'.webp': 'image/webp',
|
|
50
|
+
'.woff': 'font/woff',
|
|
51
|
+
'.woff2': 'font/woff2',
|
|
52
|
+
'.xml': 'text/xml; charset=utf-8',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* createDevServer is `juno app dev`'s HTTP server: the instance's apps
|
|
57
|
+
* listener (internal/appserve) for one app, answered from the working copy
|
|
58
|
+
* instead of a stored version.
|
|
59
|
+
*
|
|
60
|
+
* | Request | Answer |
|
|
61
|
+
* | ----------------------------- | -------------------------------------------- |
|
|
62
|
+
* | `GET /` | `routes.shell`: the page in a frame |
|
|
63
|
+
* | `/_dev/<rest>` | `routes.dev` |
|
|
64
|
+
* | `GET /<id>` | 301 → `/<id>/` |
|
|
65
|
+
* | `GET /<id>/`, `/<id>/<entry>` | `page/<entry>` as written |
|
|
66
|
+
* | `GET /<id>/<path>.ts` | that page/ module, bundled for the browser |
|
|
67
|
+
* | `GET /<id>/<path>` | `public/<path>` |
|
|
68
|
+
* | `GET /<id>/_juno/app.js` | the stamp line and the client |
|
|
69
|
+
* | `POST /<id>/_juno/call/<h>` | the harness's answer, as the invoker maps it |
|
|
70
|
+
* | `GET /<id>/_juno/stream` | `routes.stream` |
|
|
71
|
+
*
|
|
72
|
+
* `routes` is the extension point: each member is `(req, res, url)` and owns
|
|
73
|
+
* the response. Left out, `shell` frames the page and `dev` and `stream` are
|
|
74
|
+
* 404s. They are copied onto the returned object's `routes`, which is what
|
|
75
|
+
* the server reads per request, so a member set there later takes effect.
|
|
76
|
+
*
|
|
77
|
+
* dev is the harness (createDevHarness), builder the esbuild wrapper
|
|
78
|
+
* (createBuilder); log receives one line per failed call.
|
|
79
|
+
*/
|
|
80
|
+
export function createDevServer({
|
|
81
|
+
cwd,
|
|
82
|
+
dev,
|
|
83
|
+
builder,
|
|
84
|
+
routes = {},
|
|
85
|
+
log = () => {},
|
|
86
|
+
}) {
|
|
87
|
+
const srv = {
|
|
88
|
+
routes: Object.assign({ shell: serveShell }, routes),
|
|
89
|
+
server: createServer((req, res) => {
|
|
90
|
+
handle(req, res).catch((err) => {
|
|
91
|
+
log(`${req.method} ${req.url}: ${err.message}`);
|
|
92
|
+
if (!res.headersSent) sendText(res, 500, 'internal error');
|
|
93
|
+
else res.destroy();
|
|
94
|
+
});
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
/** Listens on 127.0.0.1 only and resolves to the port. */
|
|
98
|
+
listen(port) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
srv.server.once('error', reject);
|
|
101
|
+
srv.server.listen(port, '127.0.0.1', () => {
|
|
102
|
+
srv.server.off('error', reject);
|
|
103
|
+
resolve(srv.server.address().port);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** Stops listening and drops every open connection (a stream included). */
|
|
109
|
+
close() {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
srv.server.close(() => resolve());
|
|
112
|
+
srv.server.closeAllConnections();
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
async function handle(req, res) {
|
|
118
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
119
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
120
|
+
const url = new URL(req.url, 'http://localhost');
|
|
121
|
+
const id = dev.manifest.id;
|
|
122
|
+
|
|
123
|
+
if (url.pathname === '/') {
|
|
124
|
+
if (!allowGet(req, res)) return;
|
|
125
|
+
return srv.routes.shell(req, res, url);
|
|
126
|
+
}
|
|
127
|
+
if (url.pathname.startsWith('/_dev/')) {
|
|
128
|
+
return route(srv.routes.dev, req, res, url);
|
|
129
|
+
}
|
|
130
|
+
if (url.pathname === `/${id}`) {
|
|
131
|
+
res.writeHead(301, { Location: `/${id}/${url.search}` });
|
|
132
|
+
return res.end();
|
|
133
|
+
}
|
|
134
|
+
if (!url.pathname.startsWith(`/${id}/`)) {
|
|
135
|
+
return sendHTML(res, 404, 'This app is not available');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Only the app's own frame, the shell's, may embed it.
|
|
139
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
|
|
140
|
+
const rest = safePath(url.pathname.slice(id.length + 2));
|
|
141
|
+
if (rest === null) return sendText(res, 404, 'not found');
|
|
142
|
+
|
|
143
|
+
if (rest.startsWith(`${RESERVED}/`)) {
|
|
144
|
+
const after = rest.slice(RESERVED.length + 1);
|
|
145
|
+
if (after === 'app.js') return serveClient(req, res, id);
|
|
146
|
+
if (after === 'stream') return route(srv.routes.stream, req, res, url);
|
|
147
|
+
if (after.startsWith('call/')) {
|
|
148
|
+
return serveCall(req, res, id, after.slice('call/'.length));
|
|
149
|
+
}
|
|
150
|
+
return sendText(res, 404, 'not found');
|
|
151
|
+
}
|
|
152
|
+
if (!allowGet(req, res)) return;
|
|
153
|
+
const entry = dev.manifest.entry || 'index.html';
|
|
154
|
+
if (rest === '' || rest === entry) {
|
|
155
|
+
return serveFile(
|
|
156
|
+
req,
|
|
157
|
+
res,
|
|
158
|
+
join(cwd, 'page', entry),
|
|
159
|
+
CONTENT_TYPES['.html']
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (rest.endsWith('.ts')) return servePageModule(req, res, rest);
|
|
163
|
+
return serveFile(req, res, join(cwd, 'public', rest), contentType(rest));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The default `/`: the page in a full-window frame, so it runs at /<id>/. */
|
|
167
|
+
function serveShell(_req, res) {
|
|
168
|
+
const id = escapeHTML(dev.manifest.id);
|
|
169
|
+
sendHTML(
|
|
170
|
+
res,
|
|
171
|
+
200,
|
|
172
|
+
`<!doctype html>\n<meta charset="utf-8">\n<title>${id} — juno app dev</title>\n` +
|
|
173
|
+
'<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%}</style>\n' +
|
|
174
|
+
`<iframe src="/${id}/"></iframe>\n`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function serveClient(req, res, id) {
|
|
179
|
+
if (!allowGet(req, res)) return;
|
|
180
|
+
const stamp = JSON.stringify({
|
|
181
|
+
id,
|
|
182
|
+
version: DEV_VERSION,
|
|
183
|
+
base: `/${id}/`,
|
|
184
|
+
});
|
|
185
|
+
const body = Buffer.concat([
|
|
186
|
+
Buffer.from(`window.__JUNO_APP__=${stamp};\n`),
|
|
187
|
+
await CLIENT,
|
|
188
|
+
]);
|
|
189
|
+
send(req, res, 200, CONTENT_TYPES['.js'], body);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A page/ module, built on this request. A `<script type="module">` runs
|
|
194
|
+
* whatever arrives with a JavaScript content type, whatever the URL's
|
|
195
|
+
* extension, so the page needs no rewriting (decision 3). A build failure
|
|
196
|
+
* is a 500 carrying esbuild's message.
|
|
197
|
+
*/
|
|
198
|
+
async function servePageModule(req, res, rest) {
|
|
199
|
+
const abs = join(cwd, 'page', rest);
|
|
200
|
+
if (!(await isFile(abs))) return sendText(res, 404, 'not found');
|
|
201
|
+
let js;
|
|
202
|
+
try {
|
|
203
|
+
js = await builder.page(abs);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
log(`build page/${rest}: ${err.message}`);
|
|
206
|
+
return sendText(res, 500, err.message);
|
|
207
|
+
}
|
|
208
|
+
send(req, res, 200, CONTENT_TYPES['.js'], Buffer.from(js));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function serveFile(req, res, abs, type) {
|
|
212
|
+
if (!(await isFile(abs))) return sendText(res, 404, 'not found');
|
|
213
|
+
send(req, res, 200, type, await readFile(abs));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One handler call, in the order internal/apps/invoker.go decides it: the
|
|
218
|
+
* handler (404), then the body (400; an empty one is {}), then the call. A
|
|
219
|
+
* throw, a harness refusal and a build failure are all 500s carrying the
|
|
220
|
+
* message. Not simulated: the timeout, the in-flight cap, the body cap.
|
|
221
|
+
*/
|
|
222
|
+
async function serveCall(req, res, id, handler) {
|
|
223
|
+
if (req.method !== 'POST') {
|
|
224
|
+
res.setHeader('Allow', 'POST');
|
|
225
|
+
return sendCall(res, 405, { error: 'handlers are called with POST' });
|
|
226
|
+
}
|
|
227
|
+
const raw = await readBody(req);
|
|
228
|
+
const handlers = dev.manifest.handlers ?? {};
|
|
229
|
+
if (!Object.hasOwn(handlers, handler)) {
|
|
230
|
+
const have = Object.keys(handlers).sort().join(', ');
|
|
231
|
+
return sendCall(res, 404, {
|
|
232
|
+
error: `app ${JSON.stringify(id)} has no handler ${JSON.stringify(handler)} (have: ${have})`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
let input;
|
|
236
|
+
try {
|
|
237
|
+
input = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return sendCall(res, 400, {
|
|
240
|
+
error: `body is not valid JSON: ${err.message}`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
let result;
|
|
244
|
+
try {
|
|
245
|
+
result = await dev.call(handler, input);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log(`${handler}: ${err.message}`);
|
|
248
|
+
return sendCall(res, 500, { error: err.message });
|
|
249
|
+
}
|
|
250
|
+
sendCall(res, 200, { result });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return srv;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Hands the request to an optional route, or answers 404 without one. */
|
|
257
|
+
function route(fn, req, res, url) {
|
|
258
|
+
if (typeof fn === 'function') return fn(req, res, url);
|
|
259
|
+
return sendText(res, 404, 'not found');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The path under /<id>/, decoded and normalised, or null for one that
|
|
264
|
+
* escapes it. A trailing slash is kept, so `dir/` names no file.
|
|
265
|
+
*/
|
|
266
|
+
function safePath(encoded) {
|
|
267
|
+
let rel;
|
|
268
|
+
try {
|
|
269
|
+
rel = decodeURIComponent(encoded);
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (rel.includes('\0') || rel.includes('\\')) return null;
|
|
274
|
+
if (rel === '') return '';
|
|
275
|
+
const norm = posix.normalize(rel);
|
|
276
|
+
if (norm === '..' || norm.startsWith('../') || norm.startsWith('/')) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return norm;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function contentType(rel) {
|
|
283
|
+
return (
|
|
284
|
+
CONTENT_TYPES[extname(rel).toLowerCase()] ?? 'application/octet-stream'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function isFile(abs) {
|
|
289
|
+
try {
|
|
290
|
+
return (await stat(abs)).isFile();
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function allowGet(req, res) {
|
|
297
|
+
if (req.method === 'GET' || req.method === 'HEAD') return true;
|
|
298
|
+
res.setHeader('Allow', 'GET, HEAD');
|
|
299
|
+
sendText(res, 405, 'method not allowed');
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function readBody(req) {
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
const chunks = [];
|
|
306
|
+
req.on('data', (c) => chunks.push(c));
|
|
307
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
308
|
+
req.on('error', reject);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A body for GET, headers only for HEAD. */
|
|
313
|
+
function send(req, res, status, type, body) {
|
|
314
|
+
res.writeHead(status, {
|
|
315
|
+
'Content-Type': type,
|
|
316
|
+
'Content-Length': body.length,
|
|
317
|
+
});
|
|
318
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function sendCall(res, status, body) {
|
|
322
|
+
res.writeHead(status, {
|
|
323
|
+
'Content-Type': 'application/json',
|
|
324
|
+
'X-Juno-App-Version': String(DEV_VERSION),
|
|
325
|
+
});
|
|
326
|
+
res.end(JSON.stringify(body));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sendText(res, status, text) {
|
|
330
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
331
|
+
res.end(text);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function sendHTML(res, status, html) {
|
|
335
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
336
|
+
res.end(html);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function escapeHTML(s) {
|
|
340
|
+
return String(s).replace(
|
|
341
|
+
/[&<>"']/g,
|
|
342
|
+
(c) =>
|
|
343
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
|
|
344
|
+
c
|
|
345
|
+
]
|
|
346
|
+
);
|
|
347
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** How often an open stream sends a `: ping` comment, as the instance does. */
|
|
2
|
+
export const PING_MS = 20_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a control stands with nothing said about it: the instance's answer for
|
|
6
|
+
* a button and for an idle control (appserve.ControlValue, `null`s sent).
|
|
7
|
+
*/
|
|
8
|
+
const IDLE = Object.freeze({ value: null, active: false, expires_at: null });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* createLive is the dev server's `_juno/stream` (plan-local-dev-6 decision
|
|
12
|
+
* 6), in the instance's wire shape (internal/appserve, `.claude/rules/
|
|
13
|
+
* apps.md` "Live values"):
|
|
14
|
+
*
|
|
15
|
+
* | Event | Data | When |
|
|
16
|
+
* | ---------- | -------------------------------- | ---------------------------- |
|
|
17
|
+
* | `snapshot` | `{"values": {states, controls}}` | on connect, and on `reset()` |
|
|
18
|
+
* | `update` | `{"values": {states, controls}}` | `set()`, one per moved map |
|
|
19
|
+
* | `pressed` | `{"binding", "at"}` | `press()` |
|
|
20
|
+
* | (comment) | `: ping` | every `pingMs` |
|
|
21
|
+
*
|
|
22
|
+
* Values are keyed by binding, as the instance keys them: every declared
|
|
23
|
+
* state binding under `states` (the scenario's value, else null) and every
|
|
24
|
+
* control binding under `controls` (idle). The panel's overrides sit on top.
|
|
25
|
+
* A read or read_write binding's override is also seeded into the harness,
|
|
26
|
+
* so a handler reads what the page is shown; a slot or signal binding holds
|
|
27
|
+
* no value a handler can read, so its override reaches the page only.
|
|
28
|
+
*
|
|
29
|
+
* `stale` is never sent: the dev server serves one version, whatever `?v=`
|
|
30
|
+
* the client names. A handler's claim or set moves no value here, because
|
|
31
|
+
* the layer stack is the instance's, as it is in the harness.
|
|
32
|
+
*
|
|
33
|
+
* dev is the harness (createDevHarness). Telling the panel the values moved
|
|
34
|
+
* is the caller's (createPanel).
|
|
35
|
+
*/
|
|
36
|
+
export function createLive({ dev, pingMs = PING_MS }) {
|
|
37
|
+
const streams = new Set();
|
|
38
|
+
// The panel's overrides, by binding. Cleared by a scenario switch.
|
|
39
|
+
let overrides = { states: {}, controls: {} };
|
|
40
|
+
|
|
41
|
+
const live = {
|
|
42
|
+
/** The values a snapshot would carry now. */
|
|
43
|
+
values() {
|
|
44
|
+
const states = {};
|
|
45
|
+
for (const name of Object.keys(dev.manifest.states ?? {})) {
|
|
46
|
+
states[name] = Object.hasOwn(overrides.states, name)
|
|
47
|
+
? overrides.states[name]
|
|
48
|
+
: (dev.scenario?.value?.states?.[name] ?? null);
|
|
49
|
+
}
|
|
50
|
+
const controls = {};
|
|
51
|
+
for (const name of Object.keys(dev.manifest.controls ?? {})) {
|
|
52
|
+
controls[name] = overrides.controls[name] ?? { ...IDLE };
|
|
53
|
+
}
|
|
54
|
+
return { states, controls };
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Moves values, `{states?, controls?}` by binding, and sends one
|
|
59
|
+
* `update` per map that moved, the other map `{}` — as the instance
|
|
60
|
+
* sends a settle generation and a control change separately. A binding
|
|
61
|
+
* the manifest does not declare, or a control value that is not an
|
|
62
|
+
* object, throws before anything moves.
|
|
63
|
+
*/
|
|
64
|
+
set({ states = {}, controls = {} } = {}) {
|
|
65
|
+
const declared = {
|
|
66
|
+
states: dev.manifest.states ?? {},
|
|
67
|
+
controls: dev.manifest.controls ?? {},
|
|
68
|
+
};
|
|
69
|
+
for (const [section, map] of Object.entries({ states, controls })) {
|
|
70
|
+
if (map === null || typeof map !== 'object' || Array.isArray(map)) {
|
|
71
|
+
throw new Error(`"${section}" maps bindings to values`);
|
|
72
|
+
}
|
|
73
|
+
for (const name of Object.keys(map)) {
|
|
74
|
+
if (!Object.hasOwn(declared[section], name)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`"${name}" is not declared in manifest.json ${section} (have: ${Object.keys(declared[section]).sort().join(', ') || '(none)'})`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const movedControls = {};
|
|
82
|
+
for (const [name, v] of Object.entries(controls)) {
|
|
83
|
+
movedControls[name] = controlValue(name, v);
|
|
84
|
+
}
|
|
85
|
+
const seeded = {};
|
|
86
|
+
for (const [name, v] of Object.entries(states)) {
|
|
87
|
+
if (holdsValue(declared.states[name])) seeded[name] = v;
|
|
88
|
+
}
|
|
89
|
+
dev.h.states(seeded);
|
|
90
|
+
|
|
91
|
+
Object.assign(overrides.states, states);
|
|
92
|
+
Object.assign(overrides.controls, movedControls);
|
|
93
|
+
if (Object.keys(states).length) {
|
|
94
|
+
broadcast('update', { values: { states, controls: {} } });
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(movedControls).length) {
|
|
97
|
+
broadcast('update', {
|
|
98
|
+
values: { states: {}, controls: movedControls },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** Sends `pressed` for a declared control binding, at now. */
|
|
104
|
+
press(binding) {
|
|
105
|
+
const controls = dev.manifest.controls ?? {};
|
|
106
|
+
if (typeof binding !== 'string' || !Object.hasOwn(controls, binding)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`"${binding}" is not declared in manifest.json controls (have: ${Object.keys(controls).sort().join(', ') || '(none)'})`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const data = { binding, at: new Date().toISOString() };
|
|
112
|
+
broadcast('pressed', data);
|
|
113
|
+
return data;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* After the harness was rebuilt (a scenario switch, a manifest save):
|
|
118
|
+
* drops the overrides with `clear`, or keeps those whose binding is still
|
|
119
|
+
* declared and seeds them into the fresh harness, then sends every open
|
|
120
|
+
* stream a new snapshot.
|
|
121
|
+
*/
|
|
122
|
+
reset({ clear }) {
|
|
123
|
+
if (clear) {
|
|
124
|
+
overrides = { states: {}, controls: {} };
|
|
125
|
+
} else {
|
|
126
|
+
const states = dev.manifest.states ?? {};
|
|
127
|
+
const controls = dev.manifest.controls ?? {};
|
|
128
|
+
overrides = {
|
|
129
|
+
states: pick(overrides.states, states),
|
|
130
|
+
controls: pick(overrides.controls, controls),
|
|
131
|
+
};
|
|
132
|
+
const seeded = {};
|
|
133
|
+
for (const [name, v] of Object.entries(overrides.states)) {
|
|
134
|
+
if (holdsValue(states[name])) seeded[name] = v;
|
|
135
|
+
}
|
|
136
|
+
dev.h.states(seeded);
|
|
137
|
+
}
|
|
138
|
+
broadcast('snapshot', { values: live.values() });
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/** The `_juno/stream` route. */
|
|
142
|
+
serve(req, res) {
|
|
143
|
+
if (req.method !== 'GET') {
|
|
144
|
+
res.writeHead(405, {
|
|
145
|
+
Allow: 'GET',
|
|
146
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
147
|
+
});
|
|
148
|
+
return res.end('method not allowed');
|
|
149
|
+
}
|
|
150
|
+
const stream = openStream(res, pingMs, () => streams.delete(stream));
|
|
151
|
+
streams.add(stream);
|
|
152
|
+
stream.send('snapshot', { values: live.values() });
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/** Ends every open stream. */
|
|
156
|
+
close() {
|
|
157
|
+
for (const s of streams) s.end();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function broadcast(event, data) {
|
|
162
|
+
for (const s of streams) s.send(event, data);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return live;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* openStream starts a server-sent event response with the instance's
|
|
170
|
+
* headers, and a `: ping` comment every pingMs so an idle connection is kept.
|
|
171
|
+
* The timer stops when the response closes, by either side. Returns
|
|
172
|
+
* `{send(event, data), end()}`.
|
|
173
|
+
*/
|
|
174
|
+
export function openStream(res, pingMs, onClose = () => {}) {
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
'Content-Type': 'text/event-stream',
|
|
177
|
+
'Cache-Control': 'no-cache',
|
|
178
|
+
'X-Accel-Buffering': 'no',
|
|
179
|
+
});
|
|
180
|
+
res.flushHeaders();
|
|
181
|
+
const ping = setInterval(() => res.write(': ping\n\n'), pingMs);
|
|
182
|
+
res.on('close', () => {
|
|
183
|
+
clearInterval(ping);
|
|
184
|
+
onClose();
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
// One line of JSON, which never contains a raw newline.
|
|
188
|
+
send(event, data) {
|
|
189
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
190
|
+
},
|
|
191
|
+
end() {
|
|
192
|
+
clearInterval(ping);
|
|
193
|
+
res.end();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A read or read_write binding: what h.states seeds and a handler reads. */
|
|
199
|
+
function holdsValue(binding) {
|
|
200
|
+
return !binding?.slot && !binding?.signal;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A control's value as the instance sends it: the three fields, nulls kept. */
|
|
204
|
+
function controlValue(name, v) {
|
|
205
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`controls "${name}" — expected {value, active, expires_at}, got ${JSON.stringify(v)}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (v.expires_at != null && typeof v.expires_at !== 'string') {
|
|
211
|
+
throw new Error(`controls "${name}" — expires_at must be a string or null`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
value: v.value ?? null,
|
|
215
|
+
active: v.active === true,
|
|
216
|
+
expires_at: v.expires_at ?? null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pick(map, declared) {
|
|
221
|
+
return Object.fromEntries(
|
|
222
|
+
Object.entries(map).filter(([name]) => Object.hasOwn(declared, name))
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { watchPaths } from '../watch.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* watchApp is `juno app dev`'s rebuild on save (plan-local-dev-6 decision 9):
|
|
7
|
+
* one debounced watch per part of the working copy, so a save reaches the
|
|
8
|
+
* one handler that knows what to rebuild.
|
|
9
|
+
*
|
|
10
|
+
* | Handler | Watches |
|
|
11
|
+
* | ---------- | ---------------------------------------- |
|
|
12
|
+
* | `manifest` | `manifest.json` |
|
|
13
|
+
* | `src` | `src/`, recursively |
|
|
14
|
+
* | `page` | `page/` and `public/`, recursively |
|
|
15
|
+
* | `fixtures` | `test/fixtures/`, recursively |
|
|
16
|
+
*
|
|
17
|
+
* manifest.json is watched through its directory, filtered by name, because
|
|
18
|
+
* a watch on the file itself is lost the first time an editor saves by
|
|
19
|
+
* renaming over it. A directory absent at start is not watched. Returns
|
|
20
|
+
* `{close()}`.
|
|
21
|
+
*/
|
|
22
|
+
export function watchApp(cwd, on, { debounceMs } = {}) {
|
|
23
|
+
const groups = {
|
|
24
|
+
manifest: [
|
|
25
|
+
{
|
|
26
|
+
path: cwd,
|
|
27
|
+
filter: (name) => name === null || name === 'manifest.json',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
src: [{ path: join(cwd, 'src'), recursive: true }],
|
|
31
|
+
page: [
|
|
32
|
+
{ path: join(cwd, 'page'), recursive: true },
|
|
33
|
+
{ path: join(cwd, 'public'), recursive: true },
|
|
34
|
+
],
|
|
35
|
+
fixtures: [{ path: join(cwd, 'test', 'fixtures'), recursive: true }],
|
|
36
|
+
};
|
|
37
|
+
const watchers = [];
|
|
38
|
+
for (const [group, paths] of Object.entries(groups)) {
|
|
39
|
+
if (typeof on[group] !== 'function') continue;
|
|
40
|
+
watchers.push(watchPaths(paths, on[group], { debounceMs }));
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
close() {
|
|
44
|
+
for (const w of watchers) w.close();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|