@monimesl/dot-simulator 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
@@ -1,11 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
- import { HostError, NavigationStack, REACT_HOST_PATH, allowsHost, dotFilePathProblem, isReactCompatible, isVersionAtLeast, majorVersion, newestReactOfMajor } from "@monimesl/dot-core";
2
+ import { HostError, NavigationStack, REACT_HOST_PATH, allowsHost, dotFilePathProblem, isReactCompatible, isVersionAtLeast, newestReactOfMajor } from "@monimesl/dot-core";
3
3
  import { appendFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { STATUS_CODES, createServer } from "node:http";
6
6
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
7
7
  import { TextDecoder } from "node:util";
8
- import { randomBytes } from "node:crypto";
8
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
9
9
  //#region src/compatibility.ts
10
10
  var IncompatibleDotError = class extends Error {
11
11
  name = "IncompatibleDotError";
@@ -16,10 +16,10 @@ var IncompatibleDotError = class extends Error {
16
16
  }
17
17
  };
18
18
  function checkCompatibility(manifest, { dashVersion, minRuntimeVersion, reactVersions }) {
19
- if (!isVersionAtLeast(manifest.runtime, minRuntimeVersion)) throw new IncompatibleDotError("runtimeTooOld", `This dot was built with Dot runtime ${manifest.runtime}, but this host runs ${minRuntimeVersion} or newer. Rebuild it with a newer @monimesl/dot-runtime.`);
19
+ if (!isVersionAtLeast(manifest.runtime, minRuntimeVersion)) throw new IncompatibleDotError("runtimeTooOld", `This dot was built with an old Dot runtime, ${manifest.runtime}. Rebuild it with @monimesl/dot-runtime ${minRuntimeVersion} or newer.`);
20
20
  const minDashVersion = manifest.dash?.minVersion;
21
- if (minDashVersion !== void 0 && !isVersionAtLeast(dashVersion, minDashVersion)) throw new IncompatibleDotError("dashTooOld", `This dot needs Dash ${minDashVersion} or newer, and this is Dash ${dashVersion}.`);
22
- if (reactVersions !== void 0 && !reactVersions.some((version) => isReactCompatible(version, manifest.react))) throw new IncompatibleDotError("reactUnavailable", `This dot needs React ${manifest.react} or a newer ${majorVersion(manifest.react)}.x, which this host doesn't ship.`);
21
+ if (minDashVersion !== void 0 && !isVersionAtLeast(dashVersion, minDashVersion)) throw new IncompatibleDotError("dashTooOld", `This dot needs Dash ${minDashVersion} or newer. This is Dash ${dashVersion}.`);
22
+ if (reactVersions !== void 0 && !reactVersions.some((version) => isReactCompatible(version, manifest.react))) throw new IncompatibleDotError("reactUnavailable", `This dot needs React ${manifest.react}, which this host doesn't have.`);
23
23
  }
24
24
  //#endregion
25
25
  //#region src/host-calls.ts
@@ -41,7 +41,7 @@ var HostCallRunner = class {
41
41
  id,
42
42
  error: {
43
43
  code: "unsupported",
44
- message: `This host has no "${method}"`
44
+ message: `This host doesn't support "${method}"`
45
45
  }
46
46
  });
47
47
  return;
@@ -64,7 +64,7 @@ var HostCallRunner = class {
64
64
  this.#end(id)?.controller.abort();
65
65
  }
