@monimesl/dot-simulator 0.1.0-alpha.2 → 0.1.0-alpha.4

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";
@@ -138,7 +141,7 @@ var PermissionGate = class {
138
141
  };
139
142
  //#endregion
140
143
  //#region src/host.ts
141
- function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, systemContext, handlers = {}, askPermission, observe }) {
144
+ function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, systemContext, handlers = {}, askPermission, observe, restorePages = [], onBarsChange }) {
142
145
  checkCompatibility(manifest, {
143
146
  dashVersion,
144
147
  minRuntimeVersion
@@ -158,6 +161,18 @@ function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, system
158
161
  ui.post(message);
159
162
  logic.post(message);
160
163
  };
164
+ const barsByInstance = /* @__PURE__ */ new Map();
165
+ const showBars = () => {
166
+ const top = stack.top;
167
+ const bars = top && barsByInstance.get(top.instanceId);
168
+ onBarsChange?.({
169
+ title: bars?.navigationBar.title ?? manifest.name,
170
+ navigationBarHidden: bars?.navigationBar.hidden ?? false,
171
+ navigationBarColor: bars?.navigationBar.backgroundColor,
172
+ navigationBarTextColor: bars?.navigationBar.textColor,
173
+ tabBarHidden: bars?.tabBar.hidden ?? false
174
+ }, stack.pages);
175
+ };
161
176
  const syncStack = () => {
162
177
  const message = {
163
178
  type: "syncStack",
@@ -167,6 +182,9 @@ function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, system
167
182
  }
168
183
  };
169
184
  broadcast(message);
185
+ const open = new Set(stack.pages.map((entry) => entry.instanceId));
186
+ for (const instanceId of barsByInstance.keys()) if (!open.has(instanceId)) barsByInstance.delete(instanceId);
187
+ showBars();
170
188
  };
171
189
  const syncSystemContext = () => {
172
190
  broadcast({
@@ -182,13 +200,40 @@ function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, system
182
200
  if (stack.top !== previousTop) calls.dismissUI();
183
201
  return true;
184
202
  };
203
+ const openFirstPages = () => {
204
+ const [first, ...rest] = restorePages.filter((entry) => manifest.pages.includes(entry.page));
205
+ if (!first) {
206
+ navigate({
207
+ action: "reset",
208
+ page: manifest.entry,
209
+ options: {}
210
+ });
211
+ return;
212
+ }
213
+ stack.apply({
214
+ action: "reset",
215
+ page: first.page,
216
+ options: first.options
217
+ });
218
+ for (const entry of rest) stack.apply({
219
+ action: "push",
220
+ page: entry.page,
221
+ options: entry.options
222
+ });
223
+ syncStack();
224
+ };
185
225
  const navigateFromPage = ({ from, ...navigation }) => {
186
226
  if (from === stack.top?.instanceId) navigate(navigation);
187
227
  };
188
228
  const hostMessageHandlers = /* @__PURE__ */ new Map([
189
229
  ["navigate", (payload) => navigateFromPage(payload)],
190
230
  ["hostCall", (payload) => calls.run(payload)],
191
- ["hostCancel", (payload) => calls.cancel(payload.id)]
231
+ ["hostCancel", (payload) => calls.cancel(payload.id)],
232
+ ["pageBars", (payload) => {
233
+ const { instanceId, ...bars } = payload;
234
+ barsByInstance.set(instanceId, bars);
235
+ showBars();
236
+ }]
192
237
  ]);
193
238
  const stopService = logic.listen((raw) => {
194
239
  const message = raw;
@@ -207,14 +252,13 @@ function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, system
207
252
  logic.post(message);
208
253
  if (message.type === "connect") {
209
254
  syncSystemContext();
210
- navigate({
211
- action: "reset",
212
- page: manifest.entry,
213
- options: {}
214
- });
255
+ openFirstPages();
215
256
  }
216
257
  });
217
258
  return {
259
+ get pages() {
260
+ return stack.pages;
261
+ },
218
262
  back: () => navigate({
219
263
  action: "back",
220
264
  delta: 1
@@ -241,6 +285,8 @@ const SIMULATOR_VERSIONS = {
241
285
  //#region src/paths.ts
242
286
  const CLIENT_PATH = "/__dot/";
243
287
  const EVENTS_PATH = `${CLIENT_PATH}events`;
288
+ const HTTP_PATH = `${CLIENT_PATH}network/http/`;
289
+ const FILE_PATH = `${CLIENT_PATH}file/`;
244
290
  const WEBVIEW_URL = `/dot/ui/index.html`;
245
291
  //#endregion
246
292
  //#region src/page.ts
@@ -274,7 +320,14 @@ function simulatorPage(appName) {
274
320
  <span class="island"></span>
275
321
  <span class="indicators">${STATUS_ICONS}</span>
276
322
  </div>
323
+ <nav class="navigation-bar">
324
+ <button id="navigation-back" type="button" aria-label="Back" hidden>${BACK_ICON}</button>
325
+ <span id="navigation-title">${escapeHtml(appName)}</span>
326
+ </nav>
277
327
  <iframe id="screen" title="${escapeHtml(appName)}"></iframe>
328
+ <nav class="tab-bar" aria-label="Dash's tabs">
329
+ <span>Home</span><span class="current">Dots</span><span>Me</span>
330
+ </nav>
278
331
  <span class="home-indicator"></span>
279
332
  </div>
280
333
  </section>
@@ -291,6 +344,7 @@ function simulatorPage(appName) {
291
344
  </html>
292
345
  `;
293
346
  }
347
+ const BACK_ICON = `<svg width="12" height="20" viewBox="0 0 12 20" aria-hidden="true"><path d="M10 2 2 10l8 8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
294
348
  const STATUS_ICONS = `<svg class="torch" width="10" height="14" viewBox="0 0 10 14" aria-label="Torch on"><path d="M0 0h10v3L7.5 6v7a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V6L0 3Z" fill="#ffd60a"/></svg><svg width="18" height="12" viewBox="0 0 18 12" fill="currentColor" aria-hidden="true"><rect x="0" y="8" width="3" height="4" rx="1"/><rect x="5" y="5.5" width="3" height="6.5" rx="1"/><rect x="10" y="3" width="3" height="9" rx="1"/><rect x="15" y="0" width="3" height="12" rx="1"/></svg><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor" aria-hidden="true"><path d="M8 2.2c2.3 0 4.4.9 6 2.4l1.2-1.3A10.4 10.4 0 0 0 8 .4 10.4 10.4 0 0 0 .8 3.3L2 4.6a8.6 8.6 0 0 1 6-2.4Zm0 3.6c1.3 0 2.5.5 3.5 1.3l1.2-1.3A6.9 6.9 0 0 0 8 4a6.9 6.9 0 0 0-4.7 1.8l1.2 1.3A5.1 5.1 0 0 1 8 5.8Zm0 3.6c-.5 0-1 .2-1.3.5L8 11.4l1.3-1.5c-.3-.3-.8-.5-1.3-.5Z"/></svg><svg width="27" height="13" viewBox="0 0 27 13" aria-hidden="true"><rect x="0.5" y="0.5" width="23" height="12" rx="3.5" fill="none" stroke="currentColor" opacity="0.4"/><rect x="2" y="2" width="20" height="9" rx="2" fill="currentColor"/><path d="M25 4.5v4c.8-.3 1.3-1.1 1.3-2s-.5-1.7-1.3-2Z" fill="currentColor" opacity="0.4"/></svg>`;
295
349
  const STYLES = `
296
350
  :root {
@@ -329,7 +383,16 @@ const STYLES = `
329
383
  .dialog-actions.stacked button + button { border-left: 0; border-top: 1px solid var(--dialog-line); }
330
384
  .dialog-actions .cancel { font-weight: 600; }
331
385
  .dialog-actions .destructive { color: #ff3b30; }
332
- .status-bar { flex: none; position: relative; height: 54px; display: flex; align-items: center; justify-content: space-between; padding: 0 30px 0 50px; color: var(--screen-text); font: 600 17px/1 system-ui, sans-serif; }
386
+ /* Stand-ins for Dash's own bars, which the dot's pages may set. */
387
+ .phone { --bar: var(--screen); --bar-text: var(--screen-text); }
388
+ .navigation-bar { flex: none; position: relative; height: 44px; display: flex; align-items: center; justify-content: center; padding: 0 48px; background: var(--bar); color: var(--bar-text); font: 600 17px/1 system-ui, sans-serif; }
389
+ #navigation-title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
390
+ #navigation-back { position: absolute; left: 8px; top: 0; bottom: 0; padding: 0 10px; border: 0; background: none; color: inherit; }
391
+ .tab-bar { flex: none; display: flex; justify-content: space-around; padding: 10px 0 30px; border-top: 1px solid rgb(128 128 128 / 0.3); background: var(--screen); color: var(--screen-text); opacity: 0.9; font: 500 11px/1 system-ui, sans-serif; }
392
+ .tab-bar .current { font-weight: 700; }
393
+ .navigation-bar[hidden], .tab-bar[hidden] { display: none; }
394
+ .phone:has(.navigation-bar[hidden]) .status-bar { background: var(--screen); color: var(--screen-text); }
395
+ .status-bar { flex: none; position: relative; height: 54px; display: flex; align-items: center; justify-content: space-between; padding: 0 30px 0 50px; background: var(--bar); color: var(--bar-text); font: 600 17px/1 system-ui, sans-serif; }
333
396
  .island { position: absolute; top: 11px; left: 50%; width: 125px; height: 37px; margin-left: -62.5px; border-radius: 19px; background: #000; }
334
397
  .indicators { display: flex; align-items: center; gap: 6px; }
335
398
  #screen { flex: 1; display: block; width: 100%; border: 0; background: var(--screen); }
@@ -351,6 +414,571 @@ const STYLES = `
351
414
  @media (max-width: 900px) { main { flex-direction: column; } aside { width: auto; height: 40vh; border-left: 0; border-top: 1px solid var(--border); } }
352
415
  `;
353
416
  //#endregion
417
+ //#region src/bytes.ts
418
+ const MEGABYTE = 1048576;
419
+ function megabytes(bytes) {
420
+ return String(Math.ceil(bytes * 10 / MEGABYTE) / 10);
421
+ }
422
+ //#endregion
423
+ //#region src/content-types.ts
424
+ const CONTENT_TYPES = {
425
+ ".png": "image/png",
426
+ ".jpg": "image/jpeg",
427
+ ".jpeg": "image/jpeg",
428
+ ".gif": "image/gif",
429
+ ".webp": "image/webp",
430
+ ".svg": "image/svg+xml",
431
+ ".pdf": "application/pdf",
432
+ ".json": "application/json",
433
+ ".txt": "text/plain",
434
+ ".csv": "text/csv",
435
+ ".html": "text/html",
436
+ ".zip": "application/zip"
437
+ };
438
+ function contentTypeFor(name) {
439
+ return CONTENT_TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
440
+ }
441
+ //#endregion
442
+ //#region src/network.ts
443
+ const MAX_RESPONSE_BYTES = 5 * MEGABYTE;
444
+ const MAX_DOWNLOAD_BYTES = 100 * MEGABYTE;
445
+ const METHODS = [
446
+ "GET",
447
+ "POST",
448
+ "PUT",
449
+ "PATCH",
450
+ "DELETE",
451
+ "HEAD",
452
+ "OPTIONS"
453
+ ];
454
+ const UPLOAD_METHODS = [
455
+ "POST",
456
+ "PUT",
457
+ "PATCH"
458
+ ];
459
+ const BODILESS_METHODS = ["GET", "HEAD"];
460
+ const REDIRECT_STATUSES = [
461
+ 301,
462
+ 302,
463
+ 303,
464
+ 307,
465
+ 308
466
+ ];
467
+ const HOST_HEADERS = [
468
+ "content-type",
469
+ "content-length",
470
+ "transfer-encoding",
471
+ "host",
472
+ "connection",
473
+ "upgrade"
474
+ ];
475
+ const CONTENT_TYPE = /^[\w.+-]+\/[\w.+*-]+\s*(;.*)?$/;
476
+ async function sendHttpRequest(request, hosts, signal) {
477
+ return exchange(textRequest(request), hosts, signal, async (response) => result(response, await readBody(response)));
478
+ }
479
+ async function downloadHttpFile(request, hosts, files, signal) {
480
+ return exchange(textRequest(request), hosts, signal, async (response) => {
481
+ if (!response.ok) return {
482
+ ...result(response, await readBody(response)),
483
+ ok: false
484
+ };
485
+ const file = await save$1(response, request.name, files);
486
+ const { status, statusText, headers } = result(response, "");
487
+ return {
488
+ status,
489
+ statusText,
490
+ headers,
491
+ ok: true,
492
+ file
493
+ };
494
+ });
495
+ }
496
+ async function uploadHttpFiles(request, hosts, files, signal) {
497
+ if (request.files.length === 0) throw invalid("An upload needs at least one file; send text with http.request");
498
+ const parts = request.files.map((upload) => {
499
+ if (!upload.field.trim()) throw invalid("Each file needs a field name");
500
+ const file = files.requireFile(upload.file);
501
+ return {
502
+ field: upload.field,
503
+ file,
504
+ name: upload.name ? safeFileName(upload.name) : file.name
505
+ };
506
+ });
507
+ const makeForm = async () => {
508
+ const form = new FormData();
509
+ for (const [name, value] of Object.entries(request.fields ?? {})) form.append(name, value);
510
+ for (const part of parts) {
511
+ const contents = await readFile(part.file.path);
512
+ form.append(part.field, new File([contents], part.name, { type: part.file.contentType }));
513
+ }
514
+ return form;
515
+ };
516
+ return exchange({
517
+ method: parseMethod(request.method ?? "POST", UPLOAD_METHODS),
518
+ headers: checkedHeaders(request.headers ?? {}),
519
+ url: parseUrl(request.url),
520
+ body: {
521
+ kind: "form",
522
+ form: makeForm
523
+ }
524
+ }, hosts, signal, async (response) => result(response, await readBody(response)));
525
+ }
526
+ async function exchange(request, hosts, signal, read) {
527
+ let outgoing = request;
528
+ for (let redirects = 0; redirects <= 10; redirects++) {
529
+ 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.`);
530
+ const response = await send$1(outgoing, signal);
531
+ const location = response.headers.get("location");
532
+ if (!REDIRECT_STATUSES.includes(response.status) || location === null) return read(response);
533
+ await response.body?.cancel();
534
+ outgoing = redirected(outgoing, location, response.status);
535
+ }
536
+ throw new HostError("failed", `"${request.url.href}" redirected more than 10 times`);
537
+ }
538
+ function textRequest(request) {
539
+ const method = parseMethod(request.method ?? "GET", METHODS);
540
+ const { contentType = "text/plain; charset=utf-8", others } = takeContentType(request.headers ?? {});
541
+ if (!CONTENT_TYPE.test(contentType)) throw invalid(`"${contentType}" is not a Content-Type`);
542
+ const headers = checkedHeaders(others);
543
+ let body;
544
+ if (request.body !== void 0) {
545
+ if (BODILESS_METHODS.includes(method)) throw invalid(`A ${method} request can't have a body`);
546
+ body = {
547
+ kind: "text",
548
+ text: request.body,
549
+ contentType
550
+ };
551
+ }
552
+ return {
553
+ url: parseUrl(request.url),
554
+ method,
555
+ headers,
556
+ body
557
+ };
558
+ }
559
+ function checkedHeaders(values) {
560
+ for (const name of Object.keys(values)) if (HOST_HEADERS.includes(name.toLowerCase())) throw invalid(`"${name}" is set by the host`);
561
+ return new Headers(values);
562
+ }
563
+ function takeContentType(values) {
564
+ let contentType;
565
+ const others = {};
566
+ for (const [name, value] of Object.entries(values)) if (name.toLowerCase() === "content-type") contentType = value;
567
+ else others[name] = value;
568
+ return {
569
+ contentType,
570
+ others
571
+ };
572
+ }
573
+ async function send$1(request, signal) {
574
+ const headers = new Headers(request.headers);
575
+ let body = null;
576
+ if (request.body?.kind === "text") {
577
+ body = request.body.text;
578
+ headers.set("content-type", request.body.contentType);
579
+ } else if (request.body?.kind === "form") body = await request.body.form();
580
+ try {
581
+ return await fetch(request.url, {
582
+ method: request.method,
583
+ headers,
584
+ body,
585
+ redirect: "manual",
586
+ signal
587
+ });
588
+ } catch (error) {
589
+ if (signal.aborted) throw error;
590
+ const cause = error.cause?.message;
591
+ throw new HostError("failed", `Could not reach ${request.url.hostname}: ${cause ?? error.message}`);
592
+ }
593
+ }
594
+ function redirected(request, location, status) {
595
+ const url = URL.parse(location, request.url.href);
596
+ 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`);
597
+ const becomesGet = status === 303 && request.method !== "HEAD" || (status === 301 || status === 302) && request.method === "POST";
598
+ const headers = new Headers(request.headers);
599
+ if (url.origin !== request.url.origin) headers.delete("authorization");
600
+ return {
601
+ url,
602
+ method: becomesGet ? "GET" : request.method,
603
+ headers,
604
+ body: becomesGet ? void 0 : request.body
605
+ };
606
+ }
607
+ function result(response, body) {
608
+ return {
609
+ status: response.status,
610
+ statusText: response.statusText || (STATUS_CODES[response.status] ?? ""),
611
+ ok: response.ok,
612
+ headers: Object.fromEntries(response.headers),
613
+ body
614
+ };
615
+ }
616
+ async function readBody(response) {
617
+ const chunks = [];
618
+ await readChunks(response, MAX_RESPONSE_BYTES, (chunk) => {
619
+ chunks.push(chunk);
620
+ return Promise.resolve();
621
+ });
622
+ const charset = /charset=([^;\s]+)/i.exec(response.headers.get("content-type") ?? "")?.[1];
623
+ return decoderFor(charset).decode(Buffer.concat(chunks));
624
+ }
625
+ async function save$1(response, requestedName, files) {
626
+ const { id, path } = await files.newPath();
627
+ const handle = await open(path, "w");
628
+ try {
629
+ await readChunks(response, MAX_DOWNLOAD_BYTES, async (chunk) => {
630
+ await handle.write(chunk);
631
+ });
632
+ } catch (error) {
633
+ await handle.close();
634
+ await rm(path, { force: true });
635
+ throw error;
636
+ }
637
+ await handle.close();
638
+ const name = requestedName ? safeFileName(requestedName) : fileName(response);
639
+ return files.add(id, path, name, contentType(response, name));
640
+ }
641
+ async function readChunks(response, limit, take) {
642
+ if (Number(response.headers.get("content-length") ?? 0) > limit) {
643
+ await response.body?.cancel();
644
+ throw tooLarge(limit);
645
+ }
646
+ if (!response.body) return;
647
+ const reader = response.body.getReader();
648
+ let length = 0;
649
+ while (true) {
650
+ const { done, value } = await reader.read();
651
+ if (done) return;
652
+ length += value.length;
653
+ if (length > limit) {
654
+ await reader.cancel();
655
+ throw tooLarge(limit);
656
+ }
657
+ await take(value);
658
+ }
659
+ }
660
+ function fileName(response) {
661
+ const disposition = response.headers.get("content-disposition") ?? "";
662
+ const encoded = /filename\*\s*=\s*[^']*''([^;]+)/i.exec(disposition)?.[1];
663
+ const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition)?.[1];
664
+ const segment = new URL(response.url).pathname.split("/").at(-1);
665
+ return [
666
+ encoded && safeDecode(encoded),
667
+ plain,
668
+ segment && safeDecode(segment)
669
+ ].map((name) => safeFileName(name ?? "")).find(Boolean) ?? "download";
670
+ }
671
+ function safeFileName(name) {
672
+ return (name.split(/[/\\]/).at(-1) ?? "").trim().slice(0, 255);
673
+ }
674
+ function safeDecode(text) {
675
+ try {
676
+ return decodeURIComponent(text);
677
+ } catch {
678
+ return text;
679
+ }
680
+ }
681
+ function contentType(response, name) {
682
+ const declared = response.headers.get("content-type")?.split(";")[0]?.trim();
683
+ if (declared && declared !== "application/octet-stream") return declared;
684
+ return contentTypeFor(name);
685
+ }
686
+ function decoderFor(charset) {
687
+ try {
688
+ return new TextDecoder(charset ?? "utf-8");
689
+ } catch {
690
+ return new TextDecoder("utf-8");
691
+ }
692
+ }
693
+ function parseUrl(text) {
694
+ const url = /^https?:\/\//i.test(text) ? URL.parse(text) : null;
695
+ if (!url?.hostname) throw invalid(`"${text}" is not an http:// or https:// URL`);
696
+ return url;
697
+ }
698
+ function parseMethod(name, methods) {
699
+ const method = name.toUpperCase();
700
+ if (!methods.includes(method)) throw invalid(`"${name}" is not an HTTP method here: use ${methods.join(", ")}`);
701
+ return method;
702
+ }
703
+ function invalid(message) {
704
+ return new HostError("invalid", message);
705
+ }
706
+ function tooLarge(limit) {
707
+ return new HostError("failed", `The response is larger than ${megabytes(limit)} MB`);
708
+ }
709
+ //#endregion
710
+ //#region src/files.ts
711
+ const DEFAULT_QUOTA = 100 * MEGABYTE;
712
+ const MAX_READ_BYTES = 5 * MEGABYTE;
713
+ var DotStorage = class {
714
+ #root;
715
+ quota;
716
+ constructor(root, quota = DEFAULT_QUOTA) {
717
+ this.#root = root;
718
+ this.quota = quota;
719
+ }
720
+ async resolve(path, { allowRoot = false } = {}) {
721
+ const problem = dotFilePathProblem(path, { allowRoot });
722
+ if (problem) throw new HostError("invalid", problem);
723
+ const resolved = path === "" ? this.#root : join(this.#root, path);
724
+ await this.#checkInside(resolved);
725
+ return resolved;
726
+ }
727
+ async usage() {
728
+ return sizeOf(this.#root);
729
+ }
730
+ async clear() {
731
+ await rm(this.#root, {
732
+ recursive: true,
733
+ force: true
734
+ });
735
+ }
736
+ async ensureRoot() {
737
+ await mkdir(this.#root, { recursive: true });
738
+ }
739
+ async #checkInside(path) {
740
+ const realRoot = await realpath(this.#root).catch(() => null);
741
+ if (realRoot === null) return;
742
+ let existing = path;
743
+ while (!await exists(existing)) existing = dirname(existing);
744
+ const real = await realpath(existing);
745
+ if (real !== realRoot && !real.startsWith(`${realRoot}/`)) throw new HostError("denied", "That path leads outside the dot's storage");
746
+ }
747
+ };
748
+ async function runFileCall(operation, params, storage, temporaryFiles) {
749
+ switch (operation) {
750
+ case "read": return read(params, storage);
751
+ case "write": return write(params, storage);
752
+ case "stat": {
753
+ const { path } = params;
754
+ return entry(path, await storage.resolve(path, { allowRoot: true }));
755
+ }
756
+ case "list": return list(params, storage);
757
+ case "createFolder": return createFolder(params.path, storage);
758
+ case "move": return transfer(params, storage, false);
759
+ case "copy": return transfer(params, storage, true);
760
+ case "remove": return remove(params, storage);
761
+ case "save": return save(params, storage, temporaryFiles);
762
+ case "open": return open$1(params.path, storage, temporaryFiles);
763
+ case "usage": return {
764
+ used: await storage.usage(),
765
+ quota: storage.quota
766
+ };
767
+ default: throw new HostError("unsupported", `This host has no "dash.file.${operation}"`);
768
+ }
769
+ }
770
+ async function read({ path, as }, storage) {
771
+ const encoding = parseEncoding(as);
772
+ const file = await storage.resolve(path);
773
+ const info = await statOrNull(file);
774
+ if (!info) throw failed(`There is no file at "${path}"`);
775
+ if (info.isDirectory()) throw failed(`"${path}" is a folder`);
776
+ if (info.size > MAX_READ_BYTES) throw failed(`"${path}" is larger than ${megabytes(MAX_READ_BYTES)} MB, too large to read at once`);
777
+ const bytes = await readFile(file);
778
+ return encoding === "base64" ? bytes.toString("base64") : bytes.toString("utf8");
779
+ }
780
+ async function write({ path, data, as, append = false }, storage) {
781
+ const encoding = parseEncoding(as);
782
+ const file = await storage.resolve(path);
783
+ const existing = await statOrNull(file);
784
+ if (existing?.isDirectory()) throw failed(`"${path}" is a folder`);
785
+ if (encoding === "base64" && !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new HostError("invalid", "The data isn't base64");
786
+ const bytes = Buffer.from(data, encoding === "base64" ? "base64" : "utf8");
787
+ const replacedBytes = append ? 0 : existing?.size ?? 0;
788
+ await checkQuota(storage, bytes.length - replacedBytes);
789
+ await createParent(storage, file, path);
790
+ if (append) await appendFile(file, bytes);
791
+ else await writeFile(file, bytes);
792
+ return written(path, file);
793
+ }
794
+ async function list({ path, recursive = false }, storage) {
795
+ const folder = await storage.resolve(path, { allowRoot: true });
796
+ const info = await statOrNull(folder);
797
+ if (!info) {
798
+ if (path === "") return [];
799
+ throw failed(`There is no folder at "${path}"`);
800
+ }
801
+ if (!info.isDirectory()) throw failed(`"${path}" is a file`);
802
+ const entries = [];
803
+ await collect(path, folder, recursive, entries);
804
+ return entries.sort((first, second) => first.path < second.path ? -1 : 1);
805
+ }
806
+ async function createFolder(path, storage) {
807
+ const folder = await storage.resolve(path);
808
+ if ((await statOrNull(folder))?.isFile()) throw failed(`"${path}" is a file`);
809
+ await createParent(storage, folder, path);
810
+ await mkdir(folder, { recursive: true });
811
+ return written(path, folder);
812
+ }
813
+ async function transfer({ from, to, overwrite = false }, storage, keepSource) {
814
+ const source = await storage.resolve(from);
815
+ const target = await storage.resolve(to);
816
+ if (!await exists(source)) throw failed(`There is nothing at "${from}"`);
817
+ if (to === from || to.startsWith(`${from}/`)) throw new HostError("invalid", `"${to}" is inside "${from}"`);
818
+ await makeRoom(storage, target, to, overwrite, keepSource ? await sizeOf(source) : 0);
819
+ if (keepSource) await cp(source, target, { recursive: true });
820
+ else await rename(source, target);
821
+ return written(to, target);
822
+ }
823
+ async function remove({ path, recursive = false }, storage) {
824
+ const target = await storage.resolve(path);
825
+ const info = await statOrNull(target);
826
+ if (!info) return;
827
+ 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`);
828
+ await rm(target, {
829
+ recursive: true,
830
+ force: true
831
+ });
832
+ }
833
+ async function save({ file: id, path, overwrite = false }, storage, temporaryFiles) {
834
+ const file = temporaryFiles.requireFile(id);
835
+ const target = await storage.resolve(path);
836
+ await makeRoom(storage, target, path, overwrite, file.size);
837
+ await cp(file.path, target);
838
+ return written(path, target);
839
+ }
840
+ async function open$1(path, storage, temporaryFiles) {
841
+ const file = await storage.resolve(path);
842
+ if (!(await statOrNull(file))?.isFile()) throw failed(`There is no file at "${path}"`);
843
+ const name = basename(path);
844
+ return temporaryFiles.reference(file, name, contentTypeFor(name));
845
+ }
846
+ async function collect(folderPath, folder, recursive, entries) {
847
+ for (const name of await readdir(folder)) {
848
+ const path = folderPath === "" ? name : `${folderPath}/${name}`;
849
+ const child = join(folder, name);
850
+ const found = await entry(path, child);
851
+ if (!found) continue;
852
+ entries.push(found);
853
+ if (recursive && found.kind === "folder") await collect(path, child, true, entries);
854
+ }
855
+ }
856
+ async function entry(path, file) {
857
+ const info = await statOrNull(file);
858
+ if (!info) return null;
859
+ const name = path === "" ? "" : basename(path);
860
+ const modifiedAt = Math.round(info.mtimeMs);
861
+ if (info.isDirectory()) return {
862
+ name,
863
+ path,
864
+ kind: "folder",
865
+ size: 0,
866
+ modifiedAt
867
+ };
868
+ return {
869
+ name,
870
+ path,
871
+ kind: "file",
872
+ size: info.size,
873
+ contentType: contentTypeFor(name),
874
+ modifiedAt
875
+ };
876
+ }
877
+ async function written(path, file) {
878
+ const found = await entry(path, file);
879
+ if (!found) throw failed(`"${path}" was removed while it was written`);
880
+ return found;
881
+ }
882
+ async function makeRoom(storage, target, path, overwrite, addedBytes) {
883
+ if (await exists(target) && !overwrite) throw failed(`"${path}" already exists: pass { overwrite: true } to replace it`);
884
+ await checkQuota(storage, addedBytes - await sizeOf(target));
885
+ await rm(target, {
886
+ recursive: true,
887
+ force: true
888
+ });
889
+ await createParent(storage, target, path);
890
+ }
891
+ async function createParent(storage, file, path) {
892
+ await storage.ensureRoot();
893
+ const parent = dirname(file);
894
+ if ((await statOrNull(parent))?.isFile()) throw failed(`A folder in "${path}" is a file`);
895
+ await mkdir(parent, { recursive: true });
896
+ }
897
+ async function checkQuota(storage, addedBytes) {
898
+ if (addedBytes <= 0) return;
899
+ const used = await storage.usage();
900
+ 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`);
901
+ }
902
+ async function sizeOf(path) {
903
+ const info = await lstat(path).catch(() => null);
904
+ if (!info) return 0;
905
+ if (!info.isDirectory()) return info.size;
906
+ let total = 0;
907
+ for (const name of await readdir(path)) total += await sizeOf(join(path, name));
908
+ return total;
909
+ }
910
+ async function exists(path) {
911
+ return await lstat(path).catch(() => null) !== null;
912
+ }
913
+ async function statOrNull(path) {
914
+ return stat(path).catch(() => null);
915
+ }
916
+ function parseEncoding(name) {
917
+ if (name === void 0 || name === "text" || name === "base64") return name ?? "text";
918
+ throw new HostError("invalid", `"${String(name)}" is not an encoding: use "text" or "base64"`);
919
+ }
920
+ function failed(message) {
921
+ return new HostError("failed", message);
922
+ }
923
+ //#endregion
924
+ //#region src/temporary-files.ts
925
+ const TEMPORARY_FILES_PATH = "/__dot/files/";
926
+ var TemporaryFiles = class {
927
+ #directory;
928
+ #files = /* @__PURE__ */ new Map();
929
+ constructor(directory) {
930
+ this.#directory = directory;
931
+ }
932
+ file(id) {
933
+ return this.#files.get(id);
934
+ }
935
+ requireFile(id) {
936
+ const file = this.#files.get(id);
937
+ if (!file) throw new HostError("invalid", `"${id}" is not a file this dot has`);
938
+ return file;
939
+ }
940
+ async clear() {
941
+ this.#files.clear();
942
+ await rm(this.#directory, {
943
+ recursive: true,
944
+ force: true
945
+ });
946
+ }
947
+ async newPath() {
948
+ await mkdir(this.#directory, { recursive: true });
949
+ const id = newId();
950
+ return {
951
+ id,
952
+ path: join(this.#directory, id)
953
+ };
954
+ }
955
+ async reference(path, name, contentType) {
956
+ return this.add(newId(), path, name, contentType);
957
+ }
958
+ async add(id, path, name, contentType) {
959
+ const { size } = await stat(path);
960
+ const url = `${TEMPORARY_FILES_PATH}${id}`;
961
+ this.#files.set(id, {
962
+ id,
963
+ name,
964
+ size,
965
+ contentType,
966
+ url,
967
+ path
968
+ });
969
+ return {
970
+ id,
971
+ name,
972
+ size,
973
+ contentType,
974
+ url
975
+ };
976
+ }
977
+ };
978
+ function newId() {
979
+ return `f_${randomBytes(12).toString("base64url")}`;
980
+ }
981
+ //#endregion
354
982
  //#region src/server.ts
355
983
  const DEFAULT_PORT = 3680;
356
984
  const PORT_ATTEMPTS = 5;
@@ -368,8 +996,10 @@ const MIME_TYPES = {
368
996
  ".webp": "image/webp",
369
997
  ".woff2": "font/woff2"
370
998
  };
371
- async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAULT_PORT, host = "localhost", clientDir = defaultClientDir() }) {
999
+ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAULT_PORT, host = "localhost", clientDir = defaultClientDir(), storageDir }) {
372
1000
  const clients = /* @__PURE__ */ new Set();
1001
+ const files = new TemporaryFiles(await mkdtemp(join(tmpdir(), "dot-simulator-files-")));
1002
+ const storage = new DotStorage(storageDir ?? await mkdtemp(join(tmpdir(), "dot-simulator-storage-")));
373
1003
  let buildError = null;
374
1004
  const broadcast = (event, data = "") => {
375
1005
  for (const client of clients) client.write(serverEvent(event, data));
@@ -396,6 +1026,19 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
396
1026
  request.on("close", () => clients.delete(response));
397
1027
  return;
398
1028
  }
1029
+ if (pathname.startsWith(HTTP_PATH) && request.method === "POST") {
1030
+ relayHttpCall(request, response, pathname.slice(HTTP_PATH.length), dotDir, files);
1031
+ return;
1032
+ }
1033
+ if (pathname.startsWith(FILE_PATH) && request.method === "POST") {
1034
+ const operation = pathname.slice(FILE_PATH.length);
1035
+ answer(request, response, (params) => runFileCall(operation, params, storage, files));
1036
+ return;
1037
+ }
1038
+ if (pathname.startsWith("/__dot/files/")) {
1039
+ serveTemporaryFile(response, files, pathname.slice(13));
1040
+ return;
1041
+ }
399
1042
  if (pathname.startsWith(REACT_HOST_PATH) && reactHostDir !== void 0) {
400
1043
  serveReact(response, reactHostDir, pathname.slice(REACT_HOST_PATH.length));
401
1044
  return;
@@ -416,18 +1059,63 @@ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAU
416
1059
  url: `http://${host}:${server.address().port}/`,
417
1060
  reload: () => {
418
1061
  buildError = null;
1062
+ files.clear();
419
1063
  broadcast("reload");
420
1064
  },
1065
+ hotReload: () => {
1066
+ buildError = null;
1067
+ broadcast("hot-reload");
1068
+ },
421
1069
  error: (message) => {
422
1070
  buildError = message;
423
1071
  broadcast("build-error", message);
424
1072
  },
425
1073
  close: () => new Promise((resolvePromise) => {
426
1074
  for (const client of clients) client.end();
427
- server.close(() => resolvePromise());
1075
+ server.close(() => void files.clear().then(() => resolvePromise()));
428
1076
  })
429
1077
  };
430
1078
  }
1079
+ function relayHttpCall(request, response, call, dotDir, files) {
1080
+ return answer(request, response, async (params, signal) => {
1081
+ const hosts = await networkHosts(dotDir);
1082
+ if (call === "download") return downloadHttpFile(params, hosts, files, signal);
1083
+ if (call === "upload") return uploadHttpFiles(params, hosts, files, signal);
1084
+ return sendHttpRequest(params, hosts, signal);
1085
+ });
1086
+ }
1087
+ async function answer(request, response, run) {
1088
+ const controller = new AbortController();
1089
+ response.on("close", () => controller.abort());
1090
+ let reply;
1091
+ try {
1092
+ const chunks = [];
1093
+ for await (const chunk of request) chunks.push(chunk);
1094
+ reply = { result: await run(JSON.parse(Buffer.concat(chunks).toString("utf8")), controller.signal) ?? null };
1095
+ } catch (error) {
1096
+ if (controller.signal.aborted) return;
1097
+ reply = { error: {
1098
+ code: error instanceof HostError ? error.code : "failed",
1099
+ message: error.message
1100
+ } };
1101
+ }
1102
+ send(response, 200, JSON.stringify(reply), ".json");
1103
+ }
1104
+ async function serveTemporaryFile(response, files, id) {
1105
+ const file = files.file(id);
1106
+ if (!file) {
1107
+ send(response, 404, "Not found");
1108
+ return;
1109
+ }
1110
+ response.writeHead(200, {
1111
+ "Content-Type": file.contentType,
1112
+ "Cache-Control": "no-store"
1113
+ });
1114
+ response.end(await readFile(file.path));
1115
+ }
1116
+ async function networkHosts(dotDir) {
1117
+ return JSON.parse(await readFile(join(dotDir, "manifest.json"), "utf8")).network?.hosts ?? [];
1118
+ }
431
1119
  function serverEvent(event, data) {
432
1120
  return `event: ${event}\n${data.split("\n").map((line) => `data: ${line}`).join("\n")}\n\n`;
433
1121
  }