@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.
- package/dist/client/service-bridge.js +486 -48
- package/dist/client/service-bridge.js.map +1 -1
- package/dist/client/simulator.js +633 -62
- package/dist/client/simulator.js.map +1 -1
- package/dist/client/webview-bridge.js +12 -0
- package/dist/client/webview-bridge.js.map +1 -1
- package/dist/node/index.d.ts +18 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +702 -14
- package/dist/node/index.js.map +1 -1
- package/package.json +3 -3
package/dist/node/index.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|