arcadible 0.1.47 → 0.1.48

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 CHANGED
@@ -46,6 +46,7 @@ Your repository is the store page: `arcadible.toml` holds the game's settings, `
46
46
  | `arc inspect` | Prints the manifest Arcadible will see, or one value from it |
47
47
  | `arc build` | Runs your project's build command |
48
48
  | `arc pack` | Packages your project into the zip that `arc deploy` uploads |
49
+ | `arc serve` | Serves your game locally, framed in a play page, as Arcadible does |
49
50
  | `arc deploy` | Validates, builds, packages, and publishes, directly or through GitHub Actions |
50
51
  | `arc play` | Plays a deployed game from any channel or version |
51
52
  | `arc open` | Opens a game's store page |
@@ -39,6 +39,7 @@ const main = function (argv) {
39
39
  .command("validate", "Validate project")
40
40
  .command("build", "Build project")
41
41
  .command("pack", "Package project")
42
+ .command("serve", "Serve a game locally")
42
43
  .command("deploy", "Deploy project")
43
44
  .command("open", "Open a game's store page")
44
45
  .command("play", "Play a game")
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // [import]
5
+
6
+ const path = require("node:path");
7
+
8
+ // [import]
9
+
10
+ const hideBin = require("yargs/helpers").hideBin;
11
+ const yargs = require("yargs/yargs");
12
+
13
+ // [import]
14
+
15
+ const args = require("../src/util/argv");
16
+ const cfgcode = require("../src/config/code");
17
+ const cfgpackage = require("../src/config/package");
18
+ const cfgproduct = require("../src/config/product");
19
+ const cfgurl = require("../src/config/url");
20
+ const error = require("../src/util/error");
21
+ const log = require("../src/util/log");
22
+ const output = require("../src/util/output");
23
+ const phrase = require("../src/phrase");
24
+ const rc = require("../src/util/rc");
25
+ const serve = require("../src/cli/serve");
26
+ const shell = require("../src/util/shell");
27
+ const spinner = require("../src/util/spinner");
28
+
29
+ // [use]
30
+
31
+ const abort = output.abort;
32
+ const ArcError = error.ArcError;
33
+ const findRc = rc.findRc;
34
+ const launch = shell.launch;
35
+ const launched = output.launched;
36
+ const relative = path.relative;
37
+ const resolve = path.resolve;
38
+ const SpinnerWheel = spinner.SpinnerWheel;
39
+
40
+ // [declare]
41
+
42
+ const COMMAND = "serve";
43
+ const PORT = 4100;
44
+ const TOOL = cfgproduct.tool;
45
+ const VERSION = cfgpackage.version;
46
+
47
+ // [define]
48
+
49
+ const conf = {
50
+ build: true,
51
+ open: false,
52
+ port: PORT,
53
+ quiet: false,
54
+ ...findRc(TOOL, COMMAND, process.argv),
55
+ };
56
+
57
+ // [function]
58
+
59
+ // fails unless the port is one a server can listen on.
60
+ const checkPort = function (port) {
61
+ if (Number.isInteger(port) && port > 0 && port < 65536) {
62
+ return Promise.resolve();
63
+ }
64
+ return Promise.reject(
65
+ new ArcError(cfgcode.ARGUMENT_INVALID, phrase.portInvalid()),
66
+ );
67
+ };
68
+
69
+ // [function]
70
+
71
+ // the page --url names, as a URL, or a failure when it isn't an http one.
72
+ const checkUrl = function (url) {
73
+ if (url === null || typeof url === "undefined") {
74
+ return Promise.resolve(null);
75
+ }
76
+ try {
77
+ const parsed = new URL(String(url));
78
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
79
+ return Promise.resolve(parsed.href);
80
+ }
81
+ } catch (ex) {
82
+ // not a URL at all: refused below, as one that isn't http.
83
+ }
84
+ return Promise.reject(
85
+ new ArcError(cfgcode.ARGUMENT_INVALID, phrase.urlInvalid()),
86
+ );
87
+ };
88
+
89
+ // [function]
90
+
91
+ // warns once for each of Arcadible's files that's a kept copy, or can't be
92
+ // had, rather than on every request.
93
+ const remoteWarnings = function ({ quiet }) {
94
+ const warned = new Set();
95
+ return function (name, file, ex) {
96
+ const message = ex
97
+ ? phrase.remoteUnreachable(name)
98
+ : file?.kept && phrase.remoteKept(name, file.fetchedAt);
99
+ if (!message || quiet || warned.has(message)) {
100
+ return;
101
+ }
102
+ warned.add(message);
103
+ log.pw(message);
104
+ };
105
+ };
106
+
107
+ // [function]
108
+
109
+ const start = function (argv) {
110
+ const quiet = argv.quiet;
111
+ // `.arcrc` values are strings, and a flag that isn't a number is NaN.
112
+ const port = Number(argv.port);
113
+ const root = resolve(process.cwd(), argv.cwd);
114
+ const sw = new SpinnerWheel("Building... %s");
115
+ return checkPort(port)
116
+ .then(() => checkUrl(argv.url))
117
+ .then((url) =>
118
+ serve({
119
+ build: argv.build,
120
+ handler: {
121
+ onBuild: () => sw.start(quiet),
122
+ onReload() {
123
+ if (!quiet) {
124
+ log.p(phrase.reloaded());
125
+ }
126
+ },
127
+ onRemote: remoteWarnings({ quiet }),
128
+ },
129
+ port,
130
+ root,
131
+ url,
132
+ }),
133
+ )
134
+ .then((served) => {
135
+ sw.stop(true);
136
+ const stop = () => {
137
+ served.close();
138
+ process.exit(0);
139
+ };
140
+ process.once("SIGINT", stop);
141
+ process.once("SIGTERM", stop);
142
+ if (!quiet) {
143
+ log.result(phrase.served(served), [
144
+ ["Play:", `${served.page}/`],
145
+ [phrase.serveAlone(), served.game],
146
+ served.url
147
+ ? phrase.serveNextUrl()
148
+ : phrase.serveNext(relative(root, served.deployDir) || "."),
149
+ ]);
150
+ if (served.probed && !served.probed.reachable) {
151
+ log.nlpw(phrase.urlUnreachable(served.url));
152
+ } else if (served.probed && !served.probed.frameable) {
153
+ log.nlpw(phrase.urlNotFrameable(served.url, cfgurl.SERVE_URL));
154
+ }
155
+ }
156
+ return argv.open ? launch(`${served.page}/`) : false;
157
+ })
158
+ .then((where) => {
159
+ if (where !== false && !quiet) {
160
+ launched(where, `http://localhost:${port}/`);
161
+ }
162
+ })
163
+ .catch((ex) => abort(phrase.aborted(COMMAND), ex, { sw }));
164
+ };
165
+
166
+ // [function]
167
+
168
+ const main = function (argv) {
169
+ return start(
170
+ yargs(args.normalize(hideBin(argv)))
171
+ .scriptName(`${TOOL} ${COMMAND}`)
172
+ .usage("$0 [options]")
173
+ .option("port", {
174
+ alias: "p",
175
+ default: conf.port,
176
+ describe: "Port to serve on",
177
+ type: "number",
178
+ })
179
+ .option("build", {
180
+ default: conf.build,
181
+ describe: "Run the build command first",
182
+ type: "boolean",
183
+ })
184
+ .option("url", {
185
+ describe: "Frame another server's page instead of deploy_dir",
186
+ type: "string",
187
+ })
188
+ .option("open", {
189
+ alias: "o",
190
+ default: conf.open,
191
+ describe: "Open the play page in a web browser",
192
+ type: "boolean",
193
+ })
194
+ .option("quiet", { ...args.QUIET, default: conf.quiet })
195
+ .option("cwd", args.CWD)
196
+ .help("help")
197
+ .alias("help", "h")
198
+ .version(VERSION)
199
+ .alias("version", "v")
200
+ .strict()
201
+ .parse(),
202
+ );
203
+ };
204
+
205
+ // [start]
206
+
207
+ if (require.main === module) {
208
+ main(process.argv);
209
+ }
210
+
211
+ // [export]
212
+
213
+ module.exports = { main };
package/package.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "arc-open": "bin/arc-open.js",
12
12
  "arc-pack": "bin/arc-pack.js",
