@joystack/cli 0.1.55 → 0.1.56

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.
@@ -1,300 +1,310 @@
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).
1
+ // The relay's dev server end: `joystack dev` as the other end of a host's
2
+ // `server()` connection (shared/ui/relay/server.js, whose README is the
3
+ // manual). A host page asks; the dev server answers what it was asked, and
4
+ // announces a fact to the creator's own pages. It never asks a page
5
+ // anything, and never passes one page's message to another.
5
6
  //
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
7
+ // POST /__joystack/relay { name, data } answered in a 200 with
8
+ // { ok: true, value } or
9
+ // { ok: false, reason, message }
10
+ // announcements on the page's reload stream, as SSE events named
11
+ // `relay`, `{ name, data }`, only down a stream a
12
+ // creator's page opened (static-server.mjs)
11
13
  //
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.
14
+ // The names a page may ask:
15
+ // server.agent who would be woken (a name), or null
16
+ // server.picks whether this server takes picks: true where its
17
+ // feed for Claude Code is on (a laptop), false where
18
+ // it is off (the maker's machine, whose page takes
19
+ // them)
20
+ // note.reply { id } the agent's reply to that note, { text, at }, or
21
+ // null; a reply is kept for a day
22
+ // each name on the engine's agent feed (shared/ui/relay/agent-feed.json):
23
+ // its line in the dev server's log, which Claude Code
24
+ // reads in .joystack/dev/last.log, and the agent woken
25
+ // for an entry with `wakes_up_agent`. `note.sent`
26
+ // files the note and answers { id, agent }; the
27
+ // others answer { agent }.
28
+ // The name the agent asks, with the token only it can read
29
+ // (`joystack relay reply`):
30
+ // agent.reply { to, text } kept by the note's id, and `note.replied { id }`
31
+ // announced
32
+ // The dev server's other announcement: `server.agent { agent }` when the
33
+ // agent comes or goes.
20
34
  //
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';
35
+ // Who may ask: only the creator's own pages (this machine, or the owner's
36
+ // live link in the maker: `fromCreator`), from a page this server served. A
37
+ // hostile website in the creator's browser can fire a POST at localhost,
38
+ // and what is posted here reaches an AI agent; a phone on the wifi or a
39
+ // --share tester is someone else.
40
+ import { randomBytes } from 'node:crypto';
41
+ import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
42
+ import { join } from 'node:path';
43
+ import { pathToFileURL } from 'node:url';
44
+ import { postToSession, sessionAlive } from '../agent-session.mjs';
45
+ import { relayDir, removeConnection, writeConnection } from './connection.mjs';
46
+ import { createConversation, newToken, TEXT_MAX_CHARS } from './conversation.mjs';
47
+ import { relayFiles } from './engine.mjs';
48
+ import { bearer, json, parse, readBody, text, MAX_BYTES } from './http.mjs';
31
49
 
32
50
  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
51
  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']);
52
+ // A note's id, as the dev server makes it when the note is sent.
53
+ const NOTE_ID_RE = /^[a-f0-9]{12}$/;
54
+ // A name, as the relay's connection checks it (shared/ui/relay/connection.js):
55
+ // it is written into the log the agent reads, so nothing else gets that far.
56
+ const NAME_RE = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/;
57
+ const NAME_MAX_CHARS = 100;
58
+ // How long a reply is kept for the page that waits for it: past the note's
59
+ // own thirty-minute wait, with room for a page reopened later that day.
60
+ const REPLY_KEEP_MS = 24 * 60 * 60 * 1000;
44
61
 
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
- }
62
+ const answer = (value) => ({ ok: true, value: value ?? null });
63
+ const refuse = (reason, message) => ({ ok: false, reason, message });
123
64
 
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' };
65
+ // The agent coming and going is a session file appearing or vanishing,
66
+ // which nothing announces, so it is looked at every few seconds while a
67
+ // creator's page is open (`start` to `stop`), and `changed(agent)` told. A
68
+ // page asks `server.agent` when its stream opens, so a change while none was
69
+ // open is not lost.
70
+ function watchAgent(agentNow, changed) {
71
+ let timer = null;
136
72
  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,
73
+ start() {
74
+ if (timer) return;
75
+ let last = agentNow();
76
+ timer = setInterval(() => {
77
+ const now = agentNow();
78
+ if (now === last) return;
79
+ last = now;
80
+ changed(now);
81
+ }, AGENT_POLL_MS);
82
+ timer.unref();
83
+ },
84
+ stop() {
85
+ if (timer) clearInterval(timer);
86
+ timer = null;
87
+ },
142
88
  };
