@kupcad/wasm 0.0.0-stage → 0.1.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,74 @@
1
- # Temporary Holding Version
1
+ # @kupcad/wasm
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
+ The KupCAD core compiled to WebAssembly: preview, build, check, format and read the parameters of
4
+ a script, in a page or in Node, with no server. The shapes it gives back are
5
+ [@kupcad/protocol](../kupcad-protocol)'s.
6
+
7
+ ```js
8
+ import { createKupcadWorker } from '@kupcad/wasm';
9
+
10
+ const kupcad = createKupcadWorker(); // the core in a Web Worker: a long build doesn't freeze the page
11
+
12
+ const { header, model } = await kupcad.preview(source, { params: { width: 40 } });
13
+ // model: { kind: '3d', glb, parts } | { kind: '2d', svg } | { kind: 'none' }
14
+ // header.error: why the run failed, where, and the parameter whose value was refused
15
+
16
+ const stl = await kupcad.build(source, { format: 'stl' }); // Uint8Array
17
+ const findings = await kupcad.check(source);
18
+ const formatted = await kupcad.format(source);
19
+ const schema = await kupcad.params(source); // the parameter panel's controls, without a run
20
+ // The files the script reads, by the paths it names: a module it imports, data (an STL, a JSON)
21
+ await kupcad.build(source, { files: { 'lib.kup': libSource, 'plate.dxf': bytes } });
22
+ ```
23
+
24
+ ## In a worker, or in this thread
25
+
26
+ * `createKupcadWorker(options)` runs the core in a module Web Worker. Calls run one at a time,
27
+ the next waiting its turn. A call's `signal` (an `AbortSignal`) drops it while it waits, and
28
+ stops the worker while it runs: a script can't be stopped inside the module, so the worker is
29
+ ended and a new one starts with the next call. A worker whose core stopped (a trap) is replaced
30
+ the same way. A
31
+ call that can't be sent (the worker doesn't start, or its arguments can't be posted to one) is
32
+ refused with why, and the next goes. `terminate()` ends it for good.
33
+ * `createKupcad(options)` instantiates the core in this thread: the same calls, for Node, tests
34
+ and a worker of the host's own. `Kupcad.fromExports(exports)` takes a module the host
35
+ instantiated itself.
36
+
37
+ Both take `wasm` (the module: a URL, its bytes or a compiled `WebAssembly.Module`, and for
38
+ `createKupcad` a fetch `Response` too, which can't be posted to a worker; by default the
39
+ `kupcad.wasm` this package ships) and `log` (where the module's log goes, a line at a time; by
40
+ default `console.debug`). A worker is given a relative URL resolved against the page, as the
41
+ page means it.
42
+
43
+ ## What a call can fail with
44
+
45
+ * `KupcadError`: the core refused, with its message: a script that doesn't parse
46
+ (`1:6: Expected 'r_paren' ...`) or run (`RuntimeError: boom`), values it refused
47
+ (`parameters: ...`), a format it doesn't write. A preview doesn't throw for these: its frame
48
+ says why.
49
+ * `KupcadCrashError`: the module stopped (a trap, or an exit), or the worker failed (its script
50
+ didn't load, or it sent what couldn't be read). A worker is replaced on the next call; a module
51
+ of `createKupcad` is made again.
52
+ * `ProtocolError` (@kupcad/protocol's): the module speaks a protocol version this package doesn't
53
+ read.
54
+
55
+ ## Inside
56
+
57
+ The module asks its host for WASI. A browser has none and Node's would give it a file system, so
58
+ the package answers it itself (`src/wasi.js`): the host's clocks and randomness, the log, and no
59
+ files. A script reads the files its call brings (`files`, by the paths the script names them):
60
+ they are packed into one buffer (`src/files.js`, as `core/src/vfs/packed.zig` reads it) and
61
+ kept in an in-memory file system of that call alone, so no script sees another's files and a
62
+ script's exports go nowhere. A build runs under the core's interactive limits (instructions,
63
+ memory), as a preview does: a runaway script stops with an error instead of hanging the worker. Every string and buffer is copied in and freed after the call;
64
+ every result is copied out and freed. `pnpm build` copies the module from the core's
65
+ build (`pnpm build:core:wasm` at the root) into `dist/`, which the package ships; its version is
66
+ the core's, which the tests hold it to.
67
+
68
+ The threaded build of the core (`-Dwasm_threads`) isn't shipped yet: it needs a host that runs
69
+ its threads in workers and pages served cross-origin isolated.
70
+
71
+ `@kupcad/wasm/types` has the API's types (`KupcadApi`, the options, the worker's messages).
72
+ `@kupcad/wasm/testing` is for the tests of a package that shows what the core makes: the real
73
+ module, made once a test process (`testCore`), and `modelOf` and `schemaOf` of a script, a
74
+ script that fails failing the test.
Binary file
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@kupcad/wasm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "The KupCAD core compiled to WebAssembly: preview, build, check, format and read the parameters of a script in the browser or Node, directly or in a Web Worker.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kupcad/kupcad.git",
9
+ "directory": "packages/kupcad-wasm"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.js",
14
+ "dist/kupcad.wasm"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "imports": {
19
+ "#read-file": {
20
+ "node": "./src/read-file.node.js",
21
+ "default": "./src/read-file.browser.js"
22
+ }
23
+ },
24
+ "exports": {
25
+ ".": "./src/index.js",
26
+ "./types": {
27
+ "types": "./src/types.d.ts"
28
+ },
29
+ "./testing": "./src/testing.js"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@kupcad/protocol": "0.1.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^26.6.5",
39
+ "vitest": "^5.0.5"
40
+ },
41
+ "scripts": {
42
+ "build": "node scripts/copy-wasm.js",
43
+ "check": "tsc -p jsconfig.json",
44
+ "test": "vitest run"
45
+ }
6
46
  }