66
66
  dismissUI() {
67
- for (const [id, call] of this.#running) if (call.method.startsWith(UI_METHOD_PREFIX)) this.#fail(id, new HostError("cancelled", "The page it was shown on is no longer on top"));
67
+ for (const [id, call] of this.#running) if (call.method.startsWith(UI_METHOD_PREFIX)) this.#fail(id, new HostError("cancelled", "Its page is no longer on screen"));
68
68
  }
69
69
  async #invoke(id, entry, call) {
70
70
  try {
@@ -126,7 +126,7 @@ var PermissionGate = class {
126
126
  }
127
127
  async check(method, permission, signal) {
128
128
  const reason = this.#declared[permission];
129
- if (reason === void 0) throw new HostError("denied", `"${method}" needs the "${permission}" permission. Add it to dot.json's permissions, with the reason Dash shows the person.`);
129
+ if (reason === void 0) throw new HostError("denied", `"${method}" needs the "${permission}" permission. Add it to permissions in dot.json, with a reason to show the person.`);
130
130
  let allowed = this.#answers.get(permission);
131
131
  if (allowed === void 0) {
132
132
  allowed = await this.#ask({
@@ -136,7 +136,7 @@ var PermissionGate = class {
136
136
  }, signal);
137
137
  this.#answers.set(permission, allowed);
138
138
  }
139
- if (!allowed) throw new HostError("denied", `The person did not allow "${permission}"`);
139
+ if (!allowed) throw new HostError("denied", `The person didn't allow "${permission}"`);
140
140
  }
141
141
  };
142
142
  //#endregion
@@ -286,7 +286,10 @@ const SIMULATOR_VERSIONS = {
286
286
  const CLIENT_PATH = "/__dot/";
287
287
  const EVENTS_PATH = `${CLIENT_PATH}events`;
288
288
  const HTTP_PATH = `${CLIENT_PATH}network/http/`;
289
- const FILE_PATH = `${CLIENT_PATH}file/`;
289
+ const FILE_PATH = `${CLIENT_PATH}storage/file/`;
290
+ const CACHE_PATH = `${CLIENT_PATH}storage/cache/`;
291
+ const SECRET_PATH = `${CLIENT_PATH}storage/secret/`;
292
+ const TEMPORARY_FILES_PATH = `${CLIENT_PATH}files/`;
290
293
  const WEBVIEW_URL = `/dot/ui/index.html`;
291
294
  //#endregion
292
295
  //#region src/page.ts
@@ -304,14 +307,6 @@ function simulatorPage(appName) {
304
307
  <header>
305
308
  <strong>${escapeHtml(appName)}</strong>
306
309
  <span class="subtle">Dot simulator</span>
307
- <select id="platform" aria-label="Target platform">
308
- <option value="ios">iOS</option>
309
- <option value="android">Android</option>
310
- </select>
311
- <select id="input-mode" aria-label="Input mode" title="Choose whether the simulator emulates a touch pointer">
312
- <option value="touch">Touch</option>
313
- <option value="browser">Browser</option>
314
- </select>
315
310
  <select id="scheme" aria-label="Device appearance">
316
311
  <option value="light">Light</option>
317
312
  <option value="dark">Dark</option>
@@ -370,14 +365,13 @@ const STYLES = `
370
365
  * { box-sizing: border-box; }
371
366
  body { margin: 0; height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--text); }
372
367
  header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
373
- #platform { margin-left: auto; }
368
+ #scheme { margin-left: auto; }
374
369
  button, select { font: inherit; color: inherit; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
375
370
  .subtle { color: var(--subtle); }
376
371
  main { flex: 1; display: flex; min-height: 0; }
377
372
  .stage { flex: 1; overflow: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; }
378
- .phone { flex: none; position: relative; display: flex; flex-direction: column; width: 393px; height: 852px; box-sizing: content-box; border: 12px solid #0b0b0c; border-radius: 67px; overflow: hidden; background: var(--screen); box-shadow: 0 0 0 2px #3a3a3c, 0 18px 50px rgb(0 0 0 / 0.3); --screen: #f2f2f2; --screen-text: #000; }
373
+ .phone { flex: none; position: relative; display: flex; flex-direction: column; width: 393px; height: 852px; box-sizing: content-box; border: 12px solid #0b0b0c; border-radius: 67px; overflow: hidden; background: var(--screen); box-shadow: 0 0 0 2px #3a3a3c, 0 18px 50px rgb(0 0 0 / 0.3); --screen: #f2f2f2; --screen-text: #000; --dialog: #f7f7f7; --dialog-line: #d1d1d6; }
379
374
  .phone[data-scheme="dark"] { --screen: #121212; --screen-text: #fff; --dialog: #2c2c2e; --dialog-line: #48484a; }
380
- .phone { --dialog: #f7f7f7; --dialog-line: #d1d1d6; }
381
375
  .torch { display: none; }
382
376
  .phone[data-torch="on"] .torch { display: block; }
383
377
  .dialog-layer { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 0.3); }
@@ -450,6 +444,7 @@ function contentTypeFor(name) {
450
444
  //#region src/network.ts
451
445
  const MAX_RESPONSE_BYTES = 5 * MEGABYTE;
452
446
  const MAX_DOWNLOAD_BYTES = 100 * MEGABYTE;
447
+ const MAX_REDIRECTS = 10;
453
448
  const METHODS = [
454
449
  "GET",
455
450
  "POST",
@@ -481,13 +476,18 @@ const HOST_HEADERS = [
481
476
  "upgrade"
482
477
  ];
483
478
  const CONTENT_TYPE = /^[\w.+-]+\/[\w.+*-]+\s*(;.*)?$/;
479
+ function runHttpCall(call, params, hosts, files, signal) {
480
+ if (call === "download") return downloadHttpFile(params, hosts, files, signal);
481
+ if (call === "upload") return uploadHttpFiles(params, hosts, files, signal);
482
+ return sendHttpRequest(params, hosts, signal);
483
+ }
484
484
  async function sendHttpRequest(request, hosts, signal) {
485
- return exchange(textRequest(request), hosts, signal, async (response) => result(response, await readBody(response)));
485
+ return exchange(textRequest(request), hosts, signal, readResponse);
486
486
  }
487
487
  async function downloadHttpFile(request, hosts, files, signal) {
488
488
  return exchange(textRequest(request), hosts, signal, async (response) => {
489
489
  if (!response.ok) return {
490
- ...result(response, await readBody(response)),
490
+ ...await readResponse(response),
491
491
  ok: false
492
492
  };
493
493
  const file = await save$1(response, request.name, files);
@@ -502,7 +502,7 @@ async function downloadHttpFile(request, hosts, files, signal) {
502
502
  });
503
503
  }
504
504
  async function uploadHttpFiles(request, hosts, files, signal) {
505
- if (request.files.length === 0) throw invalid("An upload needs at least one file; send text with http.request");
505
+ if (request.files.length === 0) throw invalid("An upload needs at least one file. To send only text, use http.request");
506
506
  const parts = request.files.map((upload) => {
507
507
  if (!upload.field.trim()) throw invalid("Each file needs a field name");
508
508
  const file = files.requireFile(upload.file);
@@ -529,19 +529,19 @@ async function uploadHttpFiles(request, hosts, files, signal) {
529
529
  kind: "form",
530
530
  form: makeForm
531
531
  }
532
- }, hosts, signal, async (response) => result(response, await readBody(response)));
532
+ }, hosts, signal, readResponse);
533
533
  }
534
534
  async function exchange(request, hosts, signal, read) {
535
535
  let outgoing = request;
536
- for (let redirects = 0; redirects <= 10; redirects++) {
537
- if (!allowsHost(hosts, outgoing.url.hostname)) throw new HostError("denied", `"${outgoing.url.hostname}" is not in dot.json's network.hosts. List every host the dot sends requests to there.`);
536
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
537
+ if (!allowsHost(hosts, outgoing.url.hostname)) throw new HostError("denied", `"${outgoing.url.hostname}" isn't in network.hosts in dot.json. Add it there to send requests to it.`);
538
538
  const response = await send$1(outgoing, signal);
539
539
  const location = response.headers.get("location");
540
540
  if (!REDIRECT_STATUSES.includes(response.status) || location === null) return read(response);
541
541
  await response.body?.cancel();
542
542
  outgoing = redirected(outgoing, location, response.status);
543
543
  }
544
- throw new HostError("failed", `"${request.url.href}" redirected more than 10 times`);
544
+ throw new HostError("failed", `"${request.url.href}" redirected more than ${MAX_REDIRECTS} times`);
545
545
  }
546
546
  function textRequest(request) {
547
547
  const method = parseMethod(request.method ?? "GET", METHODS);
@@ -601,7 +601,7 @@ async function send$1(request, signal) {
601
601
  }
602
602
  function redirected(request, location, status) {
603
603
  const url = URL.parse(location, request.url.href);
604
- if (!url || url.protocol !== "http:" && url.protocol !== "https:") throw new HostError("failed", `${request.url.hostname} redirected to "${location}", which is not an http(s) URL`);
604
+ if (!url || url.protocol !== "http:" && url.protocol !== "https:") throw new HostError("failed", `${request.url.hostname} redirected to "${location}", which isn't a web address`);
605
605
  const becomesGet = status === 303 && request.method !== "HEAD" || (status === 301 || status === 302) && request.method === "POST";
606
606
  const headers = new Headers(request.headers);
607
607
  if (url.origin !== request.url.origin) headers.delete("authorization");
@@ -621,6 +621,9 @@ function result(response, body) {
621
621
  body
622
622
  };
623
623
  }
624
+ async function readResponse(response) {
625
+ return result(response, await readBody(response));
626
+ }
624
627
  async function readBody(response) {
625
628
  const chunks = [];
626
629
  await readChunks(response, MAX_RESPONSE_BYTES, (chunk) => {
@@ -705,7 +708,7 @@ function parseUrl(text) {
705
708
  }
706
709
  function parseMethod(name, methods) {
707
710
  const method = name.toUpperCase();
708
- if (!methods.includes(method)) throw invalid(`"${name}" is not an HTTP method here: use ${methods.join(", ")}`);
711
+ if (!methods.includes(method)) throw invalid(`"${name}" is not an HTTP method. Use ${methods.join(", ")}`);
709
712
  return method;
710
713
  }
711
714
  function invalid(message) {
@@ -715,15 +718,291 @@ function tooLarge(limit) {
715
718
  return new HostError("failed", `The response is larger than ${megabytes(limit)} MB`);
716
719
  }
717
720
  //#endregion
721
+ //#region src/serial.ts
722
+ var Serial = class {
723
+ #queue = Promise.resolve();
724
+ run(work) {
725
+ const result = this.#queue.then(work);
726
+ this.#queue = result.catch(() => void 0);
727
+ return result;
728
+ }
729
+ };
730
+ //#endregion
731
+ //#region src/cache.ts
732
+ const DEFAULT_LIMIT = 64 * MEGABYTE;
733
+ const MAX_ENTRY_BYTES = MEGABYTE;
734
+ const MAX_KEY_LENGTH = 512;
735
+ const PARTIAL = ".partial";
736
+ var DotCache = class {
737
+ root;
738
+ limit;
739
+ #now;
740
+ #lock = new Serial();
741
+ #index = null;
742
+ #used = 0;
743
+ constructor(root, limit = DEFAULT_LIMIT, now = Date.now) {
744
+ this.root = root;
745
+ this.limit = limit;
746
+ this.#now = now;
747
+ }
748
+ get(key) {
749
+ return this.#lock.run(async () => await this.#live(key) ? this.#readValue(key) : null);
750
+ }
751
+ exists(key) {
752
+ return this.#lock.run(async () => await this.#live(key) !== null);
753
+ }
754
+ set(key, value, ttl) {
755
+ return this.#lock.run(async () => {
756
+ checkKey(key);
757
+ if (value === null) throw new HostError("invalid", `"${key}" needs a value. To delete it, use remove`);
758
+ const entry = {
759
+ key,
760
+ value
761
+ };
762
+ if (ttl !== void 0 && ttl > 0) entry.expiresAt = this.#now() + Math.trunc(ttl * 1e3);
763
+ const bytes = Buffer.from(JSON.stringify(entry));
764
+ if (bytes.length > MAX_ENTRY_BYTES) throw new HostError("invalid", `"${key}" is ${megabytes(bytes.length)} MB. A cache value can be at most ${megabytes(MAX_ENTRY_BYTES)} MB`);
765
+ const index = await this.#loadIndex();
766
+ const addedBytes = bytes.length - (index.get(key)?.size ?? 0);
767
+ if (this.#used + addedBytes > this.limit) {
768
+ await this.#dropExpired();
769
+ if (this.#used + addedBytes > this.limit) throw new HostError("failed", `The cache is full: ${megabytes(this.#used)} of ${megabytes(this.limit)} MB used`);
770
+ }
771
+ await mkdir(this.root, { recursive: true });
772
+ const file = this.#fileFor(key);
773
+ await writeFile(`${file}${PARTIAL}`, bytes);
774
+ await rename(`${file}${PARTIAL}`, file);
775
+ this.#remember(index, key, {
776
+ size: bytes.length,
777
+ expiresAt: entry.expiresAt
778
+ });
779
+ });
780
+ }
781
+ remove(key) {
782
+ return this.#lock.run(async () => {
783
+ checkKey(key);
784
+ await this.#forget(key);
785
+ });
786
+ }
787
+ keys() {
788
+ return this.#lock.run(async () => {
789
+ await this.#dropExpired();
790
+ return [...(await this.#loadIndex()).keys()].sort();
791
+ });
792
+ }
793
+ clear() {
794
+ return this.#lock.run(async () => {
795
+ await rm(this.root, {
796
+ recursive: true,
797
+ force: true
798
+ });
799
+ this.#index = /* @__PURE__ */ new Map();
800
+ this.#used = 0;
801
+ });
802
+ }
803
+ tidy() {
804
+ return this.#lock.run(async () => {
805
+ this.#index = null;
806
+ await this.#loadIndex();
807
+ });
808
+ }
809
+ usage() {
810
+ return this.#lock.run(async () => {
811
+ await this.#loadIndex();
812
+ return this.#used;
813
+ });
814
+ }
815
+ async #loadIndex() {
816
+ if (this.#index) return this.#index;
817
+ const index = /* @__PURE__ */ new Map();
818
+ this.#index = index;
819
+ this.#used = 0;
820
+ for (const name of await readdir(this.root).catch(() => [])) {
821
+ const file = join(this.root, name);
822
+ const entry = name.endsWith(PARTIAL) ? null : await entryAt(file);
823
+ if (!entry || this.#expired(entry.expiresAt)) {
824
+ await rm(file, { force: true });
825
+ continue;
826
+ }
827
+ this.#remember(index, entry.key, {
828
+ size: (await stat(file)).size,
829
+ expiresAt: entry.expiresAt
830
+ });
831
+ }
832
+ return index;
833
+ }
834
+ async #live(key) {
835
+ checkKey(key);
836
+ const indexed = (await this.#loadIndex()).get(key);
837
+ if (!indexed) return null;
838
+ if (this.#expired(indexed.expiresAt)) {
839
+ await this.#forget(key);
840
+ return null;
841
+ }
842
+ return indexed;
843
+ }
844
+ async #readValue(key) {
845
+ const entry = await entryAt(this.#fileFor(key));
846
+ if (!entry) await this.#forget(key);
847
+ return entry?.value ?? null;
848
+ }
849
+ #remember(index, key, indexed) {
850
+ this.#used += indexed.size - (index.get(key)?.size ?? 0);
851
+ index.set(key, indexed);
852
+ }
853
+ async #forget(key) {
854
+ await rm(this.#fileFor(key), { force: true });
855
+ const index = await this.#loadIndex();
856
+ this.#used -= index.get(key)?.size ?? 0;
857
+ index.delete(key);
858
+ }
859
+ async #dropExpired() {
860
+ for (const [key, indexed] of await this.#loadIndex()) if (this.#expired(indexed.expiresAt)) await this.#forget(key);
861
+ }
862
+ #expired(expiresAt) {
863
+ return expiresAt !== void 0 && expiresAt <= this.#now();
864
+ }
865
+ #fileFor(key) {
866
+ return join(this.root, `${createHash("sha256").update(key).digest("hex")}.json`);
867
+ }
868
+ };
869
+ function runCacheCall(operation, params, cache) {
870
+ switch (operation) {
871
+ case "get": return cache.get(params.key);
872
+ case "set": {
873
+ const { key, value, ttl } = params;
874
+ return cache.set(key, value ?? null, ttl);
875
+ }
876
+ case "remove": return cache.remove(params.key);
877
+ case "exists": return cache.exists(params.key);
878
+ case "keys": return cache.keys();
879
+ case "clear": return cache.clear();
880
+ default: return Promise.reject(new HostError("unsupported", `This host doesn't support "dash.storage.cache.${operation}"`));
881
+ }
882
+ }
883
+ async function entryAt(file) {
884
+ if (!file.endsWith(".json")) return null;
885
+ try {
886
+ return JSON.parse(await readFile(file, "utf8"));
887
+ } catch {
888
+ return null;
889
+ }
890
+ }
891
+ function checkKey(key) {
892
+ if (key === "") throw new HostError("invalid", "A key is needed");
893
+ if (key.length > MAX_KEY_LENGTH) throw new HostError("invalid", `"${key.slice(0, 40)}…" is longer than ${MAX_KEY_LENGTH} characters`);
894
+ }
895
+ //#endregion
896
+ //#region src/secrets.ts
897
+ const MAX_ENTRIES = 8;
898
+ const MAX_VALUE_BYTES = 4096;
899
+ var DotSecrets = class {
900
+ file;
901
+ #partial;
902
+ #lock = new Serial();
903
+ constructor(file) {
904
+ this.file = file;
905
+ this.#partial = `${file}.partial`;
906
+ }
907
+ get(key) {
908
+ return this.#lock.run(async () => {
909
+ checkKey(key);
910
+ return (await this.#read()).get(key) ?? null;
911
+ });
912
+ }
913
+ set(key, value) {
914
+ return this.#lock.run(async () => {
915
+ checkKey(key);
916
+ if (typeof value !== "string") throw new HostError("invalid", `"${key}" needs a text value`);
917
+ if (Buffer.byteLength(value) > MAX_VALUE_BYTES) throw new HostError("invalid", `"${key}" is too long: a secret can be at most 4 KB`);
918
+ const entries = await this.#read();
919
+ if (!entries.has(key) && entries.size >= MAX_ENTRIES) throw new HostError("failed", `A dot can only write a maximum of ${MAX_ENTRIES} secrets. Remove one first`);
920
+ await this.#write(entries.set(key, value));
921
+ });
922
+ }
923
+ remove(key) {
924
+ return this.#lock.run(async () => {
925
+ checkKey(key);
926
+ const entries = await this.#read();
927
+ if (entries.delete(key)) await this.#write(entries);
928
+ });
929
+ }
930
+ clear() {
931
+ return this.#lock.run(() => this.#write(/* @__PURE__ */ new Map()));
932
+ }
933
+ tidy() {
934
+ return this.#lock.run(() => rm(this.#partial, { force: true }));
935
+ }
936
+ async #read() {
937
+ try {
938
+ return new Map(Object.entries(JSON.parse(await readFile(this.file, "utf8"))));
939
+ } catch {
940
+ return /* @__PURE__ */ new Map();
941
+ }
942
+ }
943
+ async #write(entries) {
944
+ if (entries.size === 0) {
945
+ await rm(this.file, { force: true });
946
+ return;
947
+ }
948
+ await mkdir(dirname(this.file), { recursive: true });
949
+ await writeFile(this.#partial, JSON.stringify(Object.fromEntries(entries)));
950
+ await rename(this.#partial, this.file);
951
+ }
952
+ };
953
+ function runSecretCall(operation, params, secrets) {
954
+ switch (operation) {
955
+ case "get": return secrets.get(params.key);
956
+ case "set": {
957
+ const { key, value } = params;
958
+ return secrets.set(key, value);
959
+ }
960
+ case "remove": return secrets.remove(params.key);
961
+ case "clear": return secrets.clear();
962
+ default: return Promise.reject(new HostError("unsupported", `This host doesn't support "dash.storage.secret.${operation}"`));
963
+ }
964
+ }
965
+ //#endregion
718
966
  //#region src/files.ts