143
89
  }
144
90
 
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
- }) {
91
+ export function createRelay(options) {
92
+ const {
93
+ projectDir, agentSession = null, log = () => {}, cliBin = 'joystack',
94
+ // The dev server's own trust checks (static-server.mjs): whether a request
95
+ // came from a page this server served (`{ requireOrigin }` refusing one
96
+ // that names no origin), and from the creator's own browser.
97
+ sameOrigin = () => true, fromCreator = () => true,
98
+ // The engine's shared/ folder, asked when the feed is first needed.
99
+ sharedDir = () => null,
100
+ // Whether Claude Code's feed is told anything: off on the maker's game
101
+ // machine, whose agent is the maker's.
102
+ feed: feedOn = true,
103
+ } = options;
151
104
  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 })));
105
+ // One conversation for both ends, so the agent's wake limit covers both.
106
+ const conversation = agentSession ? createConversation({ projectDir, agentSession, log, cliBin }) : null;
155
107
  let port = null;
156
- let agentTimer = null;
157
- let lastAgent = null;
108
+ const replies = replyStore(projectDir);
109
+ // The creator's pages' reload streams, each a `send(name, data)`.
110
+ const streams = new Set();
111
+ let feedLoading = null;
158
112
 
159
113
  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
114
 
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()];
115
+ function announce(name, data) {
116
+ for (const send of streams) {
117
+ try { send(name, data); } catch { /* that stream is closing */ }
118
+ }
119
+ }
120
+
121
+ const watcher = agentSession ? watchAgent(agentNow, (agent) => announce('server.agent', { agent })) : { start() {}, stop() {} };
122
+
123
+ // The engine's feed: its list and the code that words it, from the game's
124
+ // own engine, loaded on first use. Null when there is none, or the feed is
125
+ // off; a feed that did not load is looked for again next time, so an
126
+ // engine updated under a running server is found.
127
+ const warned = { older: false, broken: false };
128
+ function feed() {
129
+ feedLoading ??= (async () => {
130
+ if (!feedOn) return null;
131
+ const dir = relayFiles(sharedDir(), ['feed.js', 'agent-feed.json']);
132
+ if (!dir) {
133
+ if (!warned.older) log('relay: your game\'s engine is older than this `joystack dev`: run `npx joystack update` in the game\'s folder, so that its dev tools reach your agent');
134
+ warned.older = true;
135
+ return null;
136
+ }
137
+ const lib = await import(pathToFileURL(join(dir, 'feed.js')).href);
138
+ let list = [];
139
+ try { list = JSON.parse(readFileSync(join(dir, 'agent-feed.json'), 'utf8')); } catch { /* no list */ }
140
+ return { lib, entries: lib.checkFeed(list) };
141
+ })().catch((err) => {
142
+ if (!warned.broken) log(`relay: the engine's feed did not load (${err?.message ?? err})`);
143
+ warned.broken = true;
144
+ return null;
145
+ });
146
+ const loading = feedLoading;
147
+ loading.then((f) => { if (!f && feedOn && feedLoading === loading) feedLoading = null; });
148
+ return loading;
170
149
  }
