@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.
- package/CHANGELOG.md +38 -0
- package/bin/joystack.mjs +1 -2
- package/lib/assets-export.mjs +17 -5
- package/lib/assets-page.mjs +17 -12
- package/lib/dev.mjs +7 -2
- package/lib/engine.mjs +35 -2
- package/lib/fingerprint/README.md +6 -0
- package/lib/fingerprint/fingerprint.mjs +41 -1
- package/lib/inject.mjs +20 -16
- package/lib/lint.mjs +15 -0
- package/lib/mime.mjs +28 -0
- package/lib/relay/cli.mjs +37 -88
- package/lib/relay/connection.mjs +2 -3
- package/lib/relay/conversation.mjs +15 -15
- package/lib/relay/engine.mjs +15 -0
- package/lib/relay/http.mjs +57 -0
- package/lib/relay/relay.mjs +267 -257
- package/lib/reload-client.mjs +58 -0
- package/lib/room-server.mjs +10 -0
- package/lib/shot/README.md +1 -1
- package/lib/start-failed.mjs +110 -0
- package/lib/static-server.mjs +91 -99
- package/lib/test-cmd.mjs +12 -0
- package/package.json +3 -2
package/lib/relay/relay.mjs
CHANGED
|
@@ -1,300 +1,310 @@
|
|
|
1
|
-
// The relay's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
const
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
189
|
+
return out;
|
|
213
190
|
}
|
|
214
191
|
|
|
215
|
-
async function
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
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
|
|
238
|
-
if (
|
|
239
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
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
|
|
278
|
-
handle(req, res, urlPath
|
|
279
|
-
if (urlPath
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
294
|
-
|
|
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>`;
|