@daugasauron/dolly-host-upload 0.0.0-stage → 0.1.4-rc.1

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/README.md CHANGED
@@ -1,3 +1,4 @@
1
- # Temporary Holding Version
1
+ # @daugasauron/dolly-host-upload
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Dolly's `upload@0` host module: ask for a file; the user picks it. Give the URL of its
4
+ `module.json` to `boot()`'s `modules`; @daugasauron/dolly's README says how.
package/abi.mjs ADDED
@@ -0,0 +1,17 @@
1
+ // Generated from host/upload/dolly-upload-0.wat, host/upload/upload.h, include/dolly/process.h.
2
+ export const DOLLY_UPLOAD_HEADER_SIZE = 64;
3
+ export const DOLLY_UPLOAD_WORD_REQUEST = 0;
4
+ export const DOLLY_UPLOAD_WORD_CANCELLED = 1;
5
+ export const DOLLY_UPLOAD_WORD_COMPLETED = 2;
6
+ export const DOLLY_UPLOAD_WORD_CHUNK = 3;
7
+ export const DOLLY_UPLOAD_WORD_CONSUMED = 4;
8
+ export const DOLLY_UPLOAD_WORD_LENGTH = 5;
9
+ export const DOLLY_UPLOAD_WORD_ERROR = 6;
10
+ export const DOLLY_UPLOAD_WORD_EOF = 7;
11
+ export const DOLLY_UPLOAD_WORD_ENABLED = 8;
12
+ export const DOLLY_UPLOAD_WORD_SIZE = 9;
13
+ export const DOLLY_UPLOAD_CHUNK_CAPACITY = 1048576;
14
+ export const DOLLY_UPLOAD_MAX_SIZE = 1073741824;
15
+ export const DOLLY_UPLOAD_FILE = 57;
16
+ 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 });
17
+ export const DOLLY_UPLOAD_ABI_DIGEST = "31e7e9d53cf59e23fc88b4d3d283cb4d9f1a85498628a9137065a476245029fc";
package/module.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "upload",
3
+ "version": 0,
4
+ "gives": "ask for a file; the user picks it",
5
+ "dependencies": ["runtime@0"],
6
+ "phase": "image",
7
+ "imports": [],
8
+ "host": "upload.mjs",
9
+ "contracts": ["dolly-upload-0.wat"],
10
+ "process": [],
11
+ "headers": ["upload.h", "upload-abi.h"],
12
+ "kernel": ["kernel.c"],
13
+ "client": ["client.c"]
14
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
+ "version": "0.1.4-rc.1",
3
+ "type": "module",
2
4
  "name": "@daugasauron/dolly-host-upload",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