171
- const broadcastListens = () => { for (const c of clients) send(c, { type: 'listens', others: othersFor(c) }); };
172
150
 
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();
151
+ async function sendNote(data) {
152
+ const id = randomBytes(6).toString('hex');
153
+ if (!conversation) return { id, agent: false };
154
+ const { image, ...rest } = data && typeof data === 'object' ? data : {};
155
+ const { agent } = await conversation.deliver({ id, data: rest, from: { creator: true, agent: false } }, { image });
156
+ return { id, agent };
187
157
  }
188
158
 
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;
159
+ // A name on the engine's feed: its line in the log, and the agent woken
160
+ // when its entry says so. Null when the name is not on the list.
161
+ async function feedLine(name, data) {
162
+ const f = await feed();
163
+ const entry = f && f.lib.feedEntry(f.entries, name);
164
+ if (!entry) return null;
165
+ const line = f.lib.feedLine(name, f.lib.fill(entry.say, data));
166
+ log(line);
167
+ return { entry, line };
168
+ }
169
+ async function told(name, data) {
170
+ const fed = await feedLine(name, data);
171
+ if (!fed) return null;
172
+ const { entry, line } = fed;
173
+ if (!entry.wakes_up_agent || !sessionAlive(agentSession)) return { agent: false };
174
+ try {
175
+ await postToSession(agentSession, `[Joystack dev tools] ${line}`);
176
+ return { agent: true };
177
+ } catch (err) {
178
+ log(`relay: could not wake the agent session (${err.message})`);
179
+ return { agent: false };
201
180
  }
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
- }
181
+ }
182
+
183
+ function keepReply(data) {
184
+ const out = replies.keep(data);
185
+ if (out.ok) {
186
+ announce('note.replied', { id: data.to });
187
+ out.value = { shown: streams.size > 0 };
211
188
  }
212
- return { delivered, agent };
189
+ return out;
213
190
  }
214
191
 
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 });
192
+ async function fromPage({ name, data }) {
193
+ if (name === 'server.agent') return answer(agentNow());
194
+ if (name === 'server.picks') return answer(feedOn);
195
+ if (name === 'note.reply') return replies.read(data?.id);
196
+ // A note is filed and sent whatever the feed says; its line in the log
197
+ // is the feed's.
198
+ if (name === 'note.sent') {
199
+ await feedLine(name, data);
200
+ return answer(await sendNote(data));
201
+ }
202
+ const out = await told(name, data);
203
+ return out ? answer(out) : refuse('unknown', `joystack dev does not handle ${name}`);
235
204
  }
236
205
 
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;
206
+ function fromAgent({ name, data }) {
207
+ if (name === 'agent.reply') return keepReply(data);
208
+ return refuse('unknown', `joystack dev takes only agent.reply from the agent, not ${name}`);
266
209
  }
267
210
 
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() });
211
+ async function handleRequest(req, res) {
212
+ if (req.method !== 'POST') return text(res, 405, 'POST only', { Allow: 'POST' });
213
+ const auth = bearer(req, token);
214
+ if (auth === 'refused') return text(res, 403, 'only `joystack relay` on this machine may speak as the agent');
215
+ if (auth !== 'agent') {
216
+ // A browser always names the origin of a POST and where it came from,
217
+ // and a page on another site cannot pass these. A program on this
218
+ // machine can write any header it likes, and so can pass them as the
219
+ // creator's page; that is accepted (only the agent's token, which
220
+ // needs read access to the game folder, speaks as the agent).
221
+ const site = req.headers['sec-fetch-site'];
222
+ if (!sameOrigin(req, { requireOrigin: true }) || !(site === undefined || site === 'same-origin')) return text(res, 403, 'cross-origin request rejected');
223
+ if (!fromCreator(req)) return text(res, 403, 'only the creator\'s own browser reaches joystack dev\'s relay');
224
+ }
225
+ const raw = await readBody(req, res, MAX_BYTES);
226
+ if (raw === null) return undefined;
227
+ const body = parse(raw);
228
+ if (!body || typeof body.name !== 'string' || body.name.length > NAME_MAX_CHARS || !NAME_RE.test(body.name)) return text(res, 400, 'expected { name, data }, the name <owner>.<what>');
229
+ return json(res, 200, await (auth === 'agent' ? fromAgent(body) : fromPage(body)));
274
230
  }