719
- const DEFAULT_QUOTA = 100 * MEGABYTE;
967
+ const DEFAULT_QUOTA = 256 * MEGABYTE;
968
+ const CACHE_FOLDER = ".cache";
969
+ const SECRETS_FOLDER = ".secrets";
970
+ const STAGING_FOLDER = ".staging";
720
971
  const MAX_READ_BYTES = 5 * MEGABYTE;
721
972
  var DotStorage = class {
722
973
  #root;
974
+ #staging;
723
975
  quota;
976
+ cache;
977
+ secrets;
978
+ lock = new Serial();
724
979
  constructor(root, quota = DEFAULT_QUOTA) {
725
980
  this.#root = root;
981
+ this.#staging = join(dirname(root), STAGING_FOLDER, basename(root));
726
982
  this.quota = quota;
983
+ this.cache = new DotCache(join(dirname(root), CACHE_FOLDER, basename(root)));
984
+ this.secrets = new DotSecrets(join(dirname(root), SECRETS_FOLDER, `${basename(root)}.json`));
985
+ }
986
+ async tidy() {
987
+ await rm(this.#staging, {
988
+ recursive: true,
989
+ force: true
990
+ });
991
+ await this.cache.tidy();
992
+ await this.secrets.tidy();
993
+ }
994
+ async replace(target, write) {
995
+ await mkdir(this.#staging, { recursive: true });
996
+ const staged = join(this.#staging, randomUUID());
997
+ try {
998
+ await write(staged);
999
+ await moveInto(staged, target);
1000
+ } finally {
1001
+ await rm(staged, {
1002
+ recursive: true,
1003
+ force: true
1004
+ });
1005
+ }
727
1006
  }
728
1007
  async resolve(path, { allowRoot = false } = {}) {
729
1008
  const problem = dotFilePathProblem(path, { allowRoot });
@@ -735,12 +1014,6 @@ var DotStorage = class {
735
1014
  async usage() {
736
1015
  return sizeOf(this.#root);
737
1016
  }
738
- async clear() {
739
- await rm(this.#root, {
740
- recursive: true,
741
- force: true
742
- });
743
- }
744
1017
  async ensureRoot() {
745
1018
  await mkdir(this.#root, { recursive: true });
746
1019
  }
@@ -750,53 +1023,54 @@ var DotStorage = class {
750
1023
  let existing = path;
751
1024
  while (!await exists(existing)) existing = dirname(existing);
752
1025
  const real = await realpath(existing);
753
- if (real !== realRoot && !real.startsWith(`${realRoot}/`)) throw new HostError("denied", "That path leads outside the dot's storage");
1026
+ if (real !== realRoot && !real.startsWith(`${realRoot}/`)) throw new HostError("denied", "That path goes outside the dot's folder");
754
1027
  }
755
1028
  };
756
1029
  async function runFileCall(operation, params, storage, temporaryFiles) {
757
- switch (operation) {
758
- case "read": return read(params, storage);
759
- case "write": return write(params, storage);
760
- case "stat": {
761
- const { path } = params;
762
- return entry(path, await storage.resolve(path, { allowRoot: true }));
1030
+ return storage.lock.run(async () => {
1031
+ switch (operation) {
1032
+ case "read": return read(params, storage);
1033
+ case "write": return write(params, storage);
1034
+ case "stat": {
1035
+ const { path } = params;
1036
+ return entry(path, await storage.resolve(path, { allowRoot: true }));
1037
+ }
1038
+ case "list": return list(params, storage);
1039
+ case "createFolder": return createFolder(params.path, storage);
1040
+ case "move": return transfer(params, storage, false);
1041
+ case "copy": return transfer(params, storage, true);
1042
+ case "remove": return remove(params, storage);
1043
+ case "save": return save(params, storage, temporaryFiles);
1044
+ case "open": return open$1(params.path, storage, temporaryFiles);
1045
+ case "usage": return {
1046
+ used: await storage.usage(),
1047
+ quota: storage.quota
1048
+ };
1049
+ default: throw new HostError("unsupported", `This host doesn't support "dash.storage.file.${operation}"`);
763
1050
  }
764
- case "list": return list(params, storage);
765
- case "createFolder": return createFolder(params.path, storage);
766
- case "move": return transfer(params, storage, false);
767
- case "copy": return transfer(params, storage, true);
768
- case "remove": return remove(params, storage);
769
- case "save": return save(params, storage, temporaryFiles);
770
- case "open": return open$1(params.path, storage, temporaryFiles);
771
- case "usage": return {
772
- used: await storage.usage(),
773
- quota: storage.quota
774
- };
775
- default: throw new HostError("unsupported", `This host has no "dash.file.${operation}"`);
776
- }
1051
+ });
777
1052
  }
778
1053
  async function read({ path, as }, storage) {
779
- const encoding = parseEncoding(as);
1054
+ const encoding = bufferEncoding(as);
780
1055
  const file = await storage.resolve(path);
781
1056
  const info = await statOrNull(file);
782
1057
  if (!info) throw failed(`There is no file at "${path}"`);
783
1058
  if (info.isDirectory()) throw failed(`"${path}" is a folder`);
784
1059
  if (info.size > MAX_READ_BYTES) throw failed(`"${path}" is larger than ${megabytes(MAX_READ_BYTES)} MB, too large to read at once`);
785
- const bytes = await readFile(file);
786
- return encoding === "base64" ? bytes.toString("base64") : bytes.toString("utf8");
1060
+ return (await readFile(file)).toString(encoding);
787
1061
  }
788
1062
  async function write({ path, data, as, append = false }, storage) {
789
- const encoding = parseEncoding(as);
1063
+ const encoding = bufferEncoding(as);
790
1064
  const file = await storage.resolve(path);
791
1065
  const existing = await statOrNull(file);
792
1066
  if (existing?.isDirectory()) throw failed(`"${path}" is a folder`);
793
1067
  if (encoding === "base64" && !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new HostError("invalid", "The data isn't base64");
794
- const bytes = Buffer.from(data, encoding === "base64" ? "base64" : "utf8");
1068
+ const bytes = Buffer.from(data, encoding);
795
1069
  const replacedBytes = append ? 0 : existing?.size ?? 0;
796
1070
  await checkQuota(storage, bytes.length - replacedBytes);
797
1071
  await createParent(storage, file, path);
798
1072
  if (append) await appendFile(file, bytes);
799
- else await writeFile(file, bytes);
1073
+ else await storage.replace(file, (staged) => writeFile(staged, bytes));
800
1074
  return written(path, file);
801
1075
  }
802
1076
  async function list({ path, recursive = false }, storage) {
@@ -822,17 +1096,17 @@ async function transfer({ from, to, overwrite = false }, storage, keepSource) {
822
1096
  const source = await storage.resolve(from);
823
1097
  const target = await storage.resolve(to);
824
1098
  if (!await exists(source)) throw failed(`There is nothing at "${from}"`);
825
- if (to === from || to.startsWith(`${from}/`)) throw new HostError("invalid", `"${to}" is inside "${from}"`);
1099
+ if (to === from || to.startsWith(`${from}/`)) throw new HostError("invalid", `Can't put "${from}" inside itself, at "${to}"`);
826
1100
  await makeRoom(storage, target, to, overwrite, keepSource ? await sizeOf(source) : 0);
827
- if (keepSource) await cp(source, target, { recursive: true });
828
- else await rename(source, target);
1101
+ if (keepSource) await storage.replace(target, (staged) => cp(source, staged, { recursive: true }));
1102
+ else await moveInto(source, target);
829
1103
  return written(to, target);
830
1104
  }
831
1105
  async function remove({ path, recursive = false }, storage) {
832
1106
  const target = await storage.resolve(path);
833
1107
  const info = await statOrNull(target);
834
1108
  if (!info) return;
835
- if (info.isDirectory() && !recursive && (await readdir(target)).length > 0) throw failed(`"${path}" is a folder with files in it: pass { recursive: true } to remove them too`);
1109
+ if (info.isDirectory() && !recursive && (await readdir(target)).length > 0) throw failed(`"${path}" is a folder with files in it. To remove them too, pass { recursive: true }`);
836
1110
  await rm(target, {
837
1111
  recursive: true,
838
1112
  force: true
@@ -842,7 +1116,7 @@ async function save({ file: id, path, overwrite = false }, storage, temporaryFil
842
1116
  const file = temporaryFiles.requireFile(id);
843
1117
  const target = await storage.resolve(path);
844
1118
  await makeRoom(storage, target, path, overwrite, file.size);
845
- await cp(file.path, target);
1119
+ await storage.replace(target, (staged) => cp(file.path, staged));
846
1120
  return written(path, target);
847
1121
  }
848
1122
  async function open$1(path, storage, temporaryFiles) {
@@ -888,24 +1162,28 @@ async function written(path, file) {
888
1162
  return found;
889
1163
  }
890
1164
  async function makeRoom(storage, target, path, overwrite, addedBytes) {
891
- if (await exists(target) && !overwrite) throw failed(`"${path}" already exists: pass { overwrite: true } to replace it`);
1165
+ if (await exists(target) && !overwrite) throw failed(`"${path}" already exists. To replace it, pass { overwrite: true }`);
892
1166
  await checkQuota(storage, addedBytes - await sizeOf(target));
893
- await rm(target, {
1167
+ await createParent(storage, target, path);
1168
+ }
1169
+ async function moveInto(from, target) {
1170
+ const targetInfo = await statOrNull(target);
1171
+ if (targetInfo && (targetInfo.isDirectory() || (await statOrNull(from))?.isDirectory())) await rm(target, {
894
1172
  recursive: true,
895
1173
  force: true
896
1174
  });
897
- await createParent(storage, target, path);
1175
+ await rename(from, target);
898
1176
  }
899
1177
  async function createParent(storage, file, path) {
900
1178
  await storage.ensureRoot();
901
1179
  const parent = dirname(file);
902
- if ((await statOrNull(parent))?.isFile()) throw failed(`A folder in "${path}" is a file`);
1180
+ if ((await statOrNull(parent))?.isFile()) throw failed(`Part of "${path}" is a file, not a folder`);
903
1181
  await mkdir(parent, { recursive: true });
904
1182
  }
905
1183
  async function checkQuota(storage, addedBytes) {
906
1184
  if (addedBytes <= 0) return;
907
1185
  const used = await storage.usage();
908
- if (used + addedBytes > storage.quota) throw failed(`The dot's storage is full: it uses ${megabytes(used)} of ${megabytes(storage.quota)} MB, and this needs ${megabytes(addedBytes)} MB more`);
1186
+ if (used + addedBytes > storage.quota) throw failed(`The dot's folder is full: ${megabytes(used)} of ${megabytes(storage.quota)} MB used, and this needs ${megabytes(addedBytes)} MB more`);
909
1187
  }
910
1188
  async function sizeOf(path) {
911
1189
  const info = await lstat(path).catch(() => null);
@@ -921,16 +1199,16 @@ async function exists(path) {
921
1199
  async function statOrNull(path) {
922
1200
  return stat(path).catch(() => null);
923
1201
  }
924
- function parseEncoding(name) {
925
- if (name === void 0 || name === "text" || name === "base64") return name ?? "text";
926
- throw new HostError("invalid", `"${String(name)}" is not an encoding: use "text" or "base64"`);
1202
+ function bufferEncoding(name) {
1203
+ if (name === void 0 || name === "text") return "utf8";
1204
+ if (name === "base64") return "base64";
1205
+ throw new HostError("invalid", `"${String(name)}" is not an encoding. Use "text" or "base64"`);
927
1206
  }
928
1207
  function failed(message) {
929
1208
  return new HostError("failed", message);
930
1209
  }
931
1210
  //#endregion
932
1211
  //#region src/temporary-files.ts
933
- const TEMPORARY_FILES_PATH = "/__dot/files/";
934
1212
  var TemporaryFiles = class {
935
1213
  #directory;
936
1214
  #files = /* @__PURE__ */ new Map();
@@ -942,7 +1220,7 @@ var TemporaryFiles = class {
942
1220
  }
943
1221
  requireFile(id) {
944
1222
  const file = this.#files.get(id);
945
- if (!file) throw new HostError("invalid", `"${id}" is not a file this dot has`);
1223
+ if (!file) throw new HostError("invalid", `"${id}" isn't one of this dot's files`);
946
1224
  return file;
947
1225
  }
948
1226
  async clear() {
@@ -1008,6 +1286,7 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
1008
1286
  const clients = /* @__PURE__ */ new Set();
1009
1287
  const files = new TemporaryFiles(await mkdtemp(join(tmpdir(), "dot-simulator-files-")));
1010
1288
  const storage = new DotStorage(storageDir ?? await mkdtemp(join(tmpdir(), "dot-simulator-storage-")));
1289
+ await storage.tidy();
1011
1290
  let buildError = null;
1012
1291
  const broadcast = (event, data = "") => {
1013
1292
  for (const client of clients) client.write(serverEvent(event, data));
@@ -1035,7 +1314,8 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
1035
1314
  return;
1036
1315
  }
1037
1316
  if (pathname.startsWith(HTTP_PATH) && request.method === "POST") {
1038
- relayHttpCall(request, response, pathname.slice(HTTP_PATH.length), dotDir, files);
1317
+ const call = pathname.slice(HTTP_PATH.length);
1318
+ answer(request, response, async (params, signal) => runHttpCall(call, params, await networkHosts(dotDir), files, signal));
1039
1319
  return;
1040
1320
  }
1041
1321
  if (pathname.startsWith(FILE_PATH) && request.method === "POST") {
@@ -1043,8 +1323,18 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
1043
1323
  answer(request, response, (params) => runFileCall(operation, params, storage, files));
1044
1324
  return;
1045
1325
  }
1046
- if (pathname.startsWith("/__dot/files/")) {
1047
- serveTemporaryFile(response, files, pathname.slice(13));
1326
+ if (pathname.startsWith(CACHE_PATH) && request.method === "POST") {
1327
+ const operation = pathname.slice(CACHE_PATH.length);
1328
+ answer(request, response, (params) => runCacheCall(operation, params, storage.cache));
1329
+ return;
1330
+ }
1331
+ if (pathname.startsWith(SECRET_PATH) && request.method === "POST") {
1332
+ const operation = pathname.slice(SECRET_PATH.length);
1333
+ answer(request, response, (params) => runSecretCall(operation, params, storage.secrets));
1334
+ return;
1335
+ }
1336
+ if (pathname.startsWith(TEMPORARY_FILES_PATH)) {
1337
+ serveTemporaryFile(response, files, pathname.slice(TEMPORARY_FILES_PATH.length));
1048
1338
  return;
1049
1339
  }
1050
1340
  if (pathname.startsWith(REACT_HOST_PATH) && reactHostDir !== void 0) {
@@ -1060,7 +1350,7 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
1060
1350
  serveFile(response, dotDir, pathname.slice(5), injectBridge);
1061
1351
  return;
1062
1352
  }
1063
- send(response, 404, "Not found");
1353
+ notFound(response);
1064
1354
  });
1065
1355
  await listen(server, port, host);
1066
1356
  return {
@@ -1078,20 +1368,15 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
1078
1368
  buildError = message;
1079
1369
  broadcast("build-error", message);
1080
1370
  },
1081
- close: () => new Promise((resolvePromise) => {
1371
+ close: async () => {
1082
1372
  for (const client of clients) client.end();
1083
- server.close(() => void files.clear().then(() => resolvePromise()));
1084
- })
1373
+ await new Promise((resolvePromise) => {
1374
+ server.close(() => resolvePromise());
1375
+ });
1376
+ await files.clear();
1377
+ }
1085
1378
  };
1086
1379
  }
1087
- function relayHttpCall(request, response, call, dotDir, files) {
1088
- return answer(request, response, async (params, signal) => {
1089
- const hosts = await networkHosts(dotDir);
1090
- if (call === "download") return downloadHttpFile(params, hosts, files, signal);
1091
- if (call === "upload") return uploadHttpFiles(params, hosts, files, signal);
1092
- return sendHttpRequest(params, hosts, signal);
1093
- });
1094
- }
1095
1380
  async function answer(request, response, run) {
1096
1381
  const controller = new AbortController();
1097
1382
  response.on("close", () => controller.abort());
@@ -1112,7 +1397,7 @@ async function answer(request, response, run) {
1112
1397
  async function serveTemporaryFile(response, files, id) {
1113
1398
  const file = files.file(id);
1114
1399
  if (!file) {
1115
- send(response, 404, "Not found");
1400
+ notFound(response);
1116
1401
  return;
1117
1402
  }
1118
1403
  response.writeHead(200, {
@@ -1147,7 +1432,7 @@ async function serveReact(response, reactHostDir, path) {
1147
1432
  const versions = await readdir(reactHostDir).catch(() => []);
1148
1433
  const version = newestReactOfMajor(versions, Number(major));
1149
1434
  if (version === void 0) {
1150
- send(response, 404, "Not found");
1435
+ notFound(response);
1151
1436
  return;
1152
1437
  }
1153
1438
  await serveFile(response, join(reactHostDir, version), file.join("/"));
@@ -1156,7 +1441,7 @@ async function serveFile(response, root, path, injectBridge = false) {
1156
1441
  const base = resolve(root);
1157
1442
  let file = resolve(base, decodeURIComponent(path));
1158
1443
  if (file !== base && !file.startsWith(base + sep)) {
1159
- send(response, 404, "Not found");
1444
+ notFound(response);
1160
1445
  return;
1161
1446
  }
1162
1447
  try {
@@ -1169,7 +1454,7 @@ async function serveFile(response, root, path, injectBridge = false) {
1169
1454
  const bridge = `<script src="${CLIENT_PATH}webview-bridge.js"><\/script>`;
1170
1455
  send(response, 200, body.toString("utf8").replace("<head>", `<head>\n ${bridge}`), ".html");
1171
1456
  } catch {
1172
- send(response, 404, "Not found");
1457
+ notFound(response);
1173
1458
  }
1174
1459
  }
1175
1460
  function send(response, status, body, extension = ".txt") {
@@ -1179,6 +1464,9 @@ function send(response, status, body, extension = ".txt") {
1179
1464
  });
1180
1465
  response.end(body);
1181
1466
  }
1467
+ function notFound(response) {
1468
+ send(response, 404, "Not found");
1469
+ }
1182
1470
  function defaultClientDir() {
1183
1471
  const manifest = createRequire(import.meta.url).resolve("@monimesl/dot-simulator/package.json");
1184
1472
  return join(dirname(manifest), "dist/client");