13
13
  "arc-play": "bin/arc-play.js",
14
+ "arc-serve": "bin/arc-serve.js",
14
15
  "arc-validate": "bin/arc-validate.js",
15
16
  "arc-whoami": "bin/arc-whoami.js"
16
17
  },
@@ -28,10 +29,12 @@
28
29
  "ini": "^6.0.0",
29
30
  "jsdom": "^27.3.0",
30
31
  "marked": "^17.0.1",
32
+ "mime-types": "^3.0.2",
31
33
  "mp4box": "^2.3.0",
32
34
  "open": "^8.4.2",
33
35
  "pretty-toml": "^1.0.0",
34
36
  "qrcode-terminal": "^0.12.0",
37
+ "range-parser": "^1.3.0",
35
38
  "semver": "^7.7.3",
36
39
  "yargs": "^17.7.2",
37
40
  "zod": "^4.1.13"
@@ -52,6 +55,7 @@
52
55
  "./pack": "./src/cli/pack.js",
53
56
  "./play": "./src/cli/play.js",
54
57
  "./predeploy": "./src/cli/predeploy.js",
58
+ "./serve": "./src/cli/serve.js",
55
59
  "./validate": "./src/cli/validate.js",
56
60
  "./whoami": "./src/cli/whoami.js"
57
61
  },