275
231
 
276
232
  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');
233
+ // Answers a request under RELAY_PATH; anything else there is not found.
234
+ handle(req, res, urlPath) {
235
+ if (urlPath !== RELAY_PATH) return text(res, 404, 'not found');
236
+ return handleRequest(req, res).catch((err) => {
237
+ log(`relay: ${err?.message ?? err}`);
238
+ if (!res.headersSent) json(res, 200, refuse('error', 'joystack dev failed to answer; its terminal says why'));
239
+ });
240
+ },
241
+ // A creator's page's reload stream, which hears every announcement as
242
+ // `send(name, data)`. Returns the function that stops it.
243
+ listen(send) {
244
+ streams.add(send);
245
+ watcher.start();
246
+ return () => {
247
+ streams.delete(send);
248
+ if (!streams.size) watcher.stop();
249
+ };
283
250
  },
284
251
  // The server is listening on `port`: write the agent's credential.
285
252
  attach(listeningPort) {
286
253
  port = listeningPort;
287
- try { writeConnection(projectDir, port, token); } catch (err) { log(`relay: the agent cannot publish (${err?.message ?? err})`); }
254
+ try { writeConnection(projectDir, port, token); } catch (err) { log(`relay: the agent cannot reply to notes (${err?.message ?? err})`); }
288
255
  },
289
256
  agent: agentNow,
290
- // For a test: what every other place listens for, as a fresh stream would be told.
291
- others: () => othersFor(null),
292
257
  close() {
293
- if (agentTimer) clearInterval(agentTimer);
294
- agentTimer = null;
295
- for (const c of clients) { try { c.res.end(); } catch { /* gone */ } }
296
- clients.clear();
258
+ watcher.stop();
259
+ streams.clear();
297
260
  if (port !== null) removeConnection(projectDir, port, token);
298
261
  },
299
262
  };
300
263
  }
