arcadible 0.1.47 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/arc-default.js +1 -0
- package/bin/arc-init.js +1 -1
- package/bin/arc-serve.js +213 -0
- package/package.json +5 -1
- package/src/action/serve/tool.js +166 -0
- package/src/cli/index.js +2 -0
- package/src/cli/serve.js +15 -0
- package/src/config/code.js +1 -0
- package/src/config/template.js +7 -7
- package/src/config/uri.js +5 -0
- package/src/config/url.js +2 -0
- package/src/phrase.js +110 -1
- package/src/serve/files.js +61 -0
- package/src/serve/game.js +185 -0
- package/src/serve/page.js +176 -0
- package/src/serve/reload.js +63 -0
- package/src/serve/remote.js +119 -0
- package/src/serve/server.js +115 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// a game's files, read as Arcadible reads them from storage: by exact path,
|
|
3
|
+
// letter case included, even on a disk that ignores it, such as a Mac's. The
|
|
4
|
+
// server reads through `find`, so another source, such as a packed game,
|
|
5
|
+
// can stand in for a folder.
|
|
6
|
+
|
|
7
|
+
// [import]
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const path = require("node:path");
|
|
11
|
+
|
|
12
|
+
// [use]
|
|
13
|
+
|
|
14
|
+
const createReadStream = fs.createReadStream;
|
|
15
|
+
const join = path.join;
|
|
16
|
+
const readdir = fs.promises.readdir;
|
|
17
|
+
const stat = fs.promises.stat;
|
|
18
|
+
|
|
19
|
+
// [function]
|
|
20
|
+
|
|
21
|
+
// whether every segment of `segments` is named exactly so in `root`.
|
|
22
|
+
const exactCase = async function (root, segments) {
|
|
23
|
+
const found = await Promise.all(
|
|
24
|
+
segments.map((segment, i) =>
|
|
25
|
+
readdir(join(root, ...segments.slice(0, i))).then((names) =>
|
|
26
|
+
names.includes(segment),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
return found.every(Boolean);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// [function]
|
|
34
|
+
|
|
35
|
+
// the files in `root`. `find(segments)` resolves with the file at that path,
|
|
36
|
+
// as `{ size, mtime, open({ start, end }) }`, or null when there's none, or
|
|
37
|
+
// it's a folder.
|
|
38
|
+
const dirFiles = function (root) {
|
|
39
|
+
return {
|
|
40
|
+
async find(segments) {
|
|
41
|
+
const file = join(root, ...segments);
|
|
42
|
+
try {
|
|
43
|
+
const info = await stat(file);
|
|
44
|
+
if (!info.isFile() || !(await exactCase(root, segments))) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
mtime: info.mtime,
|
|
49
|
+
open: (range) => createReadStream(file, range),
|
|
50
|
+
size: info.size,
|
|
51
|
+
};
|
|
52
|
+
} catch (ex) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// [export]
|
|
60
|
+
|
|
61
|
+
module.exports = { dirFiles };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// the game's origin, as Arcadible's router and storage answer it
|
|
3
|
+
// (app/src/cloudfront/io-arcadible-router): a path ending in / gets its
|
|
4
|
+
// index.html, a path is found only with its exact letter case, /arcadible/
|
|
5
|
+
// holds only Arcadible's own files, and each file has the type the release
|
|
6
|
+
// pipeline gives it. The headers are the build origin's, but nothing is
|
|
7
|
+
// cached, so a change shows on reload.
|
|
8
|
+
|
|
9
|
+
// [import]
|
|
10
|
+
|
|
11
|
+
const http = require("node:http");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
// [import]
|
|
15
|
+
|
|
16
|
+
const mime = require("mime-types");
|
|
17
|
+
const parseRange = require("range-parser");
|
|
18
|
+
|
|
19
|
+
// [use]
|
|
20
|
+
|
|
21
|
+
const extname = path.extname;
|
|
22
|
+
|
|
23
|
+
// [declare]
|
|
24
|
+
|
|
25
|
+
const RESERVED = "arcadible";
|
|
26
|
+
|
|
27
|
+
// [function]
|
|
28
|
+
|
|
29
|
+
// the path's segments, decoded, or null for one that can't be a file's:
|
|
30
|
+
// undecodable, or `.`, `..`, or holding a separator.
|
|
31
|
+
const segmentsOf = function (pathname) {
|
|
32
|
+
const segments = pathname.slice(1).split("/");
|
|
33
|
+
try {
|
|
34
|
+
const decoded = segments.map(decodeURIComponent);
|
|
35
|
+
const unsafe = decoded.some(
|
|
36
|
+
(s, i) =>
|
|
37
|
+
s === "." ||
|
|
38
|
+
s === ".." ||
|
|
39
|
+
/[/\\\0]/u.test(s) ||
|
|
40
|
+
(s === "" && i < decoded.length - 1),
|
|
41
|
+
);
|
|
42
|
+
return unsafe ? null : decoded;
|
|
43
|
+
} catch (ex) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// [function]
|
|
49
|
+
|
|
50
|
+
const plain = function (res, status, headers = {}) {
|
|
51
|
+
res.writeHead(status, {
|
|
52
|
+
"content-type": "text/plain; charset=utf-8",
|
|
53
|
+
...headers,
|
|
54
|
+
});
|
|
55
|
+
res.end(`${http.STATUS_CODES[status]}\n`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// [function]
|
|
59
|
+
|
|
60
|
+
// the headers every response from the game's origin has, as Arcadible's: the
|
|
61
|
+
// game is cross-origin isolated, and only the play page can frame it.
|
|
62
|
+
const baseHeaders = function (page) {
|
|
63
|
+
return {
|
|
64
|
+
"cache-control": "no-store",
|
|
65
|
+
"content-security-policy": `frame-ancestors ${page};`,
|
|
66
|
+
"cross-origin-embedder-policy": "require-corp",
|
|
67
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
68
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// [function]
|
|
73
|
+
|
|
74
|
+
// a file from `files`, whole or the one range asked for. A range that can't be
|
|
75
|
+
// satisfied is a 416; several ranges, or a malformed one, get the whole file.
|
|
76
|
+
const sendFile = function (req, res, file, headers) {
|
|
77
|
+
const size = file.size;
|
|
78
|
+
const ranges = req.headers.range ? parseRange(size, req.headers.range) : -2;
|
|
79
|
+
if (ranges === -1) {
|
|
80
|
+
res.writeHead(416, { ...headers, "content-range": `bytes */${size}` });
|
|
81
|
+
res.end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const single =
|
|
85
|
+
Array.isArray(ranges) && ranges.length === 1 ? ranges[0] : null;
|
|
86
|
+
const range = single ?? { end: size - 1, start: 0 };
|
|
87
|
+
const length = size ? range.end - range.start + 1 : 0;
|
|
88
|
+
res.writeHead(single ? 206 : 200, {
|
|
89
|
+
...headers,
|
|
90
|
+
"accept-ranges": "bytes",
|
|
91
|
+
"content-length": length,
|
|
92
|
+
"last-modified": file.mtime.toUTCString(),
|
|
93
|
+
...(single
|
|
94
|
+
? { "content-range": `bytes ${range.start}-${range.end}/${size}` }
|
|
95
|
+
: {}),
|
|
96
|
+
});
|
|
97
|
+
if (req.method === "HEAD" || !length) {
|
|
98
|
+
res.end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
file.open(range).pipe(res);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// [function]
|
|
105
|
+
|
|
106
|
+
// ends the response with `body`, or with none for a HEAD request.
|
|
107
|
+
const endWith = function (req, res, body) {
|
|
108
|
+
if (req.method === "HEAD") {
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
res.end(body);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// [function]
|
|
116
|
+
|
|
117
|
+
// one of Arcadible's files under /arcadible/, from `remote`. `onRemote` hears
|
|
118
|
+
// of each, to say when it's a kept copy or can't be had.
|
|
119
|
+
const sendRemote = function (req, res, { headers, name, onRemote, remote }) {
|
|
120
|
+
return remote
|
|
121
|
+
.get(name)
|
|
122
|
+
.then((file) => {
|
|
123
|
+
onRemote(name, file);
|
|
124
|
+
if (!file) {
|
|
125
|
+
plain(res, 404, headers);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
res.writeHead(200, {
|
|
129
|
+
...headers,
|
|
130
|
+
"content-length": file.body.length,
|
|
131
|
+
"content-type": file.type,
|
|
132
|
+
});
|
|
133
|
+
endWith(req, res, file.body);
|
|
134
|
+
})
|
|
135
|
+
.catch((ex) => {
|
|
136
|
+
onRemote(name, null, ex);
|
|
137
|
+
plain(res, 502, headers);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// [function]
|
|
142
|
+
|
|
143
|
+
// the request handler for the game's origin. `page` is the play page's
|
|
144
|
+
// origin, the only one that may frame the game.
|
|
145
|
+
const gameHandler = function ({ files, onRemote = () => null, page, remote }) {
|
|
146
|
+
return function (req, res) {
|
|
147
|
+
const headers = baseHeaders(page);
|
|
148
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
149
|
+
plain(res, 405, { ...headers, allow: "GET, HEAD" });
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
}
|
|
152
|
+
const segments = segmentsOf(new URL(req.url, "http://game").pathname);
|
|
153
|
+
if (!segments) {
|
|
154
|
+
plain(res, 404, headers);
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
157
|
+
// /arcadible/, in any letter case, is Arcadible's: only its exact path
|
|
158
|
+
// reaches Arcadible's files.
|
|
159
|
+
if (segments[0].toLowerCase() === RESERVED && segments.length > 1) {
|
|
160
|
+
if (segments[0] !== RESERVED) {
|
|
161
|
+
plain(res, 404, headers);
|
|
162
|
+
return Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
const name = segments.slice(1).join("/");
|
|
165
|
+
return sendRemote(req, res, { headers, name, onRemote, remote });
|
|
166
|
+
}
|
|
167
|
+
if (segments.at(-1) === "") {
|
|
168
|
+
segments[segments.length - 1] = "index.html";
|
|
169
|
+
}
|
|
170
|
+
return files.find(segments).then((file) => {
|
|
171
|
+
if (!file) {
|
|
172
|
+
plain(res, 404, headers);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const type =
|
|
176
|
+
mime.contentType(extname(segments.at(-1))) ||
|
|
177
|
+
"application/octet-stream";
|
|
178
|
+
sendFile(req, res, file, { ...headers, "content-type": type });
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// [export]
|
|
184
|
+
|
|
185
|
+
module.exports = { endWith, gameHandler };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// the local play page: it frames the game as the play page does
|
|
3
|
+
// (site/src/javascripts/arcadible/play/app/page.coffee), with the same
|
|
4
|
+
// sandbox and permissions, and runs the play page's own host for the SDK,
|
|
5
|
+
// so saves are kept by the page, across builds. It reloads the
|
|
6
|
+
// game, and only the game, when told to.
|
|
7
|
+
|
|
8
|
+
// [import]
|
|
9
|
+
|
|
10
|
+
const serveGame = require("./game");
|
|
11
|
+
|
|
12
|
+
// [use]
|
|
13
|
+
|
|
14
|
+
const endWith = serveGame.endWith;
|
|
15
|
+
|
|
16
|
+
// [declare]
|
|
17
|
+
|
|
18
|
+
// the play page's iframe, word for word (test/unit/serve/page.test.js).
|
|
19
|
+
const ALLOW =
|
|
20
|
+
"accelerometer; autoplay; cross-origin-isolated; fullscreen; gamepad; gyroscope; screen-wake-lock";
|
|
21
|
+
const SANDBOX =
|
|
22
|
+
"allow-orientation-lock allow-pointer-lock allow-same-origin allow-scripts";
|
|
23
|
+
// the page's own paths. Its origin isn't the game's, so they can't collide.
|
|
24
|
+
const HOST = "/_arcadible/host.js";
|
|
25
|
+
const RELOAD = "/_arcadible/reload";
|
|
26
|
+
|
|
27
|
+
// [function]
|
|
28
|
+
|
|
29
|
+
// `value` as JSON that's safe inside a <script>.
|
|
30
|
+
const scriptJson = function (value) {
|
|
31
|
+
return JSON.stringify(value).replace(/</gu, "\\u003c");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// [function]
|
|
35
|
+
|
|
36
|
+
const escapeHtml = function (text) {
|
|
37
|
+
return text.replace(
|
|
38
|
+
/[&<>"]/gu,
|
|
39
|
+
(c) => ({ '"': """, "&": "&", "<": "<", ">": ">" })[c],
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// [function]
|
|
44
|
+
|
|
45
|
+
// the page for `game`: `{ id, channel, version, title, src }`, where `src` is
|
|
46
|
+
// the game's URL, on its own origin.
|
|
47
|
+
const html = function (game) {
|
|
48
|
+
const config = {
|
|
49
|
+
allow: ALLOW,
|
|
50
|
+
channel: game.channel,
|
|
51
|
+
game: game.id,
|
|
52
|
+
sandbox: SANDBOX,
|
|
53
|
+
src: game.src,
|
|
54
|
+
version: game.version,
|
|
55
|
+
};
|
|
56
|
+
return `<!doctype html>
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8">
|
|
60
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
|
61
|
+
<title>${escapeHtml(game.title)}</title>
|
|
62
|
+
<style>html,body{margin:0;height:100%;overflow:hidden;background:#000}iframe{display:block;width:100%;height:100%;border:0;visibility:hidden}</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<script src="${HOST}"></script>
|
|
66
|
+
<script>
|
|
67
|
+
const config = ${scriptJson(config)};
|
|
68
|
+
const frame = document.createElement("iframe");
|
|
69
|
+
frame.allow = config.allow;
|
|
70
|
+
frame.sandbox = config.sandbox;
|
|
71
|
+
frame.addEventListener("load", () => {
|
|
72
|
+
frame.style.visibility = "visible";
|
|
73
|
+
frame.focus();
|
|
74
|
+
});
|
|
75
|
+
// the host answers the game's hello, so it's created before the frame
|
|
76
|
+
// exists. Without it, the game stands alone, as off Arcadible.
|
|
77
|
+
const lib = window.arcadibleHost;
|
|
78
|
+
if (lib) {
|
|
79
|
+
try {
|
|
80
|
+
lib.createHost({
|
|
81
|
+
backend: lib.indexedDBBackend({ persist: true }),
|
|
82
|
+
channel: config.channel,
|
|
83
|
+
frame,
|
|
84
|
+
game: config.game,
|
|
85
|
+
origin: new URL(config.src).origin,
|
|
86
|
+
version: config.version,
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error("arcadible: no host for the game:", err);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
console.error("arcadible: no host for the game: the play page's host didn't load.");
|
|
93
|
+
}
|
|
94
|
+
frame.src = config.src;
|
|
95
|
+
document.body.prepend(frame);
|
|
96
|
+
new EventSource(${scriptJson(RELOAD)}).addEventListener("reload", () => {
|
|
97
|
+
frame.style.visibility = "hidden";
|
|
98
|
+
frame.src = config.src;
|
|
99
|
+
});
|
|
100
|
+
</script>
|
|
101
|
+
</body>
|
|
102
|
+
</html>
|
|
103
|
+
`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// [function]
|
|
107
|
+
|
|
108
|
+
// the headers of the page's responses, as the play page's: it's
|
|
109
|
+
// cross-origin isolated, and nothing frames it.
|
|
110
|
+
const HEADERS = {
|
|
111
|
+
"cache-control": "no-store",
|
|
112
|
+
"content-security-policy": "frame-ancestors 'none';",
|
|
113
|
+
"cross-origin-embedder-policy": "require-corp",
|
|
114
|
+
"cross-origin-opener-policy": "same-origin",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// [function]
|
|
118
|
+
|
|
119
|
+
// the request handler for the page's origin. `reloads` keeps the pages
|
|
120
|
+
// listening for a reload; `remote` has the host.
|
|
121
|
+
const pageHandler = function ({
|
|
122
|
+
game,
|
|
123
|
+
onRemote = () => null,
|
|
124
|
+
reloads,
|
|
125
|
+
remote,
|
|
126
|
+
}) {
|
|
127
|
+
return function (req, res) {
|
|
128
|
+
const pathname = new URL(req.url, "http://page").pathname;
|
|
129
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
130
|
+
res.writeHead(405, { ...HEADERS, allow: "GET, HEAD" }).end();
|
|
131
|
+
return Promise.resolve();
|
|
132
|
+
}
|
|
133
|
+
if (pathname === "/") {
|
|
134
|
+
res.writeHead(200, {
|
|
135
|
+
...HEADERS,
|
|
136
|
+
"content-type": "text/html; charset=utf-8",
|
|
137
|
+
});
|
|
138
|
+
endWith(req, res, html(game));
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
}
|
|
141
|
+
if (pathname === RELOAD) {
|
|
142
|
+
reloads.listen(res);
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
if (pathname === HOST) {
|
|
146
|
+
return remote
|
|
147
|
+
.get("host")
|
|
148
|
+
.then((file) => {
|
|
149
|
+
onRemote("host", file);
|
|
150
|
+
if (!file) {
|
|
151
|
+
res.writeHead(502, HEADERS).end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
res.writeHead(200, {
|
|
155
|
+
...HEADERS,
|
|
156
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
157
|
+
});
|
|
158
|
+
endWith(req, res, file.body);
|
|
159
|
+
})
|
|
160
|
+
.catch((ex) => {
|
|
161
|
+
onRemote("host", null, ex);
|
|
162
|
+
res.writeHead(502, HEADERS).end();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
res.writeHead(404, {
|
|
166
|
+
...HEADERS,
|
|
167
|
+
"content-type": "text/plain; charset=utf-8",
|
|
168
|
+
});
|
|
169
|
+
res.end("Not Found\n");
|
|
170
|
+
return Promise.resolve();
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// [export]
|
|
175
|
+
|
|
176
|
+
module.exports = { ALLOW, SANDBOX, html, pageHandler };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// reloading the game when its files change: a watch on the folder served,
|
|
3
|
+
// which waits for the changes to settle, so a build that writes many files
|
|
4
|
+
// reloads once, and the pages listening, told over server-sent events.
|
|
5
|
+
|
|
6
|
+
// [import]
|
|
7
|
+
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
|
|
10
|
+
// [declare]
|
|
11
|
+
|
|
12
|
+
// how long the folder must be still before the game reloads, in ms.
|
|
13
|
+
const SETTLE = 100;
|
|
14
|
+
|
|
15
|
+
// [function]
|
|
16
|
+
|
|
17
|
+
// the pages listening for a reload. `listen(res)` keeps a response open as
|
|
18
|
+
// an event stream; `reload()` tells every one; `close()` ends them.
|
|
19
|
+
const reloader = function () {
|
|
20
|
+
const listening = new Set();
|
|
21
|
+
return {
|
|
22
|
+
close() {
|
|
23
|
+
for (const res of listening) {
|
|
24
|
+
res.end();
|
|
25
|
+
}
|
|
26
|
+
listening.clear();
|
|
27
|
+
},
|
|
28
|
+
listen(res) {
|
|
29
|
+
res.writeHead(200, {
|
|
30
|
+
"cache-control": "no-store",
|
|
31
|
+
"content-type": "text/event-stream",
|
|
32
|
+
});
|
|
33
|
+
res.write(": listening\n\n");
|
|
34
|
+
listening.add(res);
|
|
35
|
+
res.on("close", () => listening.delete(res));
|
|
36
|
+
},
|
|
37
|
+
reload() {
|
|
38
|
+
for (const res of listening) {
|
|
39
|
+
res.write("event: reload\ndata: \n\n");
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// [function]
|
|
46
|
+
|
|
47
|
+
// calls `onChange` once `dir` has stopped changing for SETTLE ms. Returns a
|
|
48
|
+
// function that stops watching.
|
|
49
|
+
const watchDir = function (dir, onChange) {
|
|
50
|
+
let timer = null;
|
|
51
|
+
const watcher = fs.watch(dir, { recursive: true }, () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
timer = setTimeout(onChange, SETTLE);
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
watcher.close();
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// [export]
|
|
62
|
+
|
|
63
|
+
module.exports = { reloader, watchDir };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// the files Arcadible serves itself, fetched from where Arcadible serves
|
|
3
|
+
// them, so a fix reaches `arc serve` without a new CLI: the SDK's hosted
|
|
4
|
+
// script, as every game's origin serves it at /arcadible/sdk/, and the play
|
|
5
|
+
// page's host, as the play page runs it. Each is fetched once a session, and
|
|
6
|
+
// the last copy is kept on disk for when Arcadible can't be reached.
|
|
7
|
+
|
|
8
|
+
// [import]
|
|
9
|
+
|
|
10
|
+
const fs = require("node:fs");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
|
|
13
|
+
// [use]
|
|
14
|
+
|
|
15
|
+
const join = path.join;
|
|
16
|
+
const mkdir = fs.promises.mkdir;
|
|
17
|
+
const readFile = fs.promises.readFile;
|
|
18
|
+
const writeFile = fs.promises.writeFile;
|
|
19
|
+
|
|
20
|
+
// [declare]
|
|
21
|
+
|
|
22
|
+
// the play page's host, published with each web deploy beside the asset
|
|
23
|
+
// site's other vendored files. Any number in /static/<n>/ is served, and each
|
|
24
|
+
// is cached at Google's edge for a year, so a new one gets the current copy.
|
|
25
|
+
const HOST_PATH = "/asset/arcadible/vnd/arcadible-sdk/host.min.js";
|
|
26
|
+
// the files under /arcadible/ that Arcadible serves: the SDK's, such as
|
|
27
|
+
// sdk/v1.js. Anything else there isn't found, as on Arcadible.
|
|
28
|
+
const SDK_PATH = /^sdk\/[\w.-]+$/u;
|
|
29
|
+
|
|
30
|
+
// [function]
|
|
31
|
+
|
|
32
|
+
// a file kept from a fetch, or null.
|
|
33
|
+
const readKept = async function (dir, name) {
|
|
34
|
+
try {
|
|
35
|
+
const [body, meta] = await Promise.all([
|
|
36
|
+
readFile(join(dir, name)),
|
|
37
|
+
readFile(join(dir, `${name}.json`), "utf8"),
|
|
38
|
+
]);
|
|
39
|
+
return { ...JSON.parse(meta), body };
|
|
40
|
+
} catch (ex) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// [function]
|
|
46
|
+
|
|
47
|
+
// keeps `file` for later; a copy that can't be written is only skipped.
|
|
48
|
+
const keep = async function (dir, name, { body, ...meta }) {
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
await Promise.all([
|
|
52
|
+
writeFile(join(dir, name), body),
|
|
53
|
+
writeFile(join(dir, `${name}.json`), JSON.stringify(meta)),
|
|
54
|
+
]);
|
|
55
|
+
} catch (ex) {
|
|
56
|
+
// the copy is a convenience: serving goes on without it.
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// [function]
|
|
61
|
+
|
|
62
|
+
// the files from Arcadible. `asset` is the asset site's origin, `sdk` an
|
|
63
|
+
// origin that serves /arcadible/sdk/, and `dir` where copies are kept.
|
|
64
|
+
// `get(name)` resolves with `{ body, type, fetchedAt, kept }`, where `kept`
|
|
65
|
+
// says the copy is from disk because Arcadible couldn't be reached, or null
|
|
66
|
+
// when Arcadible has no such file; it rejects when Arcadible can't be reached
|
|
67
|
+
// and there's no copy. `name` is `host`, or a path under /arcadible/.
|
|
68
|
+
const remoteFiles = function ({ asset, dir, request = fetch, sdk }) {
|
|
69
|
+
const session = {};
|
|
70
|
+
const url = function (name) {
|
|
71
|
+
if (name === "host") {
|
|
72
|
+
return `${asset}/static/${Date.now()}${HOST_PATH}`;
|
|
73
|
+
}
|
|
74
|
+
return SDK_PATH.test(name) ? `${sdk}/arcadible/${name}` : null;
|
|
75
|
+
};
|
|
76
|
+
const load = async function (name) {
|
|
77
|
+
const target = url(name);
|
|
78
|
+
if (!target) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const kept = name.replaceAll("/", "-");
|
|
82
|
+
try {
|
|
83
|
+
const res = await request(target);
|
|
84
|
+
if (res.status === 403 || res.status === 404) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
throw new Error(`${res.status}`);
|
|
89
|
+
}
|
|
90
|
+
const file = {
|
|
91
|
+
body: Buffer.from(await res.arrayBuffer()),
|
|
92
|
+
fetchedAt: new Date().toISOString(),
|
|
93
|
+
type: res.headers.get("content-type") ?? "text/javascript",
|
|
94
|
+
};
|
|
95
|
+
await keep(dir, kept, file);
|
|
96
|
+
return { ...file, kept: false };
|
|
97
|
+
} catch (ex) {
|
|
98
|
+
const file = await readKept(dir, kept);
|
|
99
|
+
if (!file) {
|
|
100
|
+
throw ex;
|
|
101
|
+
}
|
|
102
|
+
return { ...file, kept: true };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
get(name) {
|
|
107
|
+
if (!Object.hasOwn(session, name)) {
|
|
108
|
+
// a failure isn't kept, so the next request tries again.
|
|
109
|
+
session[name] = load(name);
|
|
110
|
+
session[name].catch(() => delete session[name]);
|
|
111
|
+
}
|
|
112
|
+
return session[name];
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// [export]
|
|
118
|
+
|
|
119
|
+
module.exports = { remoteFiles };
|