@@ -75,5 +79,5 @@
75
79
  "scripts": {
76
80
  "test": "node --test \"test/**/*.test.js\""
77
81
  },
78
- "version": "0.1.47"
82
+ "version": "0.1.48"
79
83
  }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ // arc serve: builds the project unless told not to, then serves deploy_dir
3
+ // framed in a local play page (src/serve), reloading the game when deploy_dir
4
+ // changes.
5
+
6
+ // [import]
7
+
8
+ const path = require("node:path");
9
+
10
+ // [import]
11
+
12
+ const fs = require("fs-extra");
13
+
14
+ // [import]
15
+
16
+ const buildTool = require("../build/tool");
17
+ const cfgcode = require("../../config/code");
18
+ const cfgproduct = require("../../config/product");
19
+ const cfguri = require("../../config/uri");
20
+ const commands = require("../../util/command");
21
+ const error = require("../../util/error");
22
+ const files = require("../../serve/files");
23
+ const given = require("../../parser/given");
24
+ const os = require("../../util/os");
25
+ const phrase = require("../../phrase");
26
+ const reload = require("../../serve/reload");
27
+ const remote = require("../../serve/remote");
28
+ const server = require("../../serve/server");
29
+
30
+ // [use]
31
+
32
+ const ArcError = error.ArcError;
33
+ const buildCommand = commands.buildCommand;
34
+ const configDir = os.configDir;
35
+ const dirFiles = files.dirFiles;
36
+ const join = path.join;
37
+ const pathExists = fs.pathExists;
38
+ const relative = path.relative;
39
+ const remoteFiles = remote.remoteFiles;
40
+ const resolvePath = path.resolve;
41
+ const serve = server.serve;
42
+ const watchDir = reload.watchDir;
43
+
44
+ // [declare]
45
+
46
+ // what build.channel says while served locally (docs: arc-serve.md).
47
+ const CHANNEL = "local";
48
+ // the saves of a game with no id yet, kept apart from any other's.
49
+ const UNNAMED = "local";
50
+
51
+ // [function]
52
+
53
+ // the headers a page needs for the play page to frame it: the play page is
54
+ // cross-origin isolated, so a cross-origin frame must be too, and allow it.
55
+ const FRAMEABLE = {
56
+ "cross-origin-embedder-policy": ["credentialless", "require-corp"],
57
+ "cross-origin-resource-policy": ["cross-origin"],
58
+ };
59
+
60
+ // [function]
61
+
62
+ // what `url` says when asked: `{ reachable, frameable }`, where `frameable`
63
+ // says whether it sends the headers the play page needs to frame it.
64
+ const probe = async function (url) {
65
+ try {
66
+ const res = await fetch(url, { method: "GET" });
67
+ await res.body?.cancel();
68
+ const frameable = Object.entries(FRAMEABLE).every(([name, values]) =>
69
+ values.includes(res.headers.get(name)?.trim().toLowerCase()),
70
+ );
71
+ return { frameable, reachable: true };
72
+ } catch (ex) {
73
+ return { frameable: false, reachable: false };
74
+ }
75
+ };
76
+
77
+ // [function]
78
+
79
+ // listens, turning a port in use into its code.
80
+ const listen = async function (options) {
81
+ try {
82
+ return await serve(options);
83
+ } catch (ex) {
84
+ if (ex.code === "EADDRINUSE") {
85
+ throw new ArcError(cfgcode.PORT_IN_USE, phrase.portInUse(options.port), {
86
+ cause: ex,
87
+ });
88
+ }
89
+ throw ex;
90
+ }
91
+ };
92
+
93
+ // [function]
94
+
95
+ // serves the project at `conf.root`. `conf.build` false skips the build, and
96
+ // `conf.url` frames that server's page instead of serving deploy_dir.
97
+ // `conf.handler` hears `onBuild()` as a build starts, `onRemote(name, file,
98
+ // error)` for each of Arcadible's files, and `onReload()` for each reload.
99
+ // resolves with `{ page, game, built, deployDir, url, probed, version,
100
+ // close() }`, where `probed` is what `conf.url` answered.
101
+ const main = async function (conf) {
102
+ const { port, root, url } = conf;
103
+ const handler = {
104
+ onBuild() {},
105
+ onReload() {},
106
+ onRemote() {},
107
+ ...conf.handler,
108
+ };
109
+ const project = await given(conf);
110
+ const game = project.game;
111
+ const options = {
112
+ info: {
113
+ channel: CHANNEL,
114
+ id: game.id ?? UNNAMED,
115
+ title: game.title ?? game.handle ?? cfgproduct.title,
116
+ version: game.version ?? null,
117
+ },
118
+ onRemote: handler.onRemote,
119
+ port,
120
+ remote: remoteFiles({
121
+ asset: cfguri.ARCADIBLE_ASSET,
122
+ dir: join(configDir(), cfgproduct.tool, "sdk"),
123
+ sdk: cfguri.ARCADIBLE_SDK,
124
+ }),
125
+ };
126
+ const version = game.version ?? null;
127
+ if (url) {
128
+ const served = await listen({ ...options, src: url });
129
+ const probed = await probe(url);
130
+ return { ...served, built: false, deployDir: null, probed, url, version };
131
+ }
132
+ const built =
133
+ conf.build !== false && Boolean(buildCommand(project.workflow.build));
134
+ if (built) {
135
+ handler.onBuild();
136
+ await buildTool({ project, root });
137
+ }
138
+ const deployDir = resolvePath(root, project.workflow.deploy_dir);
139
+ if (!(await pathExists(deployDir))) {
140
+ throw new ArcError(
141
+ cfgcode.DEPLOY_DIR_MISSING,
142
+ phrase.deployDirMissing(relative(root, deployDir) || "."),
143
+ );
144
+ }
145
+ const served = await listen({ ...options, files: dirFiles(deployDir) });
146
+ const stop = watchDir(deployDir, () => {
147
+ served.reload();
148
+ handler.onReload();
149
+ });
150
+ return {
151
+ ...served,
152
+ built,
153
+ close() {
154
+ stop();
155
+ served.close();
156
+ },
157
+ deployDir,
158
+ probed: null,
159
+ url: null,
160
+ version,
161
+ };
162
+ };
163
+
164
+ // [export]
165
+
166
+ module.exports = main;
package/src/cli/index.js CHANGED
@@ -12,6 +12,7 @@ const open = require("./open");
12
12
  const pack = require("./pack");