package/src/client.js ADDED
@@ -0,0 +1,291 @@
1
+ // The core in a Web Worker, with createKupcad's API: a long build runs there and the page goes
2
+ // on. Calls run one at a time, the next waiting its turn. A call's AbortSignal drops it while
3
+ // it waits, and stops the worker while it runs: the worker is replaced on the next call, as
4
+ // one that crashed is.
5
+
6
+ import { ProtocolError } from '@kupcad/protocol';
7
+ import { KupcadCrashError, KupcadError } from './errors.js';
8
+
9
+ /** @import { Finding, Preview, UiSchema, VersionInfo } from '@kupcad/protocol/types' */
10
+ /** @import { BuildOptions, CallOptions, FromWorker, KupcadApi, Method, PreviewOptions, SentError, ToWorker, WorkerLike, WorkerOptions, WorkerWasmSource } from './types.js' */
11
+
12
+ /**
13
+ * @typedef {object} Call
14
+ * @property {number} id
15
+ * @property {Method} method
16
+ * @property {unknown[]} args
17
+ * @property {(value: any) => void} resolve
18
+ * @property {(reason: unknown) => void} reject
19
+ * @property {AbortSignal | undefined} signal
20
+ * @property {() => void} onAbort
21
+ */
22
+
23
+ /** @type {() => WorkerLike} */
24
+ const moduleWorker = () => new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
25
+ /** Why a worker failed when its error says nothing: its script didn't load, most often. */
26
+ const SILENT_FAILURE = 'it failed without a message (its script may not have loaded)';
27
+
28
+ /**
29
+ * The core in a Web Worker.
30
+ * @param {WorkerOptions} [options]
31
+ * @returns {KupcadWorker}
32
+ */
33
+ export function createKupcadWorker(options = {}) {
34
+ return new KupcadWorker(options);
35
+ }
36
+
37
+ /** @implements {KupcadApi} */
38
+ export class KupcadWorker {
39
+ /** @type {() => WorkerLike} */
40
+ #spawn;
41
+ /** The module, as it can be posted (a URL as its href). */
42
+ #wasm;
43
+ /** @type {(line: string) => void} */
44
+ #log;
45
+ /** @type {WorkerLike | null} */
46
+ #worker = null;
47
+ /** @type {Call[]} */
48
+ #queue = [];
49
+ /** @type {Call | null} */
50
+ #running = null;
51
+ #nextId = 1;
52
+ /** @type {Error | null} */
53
+ #terminated = null;
54
+
55
+ /** @param {WorkerOptions} options */
56
+ constructor(options) {
57
+ this.#spawn = options.worker ?? moduleWorker;
58
+ this.#wasm = postable(options.wasm);
59
+ this.#log = options.log ?? ((line) => console.debug(line));
60
+ }
61
+
62
+ /**
63
+ * @param {string} source
64
+ * @param {PreviewOptions} [options]
65
+ * @returns {Promise<Preview>}
66
+ */
67
+ preview(source, { signal, ...options } = {}) {
68
+ return this.#call('preview', [source, options], signal);
69
+ }
70
+
71
+ /**
72
+ * @param {string} source
73
+ * @param {BuildOptions} [options]
74
+ * @returns {Promise<Uint8Array>}
75
+ */
76
+ build(source, { signal, ...options } = {}) {
77
+ return this.#call('build', [source, options], signal);
78
+ }
79
+
80
+ /**
81
+ * @param {string} source
82
+ * @param {CallOptions} [options]
83
+ * @returns {Promise<Finding[]>}
84
+ */
85
+ check(source, { signal } = {}) {
86
+ return this.#call('check', [source], signal);
87
+ }
88
+
89
+ /**
90
+ * @param {string} source
91
+ * @param {CallOptions} [options]
92
+ * @returns {Promise<string>}
93
+ */
94
+ format(source, { signal } = {}) {
95
+ return this.#call('format', [source], signal);
96
+ }
97
+
98
+ /**
99
+ * @param {string} source
100
+ * @param {CallOptions} [options]
101
+ * @returns {Promise<UiSchema>}
102
+ */
103
+ params(source, { signal } = {}) {
104
+ return this.#call('params', [source], signal);
105
+ }
106
+
107
+ /** @returns {Promise<VersionInfo>} */
108
+ version() {
109
+ return this.#call('version', []);
110
+ }
111
+
112
+ /** Stops the worker for good: the calls waiting, and any made after, are refused. */
113
+ terminate() {
114
+ this.#terminated = new Error('the kupcad worker was terminated');
115
+ this.#stopWorker();
116
+ for (const call of [...(this.#running ? [this.#running] : []), ...this.#queue]) {
117
+ this.#settle(call, () => call.reject(this.#terminated));
118
+ }
119
+ this.#running = null;
120
+ this.#queue = [];
121
+ }
122
+
123
+ /**
124
+ * @param {Method} method
125
+ * @param {unknown[]} args
126
+ * @param {AbortSignal} [signal]
127
+ * @returns {Promise<any>}
128
+ */
129
+ #call(method, args, signal) {
130
+ if (this.#terminated) return Promise.reject(this.#terminated);
131
+ if (signal?.aborted) return Promise.reject(signal.reason);
132
+ return new Promise((resolve, reject) => {
133
+ /** @type {Call} */
134
+ const call = {
135
+ id: this.#nextId++,
136
+ method,
137
+ args,
138
+ resolve,
139
+ reject,
140
+ signal,
141
+ onAbort: () => this.#abort(call),
142
+ };
143
+ signal?.addEventListener('abort', call.onAbort, { once: true });
144
+ this.#queue.push(call);
145
+ this.#pump();
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Sends the next call when none runs, starting a worker if none does. One that can't be
151
+ * sent (no worker starts, or its arguments can't be posted) is refused, and the next goes:
152
+ * it was taken for running, and every call after waited for good.
153
+ */
154
+ #pump() {
155
+ while (!this.#running) {
156
+ const next = this.#queue.shift();
157
+ if (!next) return;
158
+ try {
159
+ this.#ensureWorker().postMessage({
160
+ type: 'call',
161
+ id: next.id,
162
+ method: next.method,
163
+ args: next.args,
164
+ });
165
+ this.#running = next;
166
+ } catch (error) {
167
+ this.#settle(next, () => next.reject(error));
168
+ }
169
+ }
170
+ }
171
+
172
+ /** @returns {WorkerLike} */
173
+ #ensureWorker() {
174
+ if (this.#worker) return this.#worker;
175
+ const worker = this.#spawn();
176
+ // A worker stopped may still have sent: only the running one's messages count
177
+ worker.addEventListener('message', ({ data }) => {
178
+ if (worker === this.#worker) this.#receive(data);
179
+ });
180
+ worker.addEventListener('error', (event) => {
181
+ event.preventDefault();
182
+ if (worker === this.#worker) {
183
+ this.#fail(
184
+ new KupcadCrashError(`the kupcad worker failed: ${event.message || SILENT_FAILURE}`),
185
+ );
186
+ }
187
+ });
188
+ // A reply that couldn't be read would leave its call waiting for good
189
+ worker.addEventListener('messageerror', () => {
190
+ if (worker === this.#worker) {
191
+ this.#fail(new KupcadCrashError("the kupcad worker sent a message that couldn't be read"));
192
+ }
193
+ });
194
+ try {
195
+ worker.postMessage({ type: 'init', wasm: this.#wasm });
196
+ } catch (error) {
197
+ worker.terminate();
198
+ throw error;
199
+ }
200
+ this.#worker = worker;
201
+ return worker;
202
+ }
203
+
204
+ /** @param {FromWorker} message */
205
+ #receive(message) {
206
+ if (message.type === 'log') {
207
+ this.#log(message.line);
208
+ return;
209
+ }
210
+ const call = this.#running;
211
+ if (!call || call.id !== message.id) return;
212
+ this.#running = null;
213
+ if (message.ok) {
214
+ this.#settle(call, () => call.resolve(message.value));
215
+ } else {
216
+ // A core that can't go on is replaced on the next call
217
+ if (message.error.fatal) this.#stopWorker();
218
+ this.#settle(call, () => call.reject(revive(message.error)));
219
+ }
220
+ this.#pump();
221
+ }
222
+
223
+ /**
224
+ * Refuses the running call with `error`, the worker gone with it.
225
+ * @param {Error} error
226
+ */
227
+ #fail(error) {
228
+ this.#stopWorker();
229
+ const call = this.#running;
230
+ this.#running = null;
231
+ if (call) this.#settle(call, () => call.reject(error));
232
+ this.#pump();
233
+ }
234
+
235
+ /** @param {Call} call */
236
+ #abort(call) {
237
+ const reason = call.signal?.reason;
238
+ if (call === this.#running) {
239
+ // A call can't be stopped inside the module: the worker running it is
240
+ this.#stopWorker();
241
+ this.#running = null;
242
+ this.#settle(call, () => call.reject(reason));
243
+ this.#pump();
244
+ return;
245
+ }
246
+ this.#queue = this.#queue.filter((queued) => queued !== call);
247
+ this.#settle(call, () => call.reject(reason));
248
+ }
249
+
250
+ /**
251
+ * @param {Call} call
252
+ * @param {() => void} done
253
+ */
254
+ #settle(call, done) {
255
+ call.signal?.removeEventListener('abort', call.onAbort);
256
+ done();
257
+ }
258
+
259
+ #stopWorker() {
260
+ const worker = this.#worker;
261
+ if (!worker) return;
262
+ this.#worker = null;
263
+ worker.terminate();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * The module as the worker is given it: a URL as its href, a relative one resolved against the
269
+ * page (the worker would resolve it against its own script's).
270
+ * @param {WorkerWasmSource | undefined} wasm
271
+ * @returns {Extract<ToWorker, { type: 'init' }>['wasm']}
272
+ */
273
+ function postable(wasm) {
274
+ if (wasm instanceof URL) return wasm.href;
275
+ if (typeof wasm === 'string' && globalThis.location) return new URL(wasm, location.href).href;
276
+ return wasm;
277
+ }
278
+
279
+ /**
280
+ * A failure from the worker, as the error class it was there.
281
+ * @param {SentError} error
282
+ * @returns {Error}
283
+ */
284
+ function revive({ name, message }) {
285
+ if (name === 'KupcadError') return new KupcadError(message);
286
+ if (name === 'KupcadCrashError') return new KupcadCrashError(message);
287
+ if (name === 'ProtocolError') return new ProtocolError(message);
288
+ const error = new Error(message);
289
+ error.name = name;
290
+ return error;
291
+ }
package/src/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ // What a call can fail with besides a JavaScript error.
2
+
3
+ /** The core refused: a script that doesn't parse or run, values it refused, a format it lacks. */
4
+ export class KupcadError extends Error {
5
+ /** @param {string} message the core's, as get_last_error gives it */
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'KupcadError';
9
+ }
10
+ }
11
+
12
+ /**
13
+ * The module stopped (a trap, or an exit): its instance can't be used again. A worker's is
14
+ * replaced on the next call; a module's own is made anew with `createKupcad`.
15
+ */
16
+ export class KupcadCrashError extends Error {
17
+ /**
18
+ * @param {string} message
19
+ * @param {{ cause?: unknown }} [options]
20
+ */
21
+ constructor(message, options) {
22
+ super(message, options);
23
+ this.name = 'KupcadCrashError';
24
+ }
25
+ }
package/src/files.js ADDED
@@ -0,0 +1,33 @@
1
+ // The files a call gives the core, packed into one buffer as the core reads them
2
+ // (core/src/vfs/packed.zig): each file its path, then its content, each with its length first
3
+ // (u32, little-endian), one after another. One buffer crosses into the module's memory, however
4
+ // many files; the core keeps them for that call alone.
5
+
6
+ /** A length, as written before a path and before a content. */
7
+ const LEN_BYTES = 4;
8
+
9
+ const encoder = new TextEncoder();
10
+
11
+ /**
12
+ * `files` packed: paths as the script names them (`import { } from "./lib.kup"` reads
13
+ * `lib.kup`; `import_json("data/size.json")` reads `data/size.json`) to their contents.
14
+ * @param {Record<string, Uint8Array | string>} files
15
+ * @returns {Uint8Array}
16
+ */
17
+ export function packFiles(files) {
18
+ const fields = Object.entries(files).flatMap(([path, content]) => [
19
+ encoder.encode(path),
20
+ typeof content === 'string' ? encoder.encode(content) : content,
21
+ ]);
22
+ const packed = new Uint8Array(
23
+ fields.reduce((total, field) => total + LEN_BYTES + field.byteLength, 0),
24
+ );
25
+ const view = new DataView(packed.buffer);
26
+ let at = 0;
27
+ for (const field of fields) {
28
+ view.setUint32(at, field.byteLength, true);
29
+ packed.set(field, at + LEN_BYTES);
30
+ at += LEN_BYTES + field.byteLength;
31
+ }
32
+ return packed;
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // @kupcad/wasm: the core compiled to WebAssembly, in this thread (createKupcad) or in a Web
2
+ // Worker (createKupcadWorker), with one API.
3
+
4
+ export { createKupcad, Kupcad } from './kupcad.js';
5
+ export { createKupcadWorker } from './client.js';
6
+ export { KupcadCrashError, KupcadError } from './errors.js';