@daugasauron/dolly 0.0.0-stage → 0.1.4-rc.0

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -2
  3. package/dist/dolly-build-id.mjs +1 -0
  4. package/dist/dolly-dso-0.wasm +0 -0
  5. package/dist/dolly-image-build-id.mjs +1 -0
  6. package/dist/dolly-kernel-plugin-abi.mjs +2 -0
  7. package/dist/dolly-process-0.wasm +0 -0
  8. package/dist/dolly-process-abi.mjs +1 -0
  9. package/dist/dolly-process-dso.mjs +1575 -0
  10. package/dist/dolly-process-gate-0.wasm +0 -0
  11. package/dist/dolly-process-stack.mjs +50 -0
  12. package/dist/dolly-process-worker.mjs +275 -0
  13. package/dist/dolly-threads-0.wasm +0 -0
  14. package/dist/dolly.wasm +0 -0
  15. package/host/abi.mjs +5 -0
  16. package/host/dso/abi.mjs +13 -0
  17. package/host/dso/dso.mjs +26 -0
  18. package/host/dso/module.json +15 -0
  19. package/host/http/abi.mjs +28 -0
  20. package/host/http/broker.mjs +325 -0
  21. package/host/http/http.mjs +74 -0
  22. package/host/http/local-services.mjs +37 -0
  23. package/host/http/module.json +15 -0
  24. package/host/http/policy.mjs +293 -0
  25. package/host/modules.mjs +373 -0
  26. package/host/nena/abi.mjs +19 -0
  27. package/host/nena/module.json +15 -0
  28. package/host/nena/nena.mjs +131 -0
  29. package/host/packages/module.json +13 -0
  30. package/host/packages/packages.mjs +15 -0
  31. package/host/packages/service.mjs +59 -0
  32. package/host/requirements.mjs +62 -0
  33. package/host/runtime/abi.mjs +8 -0
  34. package/host/runtime/boot-files.mjs +45 -0
  35. package/host/runtime/grower.mjs +14 -0
  36. package/host/runtime/lease-bridge.mjs +65 -0
  37. package/host/runtime/module.json +16 -0
  38. package/host/runtime/runtime.mjs +209 -0
  39. package/host/snapshot/abi.mjs +16 -0
  40. package/host/snapshot/module.json +15 -0
  41. package/host/snapshot/snapshot.mjs +138 -0
  42. package/host/snapshot/transport.mjs +132 -0
  43. package/host/snapshot/ui.mjs +207 -0
  44. package/host/stack/abi.mjs +5 -0
  45. package/host/stack/module.json +17 -0
  46. package/host/stack/stack.mjs +19 -0
  47. package/host/threads/abi.mjs +8 -0
  48. package/host/threads/module.json +13 -0
  49. package/host/threads/threads.mjs +36 -0
  50. package/package.json +12 -4
  51. package/src/alphabetical.mjs +4 -0
  52. package/src/browser.mjs +237 -0
  53. package/src/build-log.mjs +30 -0
  54. package/src/image-entry.mjs +29 -0
  55. package/src/image-inputs.mjs +20 -0
  56. package/src/image-names.mjs +11 -0
  57. package/src/images.mjs +10 -0
  58. package/src/kernel-plugin.mjs +77 -0
  59. package/src/packaged-snapshot.mjs +175 -0
  60. package/src/page-chords.mjs +43 -0
  61. package/src/page-indicators.mjs +27 -0
  62. package/src/process-abi.mjs +69 -0
  63. package/src/process-constants.mjs +242 -0
  64. package/src/process-supervisor.mjs +1002 -0
  65. package/src/runtime-worker.mjs +55 -0
  66. package/src/session-store.mjs +198 -0
  67. package/src/snapshot-records.mjs +109 -0
  68. package/src/static-asset.mjs +97 -0
  69. package/src/terminal-text.mjs +72 -0
  70. package/src/version.mjs +3 -0
  71. package/src/wasm-interface.mjs +440 -0
  72. package/src/worker-image.mjs +137 -0
