@joystack/cli 0.1.54 → 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 +79 -0
- package/bin/joystack.mjs +39 -2
- package/lib/assets-export.mjs +210 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +26 -11
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +21 -11
- package/lib/engine.mjs +35 -2
- package/lib/fingerprint/README.md +22 -0
- package/lib/fingerprint/fingerprint.mjs +235 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +78 -158
- package/lib/lint.mjs +15 -0
- package/lib/maker.mjs +16 -2
- 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 +75 -0
- package/lib/shot/check.mjs +57 -0
- package/lib/shot/lock.mjs +109 -0
- package/lib/shot/ready.mjs +45 -0
- package/lib/shot/run.mjs +217 -0
- package/lib/shot/shot.mjs +320 -0
- package/lib/start-failed.mjs +110 -0
- package/lib/static-server.mjs +184 -110
- package/lib/test-cmd.mjs +41 -12
- package/package.json +3 -2
package/lib/maker.mjs
CHANGED
|
@@ -24,8 +24,8 @@ export const MAKER_LANE =
|
|
|
24
24
|
|
|
25
25
|
export const MAKER_DEV =
|
|
26
26
|
"You are in the Joystack maker: this folder's dev server is already running, and the " +
|
|
27
|
-
'person plays the game on their page, beside the chat. Every file you save shows up ' +
|
|
28
|
-
'there. Nothing was started.';
|
|
27
|
+
'person plays the game on their page, beside the chat (on a phone, under Play). Every file you save shows up ' +
|
|
28
|
+
'there. Nothing was started. There is no link to give them: to hand the game over, tell them it is ready to play on their page.';
|
|
29
29
|
|
|
30
30
|
// `publish` and `land` both do what the page's Publish button does: the draft
|
|
31
31
|
// goes into the game, and the game goes online under the name it has. The
|
|
@@ -61,3 +61,17 @@ export async function makerPublish(at, { slug = null, log = console.log, fetchIm
|
|
|
61
61
|
);
|
|
62
62
|
return body;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
// The port of this draft's dev server on the maker's machine, starting it if
|
|
66
|
+
// it sleeps (`POST <JOYSTACK_MAKER>/serve`, which answers `{ port }`), or null
|
|
67
|
+
// when the maker does not say. A draft's server is on a port of the maker's
|
|
68
|
+
// choosing, which the ports `joystack dev` takes on a laptop do not include.
|
|
69
|
+
export async function makerDevPort(at, { fetchImpl = fetch } = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchImpl(`${at}/serve`, { method: 'POST', signal: AbortSignal.timeout(60_000) });
|
|
72
|
+
const port = res.ok ? Number((await res.json())?.port) : NaN;
|
|
73
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/lib/mime.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The content type the dev server sends for a file, by its extension.
|
|
2
|
+
export const MIME = {
|
|
3
|
+
'.html': 'text/html; charset=utf-8',
|
|
4
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
5
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
6
|
+
'.css': 'text/css; charset=utf-8',
|
|
7
|
+
'.json': 'application/json; charset=utf-8',
|
|
8
|
+
'.svg': 'image/svg+xml',
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.jpg': 'image/jpeg',
|
|
11
|
+
'.jpeg': 'image/jpeg',
|
|
12
|
+
'.gif': 'image/gif',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
'.ico': 'image/x-icon',
|
|
15
|
+
'.wasm': 'application/wasm',
|
|
16
|
+
'.mp3': 'audio/mpeg',
|
|
17
|
+
'.ogg': 'audio/ogg',
|
|
18
|
+
'.wav': 'audio/wav',
|
|
19
|
+
'.m4a': 'audio/mp4',
|
|
20
|
+
'.flac': 'audio/flac',
|
|
21
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
22
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
23
|
+
'.woff': 'font/woff',
|
|
24
|
+
'.woff2': 'font/woff2',
|
|
25
|
+
'.glb': 'model/gltf-binary',
|
|
26
|
+
'.gltf': 'model/gltf+json',
|
|
27
|
+
'.bin': 'application/octet-stream',
|
|
28
|
+
};
|
package/lib/relay/cli.mjs
CHANGED
|
@@ -1,112 +1,61 @@
|
|
|
1
|
-
// `joystack relay
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// `joystack relay reply`: the agent's end of the relay, from the game
|
|
2
|
+
// folder. Each running dev server for the folder wrote its port and a bearer token
|
|
3
|
+
// to .joystack/relay/.connection-<port> (connection.mjs), so only a process
|
|
4
|
+
// that can read the folder can speak as the agent, and with several servers
|
|
5
|
+
// for one game every one of them hears.
|
|
6
6
|
import { connections } from './connection.mjs';
|
|
7
7
|
|
|
8
|
-
export const RELAY_HELP = `joystack relay
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
in your game's AGENTS.md and the engine's docs.
|
|
13
|
-
joystack relay tail [--dir <game>] [<pattern>…]
|
|
14
|
-
Print every event on the relay as it happens, one JSON line each, until
|
|
15
|
-
stopped. A pattern is a name whose segments may be *; none means every event.`;
|
|
8
|
+
export const RELAY_HELP = `joystack relay reply [--dir <game>] <note id> <text>
|
|
9
|
+
Answer a note the creator sent you from the running game: the text shows
|
|
10
|
+
under their note, even if the game reloaded since. The command line with
|
|
11
|
+
the note's id is in the message the note woke you with.`;
|
|
16
12
|
|
|
17
|
-
const
|
|
13
|
+
const RELAY_REQUEST = '/__joystack/relay';
|
|
18
14
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// the folder is running.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
let reached = 0;
|
|
25
|
-
let delivered = 0;
|
|
26
|
-
let agent = false;
|
|
15
|
+
// Ask every running dev server for `dir` the agent's `name` with `data`, as
|
|
16
|
+
// the agent. Resolves the answers, one per server reached, a refusal as an
|
|
17
|
+
// error answer; throws when no server for the folder is running.
|
|
18
|
+
async function askAsAgent(dir, name, data) {
|
|
19
|
+
const answers = [];
|
|
27
20
|
for (const { port, token } of connections(dir)) {
|
|
28
21
|
let res;
|
|
29
22
|
try {
|
|
30
23
|
// Always loopback: the credential names a port, never an address.
|
|
31
|
-
res = await fetch(`http://127.0.0.1:${port}${
|
|
24
|
+
res = await fetch(`http://127.0.0.1:${port}${RELAY_REQUEST}`, {
|
|
32
25
|
method: 'POST',
|
|
33
26
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
34
27
|
body: JSON.stringify({ name, data }),
|
|
35
28
|
signal: AbortSignal.timeout(5000),
|
|
36
29
|
});
|
|
37
30
|
} catch { continue; } // that server has stopped
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
const out = await res.json().catch(() => ({}));
|
|
45
|
-
delivered += Number(out.delivered) || 0;
|
|
46
|
-
agent = agent || out.agent === true;
|
|
31
|
+
// Whatever answers on that port wrote the body; a refusal is reported by
|
|
32
|
+
// status only, never echoed. Another server for the same folder (one
|
|
33
|
+
// from an older CLI, say) may still take it.
|
|
34
|
+
if (!res.ok) { answers.push({ ok: false, reason: 'error', message: `The dev server on port ${port} refused it (HTTP ${res.status}).` }); continue; }
|
|
35
|
+
answers.push(await res.json().catch(() => ({ ok: false, reason: 'error', message: 'not an answer' })));
|
|
47
36
|
}
|
|
48
|
-
if (!
|
|
49
|
-
|
|
50
|
-
return { delivered, agent };
|
|
37
|
+
if (!answers.length) throw new Error(`No running 'joystack dev' for ${dir}.`);
|
|
38
|
+
return answers;
|
|
51
39
|
}
|
|
52
40
|
|
|
53
|
-
//
|
|
54
|
-
// the
|
|
55
|
-
async function
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
while ((at = buffer.indexOf('\n\n')) !== -1) {
|
|
64
|
-
const block = buffer.slice(0, at);
|
|
65
|
-
buffer = buffer.slice(at + 2);
|
|
66
|
-
const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
|
|
67
|
-
if (!data) continue;
|
|
68
|
-
let frame;
|
|
69
|
-
try { frame = JSON.parse(data); } catch { continue; }
|
|
70
|
-
if (frame.type === 'event' && frame.event) onEvent(frame.event, port);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Tail every server for `dir`. Resolves when every stream has ended (the
|
|
76
|
-
// servers stopped, or `signal` aborted); throws when none is running.
|
|
77
|
-
export async function relayTail({ dir = process.cwd(), patterns = [], onEvent = (e) => console.log(JSON.stringify(e)), signal = undefined, log = console.error } = {}) {
|
|
78
|
-
const servers = connections(dir);
|
|
79
|
-
if (!servers.length) throw new Error(`No running 'joystack dev' for ${dir}.`);
|
|
80
|
-
const wanted = patterns.length ? patterns : ['*'];
|
|
81
|
-
log(`Tailing ${wanted.join(', ')} on ${servers.length} dev server${servers.length === 1 ? '' : 's'} (port${servers.length === 1 ? '' : 's'} ${servers.map((s) => s.port).join(', ')})…`);
|
|
82
|
-
const results = await Promise.allSettled(servers.map(({ port, token }) => tailOne(port, token, wanted, onEvent, signal)));
|
|
83
|
-
const failed = results.filter((r) => r.status === 'rejected' && r.reason?.name !== 'AbortError');
|
|
84
|
-
if (failed.length === results.length) throw failed[0].reason;
|
|
41
|
+
// Answer the note `id` with `text`, on every dev server running for `dir`:
|
|
42
|
+
// each keeps the same file and announces it to the pages it serves.
|
|
43
|
+
export async function relayReply({ dir = process.cwd(), id, text, log = console.log }) {
|
|
44
|
+
if (!id || !text?.trim()) throw new Error(`Usage: ${RELAY_HELP}`);
|
|
45
|
+
const answers = await askAsAgent(dir, 'agent.reply', { to: id, text });
|
|
46
|
+
const kept = answers.filter((a) => a?.ok);
|
|
47
|
+
if (!kept.length) throw new Error(answers[0]?.message || 'The reply was refused.');
|
|
48
|
+
const shown = kept.some((a) => a.value?.shown);
|
|
49
|
+
log(shown ? `Replied to note ${id}; it shows under the note.` : `Replied to note ${id}; it shows under the note when the creator's page is open again.`);
|
|
50
|
+
return { shown };
|
|
85
51
|
}
|
|
86
52
|
|
|
87
|
-
// The command line: `relay
|
|
88
|
-
// [--dir d] [<pattern>…]`.
|
|
53
|
+
// The command line: `relay reply [--dir d] <note id> <text>`.
|
|
89
54
|
export async function relayCommand(rest, { dir, log = console.log } = {}) {
|
|
90
55
|
const [verb, ...args] = rest;
|
|
91
|
-
if (verb === '
|
|
92
|
-
const [
|
|
93
|
-
|
|
94
|
-
if (raw !== undefined) {
|
|
95
|
-
try { data = JSON.parse(raw); } catch { throw new Error(`The data must be JSON, for example '{"to":"ab12cd34","text":"Done."}'. Usage: ${RELAY_HELP}`); }
|
|
96
|
-
}
|
|
97
|
-
return relayPublish({ dir, name, data, log });
|
|
98
|
-
}
|
|
99
|
-
if (verb === 'tail') {
|
|
100
|
-
const controller = new AbortController();
|
|
101
|
-
const stop = () => controller.abort();
|
|
102
|
-
process.once('SIGINT', stop);
|
|
103
|
-
process.once('SIGTERM', stop);
|
|
104
|
-
try {
|
|
105
|
-
return await relayTail({ dir, patterns: args, signal: controller.signal });
|
|
106
|
-
} finally {
|
|
107
|
-
process.off('SIGINT', stop);
|
|
108
|
-
process.off('SIGTERM', stop);
|
|
109
|
-
}
|
|
56
|
+
if (verb === 'reply') {
|
|
57
|
+
const [id, ...words] = args;
|
|
58
|
+
return relayReply({ dir, id, text: words.join(' '), log });
|
|
110
59
|
}
|
|
111
60
|
throw new Error(`Usage: ${RELAY_HELP}`);
|
|
112
61
|
}
|
package/lib/relay/connection.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The relay's credential for the agent: each running dev server writes its port
|
|
2
2
|
// and a bearer token to .joystack/relay/.connection-<port>, owner-only, so
|
|
3
|
-
// only a process that can read the game folder can
|
|
4
|
-
// agent. `joystack relay` reads every file there and speaks to each server.
|
|
3
|
+
// only a process that can read the game folder can reply as the agent. `joystack relay` reads every file there and speaks to each server.
|
|
5
4
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import { stateDir } from '../legacy-names.mjs';
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// The laptop conversation: a note the person sent (shared/ui/note/, once
|
|
2
|
+
// they pressed Send) becomes files the agent can open under .joystack/relay/
|
|
3
|
+
// and a message in the Claude Code session that started this dev server,
|
|
4
|
+
// through that session's inbox socket (agent-session.mjs). Made only when a
|
|
5
|
+
// session started the server; a server from a plain terminal has none, and
|
|
6
|
+
// the maker's dev server has none either (its conversation is the maker
|
|
7
|
+
// page's).
|
|
8
8
|
//
|
|
9
|
-
// Reaching the agent starts paid work, so only a note the
|
|
10
|
-
//
|
|
11
|
-
// page a phone on the wifi or a --share tester
|
|
9
|
+
// Reaching the agent starts paid work, so only a note the creator's own
|
|
10
|
+
// browser sent reaches here: the relay's route refuses any other page
|
|
11
|
+
// (relay.mjs), and a page a phone on the wifi or a --share tester
|
|
12
|
+
// loaded is never the creator's.
|
|
12
13
|
import { randomBytes } from 'node:crypto';
|
|
13
14
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
16
|
import { postToSession, sessionAlive } from '../agent-session.mjs';
|
|
16
17
|
import { relayDir } from './connection.mjs';
|
|
17
18
|
|
|
18
|
-
// A page can
|
|
19
|
+
// A page can send notes in a loop. The agent is woken at most this often: a note
|
|
19
20
|
// sent sooner waits its turn, and once this many are waiting, the rest are
|
|
20
21
|
// filed without waking it (a burst that size is a script, not someone typing).
|
|
21
22
|
export const WAKE_MIN_INTERVAL_MS = 1000;
|
|
@@ -41,12 +42,12 @@ export function shellWord(text) {
|
|
|
41
42
|
// so no line of it can read as one the server wrote (a second header, a
|
|
42
43
|
// different reply command).
|
|
43
44
|
const LINE_BREAK_RE = /\r\n?|[\u0085\u2028\u2029]/g;
|
|
44
|
-
const asLines = (text) => String(text).replace(LINE_BREAK_RE, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n');
|
|
45
|
+
const asLines = (text) => String(text).replace(LINE_BREAK_RE, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u0084\u0086-\u009f]/g, '').split('\n');
|
|
45
46
|
const indented = (text) => asLines(text).map((l) => ` ${l}`).join('\n');
|
|
46
47
|
// A label is one line: JSON.stringify escapes \n and \r but not the Unicode
|
|
47
48
|
// separators, so they are made spaces first.
|
|
48
49
|
const oneLine = (text) => asLines(text).join(' ');
|
|
49
|
-
// The kind of thing attached is
|
|
50
|
+
// The kind of thing attached is a relay name (`<owner>.<what>`) or nothing.
|
|
50
51
|
const KIND_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/;
|
|
51
52
|
|
|
52
53
|
// The message that wakes the agent. Everything the server writes comes
|
|
@@ -58,8 +59,7 @@ export function agentMessage({ kind, label, words, text, file, image, id, projec
|
|
|
58
59
|
if (words) lines.push(indented(words));
|
|
59
60
|
lines.push(`Data: ${file}`);
|
|
60
61
|
if (image) lines.push(`Picture of what they see: ${image}`);
|
|
61
|
-
|
|
62
|
-
lines.push(`Answer where they asked, in one or two sentences: node ${shellWord(cliBin)} relay publish --dir ${shellWord(projectDir)} agent.replied ${shellWord(reply)}`);
|
|
62
|
+
lines.push(`Answer where they asked, in one or two sentences: node ${shellWord(cliBin)} relay reply --dir ${shellWord(projectDir)} ${id} '<what you did>'`);
|
|
63
63
|
lines.push('', ...asLines(String(text).trim()).map((line) => `> ${line}`));
|
|
64
64
|
return lines.join('\n');
|
|
65
65
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The folder of the engine's relay the dev server loads its pure files from:
|
|
2
|
+
// the game's installed engine, or this CLI's own repository when it runs
|
|
3
|
+
// from inside it. Null when that engine has no relay with `files` in it.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export function relayFiles(sharedDir, files) {
|
|
8
|
+
// The repository's own relay only from a checkout of it: an installed CLI
|
|
9
|
+
// sits in node_modules, where the same path names a package called
|
|
10
|
+
// `shared`.
|
|
11
|
+
const repo = join(import.meta.dirname, '../../../..');
|
|
12
|
+
const fromRepo = existsSync(join(repo, 'packages', 'cli', 'package.json')) ? join(repo, 'shared', 'ui', 'relay') : null;
|
|
13
|
+
const candidates = [sharedDir && join(sharedDir, 'ui', 'relay'), fromRepo];
|
|
14
|
+
return candidates.find((dir) => dir && files.every((f) => existsSync(join(dir, f)))) ?? null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The relay's HTTP pieces on the dev server: answers, a bounded body, and
|
|
2
|
+
// the agent's bearer token.
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// What one post may weigh: a pick carries its picture.
|
|
6
|
+
export const MAX_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
8
|
+
|
|
9
|
+
export const json = (res, status, body, extra = {}) => {
|
|
10
|
+
const buf = Buffer.from(JSON.stringify(body));
|
|
11
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': buf.length, 'Cache-Control': 'no-store', ...extra });
|
|
12
|
+
res.end(buf);
|
|
13
|
+
};
|
|
14
|
+
export const text = (res, status, body, extra = {}) => {
|
|
15
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', ...extra });
|
|
16
|
+
res.end(body);
|
|
17
|
+
};
|
|
18
|
+
// The agent's credential: a bearer token from a process on this machine,
|
|
19
|
+
// never a browser (which always sends Origin on a cross-origin request and
|
|
20
|
+
// cannot set this header from a page the server did not serve). 'agent'
|
|
21
|
+
// when it is the server's token, 'refused' when a bearer was offered and is
|
|
22
|
+
// not, null when none was offered.
|
|
23
|
+
export function bearer(req, token) {
|
|
24
|
+
const header = String(req.headers.authorization ?? '');
|
|
25
|
+
if (!header.startsWith('Bearer ')) return null;
|
|
26
|
+
const supplied = Buffer.from(header.slice(7));
|
|
27
|
+
const expected = Buffer.from(token);
|
|
28
|
+
const ok = LOOPBACK.has(req.socket.remoteAddress) && !req.headers.origin
|
|
29
|
+
&& supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
30
|
+
return ok ? 'agent' : 'refused';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readBody(req, res, maxBytes) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
let overflowed = false;
|
|
38
|
+
req.on('data', (chunk) => {
|
|
39
|
+
if (overflowed) return; // draining the rest so the client can read the 413
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > maxBytes) {
|
|
42
|
+
overflowed = true;
|
|
43
|
+
chunks.length = 0;
|
|
44
|
+
text(res, 413, `message too large (max ${maxBytes} bytes)`, { Connection: 'close' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
});
|
|
49
|
+
req.on('end', () => resolve(overflowed ? null : Buffer.concat(chunks).toString('utf8')));
|
|
50
|
+
// A client that goes before the body ends is answered by nobody; the
|
|
51
|
+
// promise still settles, so nothing waits on it for good.
|
|
52
|
+
req.on('close', () => { if (!req.complete) resolve(null); });
|
|
53
|
+
req.on('error', () => resolve(null));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export const parse = (raw) => { try { return JSON.parse(raw); } catch { return undefined; } };
|
|
57
|
+
|