@aletheia-ios/tools 0.2.1 → 0.2.3

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 +56 -8
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -748,23 +748,37 @@ async function create(repo, slug, name) {
748
748
  }
749
749
  //#endregion
750
750
  //#region src/lib/zip.ts
751
+ /** The calendar fields stamped on every entry, as a zip records them. */
752
+ const EPOCH = {
753
+ year: 2e3,
754
+ month: 0,
755
+ day: 1
756
+ };
751
757
  /**
752
758
  * The modification time stamped on every entry.
753
759
  *
754
- * A fixed mtime is what makes two builds of the same content byte-identical, which the app
755
- * relies on to treat one package published by two lists as one package.
760
+ * Built from the local-time constructor rather than a UTC instant, because a zip stores a DOS
761
+ * timestamp of the *local* calendar fields. A UTC instant renders as 00:00 on a runner in UTC
762
+ * and 11:00 on a machine in Sydney, which produced byte-different archives of identical files
763
+ * and broke the one guarantee this function exists to make. These arguments read back as
764
+ * 2000-01-01 00:00 from every timezone.
765
+ *
766
+ * Constructed per call so that construction and encoding always see the same timezone.
756
767
  */
757
- const EPOCH = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
768
+ function epoch() {
769
+ return new Date(EPOCH.year, EPOCH.month, EPOCH.day);
770
+ }
758
771
  /**
759
772
  * Zips files into bytes that depend only on their names and contents.
760
773
  *
761
- * Entries are written in sorted name order with the fixed mtime and maximum deflate, so
762
- * the same input always produces the same archive and the same sha256.
774
+ * Entries are written in sorted name order with a fixed mtime and maximum deflate, so the same
775
+ * input produces the same archive and the same sha256 on any machine, in any timezone.
763
776
  */
764
777
  function deterministicZip(files) {
778
+ const mtime = epoch();
765
779
  const entries = {};
766
780
  for (const name of Object.keys(files).sort()) entries[name] = [files[name], {
767
- mtime: EPOCH,
781
+ mtime,
768
782
  level: 9
769
783
  }];
770
784
  return zipSync(entries);
@@ -1061,7 +1075,10 @@ const TRAVERSAL = /^(\.\.[/\\])+/;
1061
1075
  /** How long after the last change to wait before rebuilding, so an editor's save burst is one build. */
1062
1076
  const DEBOUNCE_MS = 300;
1063
1077
  const OK = 200;
1078
+ const NOT_MODIFIED = 304;
1064
1079
  const NOT_FOUND = 404;
1080
+ /** Base 16, so the validator stays short. */
1081
+ const HEX = 16;
1065
1082
  /**
1066
1083
  * Packs and indexes the whole repository, reporting failures instead of throwing.
1067
1084
  *
@@ -1103,7 +1120,27 @@ async function resolveFile(dist, url) {
1103
1120
  if (!(existsSync(file) && (await stat(file)).isFile())) return null;
1104
1121
  return file;
1105
1122
  }
1106
- /** A static server over `dist/` that sends `cache-control: no-store` so the app never caches a dev build. */
1123
+ /**
1124
+ * A validator for the file as it is right now, from its size and modification time.
1125
+ *
1126
+ * Every rebuild rewrites the file, so the value changes and a conditional request gets the new
1127
+ * bytes. Between rebuilds it is stable, which is the only case that answers 304.
1128
+ */
1129
+ function etagOf(size, mtimeMs) {
1130
+ return `"${size.toString(HEX)}-${Math.trunc(mtimeMs).toString(HEX)}"`;
1131
+ }
1132
+ /** Whether the request already holds this version. A client may offer several. */
1133
+ function offers(header, etag) {
1134
+ if (header === void 0) return false;
1135
+ return header.split(",").some((candidate) => candidate.trim() === etag);
1136
+ }
1137
+ /**
1138
+ * A static server over `dist/`.
1139
+ *
1140
+ * Sends `cache-control: no-store` so nothing holds on to a dev build, alongside an `ETag` so a
1141
+ * client that asks can still be told nothing changed. The two are not in tension: `no-store`
1142
+ * governs whether a copy may be kept, the validator governs whether a fetch has to transfer.
1143
+ */
1107
1144
  function fileServer(dist) {
1108
1145
  return createServer(async (request, response) => {
1109
1146
  const file = await resolveFile(dist, request.url ?? "/");
@@ -1111,9 +1148,20 @@ function fileServer(dist) {
1111
1148
  response.writeHead(NOT_FOUND).end();
1112
1149
  return;
1113
1150
  }
1151
+ const { size, mtimeMs } = await stat(file);
1152
+ const etag = etagOf(size, mtimeMs);
1153
+ if (offers(request.headers["if-none-match"], etag)) {
1154
+ response.writeHead(NOT_MODIFIED, {
1155
+ etag,
1156
+ "cache-control": "no-store"
1157
+ }).end();
1158
+ return;
1159
+ }
1114
1160
  response.writeHead(OK, {
1115
1161
  "content-type": TYPES[extname(file)] ?? "application/octet-stream",
1116
- "cache-control": "no-store"
1162
+ "cache-control": "no-store",
1163
+ "content-length": size,
1164
+ etag
1117
1165
  });
1118
1166
  createReadStream(file).pipe(response);
1119
1167
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aletheia-ios/tools",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "The aletheia CLI: build, check, pack, index and serve source packages",
5
5
  "license": "MIT",
6
6
  "repository": {