13
13
  const play = require("./play");
14
14
  const predeploy = require("./predeploy");
15
+ const serve = require("./serve");
15
16
  const validate = require("./validate");
16
17
  const whoami = require("./whoami");
17
18
 
@@ -28,6 +29,7 @@ module.exports = {
28
29
  pack,
29
30
  play,
30
31
  predeploy,
32
+ serve,
31
33
  validate,
32
34
  whoami,
33
35
  };
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ // [import]
4
+
5
+ const serveTool = require("../action/serve/tool");
6
+
7
+ // [function]
8
+
9
+ const main = function (conf) {
10
+ return serveTool(conf);
11
+ };
12
+
13
+ // [export]
14
+
15
+ module.exports = main;
@@ -37,6 +37,7 @@ const CLI = {
37
37
  PACKAGE_UNCOMPRESSED_TOO_LARGE: "package_uncompressed_too_large",
38
38
  PARSE_FAILED: "parse_failed",
39
39
  PATH_NOT_FOUND: "path_not_found",
40
+ PORT_IN_USE: "port_in_use",
40
41
  PROJECT_NOT_REPO_ROOT: "project_not_repo_root",
41
42
  SESSION_EXPIRED: "session_expired",
42
43
  TEMPLATE_MISSING: "template_missing",
package/src/config/uri.js CHANGED
@@ -12,6 +12,11 @@ const PROTOCOL = cfgproduct.protocol;
12
12
  // [export]
13
13
 
14
14
  module.exports = {
15
+ // where arc serve fetches Arcadible's own files (src/serve/remote.js): the
16
+ // asset site, which has the play page's host, and a build origin, any of
17
+ // which serves the SDK's hosted script at /arcadible/sdk/.
18
+ ARCADIBLE_ASSET: "https://asset.offst.net",
19
+ ARCADIBLE_SDK: `https://sdk.${cfgproduct.handle}.io`,
15
20
  HOST_APP_PLAY: `${PROTOCOL}://play`,
16
21
  HOST_APP_STORE: `${PROTOCOL}://store`,
17
22
  HOST_HTTP_ACCOUNT: `https://account.${DOMAIN}`,
package/src/config/url.js CHANGED
@@ -15,4 +15,6 @@ module.exports = {
15
15
  ERRORS: `${cfguri.HOST_HTTP_DEVELOPER}/references/errors`,
16
16
  LINK: `${cfguri.HOST_HTTP_ACCOUNT}/link`,
17
17
  MANIFEST: `${cfguri.HOST_HTTP_DEVELOPER}/references/${HANDLE}-manifest`,
18
+ // how to let arc serve's play page frame another server.
19
+ SERVE_URL: `${cfguri.HOST_HTTP_DEVELOPER}/references/${HANDLE}-cli/arc-serve#url`,
18
20
  };
package/src/phrase.js CHANGED
@@ -272,6 +272,103 @@ const packed = function () {
272
272
 
273
273
  // [function]
274
274
 
275
+ const portInvalid = function () {
276
+ return "The port must be a whole number from 1 to 65535.";
277
+ };
278
+
279
+ // [function]
280
+
281
+ const portInUse = function (port) {
282
+ return blocks(
283
+ `Port ${port} is in use.`,
284
+ "Stop what's using it, or pass another with --port.",
285
+ );
286
+ };
287
+
288
+ // [function]
289
+
290
+ // what one of Arcadible's files is, for arc serve's warnings: `host`, the play
291
+ // page's, or a path under /arcadible/, such as sdk/v1.js.
292
+ const remoteName = function (name) {
293
+ return name === "host"
294
+ ? "the play page's host"
295
+ : `/${cfgproduct.handle}/${name}`;
296
+ };
297
+
298
+ // [function]
299
+
300
+ // arc serve's copy of one of Arcadible's files, kept from `fetchedAt`.
301
+ const remoteKept = function (name, fetchedAt) {
302
+ return `${TITLE} can't be reached: serving ${remoteName(name)} from ${fetchedAt.slice(0, 10)}.`;
303
+ };
304
+
305
+ // [function]
306
+
307
+ const remoteUnreachable = function (name) {
308
+ return `${TITLE} can't be reached, and there's no copy of ${remoteName(name)} yet: the game runs without it until ${TITLE} can be.`;
309
+ };
310
+
311
+ // [function]
312
+
313
+ const reloaded = function () {
314
+ return `Reloaded at ${new Date().toTimeString().slice(0, 8)}.`;
315
+ };
316
+
317
+ // [function]
318
+
319
+ const serveAlone = function () {
320
+ return "The game alone, without the play page:";
321
+ };
322
+
323
+ // [function]
324
+
325
+ const serveNext = function (dir) {
326
+ return `Reloads when ${dir}/ changes. Press Ctrl+C to stop.`;
327
+ };
328
+
329
+ // [function]
330
+
331
+ const served = function ({ built, url, version }) {
332
+ const what = version ? `v${version}` : "the game";
333
+ if (url) {
334
+ return `Serving ${what} locally, from ${url}`;
335
+ }
336
+ return built
337
+ ? `Built and serving ${what} locally.`
338
+ : `Serving ${what} locally.`;
339
+ };
340
+
341
+ // [function]
342
+
343
+ const serveNextUrl = function () {
344
+ return "Your server reloads the game. Press Ctrl+C to stop.";
345
+ };
346
+
347
+ // [function]
348
+
349
+ const urlInvalid = function () {
350
+ return "The URL must be an http or https one, such as http://localhost:5173.";
351
+ };
352
+
353
+ // [function]
354
+
355
+ // --url's server sent no headers that let the play page frame it. `link`
356
+ // says how to add them.
357
+ const urlNotFrameable = function (url, link) {
358
+ return blocks(
359
+ `⚠ ${url} can't be framed by the play page: it needs to send Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Resource-Policy: cross-origin.`,
360
+ ["Add them to your server, as the starters' vite.config.js does:", link],
361
+ );
362
+ };
363
+
364
+ // [function]
365
+
366
+ const urlUnreachable = function (url) {
367
+ return `⚠ Nothing answers at ${url} yet. Start your server, then reload the play page.`;
368
+ };
369
+
370
+ // [function]
371
+
275
372
  const pressEnterToOpen = function () {
276
373
  return "Press Enter to open the URL.";
277
374
  };
@@ -971,6 +1068,8 @@ module.exports = {
971
1068
  packageTooManyFiles,
972
1069
  packageUncompressedTooLarge,
973
1070
  packed,
1071
+ portInUse,
1072
+ portInvalid,
974
1073
  pressEnterToOpen,
975
1074
  projectNotRepoRoot,
976
1075
  releaseFailed,
@@ -978,7 +1077,14 @@ module.exports = {
978
1077
  releasePublished,
979
1078
  releaseTimedOut,
980
1079
  releaseWaiting,
1080
+ reloaded,
1081
+ remoteKept,
1082
+ remoteUnreachable,
981
1083
  repoNotConnected,
1084
+ serveAlone,
1085
+ serveNext,
1086
+ serveNextUrl,
1087
+ served,
982
1088
  sessionExpired,
983
1089
  sizeB,
984
1090
  sizeGb,
@@ -1001,6 +1107,9 @@ module.exports = {
1001
1107
  unknownTable,
1002
1108
  uploadFailed,
1003
1109
  uploadRateLimited,
1110
+ urlInvalid,
1111
+ urlNotFrameable,
1112
+ urlUnreachable,
1004
1113
  versionExists,
1005
1114
  versionPrerelease,
1006
1115
  versionRelease,
@@ -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, segmentsOf };
@@ -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) => ({ '"': "&quot;", "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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 = { SETTLE, 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 };
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ // arc serve's server: a local play page and the game it frames, on one port
3
+ // and two origins, as on Arcadible, where the play page and each build have
4
+ // origins of their own. http://localhost:<port> is the page, and
5
+ // http://127.0.0.1:<port> the game. It listens on loopback only, and needs
6
+ // nothing from the CLI, so another app can run a game this way too.
7
+
8
+ // [import]
9
+
10
+ const http = require("node:http");
11
+
12
+ // [import]
13
+
14
+ const game = require("./game");
15
+ const page = require("./page");
16
+ const reload = require("./reload");
17
+
18
+ // [use]
19
+
20
+ const gameHandler = game.gameHandler;
21
+ const pageHandler = page.pageHandler;
22
+ const reloader = reload.reloader;
23
+
24
+ // [declare]
25
+
26
+ // the hostnames a browser may use for the page, on IPv4 and IPv6. `localhost`
27
+ // is IPv6's ::1 on some systems, so both are listened on.
28
+ const LOOPBACK = ["127.0.0.1", "::1"];
29
+
30
+ // [function]
31
+
32
+ // listens on `port` at `address`, resolving with the server, or rejecting with
33
+ // the listen error, such as EADDRINUSE.
34
+ const listen = function (handler, port, address) {
35
+ return new Promise((resolve, reject) => {
36
+ const server = http.createServer(handler);
37
+ server.once("error", reject);
38
+ server.listen(port, address, () => {
39
+ server.off("error", reject);
40
+ resolve(server);
41
+ });
42
+ });
43
+ };
44
+
45
+ // [function]
46
+
47
+ // starts serving `files` for `info`, `{ id, channel, version, title }`, on
48
+ // `port`, with Arcadible's own files from `remote`. With `src`, another
49
+ // server's page, it frames that instead, and serves no files. Resolves with
50
+ // `{ page, game, reload(), close() }`: the page's origin, the game's URL,
51
+ // and the controls.
52
+ const serve = async function ({ files, info, onRemote, port, remote, src }) {
53
+ const origins = {
54
+ game: `http://127.0.0.1:${port}`,
55
+ page: `http://localhost:${port}`,
56
+ };
57
+ const framed = src ?? `${origins.game}/`;
58
+ const reloads = reloader();
59
+ const forGame = files
60
+ ? gameHandler({ files, onRemote, page: origins.page, remote })
61
+ : null;
62
+ const forPage = pageHandler({
63
+ game: { ...info, src: framed },
64
+ onRemote,
65
+ reloads,
66
+ remote,
67
+ });
68
+ // the Host header picks the origin; anything else is refused, so a page
69
+ // elsewhere can't reach the game's files by another name (DNS rebinding).
70
+ const handler = function (req, res) {
71
+ const host = req.headers.host;
72
+ const pick = {
73
+ [`127.0.0.1:${port}`]: forGame,
74
+ [`localhost:${port}`]: forPage,
75
+ }[host];
76
+ if (!pick) {
77
+ res.writeHead(421, { "content-type": "text/plain; charset=utf-8" });
78
+ res.end("Misdirected Request\n");
79
+ return;
80
+ }
81
+ pick(req, res).catch(() => {
82
+ if (!res.headersSent) {
83
+ res.writeHead(500);
84
+ }
85
+ res.end();
86
+ });
87
+ };
88
+ const ipv4 = await listen(handler, port, LOOPBACK[0]);
89
+ // the IPv6 loopback is a bonus: a system without it still has 127.0.0.1.
90
+ let ipv6 = null;
91
+ try {
92
+ ipv6 = await listen(handler, port, LOOPBACK[1]);
93
+ } catch (ex) {
94
+ if (ex.code === "EADDRINUSE") {
95
+ ipv4.close();
96
+ throw ex;
97
+ }
98
+ }
99
+ return {
100
+ close() {
101
+ reloads.close();
102
+ for (const server of [ipv4, ipv6].filter(Boolean)) {
103
+ server.closeAllConnections();
104
+ server.close();
105
+ }
106
+ },
107
+ game: framed,
108
+ page: origins.page,
109
+ reload: reloads.reload,
110
+ };
111
+ };
112
+
113
+ // [export]
114
+
115
+ module.exports = { serve };