@monimesl/dot-simulator 0.1.0-alpha.10

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.
@@ -0,0 +1,1477 @@
1
+ import { createRequire } from "node:module";
2
+ import { HostError, NavigationStack, REACT_HOST_PATH, allowsHost, dotFilePathProblem, isReactCompatible, isVersionAtLeast, 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 { createHash, randomBytes, randomUUID } from "node:crypto";
9
+ //#region src/compatibility.ts
10
+ var IncompatibleDotError = class extends Error {
11
+ name = "IncompatibleDotError";
12
+ reason;
13
+ constructor(reason, message) {
14
+ super(message);
15
+ this.reason = reason;
16
+ }
17
+ };
18
+ function checkCompatibility(manifest, { dashVersion, minRuntimeVersion, reactVersions }) {
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
+ const minDashVersion = manifest.dash?.minVersion;
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
+ }
24
+ //#endregion
25
+ //#region src/host-calls.ts
26
+ const UI_METHOD_PREFIX = "dash.ui.";
27
+ var HostCallRunner = class {
28
+ #handlers;
29
+ #permissions;
30
+ #answer;
31
+ #running = /* @__PURE__ */ new Map();
32
+ constructor(handlers, permissions, answer) {
33
+ this.#handlers = handlers;
34
+ this.#permissions = permissions;
35
+ this.#answer = answer;
36
+ }
37
+ run({ id, method, params, timeout }) {
38
+ const entry = this.#handlers[method];
39
+ if (!entry) {
40
+ this.#answer({
41
+ id,
42
+ error: {
43
+ code: "unsupported",
44
+ message: `This host doesn't support "${method}"`
45
+ }
46
+ });
47
+ return;
48
+ }
49
+ const call = {
50
+ method,
51
+ controller: new AbortController()
52
+ };
53
+ if (timeout !== void 0) call.timer = setTimeout(() => {
54
+ this.#fail(id, new HostError("timeout", `"${method}" took longer than ${timeout}ms`));
55
+ }, timeout);
56
+ this.#running.set(id, call);
57
+ this.#invoke(id, entry, {
58
+ method,
59
+ params,
60
+ signal: call.controller.signal
61
+ });
62
+ }
63
+ cancel(id) {
64
+ this.#end(id)?.controller.abort();
65
+ }
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 that showed this dialog is no longer on screen"));
68
+ }
69
+ async #invoke(id, entry, call) {
70
+ try {
71
+ if (typeof entry === "function") {
72
+ this.#succeed(id, await entry(call));
73
+ return;
74
+ }
75
+ await this.#permissions.check(call.method, entry.permission, call.signal);
76
+ this.#succeed(id, await entry.handle(call));
77
+ } catch (error) {
78
+ this.#fail(id, error);
79
+ }
80
+ }
81
+ #succeed(id, value) {
82
+ if (this.#end(id)) this.#answer({
83
+ id,
84
+ value
85
+ });
86
+ }
87
+ #fail(id, error) {
88
+ const call = this.#end(id);
89
+ if (!call) return;
90
+ call.controller.abort();
91
+ this.#answer({
92
+ id,
93
+ error: describeError(error)
94
+ });
95
+ }
96
+ #end(id) {
97
+ const call = this.#running.get(id);
98
+ if (call) {
99
+ this.#running.delete(id);
100
+ clearTimeout(call.timer);
101
+ }
102
+ return call;
103
+ }
104
+ };
105
+ function describeError(error) {
106
+ if (error instanceof HostError) return {
107
+ code: error.code,
108
+ message: error.message
109
+ };
110
+ return {
111
+ code: "failed",
112
+ message: error instanceof Error ? error.message : String(error)
113
+ };
114
+ }
115
+ //#endregion
116
+ //#region src/permissions.ts
117
+ var PermissionGate = class {
118
+ #dotName;
119
+ #declared;
120
+ #ask;
121
+ #answers = /* @__PURE__ */ new Map();
122
+ constructor(dotName, declared, ask) {
123
+ this.#dotName = dotName;
124
+ this.#declared = declared;
125
+ this.#ask = ask;
126
+ }
127
+ async check(method, permission, signal) {
128
+ const reason = this.#declared[permission];
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
+ let allowed = this.#answers.get(permission);
131
+ if (allowed === void 0) {
132
+ allowed = await this.#ask({
133
+ dotName: this.#dotName,
134
+ permission,
135
+ reason
136
+ }, signal);
137
+ this.#answers.set(permission, allowed);
138
+ }
139
+ if (!allowed) throw new HostError("denied", `The person didn't allow the "${permission}" permission`);
140
+ }
141
+ };
142
+ //#endregion
143
+ //#region src/host.ts
144
+ function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, systemContext, handlers = {}, askPermission, observe, restorePages = [], onBarsChange }) {
145
+ checkCompatibility(manifest, {
146
+ dashVersion,
147
+ minRuntimeVersion
148
+ });
149
+ const stack = new NavigationStack();
150
+ let context = systemContext;
151
+ const calls = new HostCallRunner(handlers, new PermissionGate(manifest.name, manifest.permissions ?? {}, askPermission), (result) => {
152
+ const message = {
153
+ type: "hostResult",
154
+ payload: result
155
+ };
156
+ observe?.("fromHost", message);
157
+ logic.post(message);
158
+ });
159
+ const broadcast = (message) => {
160
+ observe?.("fromHost", message);
161
+ ui.post(message);
162
+ logic.post(message);
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
+ };
176
+ const syncStack = () => {
177
+ const message = {
178
+ type: "syncStack",
179
+ payload: {
180
+ pages: stack.pages,
181
+ limit: stack.limit
182
+ }
183
+ };
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();
188
+ };
189
+ const syncSystemContext = () => {
190
+ broadcast({
191
+ type: "syncSystemContext",
192
+ payload: context
193
+ });
194
+ };
195
+ const navigate = (navigation) => {
196
+ if (navigation.action !== "back" && !manifest.pages.includes(navigation.page)) return false;
197
+ const previousTop = stack.top;
198
+ if (!stack.apply(navigation)) return false;
199
+ syncStack();
200
+ if (stack.top !== previousTop) calls.dismissUI();
201
+ return true;
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
+ };
225
+ const navigateFromPage = ({ from, ...navigation }) => {
226
+ if (from === stack.top?.instanceId) navigate(navigation);
227
+ };
228
+ const hostMessageHandlers = /* @__PURE__ */ new Map([
229
+ ["navigate", (payload) => navigateFromPage(payload)],
230
+ ["hostCall", (payload) => calls.run(payload)],
231
+ ["hostCancel", (payload) => calls.cancel(payload.id)],
232
+ ["pageBars", (payload) => {
233
+ const { instanceId, ...bars } = payload;
234
+ barsByInstance.set(instanceId, bars);
235
+ showBars();
236
+ }]
237
+ ]);
238
+ const stopService = logic.listen((raw) => {
239
+ const message = raw;
240
+ const handleHostMessage = hostMessageHandlers.get(message.type);
241
+ if (handleHostMessage) {
242
+ observe?.("toHost", message);
243
+ handleHostMessage(message.payload);
244
+ return;
245
+ }
246
+ observe?.("toWebview", message);
247
+ ui.post(message);
248
+ });
249
+ const stopWebview = ui.listen((raw) => {
250
+ const message = raw;
251
+ observe?.("toService", message);
252
+ logic.post(message);
253
+ if (message.type === "connect") {
254
+ syncSystemContext();
255
+ openFirstPages();
256
+ }
257
+ });
258
+ return {
259
+ get pages() {
260
+ return stack.pages;
261
+ },
262
+ back: () => navigate({
263
+ action: "back",
264
+ delta: 1
265
+ }),
266
+ setSystemContext: (next) => {
267
+ context = next;
268
+ syncSystemContext();
269
+ },
270
+ stop: () => {
271
+ stopService();
272
+ stopWebview();
273
+ }
274
+ };
275
+ }
276
+ //#endregion
277
+ //#region src/versions.ts
278
+ const SIMULATED_DASH_VERSION = "1.0.0";
279
+ const MIN_RUNTIME_VERSION = "0.1.0-alpha.1";
280
+ const SIMULATOR_VERSIONS = {
281
+ dashVersion: SIMULATED_DASH_VERSION,
282
+ minRuntimeVersion: MIN_RUNTIME_VERSION
283
+ };
284
+ //#endregion
285
+ //#region src/paths.ts
286
+ const CLIENT_PATH = "/__dot/";
287
+ const EVENTS_PATH = `${CLIENT_PATH}events`;
288
+ const HTTP_PATH = `${CLIENT_PATH}network/http/`;
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/`;
293
+ const WEBVIEW_URL = `/dot/ui/index.html`;
294
+ //#endregion
295
+ //#region src/page.ts
296
+ const escapeHtml = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
297
+ function simulatorPage(appName) {
298
+ return `<!doctype html>
299
+ <html lang="en">
300
+ <head>
301
+ <meta charset="UTF-8" />
302
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
303
+ <title>${escapeHtml(appName)} · Dot simulator</title>
304
+ <style>${STYLES}</style>
305
+ </head>
306
+ <body>
307
+ <header>
308
+ <strong>${escapeHtml(appName)}</strong>
309
+ <span class="subtle">Dot simulator</span>
310
+ <select id="scheme" aria-label="Device appearance">
311
+ <option value="light">Light</option>
312
+ <option value="dark">Dark</option>
313
+ </select>
314
+ <button id="back" type="button" title="The system back button">◀ Back</button>
315
+ <button id="reload" type="button">Reload</button>
316
+ </header>
317
+ <main>
318
+ <section class="stage">
319
+ <pre id="error" hidden></pre>
320
+ <div class="phone" data-scheme="light">
321
+ <div class="status-bar">
322
+ <span class="clock"></span>
323
+ <span class="island"></span>
324
+ <span class="indicators">${STATUS_ICONS}</span>
325
+ </div>
326
+ <nav class="navigation-bar">
327
+ <button id="navigation-back" type="button" aria-label="Back" hidden>${BACK_ICON}</button>
328
+ <span id="navigation-title">${escapeHtml(appName)}</span>
329
+ </nav>
330
+ <iframe id="screen" title="${escapeHtml(appName)}"></iframe>
331
+ <nav class="tab-bar" aria-label="Dash's tabs">
332
+ <span>Home</span><span class="current">Dots</span><span>Me</span>
333
+ </nav>
334
+ <span class="home-indicator"></span>
335
+ </div>
336
+ </section>
337
+ <aside>
338
+ <div class="panel-head">
339
+ <span>Messages</span>
340
+ <button id="clear" type="button">Clear</button>
341
+ </div>
342
+ <ol id="log"></ol>
343
+ </aside>
344
+ </main>
345
+ <script src="${CLIENT_PATH}simulator.js"><\/script>
346
+ </body>
347
+ </html>
348
+ `;
349
+ }
350
+ 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>`;
351
+ 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>`;
352
+ const STYLES = `
353
+ :root {
354
+ color-scheme: light dark;
355
+ --bg: #eef0f3; --panel: #ffffff; --border: #d6d9de; --text: #1f2328; --subtle: #6b7280;
356
+ --to-service: #2563eb; --to-webview: #059669; --danger: #dc2626;
357
+ font: 13px/1.4 system-ui, sans-serif;
358
+ }
359
+ @media (prefers-color-scheme: dark) {
360
+ :root {
361
+ --bg: #0d1117; --panel: #161b22; --border: #30363d; --text: #e6edf3; --subtle: #8b949e;
362
+ --to-service: #58a6ff; --to-webview: #3fb950; --danger: #f85149;
363
+ }
364
+ }
365
+ * { box-sizing: border-box; }
366
+ body { margin: 0; height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--text); }
367
+ header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
368
+ #scheme { margin-left: auto; }
369
+ button, select { font: inherit; color: inherit; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
370
+ .subtle { color: var(--subtle); }
371
+ main { flex: 1; display: flex; min-height: 0; }
372
+ .stage { flex: 1; overflow: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; }
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; }
374
+ .phone[data-scheme="dark"] { --screen: #121212; --screen-text: #fff; --dialog: #2c2c2e; --dialog-line: #48484a; }
375
+ .torch { display: none; }
376
+ .phone[data-torch="on"] .torch { display: block; }
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); }
378
+ .dialog { width: 270px; border-radius: 14px; overflow: hidden; background: var(--dialog); color: var(--screen-text); text-align: center; font: 13px/1.35 system-ui, sans-serif; }
379
+ .dialog-body { display: flex; flex-direction: column; gap: 4px; padding: 18px 16px 16px; }
380
+ .dialog-title { font-size: 17px; font-weight: 600; }
381
+ .dialog-actions { display: flex; border-top: 1px solid var(--dialog-line); }
382
+ .dialog-actions.stacked { flex-direction: column; }
383
+ .dialog-actions button { flex: 1; margin: 0; padding: 11px; border: 0; border-radius: 0; background: transparent; color: #0a84ff; font-size: 17px; }
384
+ .dialog-actions button + button { border-left: 1px solid var(--dialog-line); }
385
+ .dialog-actions.stacked button + button { border-left: 0; border-top: 1px solid var(--dialog-line); }
386
+ .dialog-actions .cancel { font-weight: 600; }
387
+ .dialog-actions .destructive { color: #ff3b30; }
388
+ /* Stand-ins for Dash's own bars, which the dot's pages may set. */
389
+ .phone { --bar: var(--screen); --bar-text: var(--screen-text); }
390
+ .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; }
391
+ #navigation-title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
392
+ #navigation-back { position: absolute; left: 8px; top: 0; bottom: 0; padding: 0 10px; border: 0; background: none; color: inherit; }
393
+ .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; }
394
+ .tab-bar .current { font-weight: 700; }
395
+ .navigation-bar[hidden], .tab-bar[hidden] { display: none; }
396
+ .phone:has(.navigation-bar[hidden]) .status-bar { background: var(--screen); color: var(--screen-text); }
397
+ .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; }
398
+ .island { position: absolute; top: 11px; left: 50%; width: 125px; height: 37px; margin-left: -62.5px; border-radius: 19px; background: #000; }
399
+ .indicators { display: flex; align-items: center; gap: 6px; }
400
+ #screen { flex: 1; display: block; width: 100%; border: 0; background: var(--screen); }
401
+ .home-indicator { position: absolute; bottom: 8px; left: 50%; width: 134px; height: 5px; margin-left: -67px; border-radius: 3px; background: var(--screen-text); pointer-events: none; }
402
+ #error { max-width: 640px; width: 100%; margin: 0; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: #fff; background: var(--danger); border-radius: 8px; }
403
+ aside { width: 380px; display: flex; flex-direction: column; background: var(--panel); border-left: 1px solid var(--border); }
404
+ .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--border); font-weight: 600; }
405
+ #log { flex: 1; overflow: auto; margin: 0; padding: 0; list-style: none; font: 12px/1.4 ui-monospace, monospace; }
406
+ #log li { border-bottom: 1px solid var(--border); padding: 4px 12px; }
407
+ #log summary { display: flex; gap: 8px; cursor: pointer; }
408
+ #log pre { margin: 4px 0; white-space: pre-wrap; word-break: break-all; color: var(--subtle); }
409
+ #log .time { color: var(--subtle); }
410
+ #log .toService .arrow { color: var(--to-service); }
411
+ #log .toWebview .arrow { color: var(--to-webview); }
412
+ #log .toHost .arrow, #log .fromHost .arrow { color: var(--subtle); }
413
+ #log .type { font-weight: 600; }
414
+ #log .note { padding: 2px 12px; text-align: center; color: var(--subtle); background: var(--bg); }
415
+ #log .failed .type { color: var(--danger); }
416
+ @media (max-width: 900px) { main { flex-direction: column; } aside { width: auto; height: 40vh; border-left: 0; border-top: 1px solid var(--border); } }
417
+ `;
418
+ //#endregion
419
+ //#region src/bytes.ts
420
+ const MEGABYTE = 1048576;
421
+ function megabytes(bytes) {
422
+ return String(Math.ceil(bytes * 10 / MEGABYTE) / 10);
423
+ }
424
+ //#endregion
425
+ //#region src/content-types.ts
426
+ const CONTENT_TYPES = {
427
+ ".png": "image/png",
428
+ ".jpg": "image/jpeg",
429
+ ".jpeg": "image/jpeg",
430
+ ".gif": "image/gif",
431
+ ".webp": "image/webp",
432
+ ".svg": "image/svg+xml",
433
+ ".pdf": "application/pdf",
434
+ ".json": "application/json",
435
+ ".txt": "text/plain",
436
+ ".csv": "text/csv",
437
+ ".html": "text/html",
438
+ ".zip": "application/zip"
439
+ };
440
+ function contentTypeFor(name) {
441
+ return CONTENT_TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
442
+ }
443
+ //#endregion
444
+ //#region src/network.ts
445
+ const MAX_RESPONSE_BYTES = 5 * MEGABYTE;
446
+ const MAX_DOWNLOAD_BYTES = 100 * MEGABYTE;
447
+ const MAX_REDIRECTS = 10;
448
+ const METHODS = [
449
+ "GET",
450
+ "POST",
451
+ "PUT",
452
+ "PATCH",
453
+ "DELETE",
454
+ "HEAD",
455
+ "OPTIONS"
456
+ ];
457
+ const UPLOAD_METHODS = [
458
+ "POST",
459
+ "PUT",
460
+ "PATCH"
461
+ ];
462
+ const BODILESS_METHODS = ["GET", "HEAD"];
463
+ const REDIRECT_STATUSES = [
464
+ 301,
465
+ 302,
466
+ 303,
467
+ 307,
468
+ 308
469
+ ];
470
+ const HOST_HEADERS = [
471
+ "content-type",
472
+ "content-length",
473
+ "transfer-encoding",
474
+ "host",
475
+ "connection",
476
+ "upgrade"
477
+ ];
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
+ async function sendHttpRequest(request, hosts, signal) {
485
+ return exchange(textRequest(request), hosts, signal, readResponse);
486
+ }
487
+ async function downloadHttpFile(request, hosts, files, signal) {
488
+ return exchange(textRequest(request), hosts, signal, async (response) => {
489
+ if (!response.ok) return {
490
+ ...await readResponse(response),
491
+ ok: false
492
+ };
493
+ const file = await save$1(response, request.name, files);
494
+ const { status, statusText, headers } = result(response, "");
495
+ return {
496
+ status,
497
+ statusText,
498
+ headers,
499
+ ok: true,
500
+ file
501
+ };
502
+ });
503
+ }
504
+ async function uploadHttpFiles(request, hosts, files, signal) {
505
+ if (request.files.length === 0) throw invalid("An upload needs at least one file. To send only text, use http.request");
506
+ const parts = request.files.map((upload) => {
507
+ if (!upload.field.trim()) throw invalid("Each file needs a field name");
508
+ const file = files.requireFile(upload.file);
509
+ return {
510
+ field: upload.field,
511
+ file,
512
+ name: upload.name ? safeFileName(upload.name) : file.name
513
+ };
514
+ });
515
+ const makeForm = async () => {
516
+ const form = new FormData();
517
+ for (const [name, value] of Object.entries(request.fields ?? {})) form.append(name, value);
518
+ for (const part of parts) {
519
+ const contents = await readFile(part.file.path);
520
+ form.append(part.field, new File([contents], part.name, { type: part.file.contentType }));
521
+ }
522
+ return form;
523
+ };
524
+ return exchange({
525
+ method: parseMethod(request.method ?? "POST", UPLOAD_METHODS),
526
+ headers: checkedHeaders(request.headers ?? {}),
527
+ url: parseUrl(request.url),
528
+ body: {
529
+ kind: "form",
530
+ form: makeForm
531
+ }
532
+ }, hosts, signal, readResponse);
533
+ }
534
+ async function exchange(request, hosts, signal, read) {
535
+ let outgoing = request;
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
+ const response = await send$1(outgoing, signal);
539
+ const location = response.headers.get("location");
540
+ if (!REDIRECT_STATUSES.includes(response.status) || location === null) return read(response);
541
+ await response.body?.cancel();
542
+ outgoing = redirected(outgoing, location, response.status);
543
+ }
544
+ throw new HostError("failed", `"${request.url.href}" redirected more than ${MAX_REDIRECTS} times`);
545
+ }
546
+ function textRequest(request) {
547
+ const method = parseMethod(request.method ?? "GET", METHODS);
548
+ const { contentType = "text/plain; charset=utf-8", others } = takeContentType(request.headers ?? {});
549
+ if (!CONTENT_TYPE.test(contentType)) throw invalid(`"${contentType}" is not a Content-Type`);
550
+ const headers = checkedHeaders(others);
551
+ let body;
552
+ if (request.body !== void 0) {
553
+ if (BODILESS_METHODS.includes(method)) throw invalid(`A ${method} request can't have a body`);
554
+ body = {
555
+ kind: "text",
556
+ text: request.body,
557
+ contentType
558
+ };
559
+ }
560
+ return {
561
+ url: parseUrl(request.url),
562
+ method,
563
+ headers,
564
+ body
565
+ };
566
+ }
567
+ function checkedHeaders(values) {
568
+ for (const name of Object.keys(values)) if (HOST_HEADERS.includes(name.toLowerCase())) throw invalid(`"${name}" is set by the host. Remove it from headers`);
569
+ return new Headers(values);
570
+ }
571
+ function takeContentType(values) {
572
+ let contentType;
573
+ const others = {};
574
+ for (const [name, value] of Object.entries(values)) if (name.toLowerCase() === "content-type") contentType = value;
575
+ else others[name] = value;
576
+ return {
577
+ contentType,
578
+ others
579
+ };
580
+ }
581
+ async function send$1(request, signal) {
582
+ const headers = new Headers(request.headers);
583
+ let body = null;
584
+ if (request.body?.kind === "text") {
585
+ body = request.body.text;
586
+ headers.set("content-type", request.body.contentType);
587
+ } else if (request.body?.kind === "form") body = await request.body.form();
588
+ try {
589
+ return await fetch(request.url, {
590
+ method: request.method,
591
+ headers,
592
+ body,
593
+ redirect: "manual",
594
+ signal
595
+ });
596
+ } catch (error) {
597
+ if (signal.aborted) throw error;
598
+ const cause = error.cause?.message;
599
+ throw new HostError("failed", `Could not reach ${request.url.hostname}: ${cause ?? error.message}`);
600
+ }
601
+ }
602
+ function redirected(request, location, status) {
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 isn't a web address`);
605
+ const becomesGet = status === 303 && request.method !== "HEAD" || (status === 301 || status === 302) && request.method === "POST";
606
+ const headers = new Headers(request.headers);
607
+ if (url.origin !== request.url.origin) headers.delete("authorization");
608
+ return {
609
+ url,
610
+ method: becomesGet ? "GET" : request.method,
611
+ headers,
612
+ body: becomesGet ? void 0 : request.body
613
+ };
614
+ }
615
+ function result(response, body) {
616
+ return {
617
+ status: response.status,
618
+ statusText: response.statusText || (STATUS_CODES[response.status] ?? ""),
619
+ ok: response.ok,
620
+ headers: Object.fromEntries(response.headers),
621
+ body
622
+ };
623
+ }
624
+ async function readResponse(response) {
625
+ return result(response, await readBody(response));
626
+ }
627
+ async function readBody(response) {
628
+ const chunks = [];
629
+ await readChunks(response, MAX_RESPONSE_BYTES, (chunk) => {
630
+ chunks.push(chunk);
631
+ return Promise.resolve();
632
+ });
633
+ const charset = /charset=([^;\s]+)/i.exec(response.headers.get("content-type") ?? "")?.[1];
634
+ return decoderFor(charset).decode(Buffer.concat(chunks));
635
+ }
636
+ async function save$1(response, requestedName, files) {
637
+ const { id, path } = await files.newPath();
638
+ const handle = await open(path, "w");
639
+ try {
640
+ await readChunks(response, MAX_DOWNLOAD_BYTES, async (chunk) => {
641
+ await handle.write(chunk);
642
+ });
643
+ } catch (error) {
644
+ await handle.close();
645
+ await rm(path, { force: true });
646
+ throw error;
647
+ }
648
+ await handle.close();
649
+ const name = requestedName ? safeFileName(requestedName) : fileName(response);
650
+ return files.add(id, path, name, contentType(response, name));
651
+ }
652
+ async function readChunks(response, limit, take) {
653
+ if (Number(response.headers.get("content-length") ?? 0) > limit) {
654
+ await response.body?.cancel();
655
+ throw tooLarge(limit);
656
+ }
657
+ if (!response.body) return;
658
+ const reader = response.body.getReader();
659
+ let length = 0;
660
+ while (true) {
661
+ const { done, value } = await reader.read();
662
+ if (done) return;
663
+ length += value.length;
664
+ if (length > limit) {
665
+ await reader.cancel();
666
+ throw tooLarge(limit);
667
+ }
668
+ await take(value);
669
+ }
670
+ }
671
+ function fileName(response) {
672
+ const disposition = response.headers.get("content-disposition") ?? "";
673
+ const encoded = /filename\*\s*=\s*[^']*''([^;]+)/i.exec(disposition)?.[1];
674
+ const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition)?.[1];
675
+ const segment = new URL(response.url).pathname.split("/").at(-1);
676
+ return [
677
+ encoded && safeDecode(encoded),
678
+ plain,
679
+ segment && safeDecode(segment)
680
+ ].map((name) => safeFileName(name ?? "")).find(Boolean) ?? "download";
681
+ }
682
+ function safeFileName(name) {
683
+ return (name.split(/[/\\]/).at(-1) ?? "").trim().slice(0, 255);
684
+ }
685
+ function safeDecode(text) {
686
+ try {
687
+ return decodeURIComponent(text);
688
+ } catch {
689
+ return text;
690
+ }
691
+ }
692
+ function contentType(response, name) {
693
+ const declared = response.headers.get("content-type")?.split(";")[0]?.trim();
694
+ if (declared && declared !== "application/octet-stream") return declared;
695
+ return contentTypeFor(name);
696
+ }
697
+ function decoderFor(charset) {
698
+ try {
699
+ return new TextDecoder(charset ?? "utf-8");
700
+ } catch {
701
+ return new TextDecoder("utf-8");
702
+ }
703
+ }
704
+ function parseUrl(text) {
705
+ const url = /^https?:\/\//i.test(text) ? URL.parse(text) : null;
706
+ if (!url?.hostname) throw invalid(`"${text}" is not an http:// or https:// URL`);
707
+ return url;
708
+ }
709
+ function parseMethod(name, methods) {
710
+ const method = name.toUpperCase();
711
+ if (!methods.includes(method)) throw invalid(`"${name}" is not an HTTP method. Use ${methods.join(", ")}`);
712
+ return method;
713
+ }
714
+ function invalid(message) {
715
+ return new HostError("invalid", message);
716
+ }
717
+ function tooLarge(limit) {
718
+ return new HostError("failed", `The response is larger than ${megabytes(limit)} MB`);
719
+ }
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. It uses ${megabytes(this.#used)} of ${megabytes(this.limit)} MB. Remove some entries first`);
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
966
+ //#region src/files.ts
967
+ const DEFAULT_QUOTA = 256 * MEGABYTE;
968
+ const CACHE_FOLDER = ".cache";
969
+ const SECRETS_FOLDER = ".secrets";
970
+ const STAGING_FOLDER = ".staging";
971
+ const MAX_READ_BYTES = 5 * MEGABYTE;
972
+ var DotStorage = class {
973
+ #root;
974
+ #staging;
975
+ quota;
976
+ cache;
977
+ secrets;
978
+ lock = new Serial();
979
+ constructor(root, quota = DEFAULT_QUOTA) {
980
+ this.#root = root;
981
+ this.#staging = join(dirname(root), STAGING_FOLDER, basename(root));
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
+ }
1006
+ }
1007
+ async resolve(path, { allowRoot = false } = {}) {
1008
+ const problem = dotFilePathProblem(path, { allowRoot });
1009
+ if (problem) throw new HostError("invalid", problem);
1010
+ const resolved = path === "" ? this.#root : join(this.#root, path);
1011
+ await this.#checkInside(resolved);
1012
+ return resolved;
1013
+ }
1014
+ async usage() {
1015
+ return sizeOf(this.#root);
1016
+ }
1017
+ async ensureRoot() {
1018
+ await mkdir(this.#root, { recursive: true });
1019
+ }
1020
+ async #checkInside(path) {
1021
+ const realRoot = await realpath(this.#root).catch(() => null);
1022
+ if (realRoot === null) return;
1023
+ let existing = path;
1024
+ while (!await exists(existing)) existing = dirname(existing);
1025
+ const real = await realpath(existing);
1026
+ if (real !== realRoot && !real.startsWith(`${realRoot}/`)) throw new HostError("denied", "That path goes outside the dot's folder");
1027
+ }
1028
+ };
1029
+ async function runFileCall(operation, params, storage, temporaryFiles) {
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}"`);
1050
+ }
1051
+ });
1052
+ }
1053
+ async function read({ path, as }, storage) {
1054
+ const encoding = bufferEncoding(as);
1055
+ const file = await storage.resolve(path);
1056
+ const info = await statOrNull(file);
1057
+ if (!info) throw failed(`There is no file at "${path}"`);
1058
+ if (info.isDirectory()) throw failed(`"${path}" is a folder`);
1059
+ if (info.size > MAX_READ_BYTES) throw failed(`"${path}" is larger than ${megabytes(MAX_READ_BYTES)} MB, too large to read at once`);
1060
+ return (await readFile(file)).toString(encoding);
1061
+ }
1062
+ async function write({ path, data, as, append = false }, storage) {
1063
+ const encoding = bufferEncoding(as);
1064
+ const file = await storage.resolve(path);
1065
+ const existing = await statOrNull(file);
1066
+ if (existing?.isDirectory()) throw failed(`"${path}" is a folder`);
1067
+ if (encoding === "base64" && !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new HostError("invalid", "The data isn't base64");
1068
+ const bytes = Buffer.from(data, encoding);
1069
+ const replacedBytes = append ? 0 : existing?.size ?? 0;
1070
+ await checkQuota(storage, bytes.length - replacedBytes);
1071
+ await createParent(storage, file, path);
1072
+ if (append) await appendFile(file, bytes);
1073
+ else await storage.replace(file, (staged) => writeFile(staged, bytes));
1074
+ return written(path, file);
1075
+ }
1076
+ async function list({ path, recursive = false }, storage) {
1077
+ const folder = await storage.resolve(path, { allowRoot: true });
1078
+ const info = await statOrNull(folder);
1079
+ if (!info) {
1080
+ if (path === "") return [];
1081
+ throw failed(`There is no folder at "${path}"`);
1082
+ }
1083
+ if (!info.isDirectory()) throw failed(`"${path}" is a file`);
1084
+ const entries = [];
1085
+ await collect(path, folder, recursive, entries);
1086
+ return entries.sort((first, second) => first.path < second.path ? -1 : 1);
1087
+ }
1088
+ async function createFolder(path, storage) {
1089
+ const folder = await storage.resolve(path);
1090
+ if ((await statOrNull(folder))?.isFile()) throw failed(`"${path}" is a file`);
1091
+ await createParent(storage, folder, path);
1092
+ await mkdir(folder, { recursive: true });
1093
+ return written(path, folder);
1094
+ }
1095
+ async function transfer({ from, to, overwrite = false }, storage, keepSource) {
1096
+ const source = await storage.resolve(from);
1097
+ const target = await storage.resolve(to);
1098
+ if (!await exists(source)) throw failed(`There is nothing at "${from}"`);
1099
+ if (to === from || to.startsWith(`${from}/`)) throw new HostError("invalid", `Can't put "${from}" inside itself, at "${to}"`);
1100
+ await makeRoom(storage, target, to, overwrite, keepSource ? await sizeOf(source) : 0);
1101
+ if (keepSource) await storage.replace(target, (staged) => cp(source, staged, { recursive: true }));
1102
+ else await moveInto(source, target);
1103
+ return written(to, target);
1104
+ }
1105
+ async function remove({ path, recursive = false }, storage) {
1106
+ const target = await storage.resolve(path);
1107
+ const info = await statOrNull(target);
1108
+ if (!info) return;
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 }`);
1110
+ await rm(target, {
1111
+ recursive: true,
1112
+ force: true
1113
+ });
1114
+ }
1115
+ async function save({ file: id, path, overwrite = false }, storage, temporaryFiles) {
1116
+ const file = temporaryFiles.requireFile(id);
1117
+ const target = await storage.resolve(path);
1118
+ await makeRoom(storage, target, path, overwrite, file.size);
1119
+ await storage.replace(target, (staged) => cp(file.path, staged));
1120
+ return written(path, target);
1121
+ }
1122
+ async function open$1(path, storage, temporaryFiles) {
1123
+ const file = await storage.resolve(path);
1124
+ if (!(await statOrNull(file))?.isFile()) throw failed(`There is no file at "${path}"`);
1125
+ const name = basename(path);
1126
+ return temporaryFiles.reference(file, name, contentTypeFor(name));
1127
+ }
1128
+ async function collect(folderPath, folder, recursive, entries) {
1129
+ for (const name of await readdir(folder)) {
1130
+ const path = folderPath === "" ? name : `${folderPath}/${name}`;
1131
+ const child = join(folder, name);
1132
+ const found = await entry(path, child);
1133
+ if (!found) continue;
1134
+ entries.push(found);
1135
+ if (recursive && found.kind === "folder") await collect(path, child, true, entries);
1136
+ }
1137
+ }
1138
+ async function entry(path, file) {
1139
+ const info = await statOrNull(file);
1140
+ if (!info) return null;
1141
+ const name = path === "" ? "" : basename(path);
1142
+ const modifiedAt = Math.round(info.mtimeMs);
1143
+ if (info.isDirectory()) return {
1144
+ name,
1145
+ path,
1146
+ kind: "folder",
1147
+ size: 0,
1148
+ modifiedAt
1149
+ };
1150
+ return {
1151
+ name,
1152
+ path,
1153
+ kind: "file",
1154
+ size: info.size,
1155
+ contentType: contentTypeFor(name),
1156
+ modifiedAt
1157
+ };
1158
+ }
1159
+ async function written(path, file) {
1160
+ const found = await entry(path, file);
1161
+ if (!found) throw failed(`"${path}" was removed while it was being written`);
1162
+ return found;
1163
+ }
1164
+ async function makeRoom(storage, target, path, overwrite, addedBytes) {
1165
+ if (await exists(target) && !overwrite) throw failed(`"${path}" already exists. To replace it, pass { overwrite: true }`);
1166
+ await checkQuota(storage, addedBytes - await sizeOf(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, {
1172
+ recursive: true,
1173
+ force: true
1174
+ });
1175
+ await rename(from, target);
1176
+ }
1177
+ async function createParent(storage, file, path) {
1178
+ await storage.ensureRoot();
1179
+ const parent = dirname(file);
1180
+ if ((await statOrNull(parent))?.isFile()) throw failed(`Part of "${path}" is a file, not a folder`);
1181
+ await mkdir(parent, { recursive: true });
1182
+ }
1183
+ async function checkQuota(storage, addedBytes) {
1184
+ if (addedBytes <= 0) return;
1185
+ const used = await storage.usage();
1186
+ if (used + addedBytes > storage.quota) throw failed(`The dot's folder is full. It uses ${megabytes(used)} of ${megabytes(storage.quota)} MB, and this needs ${megabytes(addedBytes)} MB more. Remove some files first`);
1187
+ }
1188
+ async function sizeOf(path) {
1189
+ const info = await lstat(path).catch(() => null);
1190
+ if (!info) return 0;
1191
+ if (!info.isDirectory()) return info.size;
1192
+ let total = 0;
1193
+ for (const name of await readdir(path)) total += await sizeOf(join(path, name));
1194
+ return total;
1195
+ }
1196
+ async function exists(path) {
1197
+ return await lstat(path).catch(() => null) !== null;
1198
+ }
1199
+ async function statOrNull(path) {
1200
+ return stat(path).catch(() => null);
1201
+ }
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"`);
1206
+ }
1207
+ function failed(message) {
1208
+ return new HostError("failed", message);
1209
+ }
1210
+ //#endregion
1211
+ //#region src/temporary-files.ts
1212
+ var TemporaryFiles = class {
1213
+ #directory;
1214
+ #files = /* @__PURE__ */ new Map();
1215
+ constructor(directory) {
1216
+ this.#directory = directory;
1217
+ }
1218
+ file(id) {
1219
+ return this.#files.get(id);
1220
+ }
1221
+ requireFile(id) {
1222
+ const file = this.#files.get(id);
1223
+ if (!file) throw new HostError("invalid", `"${id}" isn't one of this dot's files`);
1224
+ return file;
1225
+ }
1226
+ async clear() {
1227
+ this.#files.clear();
1228
+ await rm(this.#directory, {
1229
+ recursive: true,
1230
+ force: true
1231
+ });
1232
+ }
1233
+ async newPath() {
1234
+ await mkdir(this.#directory, { recursive: true });
1235
+ const id = newId();
1236
+ return {
1237
+ id,
1238
+ path: join(this.#directory, id)
1239
+ };
1240
+ }
1241
+ async reference(path, name, contentType) {
1242
+ return this.add(newId(), path, name, contentType);
1243
+ }
1244
+ async add(id, path, name, contentType) {
1245
+ const { size } = await stat(path);
1246
+ const url = `${TEMPORARY_FILES_PATH}${id}`;
1247
+ this.#files.set(id, {
1248
+ id,
1249
+ name,
1250
+ size,
1251
+ contentType,
1252
+ url,
1253
+ path
1254
+ });
1255
+ return {
1256
+ id,
1257
+ name,
1258
+ size,
1259
+ contentType,
1260
+ url
1261
+ };
1262
+ }
1263
+ };
1264
+ function newId() {
1265
+ return `f_${randomBytes(12).toString("base64url")}`;
1266
+ }
1267
+ //#endregion
1268
+ //#region src/server.ts
1269
+ const DEFAULT_PORT = 3680;
1270
+ const PORT_ATTEMPTS = 5;
1271
+ const MIME_TYPES = {
1272
+ ".html": "text/html; charset=utf-8",
1273
+ ".js": "text/javascript; charset=utf-8",
1274
+ ".map": "application/json; charset=utf-8",
1275
+ ".json": "application/json; charset=utf-8",
1276
+ ".css": "text/css; charset=utf-8",
1277
+ ".svg": "image/svg+xml",
1278
+ ".png": "image/png",
1279
+ ".jpg": "image/jpeg",
1280
+ ".jpeg": "image/jpeg",
1281
+ ".gif": "image/gif",
1282
+ ".webp": "image/webp",
1283
+ ".woff2": "font/woff2"
1284
+ };
1285
+ async function startSimulator({ dotDir, reactHostDir, name = "Dot", port = DEFAULT_PORT, host = "localhost", clientDir = defaultClientDir(), storageDir }) {
1286
+ const clients = /* @__PURE__ */ new Set();
1287
+ const files = new TemporaryFiles(await mkdtemp(join(tmpdir(), "dot-simulator-files-")));
1288
+ const storage = new DotStorage(storageDir ?? await mkdtemp(join(tmpdir(), "dot-simulator-storage-")));
1289
+ await storage.tidy();
1290
+ let buildError = null;
1291
+ const broadcast = (event, data = "") => {
1292
+ for (const client of clients) client.write(serverEvent(event, data));
1293
+ };
1294
+ const server = createServer((request, response) => {
1295
+ const { pathname } = new URL(request.url ?? "/", "http://localhost");
1296
+ if (pathname === "/") {
1297
+ send(response, 200, simulatorPage(name), ".html");
1298
+ return;
1299
+ }
1300
+ if (pathname === "/favicon.ico") {
1301
+ send(response, 204, "");
1302
+ return;
1303
+ }
1304
+ if (pathname === EVENTS_PATH) {
1305
+ response.writeHead(200, {
1306
+ "Content-Type": "text/event-stream",
1307
+ "Cache-Control": "no-store",
1308
+ Connection: "keep-alive"
1309
+ });
1310
+ response.write(": connected\n\n");
1311
+ if (buildError !== null) response.write(serverEvent("build-error", buildError));
1312
+ clients.add(response);
1313
+ request.on("close", () => clients.delete(response));
1314
+ return;
1315
+ }
1316
+ if (pathname.startsWith(HTTP_PATH) && request.method === "POST") {
1317
+ const call = pathname.slice(HTTP_PATH.length);
1318
+ answer(request, response, async (params, signal) => runHttpCall(call, params, await networkHosts(dotDir), files, signal));
1319
+ return;
1320
+ }
1321
+ if (pathname.startsWith(FILE_PATH) && request.method === "POST") {
1322
+ const operation = pathname.slice(FILE_PATH.length);
1323
+ answer(request, response, (params) => runFileCall(operation, params, storage, files));
1324
+ return;
1325
+ }
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));
1338
+ return;
1339
+ }
1340
+ if (pathname.startsWith(REACT_HOST_PATH) && reactHostDir !== void 0) {
1341
+ serveReact(response, reactHostDir, pathname.slice(REACT_HOST_PATH.length));
1342
+ return;
1343
+ }
1344
+ if (pathname.startsWith("/__dot/")) {
1345
+ serveFile(response, clientDir, pathname.slice(7));
1346
+ return;
1347
+ }
1348
+ if (pathname.startsWith("/dot/")) {
1349
+ const injectBridge = pathname === WEBVIEW_URL || `${pathname}index.html` === WEBVIEW_URL;
1350
+ serveFile(response, dotDir, pathname.slice(5), injectBridge);
1351
+ return;
1352
+ }
1353
+ notFound(response);
1354
+ });
1355
+ await listen(server, port, host);
1356
+ return {
1357
+ url: `http://${host}:${server.address().port}/`,
1358
+ reload: () => {
1359
+ buildError = null;
1360
+ files.clear();
1361
+ broadcast("reload");
1362
+ },
1363
+ hotReload: () => {
1364
+ buildError = null;
1365
+ broadcast("hot-reload");
1366
+ },
1367
+ error: (message) => {
1368
+ buildError = message;
1369
+ broadcast("build-error", message);
1370
+ },
1371
+ close: async () => {
1372
+ for (const client of clients) client.end();
1373
+ await new Promise((resolvePromise) => {
1374
+ server.close(() => resolvePromise());
1375
+ });
1376
+ await files.clear();
1377
+ }
1378
+ };
1379
+ }
1380
+ async function answer(request, response, run) {
1381
+ const controller = new AbortController();
1382
+ response.on("close", () => controller.abort());
1383
+ let reply;
1384
+ try {
1385
+ const chunks = [];
1386
+ for await (const chunk of request) chunks.push(chunk);
1387
+ reply = { result: await run(JSON.parse(Buffer.concat(chunks).toString("utf8")), controller.signal) ?? null };
1388
+ } catch (error) {
1389
+ if (controller.signal.aborted) return;
1390
+ reply = { error: {
1391
+ code: error instanceof HostError ? error.code : "failed",
1392
+ message: error.message
1393
+ } };
1394
+ }
1395
+ send(response, 200, JSON.stringify(reply), ".json");
1396
+ }
1397
+ async function serveTemporaryFile(response, files, id) {
1398
+ const file = files.file(id);
1399
+ if (!file) {
1400
+ notFound(response);
1401
+ return;
1402
+ }
1403
+ response.writeHead(200, {
1404
+ "Content-Type": file.contentType,
1405
+ "Cache-Control": "no-store"
1406
+ });
1407
+ response.end(await readFile(file.path));
1408
+ }
1409
+ async function networkHosts(dotDir) {
1410
+ return JSON.parse(await readFile(join(dotDir, "manifest.json"), "utf8")).network?.hosts ?? [];
1411
+ }
1412
+ function serverEvent(event, data) {
1413
+ return `event: ${event}\n${data.split("\n").map((line) => `data: ${line}`).join("\n")}\n\n`;
1414
+ }
1415
+ async function listen(server, port, host) {
1416
+ for (let attempt = 0; attempt < PORT_ATTEMPTS; attempt++) if (!await new Promise((resolvePromise, reject) => {
1417
+ const onError = (error) => {
1418
+ if (error.code === "EADDRINUSE") resolvePromise(true);
1419
+ else reject(error);
1420
+ };
1421
+ server.once("error", onError);
1422
+ server.listen(port + attempt, host, () => {
1423
+ server.off("error", onError);
1424
+ resolvePromise(false);
1425
+ });
1426
+ })) return;
1427
+ const last = port + PORT_ATTEMPTS - 1;
1428
+ throw new Error(`Ports ${port} to ${last} are all in use. Pass another with --port.`);
1429
+ }
1430
+ async function serveReact(response, reactHostDir, path) {
1431
+ const [major = "", ...file] = path.split("/");
1432
+ const versions = await readdir(reactHostDir).catch(() => []);
1433
+ const version = newestReactOfMajor(versions, Number(major));
1434
+ if (version === void 0) {
1435
+ notFound(response);
1436
+ return;
1437
+ }
1438
+ await serveFile(response, join(reactHostDir, version), file.join("/"));
1439
+ }
1440
+ async function serveFile(response, root, path, injectBridge = false) {
1441
+ const base = resolve(root);
1442
+ let file = resolve(base, decodeURIComponent(path));
1443
+ if (file !== base && !file.startsWith(base + sep)) {
1444
+ notFound(response);
1445
+ return;
1446
+ }
1447
+ try {
1448
+ if ((await stat(file)).isDirectory()) file = join(file, "index.html");
1449
+ const body = await readFile(file);
1450
+ if (!injectBridge) {
1451
+ send(response, 200, body, extname(file));
1452
+ return;
1453
+ }
1454
+ const bridge = `<script src="${CLIENT_PATH}webview-bridge.js"><\/script>`;
1455
+ send(response, 200, body.toString("utf8").replace("<head>", `<head>\n ${bridge}`), ".html");
1456
+ } catch {
1457
+ notFound(response);
1458
+ }
1459
+ }
1460
+ function send(response, status, body, extension = ".txt") {
1461
+ response.writeHead(status, {
1462
+ "Content-Type": MIME_TYPES[extension] ?? "text/plain; charset=utf-8",
1463
+ "Cache-Control": "no-store"
1464
+ });
1465
+ response.end(body);
1466
+ }
1467
+ function notFound(response) {
1468
+ send(response, 404, "Not found");
1469
+ }
1470
+ function defaultClientDir() {
1471
+ const manifest = createRequire(import.meta.url).resolve("@monimesl/dot-simulator/package.json");
1472
+ return join(dirname(manifest), "dist/client");
1473
+ }
1474
+ //#endregion
1475
+ export { DEFAULT_PORT, IncompatibleDotError, MIN_RUNTIME_VERSION, SIMULATED_DASH_VERSION, SIMULATOR_VERSIONS, checkCompatibility, startHost, startSimulator };
1476
+
1477
+ //# sourceMappingURL=index.js.map