@live-codes/lfortran-wasm 0.1.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.
Binary file
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // Vendors the built LFortran wasm module into this package's assets/.
3
+ //
4
+ // node bin/copy-assets.mjs [sourceDir]
5
+ //
6
+ // `sourceDir` defaults to docker/lfortran-wasm/out in this repository, which is where the build
7
+ // container writes its output. The raw wasm is never copied: it is 70.75 MiB, past what a CDN will
8
+ // serve for a package file, so this gzips it to 19.04 MiB and the loader decompresses it in the
9
+ // client with DecompressionStream. See src/index.js for why gzip rather than brotli.
10
+ //
11
+ // The three artifacts come from one another, so they must come from a single build:
12
+ // wasm_run.js the emscripten ES module glue (EXPORT_ES6=1)
13
+ // wasm_run.wasm.gz the compiler and the LLVM backend it carries
14
+ // wasm_run.data the runtime .mod files, preloaded into the wasm filesystem at /lib
15
+ import { createHash } from 'node:crypto';
16
+ import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { gzipSync } from 'node:zlib';
20
+
21
+ const PACKAGE_DIR = fileURLToPath(new URL('../', import.meta.url));
22
+ const DEFAULT_SOURCE = fileURLToPath(new URL('../../../docker/lfortran-wasm/out/', import.meta.url));
23
+ const sourceDir = process.argv[2] ? process.argv[2] : DEFAULT_SOURCE;
24
+ const assetsDir = join(PACKAGE_DIR, 'assets');
25
+
26
+ const mib = (bytes) => `${(bytes / 1048576).toFixed(2)} MiB`;
27
+
28
+ async function requireFile(name) {
29
+ const path = join(sourceDir, name);
30
+ try {
31
+ await stat(path);
32
+ } catch {
33
+ throw new Error(
34
+ `${path} is missing. Build it first:\n` +
35
+ ' docker build -t lfortran-wasm-build docker/lfortran-wasm\n' +
36
+ ' docker run -d --name lfortran-wasm-run lfortran-wasm-build\n' +
37
+ ' docker cp lfortran-wasm-run:/src/build-wasm/src/bin/wasm_run.js docker/lfortran-wasm/out/\n' +
38
+ ' docker cp lfortran-wasm-run:/src/build-wasm/src/bin/wasm_run.wasm docker/lfortran-wasm/out/\n' +
39
+ ' docker cp lfortran-wasm-run:/src/build-wasm/src/bin/wasm_run.data docker/lfortran-wasm/out/',
40
+ );
41
+ }
42
+ return path;
43
+ }
44
+
45
+ await mkdir(assetsDir, { recursive: true });
46
+
47
+ // Copied or generated, then verified by reading each one back.
48
+ const writes = [
49
+ { name: 'wasm_run.js', from: await requireFile('wasm_run.js') },
50
+ { name: 'wasm_run.data', from: await requireFile('wasm_run.data') },
51
+ ];
52
+
53
+ const rawWasm = await readFile(await requireFile('wasm_run.wasm'));
54
+ const gzipped = gzipSync(rawWasm, { level: 9 });
55
+ await writeFile(join(assetsDir, 'wasm_run.wasm.gz'), gzipped);
56
+
57
+ for (const asset of writes) {
58
+ await copyFile(asset.from, join(assetsDir, asset.name));
59
+ }
60
+
61
+ let total = 0;
62
+ console.log(`vendored into ${assetsDir}\n`);
63
+ for (const name of ['wasm_run.js', 'wasm_run.wasm.gz', 'wasm_run.data']) {
64
+ const bytes = await readFile(join(assetsDir, name));
65
+ const digest = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
66
+ total += bytes.length;
67
+ const note = name === 'wasm_run.wasm.gz' ? ` (from ${mib(rawWasm.length)} raw)` : '';
68
+ console.log(` ${name.padEnd(20)} ${mib(bytes.length).padStart(9)} sha256:${digest}${note}`);
69
+ }
70
+ console.log(` ${'total'.padEnd(20)} ${mib(total).padStart(9)}`);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@live-codes/lfortran-wasm",
3
+ "version": "0.1.0",
4
+ "description": "Run modern Fortran in the browser or in Node, on LFortran's LLVM backend compiled to WebAssembly",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "lfortran-wasm-copy-assets": "./bin/copy-assets.mjs"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "bin",
16
+ "assets",
17
+ "THIRD-PARTY-NOTICES.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "copy-assets": "node bin/copy-assets.mjs",
24
+ "test": "node --test \"test/*.test.js\""
25
+ },
26
+ "keywords": [
27
+ "fortran",
28
+ "lfortran",
29
+ "wasm",
30
+ "webassembly",
31
+ "compiler",
32
+ "f90",
33
+ "llvm",
34
+ "playground"
35
+ ]
36
+ }
package/src/index.js ADDED
@@ -0,0 +1,170 @@
1
+ // Runs modern Fortran in the browser or in Node, on LFortran's LLVM backend compiled to WebAssembly.
2
+ //
3
+ // The module is LFortran (BSD-3-Clause) built with Emscripten against LLVM, linked `-s MAIN_MODULE=1`
4
+ // because its executor loads the program it just compiled with `dlopen`. There is no linker
5
+ // subprocess in a browser, so compilation and execution both happen in-process. See
6
+ // THIRD-PARTY-NOTICES.md. Nothing here is threaded: no pthreads anywhere, so the host needs neither
7
+ // SharedArrayBuffer nor cross-origin isolation.
8
+ //
9
+ // Three details in this loader are load-bearing, each learned the hard way:
10
+ //
11
+ // 1. The glue is built EXPORT_ES6=1 + MODULARIZE=1, so importing it yields a factory. That is why
12
+ // there is one code path for a page, a worker and Node: the alternative is a script tag in a
13
+ // browser and a `vm` context in Node, and the realm mismatch that creates made a handled
14
+ // TypeError inside emscripten's addFunction surface as a raw WebAssembly.Table.set error.
15
+ //
16
+ // 2. The wasm ships gzipped. Raw it is 70.75 MiB, past what a CDN will serve for a package file;
17
+ // gzipped it is 19.04 MiB. Decompressing here and passing `wasmBinary` also stops emscripten from
18
+ // fetching the uncompressed file itself. gzip rather than the smaller brotli (12.90 MiB) because
19
+ // DecompressionStream can only do gzip and deflate.
20
+ //
21
+ // 3. stdin is wired by pointing fd 0 at a MEMFS file, not by setting `Module.stdin`.
22
+ // FS.createStandardStreams only creates a device for /dev/stdin `if (input)`, and that sits
23
+ // behind emscripten's compile-time expectToReceiveOnModule('stdin') check; otherwise /dev/stdin
24
+ // symlinks to /dev/tty, whose fallback reads the host's stdin. That is how a stray NUL byte
25
+ // reached Fortran's strtol and produced `Invalid input for int32_t`.
26
+
27
+ const isNode = typeof process !== 'undefined' && process.versions?.node != null;
28
+
29
+ // Emscripten calls `print` and `printErr` once per line, with the newline consumed, so the separator
30
+ // has to be put back or consecutive writes run together.
31
+ const joinLines = (lines) => lines.join('\n');
32
+
33
+ async function readGzippedBytes(url) {
34
+ // Node's fetch does not handle file: URLs, so Node reads and inflates directly.
35
+ if (String(url).startsWith('file:')) {
36
+ const { readFile } = await import('node:fs/promises');
37
+ const { gunzipSync } = await import('node:zlib');
38
+ return new Uint8Array(gunzipSync(await readFile(url)));
39
+ }
40
+ const response = await fetch(url);
41
+ if (!response.ok) {
42
+ throw new Error(`fetching ${url}: ${response.status} ${response.statusText}`);
43
+ }
44
+ const decompressed = response.body.pipeThrough(new DecompressionStream('gzip'));
45
+ return new Uint8Array(await new Response(decompressed).arrayBuffer());
46
+ }
47
+
48
+ function makeSetStdin(module) {
49
+ const encoder = new TextEncoder();
50
+ return (text) => {
51
+ const FS = module.FS;
52
+ if (!FS) {
53
+ throw new Error('FS was not exported, so stdin cannot be wired');
54
+ }
55
+ FS.writeFile('/.stdin', encoder.encode(text));
56
+ const stream = FS.open('/.stdin', 'r');
57
+ if (FS.streams[0]) {
58
+ FS.close(FS.streams[0]);
59
+ }
60
+ FS.streams[0] = stream;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Load the compiler. Do this once and reuse it; each `run` is milliseconds.
66
+ *
67
+ * @param {object} [options]
68
+ * @param {string|URL} [options.baseUrl] where to fetch `wasm_run.js`, `wasm_run.wasm.gz` and
69
+ * `wasm_run.data`. Defaults to the assets shipped in this package, which is what a bundler sees;
70
+ * pass a CDN URL to load them from elsewhere instead.
71
+ * @param {string|URL} [options.glueUrl] override the emscripten ES module location
72
+ * @param {string|URL} [options.assetBaseUrl] override where `wasm_run.data` is read from
73
+ * @param {string|URL} [options.wasmUrl] override the gzipped wasm location
74
+ * @param {ArrayBuffer} [options.wasmBinary] already-decompressed wasm, skips the download
75
+ * @param {(line: string) => void} [options.print] receive program output as it is written
76
+ * @param {(line: string) => void} [options.printErr] receive the runtime's stderr
77
+ * @returns {Promise<{run: (code: string, stdin?: string) => Promise<{stdout: string, errors: string, exitCode: number|null, runMs: number}>}>}
78
+ */
79
+ export async function createCompiler(options = {}) {
80
+ const { baseUrl, glueUrl, assetBaseUrl, wasmUrl, wasmBinary, print, printErr } = options;
81
+
82
+ const assetBase = new URL(assetBaseUrl ?? (baseUrl ? new URL(baseUrl, import.meta.url) : new URL('../assets/', import.meta.url)), import.meta.url);
83
+ // Under Node, emscripten reads its assets with `fs`, so locateFile has to hand back a filesystem
84
+ // path; in a browser it fetches, so it needs a URL. Returning a file: URL to the Node path gets it
85
+ // concatenated onto the script directory and the read fails.
86
+ const inNode = isNode && assetBase.protocol === 'file:';
87
+ const toPath = inNode ? (await import('node:url')).fileURLToPath : null;
88
+ const locateFile = (name) => {
89
+ const url = new URL(name, assetBase);
90
+ return inNode ? toPath(url) : url.href;
91
+ };
92
+
93
+ const stdout = [];
94
+ const stderr = [];
95
+ const binary = wasmBinary ?? (await readGzippedBytes(new URL(wasmUrl ?? 'wasm_run.wasm.gz', assetBase)));
96
+ const glue = await import(new URL(glueUrl ?? 'wasm_run.js', assetBase).href);
97
+ const factory = glue.default ?? glue.createLFortran;
98
+ if (typeof factory !== 'function') {
99
+ throw new Error('the glue module did not export a factory');
100
+ }
101
+
102
+ const module = await factory({
103
+ locateFile,
104
+ wasmBinary: binary,
105
+ print: (line) => (print ? print(line) : stdout.push(line)),
106
+ printErr: (line) => (printErr ? printErr(line) : stderr.push(line)),
107
+ });
108
+ const runFortran = module.cwrap('run_fortran', 'string', ['string']);
109
+ const setStdin = makeSetStdin(module);
110
+
111
+ return {
112
+ // The raw emscripten module. Exposed because the virtual filesystem is genuinely useful to a
113
+ // caller: program output files can be read out of it, and inputs written in. It is also how the
114
+ // build probes enumerate what a compiled program imports.
115
+ module,
116
+
117
+ /**
118
+ * Compile and run one Fortran program.
119
+ *
120
+ * `exitCode` is 0 when the program ran, and null when it did not — a compile error, or the
121
+ * program calling exit(). A Fortran exit() arrives as a thrown ExitStatus rather than a
122
+ * return value, so it is caught here and reported instead of escaping.
123
+ */
124
+ async run(code, stdin = '') {
125
+ stdout.length = 0;
126
+ stderr.length = 0;
127
+ setStdin(stdin);
128
+ const startedAt = performance.now();
129
+ // A Fortran exit() reaches node as a thrown ExitStatus *and* sets the host process's exit
130
+ // code, which makes a test runner report the whole file as failed even though every test
131
+ // assertion passed. A program run in the sandbox must not decide its host's exit status,
132
+ // so it is put back.
133
+ const hostExitCode = isNode ? process.exitCode : undefined;
134
+ try {
135
+ let status;
136
+ try {
137
+ status = runFortran(code);
138
+ } catch (error) {
139
+ return {
140
+ stdout: joinLines(stdout),
141
+ errors: joinLines(stderr) || `the program terminated: ${error?.message ?? error}`,
142
+ exitCode: null,
143
+ runMs: performance.now() - startedAt,
144
+ };
145
+ }
146
+ if (status === '0') {
147
+ return {
148
+ stdout: joinLines(stdout),
149
+ errors: joinLines(stderr),
150
+ exitCode: 0,
151
+ runMs: performance.now() - startedAt,
152
+ };
153
+ }
154
+ // Everything after the comma is LFortran's own rendered diagnostic.
155
+ return {
156
+ stdout: joinLines(stdout),
157
+ errors: status.replace(/^1,/, '') || joinLines(stderr),
158
+ exitCode: null,
159
+ runMs: performance.now() - startedAt,
160
+ };
161
+ } finally {
162
+ if (isNode) {
163
+ process.exitCode = hostExitCode;
164
+ }
165
+ }
166
+ },
167
+ };
168
+ }
169
+
170
+ export default createCompiler;