@live-codes/odin-wasm 0.0.0-stage → 0.2.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.
package/src/engine.js ADDED
@@ -0,0 +1,220 @@
1
+ // The compile → run engine. It has no opinion about where the compiler module
2
+ // and the standard library came from; `assets.js` reads them, this runs them.
3
+ //
4
+ // The compiler is a WASI command module, so every compile is a fresh instance
5
+ // over a fresh in-memory file system: the standard library, the user's file,
6
+ // and an `out/` to write into. The module it emits then runs on one of two
7
+ // hosts, depending on the target it was built for.
8
+
9
+ import { WasiFileSystem, WasiExit, createWasi, runWasi } from './wasi-host.js';
10
+ import { createJsHost, JsExit } from './host-js.js';
11
+
12
+ // Where the compiler looks for base/, core/ and vendor/.
13
+ const ODIN_ROOT = '/odin';
14
+ const PROGRAM = '/out/main.wasm';
15
+
16
+ /** The targets the compiler can emit, and the page can run. */
17
+ export const TARGETS = ['wasi_wasm32', 'js_wasm32'];
18
+
19
+ const encoder = new TextEncoder();
20
+
21
+ const capture = () => {
22
+ const stdoutParts = [];
23
+ const stderrParts = [];
24
+ const order = [];
25
+ return {
26
+ stdout: (text) => {
27
+ stdoutParts.push(text);
28
+ order.push(text);
29
+ },
30
+ stderr: (text) => {
31
+ stderrParts.push(text);
32
+ order.push(text);
33
+ },
34
+ stdoutText: () => stdoutParts.join(''),
35
+ stderrText: () => stderrParts.join(''),
36
+ outputText: () => order.join(''),
37
+ };
38
+ };
39
+
40
+ /** The lines a caller reads as diagnostics, with the empty ones dropped. */
41
+ const diagnosticLines = (text) =>
42
+ text
43
+ .split('\n')
44
+ .map((line) => line.trimEnd())
45
+ .filter((line) => line.trim() !== '');
46
+
47
+ /** Why a compile produced no module, as the lines a caller reads. */
48
+ const compileFailure = (compiled) =>
49
+ compiled.crash
50
+ ? [compiled.crash]
51
+ : compiled.diagnostics.length > 0
52
+ ? compiled.diagnostics
53
+ : [`The compiler produced no module (exit code ${compiled.exitCode}).`];
54
+
55
+ /**
56
+ * @param {object} assets
57
+ * @param {WebAssembly.Module} assets.compilerModule - the Odin compiler
58
+ * @param {[string, Uint8Array][]} assets.libraryEntries - the packed standard library
59
+ */
60
+ export function createEngine({ compilerModule, libraryEntries }) {
61
+ /** A fresh file system with the standard library under /odin. */
62
+ const libraryFs = () => {
63
+ const fs = new WasiFileSystem();
64
+ for (const [path, data] of libraryEntries) fs.writeFile(`${ODIN_ROOT}/${path}`, data);
65
+ // The compiler registers its collections (base, core, vendor) from
66
+ // directories and requires all three to exist; the pack ships no vendor
67
+ // packages, so the directory is empty.
68
+ fs.mkdirAll(`${ODIN_ROOT}/vendor`);
69
+ return fs;
70
+ };
71
+
72
+ const compile = ({ code, target, compileArgs, fileName }) => {
73
+ const fs = libraryFs();
74
+ fs.writeFile(`/src/${fileName}`, encoder.encode(code));
75
+ fs.mkdirAll('/out');
76
+
77
+ const compilerOut = [];
78
+ const compilerErr = [];
79
+ const args = [
80
+ 'odin',
81
+ 'build',
82
+ '/src',
83
+ `-out:${PROGRAM}`,
84
+ '-backend:wasm',
85
+ `-target:${target}`,
86
+ ...compileArgs,
87
+ ];
88
+
89
+ let exitCode = 1;
90
+ let crash = '';
91
+ try {
92
+ exitCode = runWasi(compilerModule, createWasi(fs, {
93
+ args,
94
+ env: [`ODIN_ROOT=${ODIN_ROOT}`],
95
+ stdout: (text) => compilerOut.push(text),
96
+ stderr: (text) => compilerErr.push(text),
97
+ }));
98
+ } catch (error) {
99
+ crash = error?.message ?? String(error);
100
+ }
101
+
102
+ return {
103
+ exitCode,
104
+ artifact: exitCode === 0 ? fs.readFile(PROGRAM) : null,
105
+ diagnostics: diagnosticLines(compilerOut.join('') + compilerErr.join('')),
106
+ crash,
107
+ };
108
+ };
109
+
110
+ /**
111
+ * Compile only, returning the module for the caller to run.
112
+ *
113
+ * A page that needs the DOM has to instantiate the program on the main thread — a worker has no
114
+ * `document`, and Odin's JS runtime is written for the page — so the caller takes the module
115
+ * instead of an output string.
116
+ *
117
+ * @returns {{ok: boolean, wasm: Uint8Array|null, errors: string[], compileMs: number}}
118
+ */
119
+ const compileOnly = ({ code, target, compileArgs = [], fileName = 'main.odin' }) => {
120
+ const started = now();
121
+ const compiled = compile({ code, target, compileArgs: [...compileArgs], fileName });
122
+ const compileMs = Math.round(now() - started);
123
+ if (compiled.artifact === null || compiled.artifact.length === 0) {
124
+ return { ok: false, wasm: null, errors: compileFailure(compiled), compileMs };
125
+ }
126
+ return { ok: true, wasm: compiled.artifact, errors: [], compileMs };
127
+ };
128
+
129
+ const runWasiTarget = (artifact, { input, args }) => {
130
+ const captured = capture();
131
+ let exitCode = 0;
132
+ try {
133
+ exitCode = runWasi(new WebAssembly.Module(artifact.slice()), createWasi(new WasiFileSystem(), {
134
+ args: ['main.wasm', ...args],
135
+ stdin: input,
136
+ stdout: captured.stdout,
137
+ stderr: captured.stderr,
138
+ }));
139
+ } catch (error) {
140
+ if (error instanceof WasiExit) exitCode = error.code;
141
+ else {
142
+ captured.stderr(`The program trapped: ${error?.message ?? error}\n`);
143
+ exitCode = 1;
144
+ }
145
+ }
146
+ return { exitCode, ...capturedText(captured) };
147
+ };
148
+
149
+ const runJsTarget = async (artifact, { input, args }) => {
150
+ const captured = capture();
151
+ const host = createJsHost({ input, args: ['main.wasm', ...args], stdout: captured.stdout, stderr: captured.stderr });
152
+ let exitCode = 0;
153
+ try {
154
+ const { instance } = await WebAssembly.instantiate(artifact.slice(), host.imports);
155
+ host.setMemory(instance.exports.memory);
156
+ if (instance.exports._start) instance.exports._start();
157
+ if (instance.exports._end) instance.exports._end();
158
+ } catch (error) {
159
+ if (error instanceof JsExit) exitCode = error.code;
160
+ else {
161
+ captured.stderr(`The program trapped: ${error?.message ?? error}\n`);
162
+ exitCode = 1;
163
+ }
164
+ }
165
+ return { exitCode, ...capturedText(captured) };
166
+ };
167
+
168
+ /**
169
+ * Compile and run one program.
170
+ *
171
+ * @param {object} params
172
+ * @param {string} params.code
173
+ * @param {string|Uint8Array} params.input
174
+ * @param {string} params.target
175
+ * @param {string[]} params.args
176
+ * @param {string[]} params.compileArgs
177
+ * @param {string} params.fileName
178
+ */
179
+ const compileAndRun = async ({ code, input = '', target, args = [], compileArgs = [], fileName = 'main.odin' }) => {
180
+ const compileStarted = now();
181
+ const compiled = compile({ code, target, compileArgs: [...compileArgs], fileName });
182
+ const compileMs = Math.round(now() - compileStarted);
183
+
184
+ if (compiled.artifact === null || compiled.artifact.length === 0) {
185
+ return { stdout: '', stderr: '', output: '', errors: compileFailure(compiled), exitCode: null, compileMs, runMs: null };
186
+ }
187
+
188
+ const runStarted = now();
189
+ let ran;
190
+ try {
191
+ ran = target === 'js_wasm32'
192
+ ? await runJsTarget(compiled.artifact, { input, args })
193
+ : runWasiTarget(compiled.artifact, { input, args });
194
+ } catch (error) {
195
+ ran = { exitCode: 1, stdout: '', stderr: `The program could not run: ${error?.message ?? error}\n`, output: '', };
196
+ ran.output = ran.stderr;
197
+ }
198
+ const runMs = Math.round(now() - runStarted);
199
+
200
+ return {
201
+ stdout: ran.stdout,
202
+ stderr: ran.stderr,
203
+ output: ran.output,
204
+ errors: [],
205
+ exitCode: ran.exitCode,
206
+ compileMs,
207
+ runMs,
208
+ };
209
+ };
210
+
211
+ return { compileAndRun, compileOnly };
212
+ }
213
+
214
+ const capturedText = (captured) => ({
215
+ stdout: captured.stdoutText(),
216
+ stderr: captured.stderrText(),
217
+ output: captured.outputText(),
218
+ });
219
+
220
+ const now = () => (globalThis.performance?.now?.() ?? Date.now());
package/src/host-js.js ADDED
@@ -0,0 +1,112 @@
1
+ // The host a `js_wasm32` program runs on, outside a page.
2
+ //
3
+ // A js_wasm32 module imports `odin_env` (the console, stdin, `evaluate`, the
4
+ // math intrinsics), `odin_dom` (elements and events), `webgl` and `webgl2`.
5
+ // Only `odin_env` can do anything without a DOM, so the other three are stubs
6
+ // that accept any call and return 0 — a program that reaches for the DOM runs
7
+ // and does nothing rather than failing to instantiate. A page that wants the
8
+ // DOM and the canvas supplies its own host; see the repository's page, which
9
+ // uses Odin's own `odin.js`.
10
+
11
+ /** Thrown by `trap`/`abort`, so an exit is not confused with a real crash. */
12
+ export class JsExit extends Error {
13
+ constructor(code) {
14
+ super(`The program exited (code ${code}).`);
15
+ this.name = 'JsExit';
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ const encoder = new TextEncoder();
21
+ const decoder = new TextDecoder();
22
+
23
+ // Any import the host does not define resolves to a no-op returning 0, so a
24
+ // module written against a different set of host functions still instantiates.
25
+ const withFallback = (target) =>
26
+ new Proxy(target, {
27
+ get: (object, property) =>
28
+ typeof property === 'string' && !(property in object) ? () => 0 : object[property],
29
+ });
30
+
31
+ /**
32
+ * @param {object} options
33
+ * @param {string|Uint8Array} [options.input] - the program's stdin, handed out once
34
+ * @param {string[]} [options.args] - argv, including argv[0]
35
+ * @param {(text: string) => void} [options.stdout]
36
+ * @param {(text: string) => void} [options.stderr]
37
+ */
38
+ export function createJsHost({ input = '', args = [], stdout = () => {}, stderr = () => {} } = {}) {
39
+ const inputBytes = typeof input === 'string' ? encoder.encode(input) : new Uint8Array(input);
40
+ let inputOffset = 0;
41
+
42
+ let memory = null;
43
+ const bytes = () => new Uint8Array(memory.buffer);
44
+ const readString = (ptr, len) => decoder.decode(bytes().subarray(ptr, ptr + len));
45
+
46
+ // Any call to a DOM or WebGL import is accepted and returns 0.
47
+ const inertNamespace = new Proxy(
48
+ {},
49
+ { get: (_target, property) => (typeof property === 'string' ? () => 0 : undefined) }
50
+ );
51
+
52
+ const odin_env = {
53
+ write(fd, ptr, len) {
54
+ const text = readString(ptr, len);
55
+ (fd === 2 ? stderr : stdout)(text);
56
+ },
57
+ // Standard input, for the standard library's js streams — see the
58
+ // repository's patches/core/os/file_js.odin, which is in the shipped pack.
59
+ read(fd, ptr, len) {
60
+ if (fd !== 0) return 0;
61
+ const count = Math.max(0, Math.min(len, inputBytes.length - inputOffset));
62
+ if (count > 0) {
63
+ bytes().set(inputBytes.subarray(inputOffset, inputOffset + count), ptr);
64
+ inputOffset += count;
65
+ }
66
+ return count;
67
+ },
68
+ trap() {
69
+ throw new JsExit(1);
70
+ },
71
+ abort() {
72
+ throw new JsExit(1);
73
+ },
74
+ alert() {},
75
+ // The one interop call that works without a DOM: it evaluates in the global
76
+ // scope, so a page that embeds this can draw through it.
77
+ evaluate(ptr, len) {
78
+ const source = readString(ptr, len);
79
+ (0, globalThis.eval)(source);
80
+ },
81
+ open() {},
82
+ time_now: () => BigInt(Date.now()),
83
+ tick_now: () => globalThis.performance?.now?.() ?? Date.now(),
84
+ time_sleep() {},
85
+ sqrt: Math.sqrt,
86
+ sin: Math.sin,
87
+ cos: Math.cos,
88
+ pow: Math.pow,
89
+ fmuladd: (x, y, z) => x * y + z,
90
+ ln: Math.log,
91
+ exp: Math.exp,
92
+ ldexp: (x, exponent) => x * 2 ** exponent,
93
+ rand_bytes(ptr, len) {
94
+ bytes().set(globalThis.crypto.getRandomValues(new Uint8Array(len)), ptr);
95
+ },
96
+ };
97
+
98
+ return {
99
+ imports: {
100
+ env: withFallback({}),
101
+ odin_env: withFallback(odin_env),
102
+ odin_dom: inertNamespace,
103
+ webgl: inertNamespace,
104
+ webgl2: inertNamespace,
105
+ },
106
+ /** The module exports its own memory, so it is set after instantiation. */
107
+ setMemory(mem) {
108
+ memory = mem;
109
+ },
110
+ args,
111
+ };
112
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // The entry every environment gets unless something more specific matches, so
2
+ // it has to work without a filesystem: no `node:fs` here, and `baseUrl` is
3
+ // required because a browser cannot read a file that lives inside an npm package.
4
+ import { createApi } from './api.js';
5
+
6
+ const api = createApi({ packaged: null });
7
+
8
+ export const createCompiler = api.createCompiler;
9
+ export const { TARGETS } = api;
@@ -0,0 +1,9 @@
1
+ // Node, where the assets that ship in this package can be read off disk, so
2
+ // `baseUrl` becomes optional and `createCompiler()` is enough on its own.
3
+ import { createApi } from './api.js';
4
+ import { packagedAssets } from './packaged.node.js';
5
+
6
+ const api = createApi({ packaged: packagedAssets });
7
+
8
+ export const createCompiler = api.createCompiler;
9
+ export const { TARGETS } = api;
package/src/pack.js ADDED
@@ -0,0 +1,40 @@
1
+ // The packed standard library: Odin's `base/` and `core/`, in the container the
2
+ // repository's packer writes.
3
+ //
4
+ // The compiler reads its standard library from files, so a page must give it
5
+ // one. A flat, self-describing container is smaller than a tar (no per-entry
6
+ // headers) and inflates with the one decompressor a browser and Node both have.
7
+ //
8
+ // Format (before gzip):
9
+ // "ODINPOC1" 8 bytes
10
+ // count u32 little-endian
11
+ // count × { pathLen:u32, path:utf8, dataLen:u32, data:bytes }
12
+
13
+ export const PACK_MAGIC = 'ODINPOC1';
14
+
15
+ /**
16
+ * @param {ArrayBuffer|Uint8Array} buffer - the inflated container
17
+ * @returns {[string, Uint8Array][]} `base/…`, `core/…` and their bytes
18
+ */
19
+ export function unpackLibrary(buffer) {
20
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
21
+ if (new TextDecoder().decode(bytes.subarray(0, 8)) !== PACK_MAGIC) {
22
+ throw new Error(`The standard library pack is not a ${PACK_MAGIC} container.`);
23
+ }
24
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
25
+ const decoder = new TextDecoder();
26
+ const count = view.getUint32(8, true);
27
+ const entries = [];
28
+ let pos = 12;
29
+ for (let i = 0; i < count; i++) {
30
+ const pathLen = view.getUint32(pos, true);
31
+ pos += 4;
32
+ const path = decoder.decode(bytes.subarray(pos, pos + pathLen));
33
+ pos += pathLen;
34
+ const dataLen = view.getUint32(pos, true);
35
+ pos += 4;
36
+ entries.push([path, bytes.subarray(pos, pos + dataLen)]);
37
+ pos += dataLen;
38
+ }
39
+ return entries;
40
+ }
@@ -0,0 +1,13 @@
1
+ // The Node half of the packaged-asset source: reading the files that ship in
2
+ // this package.
3
+ //
4
+ // This module is only reachable through the `node` condition in package.json,
5
+ // so a browser bundle never includes it and never trips over `node:fs`.
6
+ import { readFile } from 'node:fs/promises';
7
+
8
+ export const PACKAGED_ROOT = new URL('../assets/', import.meta.url);
9
+
10
+ export const packagedAssets = {
11
+ root: PACKAGED_ROOT,
12
+ readFile: async (name) => new Uint8Array(await readFile(new URL(name, PACKAGED_ROOT))),
13
+ };
package/src/runtime.js ADDED
@@ -0,0 +1,40 @@
1
+ // The engines, one per asset source.
2
+ //
3
+ // A source is keyed by where its bytes came from, so creating two compilers
4
+ // against the same `baseUrl` — or the same package — compiles the compiler
5
+ // module and unpacks the standard library once. Every compiler holds a
6
+ // reference; the entry is dropped when the last one is disposed.
7
+
8
+ import { inflateGzip } from './assets.js';
9
+ import { createEngine } from './engine.js';
10
+ import { unpackLibrary } from './pack.js';
11
+
12
+ const engines = new Map();
13
+
14
+ async function loadEngine(source, onProgress) {
15
+ const report = (name) => (loaded, total) => onProgress?.({ name, loaded, total });
16
+ const wasmBytes = await source.readAsset('odin.wasm', report('odin.wasm'));
17
+ const packBytes = await source.readAsset('odin-src.pack.gz', report('odin-src.pack.gz'));
18
+ const compilerModule = await WebAssembly.compile(wasmBytes);
19
+ const libraryEntries = unpackLibrary(await inflateGzip(packBytes));
20
+ return createEngine({ compilerModule, libraryEntries });
21
+ }
22
+
23
+ /** @returns {{ promise: Promise<object>, refs: number, key: string }} */
24
+ export function acquireEngine(source, onProgress) {
25
+ let entry = engines.get(source.key);
26
+ if (entry === undefined) {
27
+ entry = { key: source.key, refs: 0, promise: loadEngine(source, onProgress) };
28
+ engines.set(source.key, entry);
29
+ // A failed load must not be cached, or every later attempt reports the same
30
+ // stale failure.
31
+ entry.promise.catch(() => engines.delete(source.key));
32
+ }
33
+ entry.refs += 1;
34
+ return entry;
35
+ }
36
+
37
+ export function releaseEngine(entry) {
38
+ entry.refs -= 1;
39
+ if (entry.refs <= 0) engines.delete(entry.key);
40
+ }