@monimesl/dot-simulator 0.1.0-alpha.2 → 0.1.0-alpha.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.
@@ -1,8 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
- import { HostError, NavigationStack, REACT_HOST_PATH, isReactCompatible, isVersionAtLeast, majorVersion, newestReactOfMajor } from "@monimesl/dot-core";
3
- import { readFile, readdir, stat } from "node:fs/promises";
4
- import { createServer } from "node:http";
5
- import { dirname, extname, join, resolve, sep } from "node:path";
2
+ import { HostError, NavigationStack, REACT_HOST_PATH, allowsHost, dotFilePathProblem, isReactCompatible, isVersionAtLeast, majorVersion, newestReactOfMajor } from "@monimesl/dot-core";
3
+ import { appendFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { STATUS_CODES, createServer } from "node:http";
6
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
7
+ import { TextDecoder } from "node:util";
8
+ import { randomBytes } from "node:crypto";
6
9
  //#region src/compatibility.ts
7
10
  var IncompatibleDotError = class extends Error {
8
11
  name = "IncompatibleDotError";
@@ -241,6 +244,8 @@ const SIMULATOR_VERSIONS = {
241
244
  //#region src/paths.ts
242
245
  const CLIENT_PATH = "/__dot/";
243
246
  const EVENTS_PATH = `${CLIENT_PATH}events`;
247
+ const HTTP_PATH = `${CLIENT_PATH}network/http/`;
248
+ const FILE_PATH = `${CLIENT_PATH}file/`;
244
249
  const WEBVIEW_URL = `/dot/ui/index.html`;
245
250
  //#endregion
246
251
  //#region src/page.ts
@@ -351,6 +356,571 @@ const STYLES = `
351
356
  @media (max-width: 900px) { main { flex-direction: column; } aside { width: auto; height: 40vh; border-left: 0; border-top: 1px solid var(--border); } }
352
357
  `;
353
358
  //#endregion
359
+ //#region src/bytes.ts
360
+ const MEGABYTE = 1048576;
361
+ function megabytes(bytes) {
362
+ return String(Math.ceil(bytes * 10 / MEGABYTE) / 10);
363
+ }
364
+ //#endregion
365
+ //#region src/content-types.ts
366
+ const CONTENT_TYPES = {
367
+ ".png": "image/png",
368
+ ".jpg": "image/jpeg",
369
+ ".jpeg": "image/jpeg",
370
+ ".gif": "image/gif",
371
+ ".webp": "image/webp",
372
+ ".svg": "image/svg+xml",
373
+ ".pdf": "application/pdf",
374
+ ".json": "application/json",
375
+ ".txt": "text/plain",
376
+ ".csv": "text/csv",
377
+ ".html": "text/html",
378
+ ".zip": "application/zip"
379
+ };
380
+ function contentTypeFor(name) {
381
+ return CONTENT_TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
382
+ }
383
+ //#endregion
384
+ //#region src/network.ts
385
+ const MAX_RESPONSE_BYTES = 5 * MEGABYTE;
386
+ const MAX_DOWNLOAD_BYTES = 100 * MEGABYTE;
387
+ const METHODS = [
388
+ "GET",
389
+ "POST",
390
+ "PUT",
391
+ "PATCH",
392
+ "DELETE",
393
+ "HEAD",
394
+ "OPTIONS"
395
+ ];
396
+ const UPLOAD_METHODS = [
397
+ "POST",
398
+ "PUT",
399
+ "PATCH"
400
+ ];
401
+ const BODILESS_METHODS = ["GET", "HEAD"];
402
+ const REDIRECT_STATUSES = [
403
+ 301,
404
+ 302,
405
+ 303,
406
+ 307,
407
+ 308
408
+ ];
409
+ const HOST_HEADERS = [
410
+ "content-type",
411
+ "content-length",
412
+ "transfer-encoding",
413
+ "host",
414
+ "connection",
415
+ "upgrade"
416
+ ];
417
+ const CONTENT_TYPE = /^[\w.+-]+\/[\w.+*-]+\s*(;.*)?$/;
418
+ async function sendHttpRequest(request, hosts, signal) {
419
+ return exchange(textRequest(request), hosts, signal, async (response) => result(response, await readBody(response)));
420
+ }
421
+ async function downloadHttpFile(request, hosts, files, signal) {
422
+ return exchange(textRequest(request), hosts, signal, async (response) => {
423
+ if (!response.ok) return {
424
+ ...result(response, await readBody(response)),
425
+ ok: false
426
+ };
427
+ const file = await save$1(response, request.name, files);
428
+ const { status, statusText, headers } = result(response, "");
429
+ return {
430
+ status,
431
+ statusText,
432
+ headers,
433
+ ok: true,
434
+ file
435
+ };
436
+ });
437
+ }
438
+ async function uploadHttpFiles(request, hosts, files, signal) {
439
+ if (request.files.length === 0) throw invalid("An upload needs at least one file; send text with http.request");
440
+ const parts = request.files.map((upload) => {
441
+ if (!upload.field.trim()) throw invalid("Each file needs a field name");
442
+ const file = files.requireFile(upload.file);
443
+ return {
444
+ field: upload.field,
445
+ file,
446
+ name: upload.name ? safeFileName(upload.name) : file.name
447
+ };
448
+ });
449
+ const makeForm = async () => {
450
+ const form = new FormData();
451
+ for (const [name, value] of Object.entries(request.fields ?? {})) form.append(name, value);
452
+ for (const part of parts) {
453
+ const contents = await readFile(part.file.path);
454
+ form.append(part.field, new File([contents], part.name, { type: part.file.contentType }));
455
+ }
456
+ return form;
457
+ };
458
+ return exchange({
459
+ method: parseMethod(request.method ?? "POST", UPLOAD_METHODS),
460
+ headers: checkedHeaders(request.headers ?? {}),
461
+ url: parseUrl(request.url),
462
+ body: {
463
+ kind: "form",
464
+ form: makeForm
465
+ }
466
+ }, hosts, signal, async (response) => result(response, await readBody(response)));
467
+ }
468
+ async function exchange(request, hosts, signal, read) {
469
+ let outgoing = request;
470
+ for (let redirects = 0; redirects <= 10; redirects++) {
471
+ 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.`);
472
+ const response = await send$1(outgoing, signal);
473
+ const location = response.headers.get("location");
474
+ if (!REDIRECT_STATUSES.includes(response.status) || location === null) return read(response);
475
+ await response.body?.cancel();
476
+ outgoing = redirected(outgoing, location, response.status);
477
+ }
478
+ throw new HostError("failed", `"${request.url.href}" redirected more than 10 times`);
479
+ }
480
+ function textRequest(request) {
481
+ const method = parseMethod(request.method ?? "GET", METHODS);
482
+ const { contentType = "text/plain; charset=utf-8", others } = takeContentType(request.headers ?? {});
483
+ if (!CONTENT_TYPE.test(contentType)) throw invalid(`"${contentType}" is not a Content-Type`);
484
+ const headers = checkedHeaders(others);
485
+ let body;
486
+ if (request.body !== void 0) {
487
+ if (BODILESS_METHODS.includes(method)) throw invalid(`A ${method} request can't have a body`);
488
+ body = {
489
+ kind: "text",
490
+ text: request.body,
491
+ contentType
492
+ };
493
+ }
494
+ return {
495
+ url: parseUrl(request.url),
496
+ method,
497
+ headers,
498
+ body
499
+ };
500
+ }
501
+ function checkedHeaders(values) {
502
+ for (const name of Object.keys(values)) if (HOST_HEADERS.includes(name.toLowerCase())) throw invalid(`"${name}" is set by the host`);
503
+ return new Headers(values);
504
+ }
505
+ function takeContentType(values) {
506
+ let contentType;
507
+ const others = {};
508
+ for (const [name, value] of Object.entries(values)) if (name.toLowerCase() === "content-type") contentType = value;
509
+ else others[name] = value;
510
+ return {
511
+ contentType,
512
+ others
513
+ };
514
+ }
515
+ async function send$1(request, signal) {
516
+ const headers = new Headers(request.headers);
517
+ let body = null;
518
+ if (request.body?.kind === "text") {
519
+ body = request.body.text;
520
+ headers.set("content-type", request.body.contentType);
521
+ } else if (request.body?.kind === "form") body = await request.body.form();
522
+ try {
523
+ return await fetch(request.url, {
524
+ method: request.method,
525
+ headers,
526
+ body,
527
+ redirect: "manual",
528
+ signal
529
+ });
530
+ } catch (error) {
531
+ if (signal.aborted) throw error;
532
+ const cause = error.cause?.message;
533
+ throw new HostError("failed", `Could not reach ${request.url.hostname}: ${cause ?? error.message}`);
534
+ }
535
+ }
536
+ function redirected(request, location, status) {
537
+ const url = URL.parse(location, request.url.href);
538
+ 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`);
539
+ const becomesGet = status === 303 && request.method !== "HEAD" || (status === 301 || status === 302) && request.method === "POST";
540
+ const headers = new Headers(request.headers);
541
+ if (url.origin !== request.url.origin) headers.delete("authorization");
542
+ return {
543
+ url,
544
+ method: becomesGet ? "GET" : request.method,
545
+ headers,
546
+ body: becomesGet ? void 0 : request.body
547
+ };
548
+ }
549
+ function result(response, body) {
550
+ return {
551
+ status: response.status,
552
+ statusText: response.statusText || (STATUS_CODES[response.status] ?? ""),
553
+ ok: response.ok,
554
+ headers: Object.fromEntries(response.headers),
555
+ body
556
+ };
557
+ }
558
+ async function readBody(response) {
559
+ const chunks = [];
560
+ await readChunks(response, MAX_RESPONSE_BYTES, (chunk) => {
561
+ chunks.push(chunk);
562
+ return Promise.resolve();
563
+ });
564
+ const charset = /charset=([^;\s]+)/i.exec(response.headers.get("content-type") ?? "")?.[1];
565
+ return decoderFor(charset).decode(Buffer.concat(chunks));
566
+ }
567
+ async function save$1(response, requestedName, files) {
568
+ const { id, path } = await files.newPath();
569
+ const handle = await open(path, "w");
570
+ try {
571
+ await readChunks(response, MAX_DOWNLOAD_BYTES, async (chunk) => {
572
+ await handle.write(chunk);
573
+ });
574
+ } catch (error) {
575
+ await handle.close();
576
+ await rm(path, { force: true });
577
+ throw error;
578
+ }
579
+ await handle.close();
580
+ const name = requestedName ? safeFileName(requestedName) : fileName(response);
581
+ return files.add(id, path, name, contentType(response, name));
582
+ }
583
+ async function readChunks(response, limit, take) {
584
+ if (Number(response.headers.get("content-length") ?? 0) > limit) {
585
+ await response.body?.cancel();
586
+ throw tooLarge(limit);
587
+ }
588
+ if (!response.body) return;
589
+ const reader = response.body.getReader();
590
+ let length = 0;
591
+ while (true) {
592
+ const { done, value } = await reader.read();
593
+ if (done) return;
594
+ length += value.length;
595
+ if (length > limit) {
596
+ await reader.cancel();
597
+ throw tooLarge(limit);
598
+ }
599
+ await take(value);
600
+ }
601
+ }
602
+ function fileName(response) {
603
+ const disposition = response.headers.get("content-disposition") ?? "";
604
+ const encoded = /filename\*\s*=\s*[^']*''([^;]+)/i.exec(disposition)?.[1];
605
+ const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition)?.[1];
606
+ const segment = new URL(response.url).pathname.split("/").at(-1);
607
+ return [
608
+ encoded && safeDecode(encoded),
609
+ plain,
610
+ segment && safeDecode(segment)
611
+ ].map((name) => safeFileName(name ?? "")).find(Boolean) ?? "download";
612
+ }
613
+ function safeFileName(name) {
614
+ return (name.split(/[/\\]/).at(-1) ?? "").trim().slice(0, 255);
615
+ }
616
+ function safeDecode(text) {
617
+ try {
618
+ return decodeURIComponent(text);
619
+ } catch {
620
+ return text;
621
+ }
622
+ }
623
+ function contentType(response, name) {
624
+ const declared = response.headers.get("content-type")?.split(";")[0]?.trim();
625
+ if (declared && declared !== "application/octet-stream") return declared;
626
+ return contentTypeFor(name);
627
+ }
628
+ function decoderFor(charset) {
629
+ try {
630
+ return new TextDecoder(charset ?? "utf-8");
631
+ } catch {
632
+ return new TextDecoder("utf-8");
633
+ }
634
+ }
635
+ function parseUrl(text) {
636
+ const url = /^https?:\/\//i.test(text) ? URL.parse(text) : null;
637
+ if (!url?.hostname) throw invalid(`"${text}" is not an http:// or https:// URL`);
638
+ return url;
639
+ }
640
+ function parseMethod(name, methods) {
641
+ const method = name.toUpperCase();
642
+ if (!methods.includes(method)) throw invalid(`"${name}" is not an HTTP method here: use ${methods.join(", ")}`);
643
+ return method;
644
+ }
645
+ function invalid(message) {
646
+ return new HostError("invalid", message);
647
+ }
648
+ function tooLarge(limit) {
649
+ return new HostError("failed", `The response is larger than ${megabytes(limit)} MB`);
650
+ }
651
+ //#endregion
652
+ //#region src/files.ts
653
+ const DEFAULT_QUOTA = 100 * MEGABYTE;
654
+ const MAX_READ_BYTES = 5 * MEGABYTE;
655
+ var DotStorage = class {
656
+ #root;
657
+ quota;
658
+ constructor(root, quota = DEFAULT_QUOTA) {
659
+ this.#root = root;
660
+ this.quota = quota;
661
+ }
662
+ async resolve(path, { allowRoot = false } = {}) {
663
+ const problem = dotFilePathProblem(path, { allowRoot });
664
+ if (problem) throw new HostError("invalid", problem);
665
+ const resolved = path === "" ? this.#root : join(this.#root, path);
666
+ await this.#checkInside(resolved);
667
+ return resolved;
668
+ }
669
+ async usage() {
670
+ return sizeOf(this.#root);
671
+ }
672
+ async clear() {
673
+ await rm(this.#root, {
674
+ recursive: true,
675
+ force: true
676
+ });
677
+ }
678
+ async ensureRoot() {
679
+ await mkdir(this.#root, { recursive: true });
680
+ }
681
+ async #checkInside(path) {
682
+ const realRoot = await realpath(this.#root).catch(() => null);
683
+ if (realRoot === null) return;
684
+ let existing = path;
685
+ while (!await exists(existing)) existing = dirname(existing);
686
+ const real = await realpath(existing);
687
+ if (real !== realRoot && !real.startsWith(`${realRoot}/`)) throw new HostError("denied", "That path leads outside the dot's storage");
688
+ }
689
+ };
690
+ async function runFileCall(operation, params, storage, temporaryFiles) {
691
+ switch (operation) {
692
+ case "read": return read(params, storage);
693
+ case "write": return write(params, storage);
694
+ case "stat": {
695
+ const { path } = params;
696
+ return entry(path, await storage.resolve(path, { allowRoot: true }));
697
+ }
698
+ case "list": return list(params, storage);
699
+ case "createFolder": return createFolder(params.path, storage);
700
+ case "move": return transfer(params, storage, false);
701
+ case "copy": return transfer(params, storage, true);
702
+ case "remove": return remove(params, storage);
703
+ case "save": return save(params, storage, temporaryFiles);
704
+ case "open": return open$1(params.path, storage, temporaryFiles);
705
+ case "usage": return {
706
+ used: await storage.usage(),
707
+ quota: storage.quota
708
+ };
709
+ default: throw new HostError("unsupported", `This host has no "dash.file.${operation}"`);
710
+ }
711
+ }
712
+ async function read({ path, as }, storage) {
713
+ const encoding = parseEncoding(as);
714
+ const file = await storage.resolve(path);
715
+ const info = await statOrNull(file);
716
+ if (!info) throw failed(`There is no file at "${path}"`);
717
+ if (info.isDirectory()) throw failed(`"${path}" is a folder`);
718
+ if (info.size > MAX_READ_BYTES) throw failed(`"${path}" is larger than ${megabytes(MAX_READ_BYTES)} MB, too large to read at once`);
719
+ const bytes = await readFile(file);
720
+ return encoding === "base64" ? bytes.toString("base64") : bytes.toString("utf8");
721
+ }
722
+ async function write({ path, data, as, append = false }, storage) {
723
+ const encoding = parseEncoding(as);
724
+ const file = await storage.resolve(path);
725
+ const existing = await statOrNull(file);
726
+ if (existing?.isDirectory()) throw failed(`"${path}" is a folder`);
727
+ if (encoding === "base64" && !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new HostError("invalid", "The data isn't base64");
728
+ const bytes = Buffer.from(data, encoding === "base64" ? "base64" : "utf8");
729
+ const replacedBytes = append ? 0 : existing?.size ?? 0;
730
+ await checkQuota(storage, bytes.length - replacedBytes);
731
+ await createParent(storage, file, path);
732
+ if (append) await appendFile(file, bytes);
733
+ else await writeFile(file, bytes);
734
+ return written(path, file);
735
+ }
736
+ async function list({ path, recursive = false }, storage) {
737
+ const folder = await storage.resolve(path, { allowRoot: true });
738
+ const info = await statOrNull(folder);
739
+ if (!info) {
740
+ if (path === "") return [];
741
+ throw failed(`There is no folder at "${path}"`);
742
+ }
743
+ if (!info.isDirectory()) throw failed(`"${path}" is a file`);
744
+ const entries = [];
745
+ await collect(path, folder, recursive, entries);
746
+ return entries.sort((first, second) => first.path < second.path ? -1 : 1);
747
+ }
748
+ async function createFolder(path, storage) {
749
+ const folder = await storage.resolve(path);
750
+ if ((await statOrNull(folder))?.isFile()) throw failed(`"${path}" is a file`);
751
+ await createParent(storage, folder, path);
752
+ await mkdir(folder, { recursive: true });
753
+ return written(path, folder);
754
+ }
755
+ async function transfer({ from, to, overwrite = false }, storage, keepSource) {
756
+ const source = await storage.resolve(from);
757
+ const target = await storage.resolve(to);
758
+ if (!await exists(source)) throw failed(`There is nothing at "${from}"`);
759
+ if (to === from || to.startsWith(`${from}/`)) throw new HostError("invalid", `"${to}" is inside "${from}"`);
760
+ await makeRoom(storage, target, to, overwrite, keepSource ? await sizeOf(source) : 0);
761
+ if (keepSource) await cp(source, target, { recursive: true });
762
+ else await rename(source, target);
763
+ return written(to, target);
764
+ }
765
+ async function remove({ path, recursive = false }, storage) {
766
+ const target = await storage.resolve(path);
767
+ const info = await statOrNull(target);
768
+ if (!info) return;
769
+ 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`);
770
+ await rm(target, {
771
+ recursive: true,
772
+ force: true
773
+ });
774
+ }
775
+ async function save({ file: id, path, overwrite = false }, storage, temporaryFiles) {
776
+ const file = temporaryFiles.requireFile(id);
777
+ const target = await storage.resolve(path);
778
+ await makeRoom(storage, target, path, overwrite, file.size);
779
+ await cp(file.path, target);
780
+ return written(path, target);
781
+ }
782
+ async function open$1(path, storage, temporaryFiles) {
783
+ const file = await storage.resolve(path);
784
+ if (!(await statOrNull(file))?.isFile()) throw failed(`There is no file at "${path}"`);
785
+ const name = basename(path);
786
+ return temporaryFiles.reference(file, name, contentTypeFor(name));
787
+ }
788
+ async function collect(folderPath, folder, recursive, entries) {
789
+ for (const name of await readdir(folder)) {
790
+ const path = folderPath === "" ? name : `${folderPath}/${name}`;
791
+ const child = join(folder, name);
792
+ const found = await entry(path, child);
793
+ if (!found) continue;
794
+ entries.push(found);
795
+ if (recursive && found.kind === "folder") await collect(path, child, true, entries);
796
+ }
797
+ }
798
+ async function entry(path, file) {
799
+ const info = await statOrNull(file);
800
+ if (!info) return null;
801
+ const name = path === "" ? "" : basename(path);
802
+ const modifiedAt = Math.round(info.mtimeMs);
803
+ if (info.isDirectory()) return {
804
+ name,
805
+ path,
806
+ kind: "folder",
807
+ size: 0,
808
+ modifiedAt
809
+ };
810
+ return {
811
+ name,
812
+ path,
813
+ kind: "file",
814
+ size: info.size,
815
+ contentType: contentTypeFor(name),
816
+ modifiedAt
817
+ };
818
+ }
819
+ async function written(path, file) {
820
+ const found = await entry(path, file);
821
+ if (!found) throw failed(`"${path}" was removed while it was written`);
822
+ return found;
823
+ }
824
+ async function makeRoom(storage, target, path, overwrite, addedBytes) {
825
+ if (await exists(target) && !overwrite) throw failed(`"${path}" already exists: pass { overwrite: true } to replace it`);
826
+ await checkQuota(storage, addedBytes - await sizeOf(target));
827
+ await rm(target, {
828
+ recursive: true,
829
+ force: true
830
+ });
831
+ await createParent(storage, target, path);
832
+ }
833
+ async function createParent(storage, file, path) {
834
+ await storage.ensureRoot();
835
+ const parent = dirname(file);
836
+ if ((await statOrNull(parent))?.isFile()) throw failed(`A folder in "${path}" is a file`);
837
+ await mkdir(parent, { recursive: true });
838
+ }
839
+ async function checkQuota(storage, addedBytes) {
840
+ if (addedBytes <= 0) return;
841
+ const used = await storage.usage();
842
+ 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`);
843
+ }
844
+ async function sizeOf(path) {
845
+ const info = await lstat(path).catch(() => null);
846
+ if (!info) return 0;
847
+ if (!info.isDirectory()) return info.size;
848
+ let total = 0;
849
+ for (const name of await readdir(path)) total += await sizeOf(join(path, name));
850
+ return total;
851
+ }
852
+ async function exists(path) {
853
+ return await lstat(path).catch(() => null) !== null;
854
+ }
855
+ async function statOrNull(path) {
856
+ return stat(path).catch(() => null);
857
+ }
858
+ function parseEncoding(name) {
859
+ if (name === void 0 || name === "text" || name === "base64") return name ?? "text";
860
+ throw new HostError("invalid", `"${String(name)}" is not an encoding: use "text" or "base64"`);
861
+ }
862
+ function failed(message) {
863
+ return new HostError("failed", message);
864
+ }
865
+ //#endregion
866
+ //#region src/temporary-files.ts
867
+ const TEMPORARY_FILES_PATH = "/__dot/files/";
868
+ var TemporaryFiles = class {
869
+ #directory;
870
+ #files = /* @__PURE__ */ new Map();
871
+ constructor(directory) {
872
+ this.#directory = directory;
873
+ }
874
+ file(id) {
875
+ return this.#files.get(id);
876
+ }
877
+ requireFile(id) {
878
+ const file = this.#files.get(id);
879
+ if (!file) throw new HostError("invalid", `"${id}" is not a file this dot has`);
880
+ return file;
881
+ }
882
+ async clear() {
883
+ this.#files.clear();
884
+ await rm(this.#directory, {
885
+ recursive: true,
886
+ force: true
887
+ });
888
+ }
889
+ async newPath() {
890
+ await mkdir(this.#directory, { recursive: true });
891
+ const id = newId();
892
+ return {
893
+ id,
894
+ path: join(this.#directory, id)
895
+ };
896
+ }
897
+ async reference(path, name, contentType) {
898
+ return this.add(newId(), path, name, contentType);
899
+ }
900
+ async add(id, path, name, contentType) {
901
+ const { size } = await stat(path);
902
+ const url = `${TEMPORARY_FILES_PATH}${id}`;
903
+ this.#files.set(id, {
904
+ id,
905
+ name,
906
+ size,
907
+ contentType,
908
+ url,
909
+ path
910
+ });
911
+ return {
912
+ id,
913
+ name,
914
+ size,
915
+ contentType,
916
+ url
917
+ };
918
+ }
919
+ };
920
+ function newId() {
921
+ return `f_${randomBytes(12).toString("base64url")}`;
922
+ }
923
+ //#endregion
354
924
  //#region src/server.ts
355
925
  const DEFAULT_PORT = 3680;
356
926
  const PORT_ATTEMPTS = 5;
@@ -368,8 +938,10 @@ const MIME_TYPES = {
368
938
  ".webp": "image/webp",
369
939
  ".woff2": "font/woff2"
370
940
  };
371
- async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAULT_PORT, host = "localhost", clientDir = defaultClientDir() }) {
941
+ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAULT_PORT, host = "localhost", clientDir = defaultClientDir(), storageDir }) {
372
942
  const clients = /* @__PURE__ */ new Set();
943
+ const files = new TemporaryFiles(await mkdtemp(join(tmpdir(), "dot-simulator-files-")));
944
+ const storage = new DotStorage(storageDir ?? await mkdtemp(join(tmpdir(), "dot-simulator-storage-")));
373
945
  let buildError = null;
374
946
  const broadcast = (event, data = "") => {
375
947
  for (const client of clients) client.write(serverEvent(event, data));
@@ -396,6 +968,19 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
396
968
  request.on("close", () => clients.delete(response));
397
969
  return;
398
970
  }
971
+ if (pathname.startsWith(HTTP_PATH) && request.method === "POST") {
972
+ relayHttpCall(request, response, pathname.slice(HTTP_PATH.length), dotDir, files);
973
+ return;
974
+ }
975
+ if (pathname.startsWith(FILE_PATH) && request.method === "POST") {
976
+ const operation = pathname.slice(FILE_PATH.length);
977
+ answer(request, response, (params) => runFileCall(operation, params, storage, files));
978
+ return;
979
+ }
980
+ if (pathname.startsWith("/__dot/files/")) {
981
+ serveTemporaryFile(response, files, pathname.slice(13));
982
+ return;
983
+ }
399
984
  if (pathname.startsWith(REACT_HOST_PATH) && reactHostDir !== void 0) {
400
985
  serveReact(response, reactHostDir, pathname.slice(REACT_HOST_PATH.length));
401
986
  return;
@@ -416,6 +1001,7 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
416
1001
  url: `http://${host}:${server.address().port}/`,
417
1002
  reload: () => {
418
1003
  buildError = null;
1004
+ files.clear();
419
1005
  broadcast("reload");
420
1006
  },
421
1007
  error: (message) => {
@@ -424,10 +1010,50 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
424
1010
  },
425
1011
  close: () => new Promise((resolvePromise) => {
426
1012
  for (const client of clients) client.end();
427
- server.close(() => resolvePromise());
1013
+ server.close(() => void files.clear().then(() => resolvePromise()));
428
1014
  })
429
1015
  };
430
1016
  }
