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 +1 -0
- package/bin/arc-default.js +1 -0
- 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/uri.js +5 -0
- package/src/config/url.js +2 -0
- package/src/phrase.js +109 -0
- 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
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 |
|
package/bin/arc-default.js
CHANGED
|
@@ -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")
|
package/bin/arc-serve.js
ADDED
|
@@ -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.
|
|
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
|
};
|
package/src/cli/serve.js
ADDED
package/src/config/code.js
CHANGED
|
@@ -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) => ({ '"': """, "&": "&", "<": "<", ">": ">" })[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 };
|