264
+
265
+ // The agent's replies, one file per note under .joystack/relay/replies/,
266
+ // shared by every dev server running for the game folder.
267
+ function replyStore(projectDir) {
268
+ const repliesDir = join(relayDir(projectDir), 'replies');
269
+ const replyFile = (id) => join(repliesDir, `${id}.json`);
270
+ // Whether a note with this id was filed in this game folder.
271
+ const noteFiled = (id) => {
272
+ try { return readdirSync(relayDir(projectDir)).some((f) => f.endsWith(`-${id}.json`)); } catch { return false; }
273
+ };
274
+
275
+ // Replies past REPLY_KEEP_MS, removed when another is kept.
276
+ function prune() {
277
+ let names = [];
278
+ try { names = readdirSync(repliesDir); } catch { return; }
279
+ for (const n of names) {
280
+ const file = join(repliesDir, n);
281
+ try { if (Date.now() - statSync(file).mtimeMs > REPLY_KEEP_MS) unlinkSync(file); } catch { /* gone already */ }
282
+ }
283
+ }
284
+
285
+ return {
286
+ read(id) {
287
+ if (!NOTE_ID_RE.test(String(id))) return refuse('error', 'a note id is 12 hex digits');
288
+ let reply;
289
+ try {
290
+ if (Date.now() - statSync(replyFile(id)).mtimeMs > REPLY_KEEP_MS) return answer(null);
291
+ reply = JSON.parse(readFileSync(replyFile(id), 'utf8'));
292
+ } catch { return answer(null); }
293
+ return answer({ text: String(reply.text ?? ''), at: String(reply.at ?? '') });
294
+ },
295
+ // Kept until REPLY_KEEP_MS has passed rather than handed out once, so a
296
+ // page that reloads while it asks, or a second tab waiting on the same
297
+ // note, still finds it. Written fresh: an existing file is removed
298
+ // rather than written through, so a planted link is never followed.
299
+ keep({ to, text: said } = {}) {
300
+ if (!NOTE_ID_RE.test(String(to)) || !noteFiled(to)) return refuse('error', `no note ${to} was sent from this game`);
301
+ const words = typeof said === 'string' ? said.trim().slice(0, TEXT_MAX_CHARS) : '';
302
+ if (!words) return refuse('error', 'a reply needs its text');
303
+ mkdirSync(repliesDir, { recursive: true });
304
+ prune();
305
+ try { unlinkSync(replyFile(to)); } catch (err) { if (err.code !== 'ENOENT') throw err; }
306
+ writeFileSync(replyFile(to), JSON.stringify({ text: words, at: new Date().toISOString() }), { flag: 'wx' });
307
+ return answer(null);
308
+ },
309
+ };
310
+ }
@@ -0,0 +1,58 @@
1
+ // The page's half of /__reload, under `joystack dev` (live-versions.mjs). Every
2
+ // page the server serves belongs to one version of the game, and leaves when
3
+ // that version stops being the one served:
4
+ //
5
+ // game a game page. It reloads as soon as an update starts, so no game
6
+ // runs while files are replaced; the server answers it with the
7
+ // "Updating…" page, then the game.
8
+ // status the "Updating…" or error page. It reloads when a version is ready.
9
+ // other any other page (the Assets page). It reloads once the new version
10
+ // is served.
11
+ //
12
+ // The stream sends `reload` after every switch, and the status (JSON) on
13
+ // connecting and whenever it changes. A page whose version is not the one in
14
+ // the status reloads at once: it was served just before a switch.
15
+ export const reloadClient = (kind, version, token) => `<script>
16
+ (() => {
17
+ const kind = ${JSON.stringify(kind)};
18
+ const version = ${JSON.stringify(version)};
19
+ let alive = false;
20
+ let opened = false;
21
+ let leaving = false;
22
+ // Loaded again by a navigation to its own address: Safari makes a reload
23
+ // without its cache, every module downloaded again however long it may be
24
+ // kept. An address with a #fragment would only move to it, so that one reloads.
25
+ const leave = () => { if (!leaving) { leaving = true; if (location.hash) location.reload(); else location.replace(location.href); } };
26
+ function connect() {
27
+ const es = new EventSource('/__reload?page=' + kind + '&token=${token}');
28
+ // The relay's announcements to this page (relay/relay.mjs), sent only
29
+ // down a stream the creator's own page opened, handed to the page's
30
+ // server() (shared/ui/relay/server.js) as window events; and the stream
31
+ // coming back after it was lost, when that server() asks again for what
32
+ // may have changed.
33
+ es.onopen = () => {
34
+ if (opened) window.dispatchEvent(new Event('joystack:relay-open'));
35
+ opened = alive = true;
36
+ };
37
+ es.addEventListener('relay', (e) => {
38
+ let m;
39
+ try { m = JSON.parse(e.data); } catch { return; }
40
+ window.dispatchEvent(new CustomEvent('joystack:relay', { detail: m }));
41
+ });
42
+ es.onmessage = (e) => {
43
+ if (e.data === 'reload') return leave();
44
+ let s;
45
+ try { s = JSON.parse(e.data); } catch { return; }
46
+ if (kind === 'game' && (s.state !== 'ready' || s.version !== version)) return leave();
47
+ if (kind === 'status' && s.state === 'ready') return leave();
48
+ if (kind === 'status') window.dispatchEvent(new CustomEvent('joystack-status', { detail: s }));
49
+ };
50
+ es.onerror = () => {
51
+ es.close();
52
+ setTimeout(connect, alive ? 200 : 1000);
53
+ alive = false;
54
+ };
55
+ }
56
+ connect();
57
+ })();
58
+ </script>`;