@aletheia-ios/tools 0.2.0 → 0.2.2

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.
Files changed (2) hide show
  1. package/dist/cli.js +68 -6
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1061,7 +1061,10 @@ const TRAVERSAL = /^(\.\.[/\\])+/;
1061
1061
  /** How long after the last change to wait before rebuilding, so an editor's save burst is one build. */
1062
1062
  const DEBOUNCE_MS = 300;
1063
1063
  const OK = 200;
1064
+ const NOT_MODIFIED = 304;
1064
1065
  const NOT_FOUND = 404;
1066
+ /** Base 16, so the validator stays short. */
1067
+ const HEX = 16;
1065
1068
  /**
1066
1069
  * Packs and indexes the whole repository, reporting failures instead of throwing.
1067
1070
  *
@@ -1103,7 +1106,27 @@ async function resolveFile(dist, url) {
1103
1106
  if (!(existsSync(file) && (await stat(file)).isFile())) return null;
1104
1107
  return file;
1105
1108
  }
1106
- /** A static server over `dist/` that sends `cache-control: no-store` so the app never caches a dev build. */
1109
+ /**
1110
+ * A validator for the file as it is right now, from its size and modification time.
1111
+ *
1112
+ * Every rebuild rewrites the file, so the value changes and a conditional request gets the new
1113
+ * bytes. Between rebuilds it is stable, which is the only case that answers 304.
1114
+ */
1115
+ function etagOf(size, mtimeMs) {
1116
+ return `"${size.toString(HEX)}-${Math.trunc(mtimeMs).toString(HEX)}"`;
1117
+ }
1118
+ /** Whether the request already holds this version. A client may offer several. */
1119
+ function offers(header, etag) {
1120
+ if (header === void 0) return false;
1121
+ return header.split(",").some((candidate) => candidate.trim() === etag);
1122
+ }
1123
+ /**
1124
+ * A static server over `dist/`.
1125
+ *
1126
+ * Sends `cache-control: no-store` so nothing holds on to a dev build, alongside an `ETag` so a
1127
+ * client that asks can still be told nothing changed. The two are not in tension: `no-store`
1128
+ * governs whether a copy may be kept, the validator governs whether a fetch has to transfer.
1129
+ */
1107
1130
  function fileServer(dist) {
1108
1131
  return createServer(async (request, response) => {
1109
1132
  const file = await resolveFile(dist, request.url ?? "/");
@@ -1111,9 +1134,20 @@ function fileServer(dist) {
1111
1134
  response.writeHead(NOT_FOUND).end();
1112
1135
  return;
1113
1136
  }
1137
+ const { size, mtimeMs } = await stat(file);
1138
+ const etag = etagOf(size, mtimeMs);
1139
+ if (offers(request.headers["if-none-match"], etag)) {
1140
+ response.writeHead(NOT_MODIFIED, {
1141
+ etag,
1142
+ "cache-control": "no-store"
1143
+ }).end();
1144
+ return;
1145
+ }
1114
1146
  response.writeHead(OK, {
1115
1147
  "content-type": TYPES[extname(file)] ?? "application/octet-stream",
1116
- "cache-control": "no-store"
1148
+ "cache-control": "no-store",
1149
+ "content-length": size,
1150
+ etag
1117
1151
  });
1118
1152
  createReadStream(file).pipe(response);
1119
1153
  });
@@ -1131,22 +1165,50 @@ function watchRepo(repo) {
1131
1165
  return [watch(repo.packages, { recursive: true }, trigger), watch(repo.lists, { recursive: true }, trigger)];
1132
1166
  }
1133
1167
  /**
1168
+ * Binds the port and reports the one it got, since port 0 picks a free one.
1169
+ *
1170
+ * A busy port is the common way this fails and it arrives as an `error` event rather than a
1171
+ * rejection, so without this it surfaces as an unhandled event and a stack trace.
1172
+ *
1173
+ * @throws `CliError` naming the port when something already holds it.
1174
+ */
1175
+ async function listen(server, port) {
1176
+ try {
1177
+ await new Promise((resolve, reject) => {
1178
+ server.once("error", reject);
1179
+ server.listen(port, "0.0.0.0", () => {
1180
+ server.removeListener("error", reject);
1181
+ resolve();
1182
+ });
1183
+ });
1184
+ } catch (error) {
1185
+ if (error.code === "EADDRINUSE") throw new CliError([`port ${port} is already in use - pass --port <n> to pick another`], { cause: error });
1186
+ throw error;
1187
+ }
1188
+ const address = server.address();
1189
+ return typeof address === "object" && address !== null ? address.port : port;
1190
+ }
1191
+ /**
1134
1192
  * The `serve` command: packs, indexes, serves `dist/` on the lan and rebuilds on change.
1135
1193
  *
1136
1194
  * Binds every interface and prints the lan URL of each list's `index.json`, which is what
1137
1195
  * the app's developer list points at. Port 0 binds a free port. Resolves once listening;
1138
1196
  * the returned `close` stops the watchers and the server.
1139
1197
  *
1140
- * @throws `CliError` when there is no `lists/` directory, before any port is bound.
1198
+ * @throws `CliError` when there is no `lists/` directory, or when the port is taken.
1141
1199
  */
1142
1200
  async function serve(repo, port) {
1143
1201
  const lists = await loadLists(repo);
1144
1202
  await rebuild(repo);
1145
1203
  const server = fileServer(repo.dist);
1146
1204
  const watchers = watchRepo(repo);
1147
- await new Promise((resolve) => server.listen(port, "0.0.0.0", resolve));
1148
- const address = server.address();
1149
- const bound = typeof address === "object" && address !== null ? address.port : port;
1205
+ let bound;
1206
+ try {
1207
+ bound = await listen(server, port);
1208
+ } catch (error) {
1209
+ for (const watcher of watchers) watcher.close();
1210
+ throw error;
1211
+ }
1150
1212
  const url = `http://${lanAddress()}:${bound}/`;
1151
1213
  info(`serving ${repo.dist} on ${url}`);
1152
1214
  for (const list of lists) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aletheia-ios/tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "The aletheia CLI: build, check, pack, index and serve source packages",
5
5
  "license": "MIT",
6
6
  "repository": {