5
+ "license": "MIT",
6
+ "dependencies": {
7
+ "@daugasauron/dolly": "0.1.4-rc.1"
8
+ },
9
+ "description": "Dolly's upload@0 host module: ask for a file; the user picks it.",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/daugasauron/dolly.git"
13
+ }
14
+ }
package/transport.mjs ADDED
@@ -0,0 +1,158 @@
1
+ import { DOLLY_ERRNO as errno } from "./abi.mjs";
2
+ import { DOLLY_UPLOAD_CHUNK_CAPACITY as chunkCapacity, DOLLY_UPLOAD_MAX_SIZE, DOLLY_UPLOAD_HEADER_SIZE as headerSize,
3
+ DOLLY_UPLOAD_WORD_REQUEST as REQUEST, DOLLY_UPLOAD_WORD_CANCELLED as CANCELLED, DOLLY_UPLOAD_WORD_COMPLETED as COMPLETED,
4
+ DOLLY_UPLOAD_WORD_CHUNK as CHUNK, DOLLY_UPLOAD_WORD_CONSUMED as CONSUMED, DOLLY_UPLOAD_WORD_LENGTH as LENGTH,
5
+ DOLLY_UPLOAD_WORD_ERROR as ERROR, DOLLY_UPLOAD_WORD_EOF as EOF, DOLLY_UPLOAD_WORD_ENABLED as ENABLED,
6
+ DOLLY_UPLOAD_WORD_SIZE as SIZE } from "./abi.mjs";
7
+
8
+ // After the user cancels, requests are refused briefly so the page stays usable.
9
+ export const UPLOAD_CANCEL_QUIET_MILLISECONDS = 2000;
10
+ const mebibytes = bytes => (bytes / 1048576).toFixed(1);
11
+
12
+ // Mailbox words as dolly-upload-0.wat names them, the Worker's end: user-selected
13
+ // bytes only, one chunk at a time. The browser never receives a path.
14
+ // chooseFile(controller) resolves to the file or null; the user's Cancel aborts
15
+ // the controller, before or during the transfer.
16
+ export class UploadTransport {
17
+ constructor(buffer, address, chooseFile, showProgress = () => {}) {
18
+ if (!(buffer instanceof SharedArrayBuffer) || !Number.isSafeInteger(address) ||
19
+ address <= 0 || address % 4 || address > buffer.byteLength - headerSize - chunkCapacity) {
20
+ throw new TypeError("invalid upload mailbox");
21
+ }
22
+ this.words = new Int32Array(buffer, address, 16);
23
+ this.bytes = new Uint8Array(buffer, address + headerSize, chunkCapacity);
24
+ this.chooseFile = chooseFile;
25
+ this.showProgress = showProgress;
26
+ this.active = null;
27
+ this.quietUntil = 0;
28
+ Atomics.store(this.words, ENABLED, 1);
29
+ }
30
+
31
+ async poll() {
32
+ const words = this.words;
33
+ const sequence = Atomics.load(words, REQUEST);
34
+ if (this.active || sequence === Atomics.load(words, COMPLETED)) return;
35
+ const controller = new AbortController();
36
+ // The kernel retired this request (its process ended or a new one began):
37
+ // the Worker reports it through retire(), which ends any chunk wait.
38
+ let retire;
39
+ const retirement = new Promise(resolve => { retire = resolve; });
40
+ this.active = { sequence, controller, retire };
41
+ const retired = () => this.retired(sequence);
42
+ let size = 0;
43
+ const publish = async (bytes, eof, error = 0) => {
44
+ // The kernel notifies the consumed word after writing each chunk.
45
+ for (let consumed; (consumed = Atomics.load(words, CONSUMED)) !== Atomics.load(words, CHUNK);) {
46
+ if (retired()) return;
47
+ await Promise.race([Atomics.waitAsync(words, CONSUMED, consumed).value, retirement]);
48
+ }
49
+ if (retired()) return;
50
+ this.bytes.set(bytes);
51
+ Atomics.store(words, LENGTH, bytes.length);
52
+ Atomics.store(words, ERROR, error);
53
+ Atomics.store(words, EOF, eof ? 1 : 0);
54
+ Atomics.store(words, SIZE, size);
55
+ Atomics.add(words, CHUNK, 1);
56
+ };
57
+ // The user's Cancel, in the picker or during the transfer.
58
+ const cancel = () => {
59
+ if (!retired()) this.quietUntil = performance.now() + UPLOAD_CANCEL_QUIET_MILLISECONDS;
60
+ return publish(new Uint8Array(), true, errno.ECANCELED);
61
+ };
62
+ try {
63
+ if (retired()) return;
64
+ if (performance.now() < this.quietUntil) {
65
+ await publish(new Uint8Array(), true, errno.ECANCELED);
66
+ return;
67
+ }
68
+ const file = await this.chooseFile(controller);
69
+ if (!file) await cancel();
70
+ else if (!(file instanceof Blob) || file.size > DOLLY_UPLOAD_MAX_SIZE) {
71
+ await publish(new Uint8Array(), true, errno.EFBIG);
72
+ } else {
73
+ // One reused staging buffer: reading allocates nothing per chunk.
74
+ const reader = file.stream().getReader({ mode: "byob" });
75
+ let staging = new ArrayBuffer(chunkCapacity), sent = 0;
76
+ size = file.size;
77
+ do {
78
+ const length = Math.min(chunkCapacity, size - sent);
79
+ for (let filled = 0; filled < length;) {
80
+ const { value, done } = await reader.read(new Uint8Array(staging, filled, length - filled));
81
+ if (done) throw new Error("the file changed while uploading");
82
+ staging = value.buffer;
83
+ filled += value.length;
84
+ }
85
+ if (controller.signal.aborted) {
86
+ void reader.cancel().catch(() => {});
87
+ break;
88
+ }
89
+ sent += length;
90
+ await publish(new Uint8Array(staging, 0, length), sent === size);
91
+ this.showProgress(sent, size);
92
+ } while (sent < size);
93
+ if (sent < size) await cancel();
94
+ }
95
+ } catch {
96
+ await publish(new Uint8Array(), true, errno.EIO);
97
+ } finally {
98
+ controller.abort();
99
+ Atomics.store(words, COMPLETED, sequence);
100
+ this.active = null;
101
+ }
102
+ }
103
+
104
+ retired(sequence) {
105
+ return Atomics.load(this.words, REQUEST) !== sequence || Atomics.load(this.words, CANCELLED) === sequence;
106
+ }
107
+ retire() {
108
+ const active = this.active;
109
+ if (!active || !this.retired(active.sequence)) return;
110
+ active.retire();
111
+ active.controller.abort();
112
+ }
113
+ close() { Atomics.store(this.words, ENABLED, 0); this.active?.controller.abort(); }
114
+ }
115
+
116
+ // One modal dialog per request: the picker, then progress until the transfer ends.
117
+ export function chooseUploadFile(controller) {
118
+ return new Promise(resolve => {
119
+ const { signal } = controller;
120
+ if (signal.aborted) { resolve(null); return; }
121
+ const dialog = document.createElement("dialog");
122
+ dialog.id = "file-upload";
123
+ dialog.innerHTML = `<style>#file-upload { color: #e8e3d7; background: #262626; border: 1px solid #77736c;
124
+ max-width: calc(100vw - 2rem); font: inherit; } #file-upload::backdrop { background: #0008; }
125
+ #file-upload input, #file-upload button { font: inherit; }</style>
126
+ <form method="dialog"><p>Upload one file into Dolly</p>
127
+ <p>Up to ${DOLLY_UPLOAD_MAX_SIZE / 1073741824} GiB. Only the file you choose enters the sandbox.</p>
128
+ <input type="file" aria-label="Choose file to upload"><progress hidden></progress>
129
+ <output></output><button>Cancel</button></form>`;
130
+ const cancel = event => { event?.preventDefault(); controller.abort(); };
131
+ dialog.addEventListener("cancel", cancel);
132
+ dialog.querySelector("form").addEventListener("submit", cancel);
133
+ const [title, note] = dialog.querySelectorAll("p");
134
+ const input = dialog.querySelector("input");
135
+ input.addEventListener("cancel", cancel);
136
+ input.addEventListener("change", () => {
137
+ const file = input.files[0];
138
+ if (!file) { cancel(); return; }
139
+ title.textContent = `Uploading ${file.name}`;
140
+ note.hidden = input.hidden = true;
141
+ dialog.querySelector("progress").hidden = false;
142
+ showUploadProgress(0, file.size);
143
+ resolve(file);
144
+ });
145
+ signal.addEventListener("abort", () => { dialog.close(); dialog.remove(); resolve(null); }, { once: true });
146
+ document.body.append(dialog);
147
+ if (document.pointerLockElement) document.exitPointerLock();
148
+ dialog.showModal();
149
+ });
150
+ }
151
+
152
+ export function showUploadProgress(sent, size) {
153
+ const progress = document.querySelector("#file-upload progress");
154
+ if (!progress) return;
155
+ progress.max = size;
156
+ progress.value = sent;
157
+ document.querySelector("#file-upload output").textContent = `${mebibytes(sent)} of ${mebibytes(size)} MiB`;
158
+ }
package/upload.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { UploadTransport, chooseUploadFile, showUploadProgress } from "./transport.mjs";
2
+ import { DOLLY_UPLOAD_WORD_REQUEST, DOLLY_UPLOAD_WORD_CANCELLED } from "./abi.mjs";
3
+ export { DOLLY_UPLOAD_ABI_DIGEST as digest } from "./abi.mjs";
4
+
5
+ // The page shows the picker and the progress; the Worker asks for a file,
6
+ // reads it and writes it into the mailbox. The user's Cancel reaches the
7
+ // Worker, and the Worker's end of a transfer closes the dialog.
8
+ export function browser({ send }) {
9
+ let controller = null;
10
+ return {
11
+ messages: {
12
+ "upload-choose"() {
13
+ const current = controller = new AbortController();
14
+ current.signal.addEventListener("abort", () => {
15
+ if (controller !== current) return;
16
+ controller = null;
17
+ send({ type: "upload-cancel" });
18
+ }, { once: true });
19
+ return chooseUploadFile(current);
20
+ },
21
+ "upload-progress"({ sent, size }) { showUploadProgress(sent, size); },
22
+ "upload-close"() {
23
+ const current = controller;
24
+ controller = null;
25
+ current?.abort();
26
+ },
27
+ },
28
+ // The open dialog takes every key; the interrupt key still interrupts the program that asked.
29
+ claimsKey: () => document.querySelector("#file-upload[open]") ? "interrupt" : false,
30
+ dispose() { controller?.abort(); },
31
+ };
32
+ }
33
+
34
+ // The kernel's mailbox reports each new request and each retired one (its
35
+ // process ended or cancelled) at the service tick.
36
+ export function worker({ send, ask }) {
37
+ let transport, reported, retired;
38
+ function choose(controller) {
39
+ controller.signal.addEventListener("abort", () => send({ type: "upload-close" }), { once: true });
40
+ return ask({ type: "upload-choose" });
41
+ }
42
+ return {
43
+ messages: { "upload-cancel"() { transport?.active?.controller.abort(); } },
44
+ service() {
45
+ if (!transport) return;
46
+ const { words } = transport;
47
+ const request = Atomics.load(words, DOLLY_UPLOAD_WORD_REQUEST), cancelled = Atomics.load(words, DOLLY_UPLOAD_WORD_CANCELLED);
48
+ if (request !== reported) {
49
+ reported = request;
50
+ void transport.poll();
51
+ }
52
+ if (cancelled !== retired) {
53
+ retired = cancelled;
54
+ transport.retire();
55
+ }
56
+ },
57
+ start({ kernel, memory }) {
58
+ transport = new UploadTransport(memory.buffer, Number(kernel.dolly_upload_mailbox_address()), choose,
59
+ (sent, size) => send({ type: "upload-progress", sent, size }));
60
+ reported = Atomics.load(transport.words, DOLLY_UPLOAD_WORD_REQUEST);
61
+ retired = Atomics.load(transport.words, DOLLY_UPLOAD_WORD_CANCELLED);
62
+ },
63
+ dispose() { transport?.close(); },
64
+ };
65
+ }