@@ -0,0 +1,207 @@
1
+ import { DOLLY_BUILD_ID } from "../../dist/dolly-build-id.mjs";
2
+ import { DOLLY_IMAGES } from "../../src/images.mjs";
3
+ import {
4
+ DOLLY_SESSION_FORMAT_VERSION,
5
+ customSessionIdentity,
6
+ encodeSessionStream,
7
+ loadStoredSession,
8
+ saveStoredSession,
9
+ sessionImageIdentity,
10
+ sessionLoadUrl,
11
+ validSessionName,
12
+ } from "../../src/session-store.mjs";
13
+
14
+ const markup = `<style>
15
+ #session-open { position: fixed; right: 0.5rem; bottom: 0.35rem; z-index: 6;
16
+ padding: 0.15rem 0.45rem; color: inherit; background: #262626;
17
+ border: 1px solid #77736c; font: inherit; font-size: 13px; cursor: pointer; }
18
+ #session-open[data-failed] { border-color: #e98773; }
19
+ #session-dialog { width: min(420px, calc(100vw - 2rem)); padding: 1.2rem;
20
+ background: #262626; color: inherit; border: 1px solid #77736c; font: inherit; }
21
+ #session-dialog::backdrop { background: #0008; }
22
+ #session-dialog h2 { margin: 0 0 1rem; font-size: 20px; }
23
+ #session-dialog p { font-size: 14px; line-height: 1.5; }
24
+ #session-dialog label { display: block; margin: 1rem 0 0.4rem; }
25
+ #session-dialog input { width: 100%; padding: 0.4rem; font: inherit;
26
+ color: inherit; background: #191919; border: 1px solid #77736c; }
27
+ #session-dialog button { padding: 0.35rem 0.65rem; font: inherit;
28
+ color: inherit; background: #333; border: 1px solid #77736c; cursor: pointer; }
29
+ #session-dialog a { color: #f2d45c; }
30
+ #session-dialog nav { display: flex; gap: 0.6rem; align-items: center; margin-top: 1rem; }
31
+ #session-dialog nav a { margin-right: auto; font-size: 14px; }
32
+ </style>
33
+ <button id="session-open" class="page-indicator" type="button" aria-label="Save session" title="Save session (Ctrl+Shift+S)" hidden>Save</button>
34
+ <dialog id="session-dialog" aria-labelledby="session-title">
35
+ <form id="session-form">
36
+ <h2 id="session-title">Save session</h2>
37
+ <p id="session-detail" role="status"></p>
38
+ <p>Keep the program’s latest checkpoint, files and agent history in this browser.
39
+ Save again to keep later changes.</p>
40
+ <label for="session-name">Session name</label>
41
+ <input id="session-name" name="name" required minlength="1" maxlength="64"
42
+ pattern="[A-Za-z0-9._\\-]+" autocomplete="off" spellcheck="false">
43
+ <nav>
44
+ <a id="session-list" target="_blank" rel="noopener">Saved sessions ↗</a>
45
+ <button id="session-close" type="button">Close</button>
46
+ <button id="session-save" type="submit">Save</button>
47
+ </nav>
48
+ </form>
49
+ </dialog>`;
50
+
51
+ // The Save button, its dialog and Ctrl+Shift+S. A save captures the session
52
+ // delta through the snapshot mailbox and keeps it in this browser. The page
53
+ // describes the running image once its ENTRY starts; restored names the
54
+ // session this page booted from, if any. On a page with a site, a save names
55
+ // itself in the tab's URL and the dialog links the site's list of saves.
56
+ export function mountSessionSave({ keyboard, showStatus, site, indicators }, transport, restored) {
57
+ document.body.insertAdjacentHTML("beforeend", markup);
58
+ const button = document.querySelector("#session-open");
59
+ const dialog = document.querySelector("#session-dialog");
60
+ const nameField = document.querySelector("#session-name");
61
+ const detail = document.querySelector("#session-detail");
62
+ const saveButton = document.querySelector("#session-save");
63
+ const dataset = document.documentElement.dataset;
64
+ let base, sessionName = null, lastSave = null, saveError = "";
65
+ let saving = null, controller = null;
66
+
67
+ function update(message) {
68
+ const failed = dataset.sessionStatus === "failed";
69
+ button.toggleAttribute("data-failed", failed);
70
+ button.textContent = failed ? "Save failed" : lastSave
71
+ ? `Save · ${lastSave.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"})}`
72
+ : sessionName ? "Save · restored" : "Save · not saved";
73
+ button.title = message ?? (lastSave
74
+ ? `Last saved ${lastSave.toLocaleTimeString()} · Ctrl+Shift+S saves again`
75
+ : sessionName ? `Restored ${sessionName} · Ctrl+Shift+S saves changes`
76
+ : "Not saved yet · Ctrl+Shift+S saves this session");
77
+ detail.textContent = message ?? (lastSave
78
+ ? `${sessionName} · Last saved ${lastSave.toLocaleTimeString()}.`
79
+ : sessionName ? `Restored ${sessionName}. Later changes need another save.`
80
+ : "Not saved yet. Refreshing or closing this tab loses its changes.");
81
+ }
82
+
83
+ function open() {
84
+ if (document.pointerLockElement) document.exitPointerLock();
85
+ nameField.value = sessionName ?? base?.image ?? "session";
86
+ update(dataset.sessionStatus === "failed" ? `Save failed: ${saveError}` : undefined);
87
+ dialog.showModal();
88
+ nameField.focus();
89
+ nameField.select();
90
+ }
91
+
92
+ button.addEventListener("click", open);
93
+ document.querySelector("#session-close").addEventListener("click", () => dialog.close());
94
+ dialog.addEventListener("close", () => keyboard.focus({ preventScroll: true }));
95
+ const list = document.querySelector("#session-list");
96
+ if (site) list.href = new URL("sessions/", site).href; else list.remove();
97
+ document.querySelector("#session-form").addEventListener("submit", event => {
98
+ event.preventDefault();
99
+ void save(nameField.value.trim()).catch(() => {});
100
+ });
101
+
102
+ async function save(requestedName) {
103
+ if (saving) return saving;
104
+ saving = (async () => {
105
+ if (!base) throw new Error("Dolly is not ready to save a session");
106
+ await base.verifyBase?.();
107
+ let name = requestedName ?? sessionName;
108
+ if (name === null) {
109
+ name = window.prompt("Save Dolly session as:", "");
110
+ if (name === null) return null;
111
+ name = name.trim();
112
+ }
113
+ if (!validSessionName(name)) {
114
+ throw new Error("Session names use 1-64 letters, numbers, '.', '_' or '-'");
115
+ }
116
+ if (name !== sessionName && await loadStoredSession(name) !== null &&
117
+ !window.confirm(`Replace the saved session '${name}'?`)) return null;
118
+ showStatus(`Saving ${name}…`, true);
119
+ dataset.sessionStatus = "capturing";
120
+ indicators.hold("session", true);
121
+ update(`Saving ${name}…`);
122
+ button.disabled = nameField.disabled = saveButton.disabled = true;
123
+ controller = new AbortController();
124
+ const capture = async onChunk => {
125
+ const result = await transport().capture(name, { signal: controller.signal, onChunk });
126
+ dataset.sessionUncompressedBytes = String(onChunk ? result : result.byteLength);
127
+ dataset.sessionStatus = "compressing";
128
+ return result;
129
+ };
130
+ const encoded = await encodeSessionStream(capture);
131
+ dataset.sessionStatus = "storing";
132
+ const encodedSize = encoded.bytes.byteLength;
133
+ try {
134
+ await saveStoredSession({
135
+ name,
136
+ formatVersion: DOLLY_SESSION_FORMAT_VERSION,
137
+ buildId: DOLLY_BUILD_ID,
138
+ image: base.image,
139
+ imageIdentity: base.identity,
140
+ ...(base.custom ? { customImage: base.custom } : {}),
141
+ updatedAt: Date.now(),
142
+ encoding: encoded.encoding,
143
+ bytes: encoded.bytes,
144
+ });
145
+ } finally { encoded.bytes.transfer(0); }
146
+ sessionName = name;
147
+ lastSave = new Date();
148
+ dataset.session = name;
149
+ dataset.sessionBytes = String(encodedSize);
150
+ dataset.sessionStatus = "saved";
151
+ if (site) history.replaceState(null, "", sessionLoadUrl(name, site));
152
+ showStatus(`Saved ${name} locally${site ? " · /sessions lists your saves" : ""}`);
153
+ indicators.hold("session", false);
154
+ update();
155
+ return name;
156
+ })().catch((error) => {
157
+ dataset.sessionStatus = "failed";
158
+ saveError = error instanceof Error ? error.message : String(error);
159
+ showStatus(`Save failed: ${saveError}`, true);
160
+ indicators.hold("session", true);
161
+ update(`Save failed: ${saveError}`);
162
+ throw error;
163
+ }).finally(() => {
164
+ saving = null;
165
+ controller = null;
166
+ button.disabled = nameField.disabled = saveButton.disabled = false;
167
+ });
168
+ return saving;
169
+ }
170
+
171
+ return {
172
+ get name() { return sessionName; },
173
+ save,
174
+ // The dialog and the Save button keep their keys; Ctrl+Shift+S saves, the
175
+ // first time through the dialog.
176
+ claimsKey(event) {
177
+ if (dialog.open || button.contains(event.target)) return true;
178
+ if (!base || !event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey || event.code !== "KeyS") return false;
179
+ event.preventDefault();
180
+ event.stopImmediatePropagation();
181
+ if (event.type === "keydown" && !event.repeat) {
182
+ if (sessionName === null) open();
183
+ else void save().catch(() => {});
184
+ }
185
+ return true;
186
+ },
187
+ // custom is the running custom image record (Dollyfile, artifact and
188
+ // what the tab inherited); verifyBase, given, checks before a save that
189
+ // the base is still the one the session restores onto (the site's routes,
190
+ // src/site.mjs). The identity names that base.
191
+ entryStarted({ image, custom, verifyBase }) {
192
+ base = { image, custom, verifyBase,
193
+ identity: custom ? customSessionIdentity(custom) : sessionImageIdentity(DOLLY_IMAGES, image) };
194
+ if (restored?.recovering) {
195
+ dataset.sessionStatus = "recovered";
196
+ showStatus(`Recovered files in /workspace/recovered-${restored.name}. Ctrl+Shift+S saves this as a new session.`, true);
197
+ } else if (restored) {
198
+ sessionName = restored.name;
199
+ dataset.session = restored.name;
200
+ dataset.sessionStatus = "restored";
201
+ }
202
+ button.hidden = false;
203
+ update();
204
+ },
205
+ abort() { controller?.abort(new Error("The runtime stopped; the previous save is unchanged")); },
206
+ };
207
+ }
@@ -0,0 +1,5 @@
1
+ // Generated from host/stack/dolly-stack-0.wat, include/dolly/process.h.
2
+ export const DOLLY_STACK_CONTINUE = 116;
3
+ export const DOLLY_STACK_CHECK_SLOTS = 16384;
4
+ export const DOLLY_ERRNO = Object.freeze({ E2BIG: 1, EACCES: 2, EADDRINUSE: 3, EADDRNOTAVAIL: 4, EAFNOSUPPORT: 5, EAGAIN: 6, EALREADY: 7, EBADF: 8, EBADMSG: 9, EBUSY: 10, ECANCELED: 11, ECHILD: 12, ECONNABORTED: 13, ECONNREFUSED: 14, ECONNRESET: 15, EDEADLK: 16, EDESTADDRREQ: 17, EDOM: 18, EDQUOT: 19, EEXIST: 20, EFAULT: 21, EFBIG: 22, EHOSTUNREACH: 23, EIDRM: 24, EILSEQ: 25, EINPROGRESS: 26, EINTR: 27, EINVAL: 28, EIO: 29, EISCONN: 30, EISDIR: 31, ELOOP: 32, EMFILE: 33, EMLINK: 34, EMSGSIZE: 35, EMULTIHOP: 36, ENAMETOOLONG: 37, ENETDOWN: 38, ENETRESET: 39, ENETUNREACH: 40, ENFILE: 41, ENOBUFS: 42, ENODEV: 43, ENOENT: 44, ENOEXEC: 45, ENOLCK: 46, ENOLINK: 47, ENOMEM: 48, ENOMSG: 49, ENOPROTOOPT: 50, ENOSPC: 51, ENOSYS: 52, ENOTCONN: 53, ENOTDIR: 54, ENOTEMPTY: 55, ENOTRECOVERABLE: 56, ENOTSOCK: 57, ENOTTY: 59, ENXIO: 60, EOVERFLOW: 61, EOWNERDEAD: 62, EPERM: 63, EPIPE: 64, EPROTO: 65, EPROTONOSUPPORT: 66, EPROTOTYPE: 67, ERANGE: 68, EROFS: 69, ESPIPE: 70, ESRCH: 71, ESTALE: 72, ETIMEDOUT: 73, ETXTBSY: 74, EXDEV: 75, ENODATA: 116, ENOTSUP: 138 });
5
+ export const DOLLY_STACK_ABI_DIGEST = "60e29cb49bedc7e2b891c2837343939cab882a4ca32d03672f9e362cfcd8c1b3";
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "stack",
3
+ "version": 0,
4
+ "gives": "recursion deeper than one browser stack",
5
+ "dependencies": ["runtime@0"],
6
+ "phase": "kernel",
7
+ "imports": [],
8
+ "host": "stack.mjs",
9
+ "processWorker": "process.mjs",
10
+ "contracts": [],
11
+ "process": ["dolly-stack-0.wat"],
12
+ "headers": ["stack-abi.h"],
13
+ "kernel": [],
14
+ "client": [],
15
+ "package": "stack-wrap",
16
+ "tools": ["stack-wrap.c"]
17
+ }
@@ -0,0 +1,19 @@
1
+ export { DOLLY_STACK_ABI_DIGEST as digest } from "./abi.mjs";
2
+
3
+ // The provider (process.mjs) runs inside the process Worker of an executable
4
+ // that records stack@0. This side fetches its bundle once, as the supervisor
5
+ // fetches the process Worker: starting a program makes no request.
6
+ export function worker({ get, runtimeBase }) {
7
+ return {
8
+ async start() {
9
+ const response = await fetch(new URL("dist/dolly-process-stack.mjs", runtimeBase), {
10
+ cache: "no-store", credentials: "same-origin", redirect: "error",
11
+ });
12
+ if (!response.ok) throw new Error(`Dolly dolly-process-stack.mjs returned HTTP ${response.status}`);
13
+ // threads: it serves each thread's Worker of a program using threads@0.
14
+ get("runtime").serveInProcess("stack@0", {
15
+ bundle: new Blob([await response.arrayBuffer()], { type: "text/javascript" }), configuration: {}, threads: true });
16
+ },
17
+ disabled() { get("runtime").serveInProcess("stack@0"); },
18
+ };
19
+ }
@@ -0,0 +1,8 @@
1
+ // Generated from host/threads/dolly-threads-supervisor-0.wat, host/threads/dolly-threads-0.wat, host/threads/threads.h, include/dolly/process.h.
2
+ export const DOLLY_THREAD_SPAWN = 144;
3
+ export const DOLLY_THREAD_SELF = 145;
4
+ export const DOLLY_THREAD_EXIT = 146;
5
+ export const DOLLY_THREAD_WAIT = 147;
6
+ export const DOLLY_THREAD_WAIT_NONBLOCK = 1;
7
+ export const DOLLY_ERRNO = Object.freeze({ E2BIG: 1, EACCES: 2, EADDRINUSE: 3, EADDRNOTAVAIL: 4, EAFNOSUPPORT: 5, EAGAIN: 6, EALREADY: 7, EBADF: 8, EBADMSG: 9, EBUSY: 10, ECANCELED: 11, ECHILD: 12, ECONNABORTED: 13, ECONNREFUSED: 14, ECONNRESET: 15, EDEADLK: 16, EDESTADDRREQ: 17, EDOM: 18, EDQUOT: 19, EEXIST: 20, EFAULT: 21, EFBIG: 22, EHOSTUNREACH: 23, EIDRM: 24, EILSEQ: 25, EINPROGRESS: 26, EINTR: 27, EINVAL: 28, EIO: 29, EISCONN: 30, EISDIR: 31, ELOOP: 32, EMFILE: 33, EMLINK: 34, EMSGSIZE: 35, EMULTIHOP: 36, ENAMETOOLONG: 37, ENETDOWN: 38, ENETRESET: 39, ENETUNREACH: 40, ENFILE: 41, ENOBUFS: 42, ENODEV: 43, ENOENT: 44, ENOEXEC: 45, ENOLCK: 46, ENOLINK: 47, ENOMEM: 48, ENOMSG: 49, ENOPROTOOPT: 50, ENOSPC: 51, ENOSYS: 52, ENOTCONN: 53, ENOTDIR: 54, ENOTEMPTY: 55, ENOTRECOVERABLE: 56, ENOTSOCK: 57, ENOTTY: 59, ENXIO: 60, EOVERFLOW: 61, EOWNERDEAD: 62, EPERM: 63, EPIPE: 64, EPROTO: 65, EPROTONOSUPPORT: 66, EPROTOTYPE: 67, ERANGE: 68, EROFS: 69, ESPIPE: 70, ESRCH: 71, ESTALE: 72, ETIMEDOUT: 73, ETXTBSY: 74, EXDEV: 75, ENODATA: 116, ENOTSUP: 138 });
8
+ export const DOLLY_THREADS_ABI_DIGEST = "fcbd4fc02150731c9a351445157d854916d5c3fd84faf9933bbcdcc16bf146af";
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "threads",
3
+ "version": 0,
4
+ "gives": "threads, a Worker each: 32 a process, 64 in all",
5
+ "dependencies": ["runtime@0"],
6
+ "imports": [],
7
+ "host": "threads.mjs",
8
+ "contracts": ["dolly-threads-supervisor-0.wat"],
9
+ "process": ["dolly-threads-0.wat"],
10
+ "headers": ["threads.h", "threads-abi.h"],
11
+ "kernel": ["kernel.c"],
12
+ "client": ["client.c"]
13
+ }
@@ -0,0 +1,36 @@
1
+ import { sameWasmType } from "../../src/wasm-interface.mjs";
2
+ export { DOLLY_THREADS_ABI_DIGEST as digest } from "./abi.mjs";
3
+
4
+ // An executable that links the threads client (every -pthread program) is
5
+ // threaded; its requirement's digest is checked with the others.
6
+ export function validateThreadProfile(parsed, requirements, schema) {
7
+ if (!requirements.has("threads@0")) return false;
8
+ const expected = schema.exports.find(entry => entry.name === "dolly_thread_start");
9
+ const actual = parsed.exports.find(entry => entry.name === expected.name);
10
+ if (!actual || !sameWasmType(actual.type, expected.type))
11
+ throw new TypeError("a program using threads@0 must export dolly_thread_start(i32, i64) -> i64; build C and C++ with -pthread");
12
+ return true;
13
+ }
14
+
15
+ export function worker({ get }) {
16
+ // Trusted resource limits, independent of kernel bookkeeping or guest claims.
17
+ // Retired threads retain their join result in Wasm, not a Worker or host slot.
18
+ const workers = new Map();
19
+ const provider = {
20
+ available(pid) {
21
+ return workers.size < 64 && [...workers.values()].filter(owner => owner === pid).length < 32;
22
+ },
23
+ create(pid, url, name) {
24
+ if (!provider.available(pid)) return null;
25
+ const worker = new Worker(url, { type: "module", name });
26
+ workers.set(worker, pid);
27
+ return worker;
28
+ },
29
+ release(worker) { worker.terminate(); workers.delete(worker); },
30
+ dispose() { for (const worker of workers.keys()) worker.terminate(); workers.clear(); },
31
+ // The runtime goes on without the module: the programs on its Workers end.
32
+ disabled() { get("runtime").setThreadProvider(undefined); },
33
+ };
34
+ get("runtime").setThreadProvider(provider);
35
+ return provider;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
+ "version": "0.1.4-rc.0",
3
+ "type": "module",
2
4
  "name": "@daugasauron/dolly",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
5
+ "license": "MIT",
6
+ "exports": {
7
+ ".": "./src/browser.mjs"
8
+ },
9
+ "description": "Dolly's runtime: a POSIX-like userspace for wasm64 in a browser tab. Run images with boot().",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/daugasauron/dolly.git"
13
+ }
14
+ }
@@ -0,0 +1,4 @@
1
+ // The one order of every list a visitor reads on the site's pages: by the
2
+ // visible name, ignoring case. Names that differ only in case keep one order.
3
+ const collator = new Intl.Collator("en", { sensitivity: "base" });
4
+ export const alphabetical = (left, right) => collator.compare(left, right) || (left < right ? -1 : left > right ? 1 : 0);
@@ -0,0 +1,237 @@
1
+ import { buildLog } from "./build-log.mjs";
2
+ import { holdIndicators, showIndicators } from "./page-indicators.mjs";
3
+ import { hostRegistry } from "../host/modules.mjs";
4
+ import { pageChords } from "./page-chords.mjs";
5
+ import { terminalText } from "./terminal-text.mjs";
6
+ import { DOLLY_IMAGES, DOLLY_STATIC_SOURCES, imageBase, loadImages } from "./images.mjs";
7
+ import * as processConstants from "./process-constants.mjs";
8
+
9
+ // The terminal's own look: its canvas, the hidden keyboard, the boot log and
10
+ // the status line. A host module brings the look of what it adds; the page, the
11
+ // boot log's font (the site's is Iosevka, dist/IosevkaTerm-SemiBold.woff2).
12
+ const style = `
13
+ .dolly-terminal { position: relative; overflow: hidden; outline: none; caret-color: transparent; }
14
+ #display { display: block; width: 100%; height: 100%; cursor: text; touch-action: none; }
15
+ #display[hidden] { display: none; }
16
+ #keyboard { position: fixed; left: -10000px; top: 0; width: 1px; height: 1px; opacity: 0; caret-color: transparent; }
17
+ #bootstrap-log { position: absolute; inset: 0; z-index: 1; margin: 0; padding: 1rem 1.15rem; overflow: auto;
18
+ white-space: pre-wrap; color: #e8e3d7; background: #262626; font: 600 20px/1.35 "Dolly IosevkaTerm SemiBold", monospace;
19
+ scrollbar-width: thin; scrollbar-color: #f2d45c #262626; }
20
+ #bootstrap-log[hidden] { display: none; }
21
+ #session-status { position: fixed; right: 1rem; top: 1rem; z-index: 5; max-width: calc(100vw - 2rem);
22
+ padding: 0.5rem 0.75rem; color: #e8e3d7; background: #262626; border: 1px solid #77736c; font-size: 14px; }
23
+ #session-status[hidden] { display: none; }
24
+ html[data-indicators="hidden"] .page-indicator { display: none; }
25
+ @media (max-width: 640px) { #bootstrap-log { padding: 0.65rem 0.75rem; } }
26
+ `;
27
+
28
+ function element(tag, properties, attributes = {}) {
29
+ const node = Object.assign(document.createElement(tag), properties);
30
+ for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
31
+ return node;
32
+ }
33
+
34
+ // Runs an image in `mount`, an element the embedding sizes, from the image
35
+ // directory `images` (images.mjs). The embedding, trusted page code, names the
36
+ // host modules the page has (`modules`, the URLs of their manifests) and sets
37
+ // the HTTP broker's policy (`http`: { policy, relays }), both before anything
38
+ // of the guest runs. `site`, given, is the root of the routes a module may link
39
+ // to (session/, sessions/, docs/). It resolves to the page's API (window.__dolly
40
+ // on the site) once the image's ENTRY runs.
41
+ export function boot(mount, { images, image, modules, site, http = {} } = {}) {
42
+ return run(mount, { images, modules, site, http }, async () => {
43
+ const definition = DOLLY_IMAGES.find(candidate => candidate.image === image);
44
+ if (!definition) throw new Error("invalid Dolly boot options");
45
+ if (definition.entry === null) throw new Error(`${image} has no ENTRY; it only builds`);
46
+ return { image, label: "PRECOMPILED SYSTEM", required: definition.hostRequirements ?? [] };
47
+ });
48
+ }
49
+
50
+ // What boot() does, for the routes of the site too (site.mjs), which build and
51
+ // restore images of their own. `route(registry)` selects what runs:
52
+ // - image, label: the route's image ("custom" for none) and its boot log line;
53
+ // - required, enabled: the host modules the image requires, and those the page
54
+ // enables (by default the required ones), which a build alone may set;
55
+ // - configuration, inherited: the modules' configuration, and what http@0
56
+ // inherits from the tab that opened this one;
57
+ // - worker: the runtime Worker's URL, by default runtime-worker.mjs;
58
+ // - start(host, report): before the Worker starts, the fields it adds to the
59
+ // configure message ({ message, transfers }), or null once an image without
60
+ // ENTRY is built;
61
+ // - message(message): each message of the Worker;
62
+ // - running(ready, host): the context host modules' entryStarted is given
63
+ // beside `image`, and members of the page's handle.
64
+ export async function run(mount, { images, modules, site, http }, route) {
65
+ if (!document.querySelector("style[data-dolly]")) {
66
+ document.head.append(element("style", { textContent: style }, { "data-dolly": "" }));
67
+ }
68
+ const canvas = element("canvas", { id: "display", hidden: true }, { "aria-label": "Dolly sandbox framebuffer" });
69
+ const keyboard = element("textarea", { id: "keyboard" }, { "aria-label": "Dolly keyboard input",
70
+ autocomplete: "off", autocapitalize: "off", spellcheck: "false" });
71
+ const bootstrapLog = element("pre", { id: "bootstrap-log" }, { "aria-live": "polite" });
72
+ const status = element("div", { id: "session-status", hidden: true }, { role: "status" });
73
+ mount.classList.add("dolly-terminal");
74
+ mount.replaceChildren(canvas, keyboard, bootstrapLog, status);
75
+ const bootstrapOutput = buildLog(bootstrapLog);
76
+ bootstrapOutput.clear();
77
+ const appendBootstrap = text => bootstrapOutput.append(text);
78
+ pageChords(keyboard);
79
+ const bootstrapDecoder = new TextDecoder();
80
+
81
+ let runtimeWorker;
82
+ let host;
83
+ let rejectReady = null;
84
+ let runtimeReady = false;
85
+ let statusTimer;
86
+
87
+ // The runtime stopped: its host modules let go of the page, and the bootstrap
88
+ // log shows the reason over whatever a module drew.
89
+ function fatal(message) {
90
+ rejectReady?.(new Error(message));
91
+ host?.dispose();
92
+ runtimeWorker?.terminate();
93
+ bootstrapLog.hidden = false;
94
+ appendBootstrap(`\nFATAL\n${message}\n`);
95
+ document.documentElement.dataset.dollyStatus = "failed";
96
+ }
97
+
98
+ // The image is over when its ENTRY process is. The page says how in its own
99
+ // text below the display, which keeps the last frame and gives up that strip:
100
+ // a program can draw a lookalike while it runs, but nothing of the guest can
101
+ // cover, change or remove this notice. A module may offer a link beside
102
+ // "start again".
103
+ function ended({ status: code, signal, failure }) {
104
+ const offers = host.ended();
105
+ host.dispose();
106
+ runtimeWorker.terminate();
107
+ const signalName = Object.keys(processConstants).find(name =>
108
+ /^DOLLY_PROCESS_SIG(?!NAL)/.test(name) && processConstants[name] === signal)?.slice("DOLLY_PROCESS_".length);
109
+ const how = failure ? `failed: ${failure}` : signal ? `was ended by ${signalName ?? `signal ${signal}`}`
110
+ : `exited with status ${code}`;
111
+ const notice = mount.appendChild(element("div", { id: "image-ended" }, { role: "alert" }));
112
+ notice.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:9;padding:0.6rem 1rem;" +
113
+ "color:#e8e3d7;background:#262626;border-top:1px solid #f2d45c;font-size:16px";
114
+ const links = [{ text: "Reload to start again", href: location.href }, ...offers].map(({ text, href }) =>
115
+ Object.assign(document.createElement("a"), { textContent: text, href, style: "color:#f2d45c;margin-left:1.5ch" }));
116
+ notice.append(`This image has ended: its program ${how}.`, ...links);
117
+ mount.style.height = `calc(100% - ${notice.offsetHeight}px)`;
118
+ Object.assign(canvas.style, { objectFit: "contain", objectPosition: "left top" });
119
+ links[0].focus();
120
+ document.documentElement.dataset.dollyStatus = "exited";
121
+ }
122
+
123
+ function showStatus(message, persistent = false) {
124
+ clearTimeout(statusTimer);
125
+ status.textContent = message;
126
+ status.hidden = false;
127
+ if (!persistent) statusTimer = setTimeout(() => { status.hidden = true; }, 6000);
128
+ }
129
+
130
+ addEventListener("pagehide", () => { host?.dispose(); runtimeWorker?.terminate(); });
131
+ try {
132
+ document.documentElement.dataset.dollyStatus = "loading";
133
+ await loadImages(images);
134
+ if (!Array.isArray(modules) || !modules.length || !(site === undefined || typeof site === "string")) {
135
+ throw new Error("invalid Dolly boot options");
136
+ }
137
+ const registry = await hostRegistry(modules);
138
+ const selected = await route(registry);
139
+ const { image } = selected;
140
+ const bootstrapSources = [
141
+ ...DOLLY_IMAGES.map((definition) => ({
142
+ path: `/${definition.dollyfile}`,
143
+ byteLength: definition.byteLength,
144
+ })),
145
+ ...DOLLY_STATIC_SOURCES,
146
+ ];
147
+ appendBootstrap(`DOLLY / ${image.toUpperCase()} / ${selected.label}\n\n`);
148
+ host = await registry.createHost("browser", selected.enabled ?? selected.required, {
149
+ send: (message, transfers = []) => runtimeWorker.postMessage(message, transfers),
150
+ resources: { mount, canvas, keyboard, applicationBase: imageBase, site: site && new URL(site), showStatus, fatal,
151
+ indicators: { hold: holdIndicators, show: showIndicators } },
152
+ configuration: { ...selected.configuration, http: { ...selected.configuration?.http, embedding: http,
153
+ sources: bootstrapSources, inherited: selected.inherited } },
154
+ });
155
+ host.require(selected.required);
156
+ const started = selected.start ? await selected.start(host, appendBootstrap) : { message: {}, transfers: [] };
157
+ if (started === null) {
158
+ document.documentElement.dataset.dollyStatus = "built";
159
+ host.dispose();
160
+ return null;
161
+ }
162
+
163
+ runtimeWorker = new Worker(selected.worker ?? new URL("./runtime-worker.mjs", import.meta.url), {
164
+ type: "module",
165
+ name: "dolly-runtime",
166
+ });
167
+ // An uncaught failure in the Worker, a kernel trap outside a system call
168
+ // among them, is the runtime's end: the page says so.
169
+ runtimeWorker.addEventListener("error", event => {
170
+ const message = event.message || "the runtime Worker failed";
171
+ if (rejectReady) rejectReady(new Error(message)); else fatal(message);
172
+ });
173
+ runtimeWorker.addEventListener("message", (event) => {
174
+ const message = event.data;
175
+ void host.handle(message).catch(error => fatal(error.message));
176
+ selected.message?.(message);
177
+ if (message.type === "bootstrap") {
178
+ appendBootstrap(message.text);
179
+ } else if (message.type === "bootstrap-bytes") {
180
+ appendBootstrap(bootstrapDecoder.decode(message.bytes, { stream: true }));
181
+ } else if (message.type === "exited") {
182
+ ended(message);
183
+ } else if (message.type === "error" && runtimeReady) {
184
+ fatal(message.stack ? `${message.message}\n${message.stack}` : message.message);
185
+ }
186
+ });
187
+ runtimeWorker.postMessage({
188
+ type: "configure",
189
+ image,
190
+ mode: "snapshot",
191
+ images: imageBase.href,
192
+ modules: registry.urls,
193
+ hostModules: host.enabled,
194
+ hostConfiguration: host.configuration,
195
+ ...started.message,
196
+ }, [...host.transfers, ...started.transfers]);
197
+
198
+ const ready = await new Promise((resolve, reject) => {
199
+ rejectReady = reject;
200
+ runtimeWorker.addEventListener("message", function onMessage(event) {
201
+ if (event.data.type === "ready") {
202
+ runtimeWorker.removeEventListener("message", onMessage);
203
+ resolve(event.data);
204
+ } else if (event.data.type === "error") {
205
+ runtimeWorker.removeEventListener("message", onMessage);
206
+ const error = new Error(event.data.message);
207
+ if (event.data.stack) error.stack = event.data.stack;
208
+ reject(error);
209
+ }
210
+ });
211
+ }).finally(() => { rejectReady = null; });
212
+ appendBootstrap(bootstrapDecoder.decode());
213
+ runtimeReady = true;
214
+ if (ready.routeImage !== image || (image !== "custom" && ready.image !== image)) {
215
+ throw new Error("runtime image mismatch");
216
+ }
217
+ document.documentElement.dataset.image = ready.image;
218
+ const { context, page } = await selected.running?.(ready, host) ?? {};
219
+ await host.entryStarted({ image, ...context });
220
+ runtimeWorker.postMessage({ type: "entry-ready-ack" });
221
+ // A module that shows the canvas replaces the bootstrap log.
222
+ bootstrapLog.hidden = !canvas.hidden;
223
+
224
+ keyboard.focus({ preventScroll: true });
225
+ document.documentElement.dataset.dollyStatus = "ready";
226
+ showIndicators();
227
+
228
+ return Object.create(host.page, {
229
+ ...Object.getOwnPropertyDescriptors({ ...terminalText(host.page), get hostModules() { return host.enabled; } }),
230
+ ...Object.getOwnPropertyDescriptors(page ?? {}),
231
+ });
232
+ } catch (error) {
233
+ runtimeReady = false;
234
+ fatal(error instanceof Error ? error.message : String(error));
235
+ throw error;
236
+ }
237
+ }
@@ -0,0 +1,30 @@
1
+ // Keep compiler output as text, bounded even when a guest floods stdout.
2
+ export function buildLog(element) {
3
+ const maximum = 1024 * 1024;
4
+ let length = 0, escape = "", carriageReturn = false;
5
+ return {
6
+ clear() { element.replaceChildren(); length = 0; escape = ""; carriageReturn = false; },
7
+ append(text) {
8
+ const follow = element.scrollHeight - element.scrollTop - element.clientHeight < 24;
9
+ text = escape + String(text);
10
+ escape = /\x1b(?:\[[0-9;:]{0,128})?$/.exec(text)?.[0] ?? "";
11
+ if (escape) text = text.slice(0, -escape.length);
12
+ text = text.replace(/\x1b\[[0-9;:]*m/g, "");
13
+ if (!text) return;
14
+ if (carriageReturn && text.startsWith("\n")) text = text.slice(1);
15
+ carriageReturn = text.endsWith("\r");
16
+ text = text.replace(/\r\n?/g, "\n").slice(-maximum);
17
+ if (!text) return;
18
+ if (element.lastChild && element.lastChild.length + text.length <= 4096) {
19
+ element.lastChild.appendData(text);
20
+ } else element.append(element.ownerDocument.createTextNode(text));
21
+ length += text.length;
22
+ while (length > maximum) {
23
+ const first = element.firstChild, excess = length - maximum;
24
+ if (first.length <= excess) { length -= first.length; first.remove(); }
25
+ else { first.deleteData(0, excess); length -= excess; }
26
+ }
27
+ if (follow) element.scrollTop = element.scrollHeight;
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,29 @@
1
+ // Shared by packaging and browser admission; empty arguments are valid.
2
+ export function decodeImageEntry(bytes) {
3
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 16 || bytes.byteLength > 64 * 1024) {
4
+ throw new TypeError("invalid image ENTRY record");
5
+ }
6
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
7
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
8
+ if (decoder.decode(bytes.subarray(0, 8)) !== "DOLLYENT" || view.getUint32(8, true) !== 1) {
9
+ throw new TypeError("invalid image ENTRY version");
10
+ }
11
+ const count = view.getUint32(12, true);
12
+ if (count === 0 || count > 256) throw new TypeError("invalid image ENTRY count");
13
+ let offset = 16;
14
+ const arguments_ = [];
15
+ for (let index = 0; index < count; ++index) {
16
+ if (offset > bytes.byteLength - 4) throw new TypeError("truncated image ENTRY");
17
+ const size = view.getUint32(offset, true);
18
+ offset += 4;
19
+ if (size > 4096 || offset > bytes.byteLength - size) throw new TypeError("invalid image ENTRY argument");
20
+ const value = decoder.decode(bytes.subarray(offset, offset + size));
21
+ if (value.includes("\0")) throw new TypeError("image ENTRY has a NUL argument");
22
+ arguments_.push(value);
23
+ offset += size;
24
+ }
25
+ if (offset !== bytes.byteLength || !arguments_[0].startsWith("/")) {
26
+ throw new TypeError("invalid image ENTRY payload");
27
+ }
28
+ return arguments_;
29
+ }
@@ -0,0 +1,20 @@
1
+ // Recipe pins identify instructions; artifact digests identify their results.
2
+ export function imageInputs(artifacts = []) {
3
+ if (!Array.isArray(artifacts) || artifacts.length > 256) throw new Error("invalid image inputs");
4
+ const inputs = new Map();
5
+ for (const artifact of artifacts) {
6
+ const { recipeSha256, sha256 } = artifact ?? {};
7
+ if (!/^[0-9a-f]{64}$/.test(recipeSha256) || !/^[0-9a-f]{64}$/.test(sha256)) {
8
+ throw new Error("invalid image input digest");
9
+ }
10
+ if (inputs.has(recipeSha256) && inputs.get(recipeSha256).sha256 !== sha256) {
11
+ throw new Error("conflicting image inputs");
12
+ }
13
+ inputs.set(recipeSha256, { recipeSha256, sha256 });
14
+ }
15
+ return [...inputs.values()].sort((a, b) => a.recipeSha256.localeCompare(b.recipeSha256));
16
+ }
17
+
18
+ export function imageInputsMatch(actual, expected) {
19
+ return JSON.stringify(imageInputs(actual)) === JSON.stringify(imageInputs(expected));
20
+ }