@joystack/cli 0.1.50 → 0.1.51

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.
@@ -0,0 +1,300 @@
1
+ // The relay's hub, inside the dev server: events from a page the server
2
+ // serves, or from the agent, passed on to every other place something
3
+ // listens. The browser half is shared/ui/relay/relay.js (its README is the
4
+ // contract both halves keep).
5
+ //
6
+ // POST /__joystack/relay publish { id?, name, data, image?, client? }
7
+ // GET /__joystack/relay/stream?listen=… SSE: `hello` (this client's id, what the
8
+ // other places listen for, the agent), then
9
+ // `listens`, `agent` and `event` frames
10
+ // GET /__joystack/relay/status { agent }, for `joystack assets`' warning
11
+ //
12
+ // Who may do what: a page this server served may publish (same origin), and
13
+ // the creator's own pages (this machine, or the owner's live link in the
14
+ // maker: `fromCreator`) may also listen; a phone on the wifi or a --share
15
+ // tester publishes with `from.creator` false and a picture refused, and
16
+ // cannot listen. The agent speaks with the bearer token the server writes to
17
+ // .joystack/relay/.connection-<port> (connection.mjs), from loopback and never
18
+ // from a browser, and is stamped `from.agent`. `from` is stamped here and
19
+ // never taken from the sender.
20
+ //
21
+ // An event goes only where something listens. Each stream names what its
22
+ // page listens for; the hub keeps the union of what the OTHER places listen
23
+ // for per client and streams it, so a window posts only what would reach
24
+ // someone. The hub's own listeners (the laptop conversation, conversation.mjs,
25
+ // when an agent session started the server; a `joystack relay tail`) are in
26
+ // that union like any page.
27
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
28
+ import { sessionAlive } from '../agent-session.mjs';
29
+ import { removeConnection, writeConnection } from './connection.mjs';
30
+ import { createConversation, newToken } from './conversation.mjs';
31
+
32
+ export const RELAY_PATH = '/__joystack/relay';
33
+ // What one publish may weigh: a picture rides only from the creator's
34
+ // browser, so its cap is larger; anyone else sends small JSON only.
35
+ export const MAX_BYTES = 2 * 1024 * 1024;
36
+ export const LOCAL_MAX_BYTES = 8 * 1024 * 1024;
37
+ const MAX_LISTENS = 32;
38
+ const AGENT_POLL_MS = 3000;
39
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/;
40
+ const PATTERN_RE = /^[a-z0-9*][a-z0-9*-]*(\.[a-z0-9*][a-z0-9*-]*)*$/;
41
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
42
+ const PNG_PREFIX = 'data:image/png;base64,';
43
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
44
+
45
+ // A pattern compiled once, when a listen is registered: the hub tests every
46
+ // listen of every client against every event, so each pattern is a RegExp
47
+ // held with its listen rather than split again per event. `*` stands for one
48
+ // segment; alone it matches every name.
49
+ export function matcher(pattern) {
50
+ if (pattern === '*') return /^[a-z0-9.-]+$/;
51
+ const segs = String(pattern).split('.').map((seg) => (seg === '*' ? '[a-z0-9-]+' : seg.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')));
52
+ return new RegExp(`^${segs.join('\\.')}$`);
53
+ }
54
+ const withMatcher = (listen) => ({ ...listen, re: matcher(listen.pattern) });
55
+
56
+ const json = (res, status, body, extra = {}) => {
57
+ const buf = Buffer.from(JSON.stringify(body));
58
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': buf.length, 'Cache-Control': 'no-store', ...extra });
59
+ res.end(buf);
60
+ };
61
+ const text = (res, status, body, extra = {}) => {
62
+ res.writeHead(status, { 'Content-Type': 'text/plain', ...extra });
63
+ res.end(body);
64
+ };
65
+
66
+ // The listen list a stream asked for: `[[pattern, image], …]` as JSON.
67
+ function parseListens(raw) {
68
+ let list;
69
+ try { list = JSON.parse(raw ?? '[]'); } catch { return null; }
70
+ if (!Array.isArray(list) || list.length > MAX_LISTENS) return null;
71
+ const out = [];
72
+ for (const item of list) {
73
+ const [pattern, image] = Array.isArray(item) ? item : [item, false];
74
+ if (typeof pattern !== 'string' || !PATTERN_RE.test(pattern)) return null;
75
+ out.push(withMatcher({ pattern, image: image === true }));
76
+ }
77
+ return out;
78
+ }
79
+
80
+ // The agent's credential: a bearer token from a process on this machine,
81
+ // never a browser (which always sends Origin on a cross-origin request and
82
+ // cannot set this header from a page the server did not serve). 'agent'
83
+ // when it is the server's token, 'refused' when a bearer was offered and is
84
+ // not, null when none was offered.
85
+ function bearer(req, token) {
86
+ const header = String(req.headers.authorization ?? '');
87
+ if (!header.startsWith('Bearer ')) return null;
88
+ const supplied = Buffer.from(header.slice(7));
89
+ const expected = Buffer.from(token);
90
+ const ok = LOOPBACK.has(req.socket.remoteAddress) && !req.headers.origin
91
+ && supplied.length === expected.length && timingSafeEqual(supplied, expected);
92
+ return ok ? 'agent' : 'refused';
93
+ }
94
+
95
+ // The path of the page that published, from the request's Referer; null
96
+ // for the agent, or when the browser sent none.
97
+ function pageOf(req) {
98
+ try {
99
+ const u = new URL(String(req.headers.referer ?? ''));
100
+ return u.pathname;
101
+ } catch { return null; }
102
+ }
103
+
104
+ function readBody(req, res, maxBytes) {
105
+ return new Promise((resolve) => {
106
+ const chunks = [];
107
+ let size = 0;
108
+ let overflowed = false;
109
+ req.on('data', (chunk) => {
110
+ if (overflowed) return; // draining the rest so the client can read the 413
111
+ size += chunk.length;
112
+ if (size > maxBytes) {
113
+ overflowed = true;
114
+ chunks.length = 0;
115
+ text(res, 413, `event too large (max ${maxBytes} bytes)`, { Connection: 'close' });
116
+ return;
117
+ }
118
+ chunks.push(chunk);
119
+ });
120
+ req.on('end', () => resolve(overflowed ? null : Buffer.concat(chunks).toString('utf8')));
121
+ });
122
+ }
123
+
124
+ // What a publish asked for, checked: `{ error }` with the words for a 400,
125
+ // or `{ name, data, image, id, skip }`. A picture is a PNG data URL and only
126
+ // from the creator's browser; an id the sender chose is kept when it is one
127
+ // the hub would have made.
128
+ function parseEvent(raw, creator) {
129
+ let body;
130
+ try { body = JSON.parse(raw); } catch { return { error: 'body must be JSON' }; }
131
+ const name = body?.name;
132
+ if (typeof name !== 'string' || !NAME_RE.test(name)) return { error: 'an event is named <publisher>.<what>' };
133
+ const { image } = body;
134
+ if (image !== undefined && (typeof image !== 'string' || !image.startsWith(PNG_PREFIX))) return { error: 'image must be a PNG data URL' };
135
+ if (image !== undefined && !creator) return { error: 'only the creator\'s own browser can attach a picture' };
136
+ return {
137
+ name,
138
+ data: body.data === undefined ? null : body.data,
139
+ image,
140
+ id: typeof body.id === 'string' && ID_RE.test(body.id) ? body.id : randomBytes(4).toString('hex'),
141
+ skip: typeof body.client === 'string' ? body.client : null,
142
+ };
143
+ }
144
+
145
+ export function createRelay({
146
+ projectDir, agentSession = null, log = () => {}, cliBin = 'joystack',
147
+ // The dev server's own trust checks (static-server.mjs): whether a request
148
+ // came from a page this server served, and from the creator's own browser.
149
+ sameOrigin = () => true, fromCreator = () => true,
150
+ }) {
151
+ const token = newToken();
152
+ const clients = new Set(); // { id, res, listens, agent }
153
+ const own = []; // the hub's own listeners: { pattern, image, deliver }
154
+ if (agentSession) own.push(withMatcher(createConversation({ projectDir, agentSession, log, cliBin })));
155
+ let port = null;
156
+ let agentTimer = null;
157
+ let lastAgent = null;
158
+
159
+ const agentNow = () => (sessionAlive(agentSession) ? agentSession.agent : null);
160
+ const send = (client, frame) => { try { client.res.write(`data: ${JSON.stringify(frame)}\n\n`); } catch { /* gone */ } };
161
+
162
+ // What everyone but `client` listens for, one entry per pattern, `image`
163
+ // when any of them asked for the picture.
164
+ function othersFor(client) {
165
+ const union = new Map();
166
+ const add = ({ pattern, image }) => { union.set(pattern, { pattern, image: image || union.get(pattern)?.image || false }); };
167
+ for (const c of clients) if (c !== client) c.listens.forEach(add);
168
+ own.forEach(add);
169
+ return [...union.values()];
170
+ }
171
+ const broadcastListens = () => { for (const c of clients) send(c, { type: 'listens', others: othersFor(c) }); };
172
+
173
+ // The agent coming and going is a session file appearing or vanishing,
174
+ // which no event announces, so it is looked at every few seconds while a
175
+ // page is listening, and told only when it changes.
176
+ function watchAgent() {
177
+ if (agentTimer || !agentSession) return;
178
+ lastAgent = agentNow();
179
+ agentTimer = setInterval(() => {
180
+ if (!clients.size) { clearInterval(agentTimer); agentTimer = null; return; }
181
+ const now = agentNow();
182
+ if (now === lastAgent) return;
183
+ lastAgent = now;
184
+ for (const c of clients) send(c, { type: 'agent', agent: now });
185
+ }, AGENT_POLL_MS);
186
+ agentTimer.unref();
187
+ }
188
+
189
+ // Deliver a stamped event: to every client (but the one that published it)
190
+ // whose listens match, with the picture only to one that asked; and to the
191
+ // hub's own listeners. Resolves { delivered, agent }.
192
+ async function deliver(event, image, skip) {
193
+ let delivered = 0;
194
+ for (const c of clients) {
195
+ if (skip && c.id === skip) continue;
196
+ const hits = c.listens.filter((l) => l.re.test(event.name));
197
+ if (!hits.length) continue;
198
+ const wantsImage = hits.some((l) => l.image) && image !== undefined;
199
+ send(c, { type: 'event', event: wantsImage ? { ...event, image } : event });
200
+ delivered += 1;
201
+ }
202
+ let agent = false;
203
+ for (const l of own) {
204
+ if (!l.re.test(event.name)) continue;
205
+ try {
206
+ const out = await l.deliver(event, { image: l.image ? image : undefined });
207
+ if (out?.agent) agent = true;
208
+ } catch (err) {
209
+ log(`relay: a listener for ${l.pattern} failed (${err?.message ?? err})`);
210
+ }
211
+ }
212
+ return { delivered, agent };
213
+ }
214
+
215
+ async function handlePublish(req, res) {
216
+ if (req.method !== 'POST') return text(res, 405, 'POST only', { Allow: 'POST' });
217
+ const auth = bearer(req, token);
218
+ if (auth === 'refused') return json(res, 403, { error: 'only `joystack relay` on this machine may publish as the agent' });
219
+ const agent = auth === 'agent';
220
+ // Only a page this server itself served may publish: a hostile website
221
+ // in the creator's browser can fire a no-preflight POST at localhost, and
222
+ // what is published here reaches an AI agent as data from the game.
223
+ if (!agent && !sameOrigin(req)) return text(res, 403, 'cross-origin post rejected');
224
+ const creator = agent || fromCreator(req);
225
+ const raw = await readBody(req, res, creator ? LOCAL_MAX_BYTES : MAX_BYTES);
226
+ if (raw === null) return undefined;
227
+ const asked = parseEvent(raw, creator);
228
+ if (asked.error) return text(res, 400, asked.error);
229
+ const { name, data, image, id, skip } = asked;
230
+ const event = { id, name, at: new Date().toISOString(), from: { page: agent ? null : pageOf(req), creator, agent }, data };
231
+ const out = await deliver(event, image, skip);
232
+ const label = typeof data?.label === 'string' ? ` ${JSON.stringify(data.label.slice(0, 80))}` : '';
233
+ log(`relay: ${name}${label} → ${out.delivered} listener${out.delivered === 1 ? '' : 's'}${out.agent ? ', the agent woken' : ''}`);
234
+ return json(res, 200, { ok: true, id, delivered: out.delivered, agent: out.agent });
235
+ }
236
+
237
+ function handleStream(req, res, query) {
238
+ if (req.method !== 'GET') return text(res, 405, 'GET only', { Allow: 'GET' });
239
+ const auth = bearer(req, token);
240
+ if (auth === 'refused') return json(res, 403, { error: 'only `joystack relay` on this machine may listen as the agent' });
241
+ const agent = auth === 'agent';
242
+ // The stream carries what the creator's tools say and show, so only the
243
+ // creator's own pages may open one. A browser names where a request came
244
+ // from; a page from another site is refused even where CORS would stop
245
+ // it from reading the answer.
246
+ const site = req.headers['sec-fetch-site'];
247
+ const siteOk = site === undefined || site === 'same-origin' || site === 'none';
248
+ if (!agent && !(fromCreator(req) && sameOrigin(req) && siteOk)) return text(res, 403, 'the relay is the creator\'s own');
249
+ const listens = agent && !query.has('listen') ? [withMatcher({ pattern: '*', image: false })] : parseListens(query.get('listen'));
250
+ if (!listens) return text(res, 400, 'listen must be a JSON list of [pattern, image] pairs');
251
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
252
+ res.write(':connected\n\n');
253
+ const client = { id: randomBytes(4).toString('hex'), res, listens, agent };
254
+ clients.add(client);
255
+ send(client, { type: 'hello', client: client.id, others: othersFor(client), agent: agentNow() });
256
+ broadcastListens();
257
+ watchAgent();
258
+ const keepalive = setInterval(() => res.write(':keepalive\n\n'), 10_000);
259
+ keepalive.unref();
260
+ res.on('close', () => {
261
+ clearInterval(keepalive);
262
+ clients.delete(client);
263
+ broadcastListens();
264
+ });
265
+ return undefined;
266
+ }
267
+
268
+ // Whether an agent session is connected. Only the creator may read it, as
269
+ // for the stream; the custom header forces a CORS preflight the server never
270
+ // grants, so no foreign page can read it.
271
+ function handleStatus(req, res) {
272
+ if (!fromCreator(req) || !sameOrigin(req) || req.headers['x-joystack-agent-ui'] !== '1') return text(res, 403, 'dev tools only');
273
+ return json(res, 200, { agent: agentNow() });
274
+ }
275
+
276
+ return {
277
+ // Answers a request under RELAY_PATH; anything else is not the relay's.
278
+ handle(req, res, urlPath, query = new URLSearchParams()) {
279
+ if (urlPath === RELAY_PATH) return handlePublish(req, res).catch((err) => { log(`relay: ${err?.message ?? err}`); if (!res.headersSent) text(res, 500, '500'); });
280
+ if (urlPath === `${RELAY_PATH}/stream`) return handleStream(req, res, query);
281
+ if (urlPath === `${RELAY_PATH}/status`) return handleStatus(req, res);
282
+ return text(res, 404, 'not found');
283
+ },
284
+ // The server is listening on `port`: write the agent's credential.
285
+ attach(listeningPort) {
286
+ port = listeningPort;
287
+ try { writeConnection(projectDir, port, token); } catch (err) { log(`relay: the agent cannot publish (${err?.message ?? err})`); }
288
+ },
289
+ agent: agentNow,
290
+ // For a test: what every other place listens for, as a fresh stream would be told.
291
+ others: () => othersFor(null),
292
+ close() {
293
+ if (agentTimer) clearInterval(agentTimer);
294
+ agentTimer = null;
295
+ for (const c of clients) { try { c.res.end(); } catch { /* gone */ } }
296
+ clients.clear();
297
+ if (port !== null) removeConnection(projectDir, port, token);
298
+ },
299
+ };
300
+ }