1017
+ function relayHttpCall(request, response, call, dotDir, files) {
1018
+ return answer(request, response, async (params, signal) => {
1019
+ const hosts = await networkHosts(dotDir);
1020
+ if (call === "download") return downloadHttpFile(params, hosts, files, signal);
1021
+ if (call === "upload") return uploadHttpFiles(params, hosts, files, signal);
1022
+ return sendHttpRequest(params, hosts, signal);
1023
+ });
1024
+ }
1025
+ async function answer(request, response, run) {
1026
+ const controller = new AbortController();
1027
+ response.on("close", () => controller.abort());
1028
+ let reply;
1029
+ try {
1030
+ const chunks = [];
1031
+ for await (const chunk of request) chunks.push(chunk);
1032
+ reply = { result: await run(JSON.parse(Buffer.concat(chunks).toString("utf8")), controller.signal) ?? null };
1033
+ } catch (error) {
1034
+ if (controller.signal.aborted) return;
1035
+ reply = { error: {
1036
+ code: error instanceof HostError ? error.code : "failed",
1037
+ message: error.message
1038
+ } };
1039
+ }
1040
+ send(response, 200, JSON.stringify(reply), ".json");
1041
+ }
1042
+ async function serveTemporaryFile(response, files, id) {
1043
+ const file = files.file(id);
1044
+ if (!file) {
1045
+ send(response, 404, "Not found");
1046
+ return;
1047
+ }
1048
+ response.writeHead(200, {
1049
+ "Content-Type": file.contentType,
1050
+ "Cache-Control": "no-store"
1051
+ });
1052
+ response.end(await readFile(file.path));
1053
+ }
1054
+ async function networkHosts(dotDir) {
1055
+ return JSON.parse(await readFile(join(dotDir, "manifest.json"), "utf8")).network?.hosts ?? [];
1056
+ }
431
1057
  function serverEvent(event, data) {
432
1058
  return `event: ${event}\n${data.split("\n").map((line) => `data: ${line}`).join("\n")}\n\n`;
433
1059
  }