arcadible 1.0.2 → 1.0.5

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.
@@ -123,16 +123,20 @@ const GALLERY_DIMENSIONS = {
123
123
  // leaving a field out is how to unset it, so an empty one is always a mistake.
124
124
  const atLeast = function (field, min, unit) {
125
125
  return min === 1
126
- ? message(field, ["Must not be empty. Leave the field out instead."])
127
- : message(field, [`Must be at least: ${min} ${unit}`]);
126
+ ? message(field, ["Must not be empty.", "Leave the field out instead."])
127
+ : message(field, [
128
+ unit === "character"
129
+ ? `Must be at least ${min} characters.`
130
+ : `Must have at least ${min} ${unit}s.`,
131
+ ]);
128
132
  };
129
133
 
130
134
  // [function]
131
135
 
132
- // a required string's error: "Must be defined." and `lines` when it's missing,
136
+ // a required string's error: "Must be set." and `lines` when it's missing,
133
137
  // and zod's own type message (null) when it's there but isn't a string.
134
138
  const required = function (field, lines) {
135
- const missing = message(field, ["Must be defined.", ...lines]).message;
139
+ const missing = message(field, ["Must be set.", ...lines]).message;
136
140
  return {
137
141
  error(issue) {
138
142
  return typeof issue.input === "undefined" ? missing : null;
@@ -144,14 +148,14 @@ const required = function (field, lines) {
144
148
 
145
149
  // a list of distinct values. Without a max, its length is up to the values.
146
150
  const zSet = function (field, schema, { max, min }) {
147
- const list = z.array(schema).min(min, atLeast(field, min, "element"));
151
+ const list = z.array(schema).min(min, atLeast(field, min, "item"));
148
152
  return (
149
153
  max
150
- ? list.max(max, message(field, [`Must not exceed: ${max} elements`]))
154
+ ? list.max(max, message(field, [`Must have at most ${max} items.`]))
151
155
  : list
152
156
  ).refine(
153
157
  (items) => new Set(items).size === items.length,
154
- message(field, ["Must not contain duplicate elements."], ISSUE.DUPLICATE),
158
+ message(field, ["Must not repeat an item."], ISSUE.DUPLICATE),
155
159
  );
156
160
  };
157
161
 
@@ -161,12 +165,12 @@ const zSetBasefile = function (field, schema, { max, min }) {
161
165
  return z
162
166
  .array(schema)
163
167
  .min(min, atLeast(field, min, "file"))
164
- .max(max, message(field, [`Must not exceed: ${max} files`]))
168
+ .max(max, message(field, [`Must have at most ${max} files.`]))
165
169
  .refine(
166
170
  (items) => new Set(items.map(basefile)).size === items.length,
167
171
  message(
168
172
  field,
169
- ["Must not contain duplicate filenames (ignoring extensions)."],
173
+ ["Must not have two files of one name, whatever their extensions."],
170
174
  ISSUE.DUPLICATE,
171
175
  ),
172
176
  );
@@ -183,7 +187,7 @@ const zString = function (field, char, { max, min }) {
183
187
  .min(min, { ...atLeast(field, min, "character"), abort: min === 1 })
184
188
  .refine(
185
189
  (str) => [...str].length <= max,
186
- message(field, [`Must not exceed: ${max} characters`], ISSUE.TOO_LONG),
190
+ message(field, [`Must be at most ${max} characters.`], ISSUE.TOO_LONG),
187
191
  )
188
192
  .superRefine((str, ctx) => {
189
193
  const invalid = invalidChars(str, char);
@@ -458,7 +462,7 @@ const zHandle = function () {
458
462
  const field = "handle";
459
463
  return z.optional(
460
464
  zString(field, /[a-z0-9-]/u, { max: 24, min: 1 })
461
- .min(3, atLeast(field, 3, "characters"))
465
+ .min(3, atLeast(field, 3, "character"))
462
466
  .refine(
463
467
  (str) => !str.startsWith("-") && !str.endsWith("-"),
464
468
  message(
@@ -487,7 +491,7 @@ const zId = function () {
487
491
  required(field, [`Run ${phrase.cmdInit()} to generate a new game ID.`]),
488
492
  )
489
493
  .min(1, { ...atLeast(field, 1), abort: true })
490
- .uuid(message(field, ["Must be a valid UUID."]))
494
+ .uuid(message(field, ["Must be a UUID."]))
491
495
  .refine(
492
496
  (value) => value === value.toLowerCase(),
493
497
  message(field, ["Must be lowercase."], ISSUE.UUID_INVALID),
@@ -514,8 +518,8 @@ const zPrice = function () {
514
518
  return z.optional(
515
519
  z
516
520
  .int()
517
- .min(0, message(field, ["Must not be below: 0"]))
518
- .max(25, message(field, ["Must not be above: 25"])),
521
+ .min(0, message(field, ["Must be 0 or more."]))
522
+ .max(25, message(field, ["Must be 25 or less."])),
519
523
  );
520
524
  };
521
525
 
@@ -547,7 +551,7 @@ const zTrailer = function () {
547
551
  return z
548
552
  .string()
549
553
  .min(1, { ...atLeast(field, 1), abort: true })
550
- .max(255, message(field, ["Must not exceed: 255 characters"]))
554
+ .max(255, message(field, ["Must be at most 255 characters."]))
551
555
  .refine(
552
556
  (url) =>
553
557
  zVimeo().safeParse(url).success || zYouTube().safeParse(url).success,
@@ -561,18 +565,26 @@ const zTrailer = function () {
561
565
  const zVersion = function () {
562
566
  const field = "version";
563
567
  return z
564
- .string(
565
- required(field, ["A version is required unless one can be derived."]),
566
- )
568
+ .string(required(field, ["Set it here, or in package.json."]))
567
569
  .min(1, { ...atLeast(field, 1), abort: true })
568
570
  .refine((value) => validSemver(value) !== null, {
569
- ...message(field, ["Must be valid semver."], ISSUE.VERSION_INVALID),
571
+ ...message(
572
+ field,
573
+ ["Must be a version, such as 1.2.0."],
574
+ ISSUE.VERSION_INVALID,
575
+ ),
570
576
  abort: true,
571
577
  })
572
578
  .refine(
573
579
  // lowercase too, since the database refuses an uppercase prerelease.
574
580
  (value) => cleanSemver(value) === value && value === value.toLowerCase(),
575
- message(field, ["Must be normalized semver."], ISSUE.VERSION_INVALID),
581
+ message(
582
+ field,
583
+ [
584
+ "Must be written as 1.2.0 or 1.2.0-beta.1: lowercase, with no v, spaces, or build metadata.",
585
+ ],
586
+ ISSUE.VERSION_INVALID,
587
+ ),
576
588
  );
577
589
  };
578
590
 
@@ -29,7 +29,8 @@ const main = function (value, { file }) {
29
29
  ...message(
30
30
  "description",
31
31
  [
32
- `${file} has content after its title, tagline, and bio, so it needs two --- rules around the description.`,
32
+ "Needs two --- rules around the description.",
33
+ `${file} has content after its title, tagline, and bio.`,
33
34
  ],
34
35
  cfgcode.ISSUE.DESCRIPTION_RULES_MISSING,
35
36
  ),
@@ -68,7 +68,7 @@ const zDeployDir = function (root, { build }) {
68
68
  return z
69
69
  .string()
70
70
  .min(1, {
71
- ...message(field, ["Must not be empty. Leave the field out instead."]),
71
+ ...message(field, ["Must not be empty.", "Leave the field out instead."]),
72
72
  abort: true,
73
73
  })
74
74
  .refine((file) => isRelativeCheck(root, file), {
@@ -82,24 +82,20 @@ const zDeployDir = function (root, { build }) {
82
82
  .refine((file) => !isRootCheck(root, file), {
83
83
  ...message(
84
84
  field,
85
- ["Must be a directory inside the project, not the project root."],
85
+ ["Must be a folder in the project, not its root."],
86
86
  ISSUE.PATH_INVALID,
87
87
  ),
88
88
  abort: true,
89
89
  })
90
90
  .refine((file) => avoidsCheck(root, file, MEDIA), {
91
- ...message(
92
- field,
93
- [`Must not contain ${MEDIA} in path.`],
94
- ISSUE.PATH_INVALID,
95
- ),
91
+ ...message(field, [`Must not be in ${MEDIA}.`], ISSUE.PATH_INVALID),
96
92
  abort: true,
97
93
  })
98
94
  .refine((file) => hasBuild(build) || existsCheck(root, file), {
99
95
  ...message(
100
96
  field,
101
97
  [
102
- "Must exist when no build step is defined.",
98
+ "Must exist, since there's no build.",
103
99
  `Run ${phrase.cmdInspect()} for details.`,
104
100
  ],
105
101
  ISSUE.PATH_INVALID,
@@ -118,7 +114,8 @@ const zDeployDir = function (root, { build }) {
118
114
  ...message(
119
115
  field,
120
116
  [
121
- `Must not have ${RESERVED.join(" or ")} at its root. They're reserved for the files ${cfgproduct.title} adds.`,
117
+ `Must not have ${RESERVED.join(" or ")} at its root.`,
118
+ `They're reserved for the files ${cfgproduct.title} adds.`,
122
119
  ],
123
120
  ISSUE.PATH_INVALID,
124
121
  ),
@@ -128,7 +125,8 @@ const zDeployDir = function (root, { build }) {
128
125
  ...message(
129
126
  field,
130
127
  [
131
- `Must not have an ${RESERVED_FOLDER} folder at its root, in any letter case. It's reserved for ${cfgproduct.title}'s own files.`,
128
+ `Must not have an ${RESERVED_FOLDER} folder at its root, in any letter case.`,
129
+ `It's reserved for ${cfgproduct.title}'s own files.`,
132
130
  ],
133
131
  ISSUE.PATH_INVALID,
134
132
  ),
package/src/serve/game.js CHANGED
@@ -98,7 +98,13 @@ const sendFile = function (req, res, file, headers) {
98
98
  res.end();
99
99
  return;
100
100
  }
101
- file.open(range).pipe(res);
101
+ // the file can go between finding and reading it, as a rebuild empties
102
+ // deploy_dir: the headers are sent, so the response just ends
103
+ const stream = file.open(range);
104
+ stream.on("error", () => {
105
+ res.destroy();
106
+ });
107
+ stream.pipe(res);
102
108
  };
103
109
 
104
110
  // [function]
package/src/serve/page.js CHANGED
@@ -24,6 +24,17 @@ const SANDBOX =
24
24
  const HOST = "/_arcadible/host.js";
25
25
  const RELOAD = "/_arcadible/reload";
26
26
 
27
+ // [declare]
28
+
29
+ // the headers of the page's responses, as the play page's: it's
30
+ // cross-origin isolated, and nothing frames it.
31
+ const HEADERS = {
32
+ "cache-control": "no-store",
33
+ "content-security-policy": "frame-ancestors 'none';",
34
+ "cross-origin-embedder-policy": "require-corp",
35
+ "cross-origin-opener-policy": "same-origin",
36
+ };
37
+
27
38
  // [function]
28
39
 
29
40
  // `value` as JSON that's safe inside a <script>.
@@ -86,10 +97,10 @@ const html = function (game) {
86
97
  version: config.version,
87
98
  });
88
99
  } catch (err) {
89
- console.error("arcadible: no host for the game:", err);
100
+ console.error("Arcadible SDK: the play page couldn't host the game, so it stands alone.", err);
90
101
  }
91
102
  } else {
92
- console.error("arcadible: no host for the game: the play page's host didn't load.");
103
+ console.error("Arcadible SDK: the play page's host didn't load, so the game stands alone.");
93
104
  }
94
105
  frame.src = config.src;
95
106
  document.body.prepend(frame);
@@ -105,17 +116,6 @@ const html = function (game) {
105
116
 
106
117
  // [function]
107
118
 
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
119
  // the request handler for the page's origin. `reloads` keeps the pages
120
120
  // listening for a reload; `remote` has the host.
121
121
  const pageHandler = function ({
@@ -52,6 +52,11 @@ const watchDir = function (dir, onChange) {
52
52
  clearTimeout(timer);
53
53
  timer = setTimeout(onChange, SETTLE);
54
54
  });
55
+ // deploy_dir removed while it's watched (EPERM on Windows): stop watching
56
+ // rather than crash the server
57
+ watcher.on("error", () => {
58
+ watcher.close();
59
+ });
55
60
  return () => {
56
61
  clearTimeout(timer);
57
62
  watcher.close();
package/src/util/log.js CHANGED
@@ -30,6 +30,13 @@ const log = function (out, message, { bold = false, color, nl = false } = {}) {
30
30
 
31
31
  // [function]
32
32
 
33
+ // a warning, marked with ⚠ (OUTPUT.md), on its first line.
34
+ const warned = function (message) {
35
+ return `⚠ ${[message].flat().join("\n").trim()}`;
36
+ };
37
+
38
+ // [function]
39
+
33
40
  const h = function (message) {
34
41
  log(console.log, message, { bold: true });
35
42
  };
@@ -91,13 +98,13 @@ const hwo = function (message) {
91
98
  // [function]
92
99
 
93
100
  const pw = function (message) {
94
- log(console.warn, message, { color: "yellow" });
101
+ log(console.warn, warned(message), { color: "yellow" });
95
102
  };
96
103
 
97
104
  // [function]
98
105
 
99
106
  const nlpw = function (message) {
100
- log(console.warn, message, { color: "yellow", nl: true });
107
+ log(console.warn, warned(message), { color: "yellow", nl: true });
101
108
  };
102
109
 
103
110
  // [function]
package/src/util/md.js CHANGED
@@ -32,7 +32,7 @@ const escape = function (str) {
32
32
  .replace(/>/gu, "&gt;");
33
33
  };
34
34
 
35
- // [declare]
35
+ // [define]
36
36
 
37
37
  // there is no video syntax in Markdown, so an image of a video renders as the
38
38
  // video the developer would have written: its src and title, and no alt.
@@ -59,6 +59,20 @@ const print = function (data, { key, nl, toml } = {}) {
59
59
 
60
60
  // [function]
61
61
 
62
+ // exits with `code` once stdout has written what it holds. A pipe on macOS
63
+ // writes later, so exiting at once would cut a large --json document short;
64
+ // with nothing held, it exits at once, as a caller that goes on expects.
65
+ const exit = function (code) {
66
+ if (!process.stdout.writableLength) {
67
+ process.exit(code);
68
+ }
69
+ process.exitCode = code;
70
+ process.stdout.write("", () => process.exit(code));
71
+ return new Promise(() => {});
72
+ };
73
+
74
+ // [function]
75
+
62
76
  // a command's error path: the error as JSON, or its heading ("Deploy
63
77
  // aborted.") and the reason on stderr, then its code and the link to it on
64
78
  // the Errors page, once the spinner and progress bar are cleared. Then exits
@@ -102,7 +116,7 @@ const abort = function (
102
116
  log.pd(message);
103
117
  log.nlpd([phrase.errorCode(shown), chalk.cyan.underline(link)]);
104
118
  }
105
- process.exit(1);
119
+ return exit(1);
106
120
  };
107
121
 
108
122
  // [function]
@@ -128,7 +142,7 @@ const warn = function (warnings, { json, quiet } = {}) {
128
142
  if (json || quiet) {
129
143
  return;
130
144
  }
131
- warnings.forEach((each) => log.pw(`⚠ ${schema.print(each)}`));
145
+ warnings.forEach((each) => log.pw(schema.print(each)));
132
146
  };
133
147
 
134
148
  // [function]
@@ -150,6 +164,7 @@ const warning = function (code, message, path = null) {
150
164
 
151
165
  module.exports = {
152
166
  abort,
167
+ exit,
153
168
  launched,
154
169
  print,
155
170
  printJson,
package/src/util/rc.js CHANGED
@@ -75,7 +75,22 @@ const files = function (cmd, argv) {
75
75
 
76
76
  // [function]
77
77
 
78
- // a file's sections, INI or JSON, or nothing when it can't be read. JSON
78
+ // ini parses anything, so a section header with no closing bracket, such as
79
+ // `[validate`, would become a key, and the options under it would be read
80
+ // outside their section. The first one's problem, or null.
81
+ const unclosedSection = function (str) {
82
+ const lines = str.split(/\r?\n/u).map((line) => line.trim());
83
+ const i = lines.findIndex(
84
+ (text) => text.startsWith("[") && !/^\[[^\]]*\]$/u.test(text),
85
+ );
86
+ return i === -1
87
+ ? null
88
+ : `Line ${i + 1}: section header isn't closed: ${lines[i]}`;
89
+ };
90
+
91
+ // [function]
92
+
93
+ // a file's sections, INI or JSON, or nothing when it can't be read. A file
79
94
  // that doesn't parse is `parse_failed`, naming the file.
80
95
  const read = function (file) {
81
96
  let str = null;
@@ -85,6 +100,13 @@ const read = function (file) {
85
100
  return {};
86
101
  }
87
102
  if (!/^\s*\{/u.test(str)) {
103
+ const unclosed = unclosedSection(str);
104
+ if (unclosed) {
105
+ throw new ArcError(
106
+ cfgcode.PARSE_FAILED,
107
+ phrase.unableToParse(file, unclosed),
108
+ );
109
+ }
88
110
  return ini.parse(str);
89
111
  }
90
112
  try {
package/src/util/s3.js CHANGED
@@ -17,13 +17,32 @@ const ArcError = error.ArcError;
17
17
  const createReadStream = fs.createReadStream;
18
18
  const request = https.request;
19
19
 
20
+ // [declare]
21
+
22
+ const CHECKSUM = "x-amz-checksum-sha256";
23
+
24
+ // [function]
25
+
26
+ // [function]
27
+
28
+ // whether the upload link signs `header`, which must then be sent.
29
+ const signs = function (url, header) {
30
+ const signed = new URL(url).searchParams.get("X-Amz-SignedHeaders");
31
+ return Boolean(signed?.split(";").includes(header));
32
+ };
33
+
20
34
  // [function]
21
35
 
22
- const requestOpts = function (size) {
36
+ // the zip's SHA-256 is sent when the link signs it, so S3 refuses a zip that
37
+ // isn't the one the upload was asked for.
38
+ const requestOpts = function (url, { sha256, size }) {
23
39
  return {
24
40
  headers: {
25
41
  "Content-Length": size,
26
42
  "Content-Type": "application/zip",
43
+ ...(signs(url, CHECKSUM)
44
+ ? { [CHECKSUM]: Buffer.from(sha256, "hex").toString("base64") }
45
+ : {}),
27
46
  },
28
47
  method: "PUT",
29
48
  };
@@ -34,9 +53,9 @@ const requestOpts = function (size) {
34
53
  // puts the file to the upload link, and resolves once S3 accepts it. A
35
54
  // request that gets no response rejects as api_unreachable; a file that can't
36
55
  // be read rejects with its own error.
37
- const upload = function (url, { path, size }, onProgress) {
56
+ const upload = function (url, { path, sha256, size }, onProgress) {
38
57
  return new Promise((resolve, reject) => {
39
- const req = request(url, requestOpts(size), (resp) => {
58
+ const req = request(url, requestOpts(url, { sha256, size }), (resp) => {
40
59
  resp.resume();
41
60
  const status = resp.statusCode;
42
61
  if (status >= 200 && status < 300) {
@@ -17,6 +17,14 @@ const ISSUE = cfgcode.ISSUE;
17
17
 
18
18
  // [declare]
19
19
 
20
+ // how a value's kind is named, as TOML writes it.
21
+ const KINDS = {
22
+ array: "a list",
23
+ boolean: "true or false",
24
+ number: "a number",
25
+ object: "a table",
26
+ string: "a string",
27
+ };
20
28
  const NUMBERS = new Set(["bigint", "int", "number"]);
21
29
 
22
30
  // [function]
@@ -65,11 +73,54 @@ const issueCodeOf = function (issue) {
65
73
 
66
74
  // [function]
67
75
 
76
+ // a value's kind, as TOML writes it: "a string", "a list", "a table".
77
+ const kindOf = function (type) {
78
+ return KINDS[type] ?? `a ${type}`;
79
+ };
80
+
81
+ // [function]
82
+
83
+ // what zod reports itself (a value of the wrong type, or not one of the
84
+ // options), in arc's words, as `{ message, details }`: "Must be a string, not
85
+ // a number." Null for a message of our own.
86
+ const zodMessage = function (issue) {
87
+ if (issue.code === "invalid_value" && issue.values) {
88
+ return { details: [issue.values.join(", ")], message: "Must be one of:" };
89
+ }
90
+ const given = Array.isArray(issue.input) ? "array" : typeof issue.input;
91
+ if (issue.code === "invalid_type" && typeof issue.input !== "undefined") {
92
+ return {
93
+ details: [],
94
+ message: `Must be ${kindOf(issue.expected)}, not ${kindOf(given)}.`,
95
+ };
96
+ }
97
+ if (
98
+ issue.code === "invalid_union" &&
99
+ issue.errors.every((branch) =>
100
+ branch.every((each) => each.code === "invalid_type"),
101
+ )
102
+ ) {
103
+ const expected = [
104
+ ...new Set(issue.errors.map((branch) => kindOf(branch[0].expected))),
105
+ ];
106
+ return {
107
+ details: [],
108
+ message: `Must be ${expected.join(" or ")}, not ${kindOf(given)}.`,
109
+ };
110
+ }
111
+ return null;
112
+ };
113
+
114
+ // [function]
115
+
68
116
  // a zod issue as arc validate reports it: the field's path, the rule's code,
69
117
  // the message's first line and its details, and the link to the field's
70
118
  // rules, or null for a field the docs leave out.
71
119
  const toIssue = function (name, issue) {
72
- const { details, message } = schemaMessage.parse(issue.message);
120
+ const parsed = schemaMessage.parse(issue.message);
121
+ const { details, message } =
122
+ (/^Invalid (input|option)\b/u.test(parsed.message) && zodMessage(issue)) ||
123
+ parsed;
73
124
  return {
74
125
  code: issueCodeOf(issue),
75
126
  details,
@@ -133,5 +184,6 @@ module.exports = {
133
184
  dereference,
134
185
  issueCodeOf,
135
186
  print,
187
+ toIssue,
136
188
  validator,
137
189
  };