diff --git a/docs/docs/languages/zig-wasm.mdx b/docs/docs/languages/zig-wasm.mdx new file mode 100644 index 000000000..5aa2d82b2 --- /dev/null +++ b/docs/docs/languages/zig-wasm.mdx @@ -0,0 +1,107 @@ +# Zig (Wasm) + +Zig is a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software. + +In LiveCodes, Zig runs in the browser using WebAssembly with a WASI-compatible runtime environment. + +## Usage + +Demo: + +import LiveCodes from '../../src/components/LiveCodes.tsx'; +export const zigConfig = { + activeEditor: 'script', + script: { + language: 'zig-wasm', + content: `const std = @import("std");\n +pub fn main() !void { + const stdout = std.io.getStdOut().writer();\n + const sorted_array = [_]i32{ 1, 3, 5, 7, 9, 11, 13, 15 }; + const item_to_search: i32 = 7;\n + const result = binarySearch(i32, &sorted_array, item_to_search);\n + if (result == -1) { + try stdout.print("Result: Item not found in the array.\\n", .{}); + } else { + try stdout.print("Result: Item found at index -> {}\\n", .{result}); + } +}\n +fn binarySearch(comptime T: type, arr: []const T, item: T) i32 { + var left: usize = 0; + var right: usize = arr.len;\n + while (left < right) { + const mid = left + (right - left) / 2;\n + if (arr[mid] == item) { + return @intCast(mid); + }\n + if (arr[mid] > item) { + right = mid; + } else { + left = mid + 1; + } + }\n + return -1; +}`, + }, + mode: 'simple', + editor: 'auto', + tools: { + status: 'full', + }, +}; + + + +### Communication with JavaScript + +The Zig code runs in the context of the result page. A few helper properties and methods are available in the browser global `livecodes.zig` object: + +- `livecodes.zig.input`: The initial standard input passed to the Zig code. +- `livecodes.zig.loaded`: A promise that resolves when the Zig environment (WebAssembly runtime) is fully loaded (and rejects if it fails to load). Other helpers should be used after this promise resolves. +- `livecodes.zig.output`: The standard output from the Zig code execution. +- `livecodes.zig.run`: A function that runs the Zig code with new input. This function takes a string as input and returns a promise that resolves with an object containing the `output`, `error`, and `exitCode` properties. + +Example: + + + +## Language Info + +### Name + +`zig-wasm` + +### Aliases / Extensions + +`zig`, `zig-wasm` + +### Editor + +`script` + +## Compiler + +Zig compiler in WebAssembly. + +### Version + +Zig 0.14.0 + +## Code Formatting + +Code formatting is not currently available for Zig. + +## Live Reload + +By default, new code changes are sent to the result page for re-evaluation without a full page reload, avoiding the need to reinitialize the Zig WebAssembly environment. This behavior can be disabled by adding the code comment `// __livecodes_reload__` to the Zig code, which forces a full page reload. + +This comment can be added in the `hiddenContent` property of the editor for embedded playgrounds. + +## Starter Template + +https://livecodes.io/?template=zig-wasm + +## Links + +- [Zig](https://ziglang.org/) +- [Zig documentation](https://ziglang.org/documentation/master/index.html) + diff --git a/docs/src/components/LanguageSliders.tsx b/docs/src/components/LanguageSliders.tsx index 671cc68a6..ab75efe4c 100644 --- a/docs/src/components/LanguageSliders.tsx +++ b/docs/src/components/LanguageSliders.tsx @@ -110,6 +110,7 @@ export default function Sliders() { { name: 'prolog', title: 'Prolog' }, { name: 'minizinc', title: 'MiniZinc' }, { name: 'blockly', title: 'Blockly' }, + { name: 'zig-wasm', title: 'Zig (Wasm)' }, ], }; const slides = ['markup', 'style', 'script']; diff --git a/docs/src/components/TemplateList.tsx b/docs/src/components/TemplateList.tsx index 0e8ef1020..933dc2252 100644 --- a/docs/src/components/TemplateList.tsx +++ b/docs/src/components/TemplateList.tsx @@ -71,6 +71,7 @@ const templates = [ { name: 'minizinc', title: 'MiniZinc Starter', thumbnail: 'minizinc.png' }, { name: 'blockly', title: 'Blockly Starter', thumbnail: 'blockly.svg' }, { name: 'diagrams', title: 'Diagrams Starter', thumbnail: 'diagrams.svg' }, + { name: 'zig-wasm', title: 'Zig (Wasm) Starter', thumbnail: 'zig.svg' }, ]; export default function TemplateList() { diff --git a/functions/vendors/templates.js b/functions/vendors/templates.js index 246b1cb49..20cea016a 100644 --- a/functions/vendors/templates.js +++ b/functions/vendors/templates.js @@ -46,6 +46,7 @@ export const starterTemplates = { "php-wasm": "PHP (Wasm) Starter", "cpp": "C++ Starter", "cpp-wasm": "C++ (Wasm) Starter", + "zig-wasm": "Zig (Wasm) Starter", "java": "Java Starter", "csharp-wasm": "C# (Wasm) Starter", "fsharp": "F# Starter", diff --git a/scripts/build.js b/scripts/build.js index 259ea71d9..665646348 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -230,6 +230,7 @@ const iifeBuild = () => 'languages/cpp-wasm/lang-cpp-wasm-script.ts', 'languages/go-wasm/lang-go-wasm-script.ts', 'languages/csharp-wasm/lang-csharp-wasm-script.ts', + 'languages/zig-wasm/lang-zig-wasm-script.ts', 'languages/dot/lang-dot-compiler.ts', 'languages/ejs/lang-ejs-compiler.ts', 'languages/eta/lang-eta-compiler.ts', diff --git a/server/php/inc/starter-templates.json b/server/php/inc/starter-templates.json index c83ec6fe4..0cd29ee21 100644 --- a/server/php/inc/starter-templates.json +++ b/server/php/inc/starter-templates.json @@ -46,6 +46,7 @@ "php-wasm": "PHP (Wasm) Starter", "cpp": "C++ Starter", "cpp-wasm": "C++ (Wasm) Starter", + "zig-wasm": "Zig (Wasm) Starter", "java": "Java Starter", "csharp-wasm": "C# (Wasm) Starter", "fsharp": "F# Starter", diff --git a/src/livecodes/UI/command-menu-actions.ts b/src/livecodes/UI/command-menu-actions.ts index 55044f39a..232bfe376 100644 --- a/src/livecodes/UI/command-menu-actions.ts +++ b/src/livecodes/UI/command-menu-actions.ts @@ -302,6 +302,7 @@ export const getCommandMenuActions = ({ 'php-wasm', 'cpp', 'cpp-wasm', + 'zig-wasm', 'java', 'csharp-wasm', 'fsharp', diff --git a/src/livecodes/assets/templates/zig.svg b/src/livecodes/assets/templates/zig.svg new file mode 100644 index 000000000..675ab56e4 --- /dev/null +++ b/src/livecodes/assets/templates/zig.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/livecodes/editor/monaco/monaco.ts b/src/livecodes/editor/monaco/monaco.ts index 90a5df0c9..f3118b99b 100644 --- a/src/livecodes/editor/monaco/monaco.ts +++ b/src/livecodes/editor/monaco/monaco.ts @@ -240,8 +240,9 @@ export const createEditor = async (options: EditorOptions): Promise const loadMonacoLanguage = async (lang: Language) => { const langSupport = getLanguageSpecs(lang)?.editorSupport?.monaco?.languageSupport; - if (langSupport && !loadedLanguages.has(lang)) { - loadedLanguages.add(lang); + const mappedLanguage = mapLanguage(lang, 'monaco'); + if (langSupport && !loadedLanguages.has(mappedLanguage)) { + loadedLanguages.add(mappedLanguage); const loadLanguage = typeof langSupport === 'string' ? (await import(langSupport)).default diff --git a/src/livecodes/html/language-info.html b/src/livecodes/html/language-info.html index 33fc4aae7..da69a65b2 100644 --- a/src/livecodes/html/language-info.html +++ b/src/livecodes/html/language-info.html @@ -341,6 +341,39 @@

C# (Wasm)

+ +
+

Zig (Wasm)

+
+ Zig compiler running on WebAssembly. +
+ +
+

Diagrams

(Experimental)
diff --git a/src/livecodes/i18n/locales/en/language-info.lokalise.json b/src/livecodes/i18n/locales/en/language-info.lokalise.json index ecb4d794b..7fe8c02dc 100644 --- a/src/livecodes/i18n/locales/en/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/en/language-info.lokalise.json @@ -1071,5 +1071,17 @@ "wat.name": { "notes": "", "translation": "WebAssembly Text Format" + }, + "zigWasm.desc": { + "notes": "", + "translation": "Zig compiler running on WebAssembly." + }, + "zigWasm.link": { + "notes": "### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n", + "translation": " Zig website Zig language documentation Learn X in Y minutes, where X=Zig LiveCodes Documentation Load starter template " + }, + "zigWasm.name": { + "notes": "", + "translation": "Zig (Wasm)" } } diff --git a/src/livecodes/i18n/locales/en/language-info.ts b/src/livecodes/i18n/locales/en/language-info.ts index e4abb00e1..a17ee49be 100644 --- a/src/livecodes/i18n/locales/en/language-info.ts +++ b/src/livecodes/i18n/locales/en/language-info.ts @@ -459,6 +459,11 @@ const languageInfo = { link: '<1><2>WebAssembly.org <3> <4>WebAssembly Text Specs <5> <6>WebAssembly on MDN <7> <8>Understanding WebAssembly text format <9> <10>wabt.js documentation <11> <12>Learn X in Y minutes, where X=WebAssembly <13> <14>Load starter template ', name: 'WebAssembly Text Format', }, + zigWasm: { + desc: 'Zig compiler running on WebAssembly.', + link: '<1> <2>Zig website <3> <4>Zig language documentation <5> <6>Learn X in Y minutes, where X=Zig <7> <8>LiveCodes Documentation <9> <10>Load starter template ', + name: 'Zig (Wasm)', + }, } as const satisfies I18nTranslationTemplate; export default languageInfo; diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index bbe04bf57..a9bc5f2ba 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -2836,6 +2836,10 @@ "notes": "", "translation": "WebAssembly Text Starter" }, + "templates.starter.zig-wasm": { + "notes": "", + "translation": "Zig (Wasm) Starter" + }, "templates.user.heading": { "notes": "", "translation": "My Templates" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index 84129fe45..085d53a53 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -1055,6 +1055,7 @@ const translation = { vue: 'Vue SFC Starter', vue2: 'Vue 2 Starter', wat: 'WebAssembly Text Starter', + 'zig-wasm': 'Zig (Wasm) Starter', }, user: { heading: 'My Templates', diff --git a/src/livecodes/languages/languages.ts b/src/livecodes/languages/languages.ts index 9921f547a..2cdfec143 100644 --- a/src/livecodes/languages/languages.ts +++ b/src/livecodes/languages/languages.ts @@ -82,6 +82,8 @@ import { vento } from './vento'; import { vue, vueApp } from './vue'; import { vue2 } from './vue2'; import { wat } from './wat'; +import { zigWasm } from './zig-wasm'; + export const languages: LanguageSpecs[] = [ html, markdown, @@ -151,6 +153,7 @@ export const languages: LanguageSpecs[] = [ phpWasm, cpp, cppWasm, + zigWasm, java, csharpWasm, fsharp, diff --git a/src/livecodes/languages/zig-wasm/index.ts b/src/livecodes/languages/zig-wasm/index.ts new file mode 100644 index 000000000..5de310f3b --- /dev/null +++ b/src/livecodes/languages/zig-wasm/index.ts @@ -0,0 +1 @@ +export * from './lang-zig-wasm'; diff --git a/src/livecodes/languages/zig-wasm/lang-zig-wasm-script.ts b/src/livecodes/languages/zig-wasm/lang-zig-wasm-script.ts new file mode 100644 index 000000000..3a7ee2b5d --- /dev/null +++ b/src/livecodes/languages/zig-wasm/lang-zig-wasm-script.ts @@ -0,0 +1,586 @@ +import { getErrorMessage, loadScript } from '../../utils/utils'; +import { jsUntarUrl, wasiShimUrl, zigWasmBaseUrl } from '../../vendors'; +// @ts-ignore +// eslint-disable-next-line import/no-unresolved +import workerContent from './lang-zig-wasm-worker.raw.js?raw'; + +interface ZigResult { + output: string | null; + error: string | null; +} + +interface ZigRunResult extends ZigResult { + exitCode: number; +} + +interface ZigRunner { + readonly ready: boolean; + init(): Promise; + run(source: string, input?: string): Promise; +} + +declare const window: Window & { + livecodes: { + zig: { + ready?: boolean; + failed?: boolean; + error?: string | null; + init?: Promise | null; + runner?: ZigRunner; + loaded?: Promise; + input?: string; + output?: string | null; + exitCode?: number | null; + run?: (input?: string) => Promise; + }; + }; +}; + +// Worker boot includes downloading and extracting the Zig toolchain +// (tens of MB), so allow generous time before falling back to the main thread. +const BOOT_TIMEOUT_MS = 180000; +// A single compile + run is normally quick; a long timeout guards against hangs +// (e.g. an infinite loop in the user program) by killing and respawning the worker. +const RUN_TIMEOUT_MS = 120000; + +// ---- main-thread runner (fallback) ------------------------------------- +// Used when Web Workers are not available in the result page. +function createMainThreadRunner(): ZigRunner { + let ready = false; + let initPromise: Promise | null = null; + let untar: any; + let wasi: any; + let zigStdLib: any; + const compiledModules: Map = new Map(); + // compiled user modules are small; bound the map to avoid unbounded growth + // over a long-lived page (live reload reuses the same runner) + const MAX_CACHED_MODULES = 16; + const isSharedArrayBufferSupported = typeof SharedArrayBuffer !== 'undefined'; + + // hashing function for cache validation + const createHash = (str: string): number => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash * 31 + char) % 0x7fffffff; + } + return hash; + }; + + const ensureDependencies = async (): Promise => { + if (!untar) { + untar = await loadScript(jsUntarUrl, 'untar'); + if (!untar) throw new Error('js-untar failed to load'); + } + + if (!wasi) { + wasi = await import(wasiShimUrl); + } + }; + + const createFile = (data: ArrayBuffer | Uint8Array): any => { + const dataArray = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + + if (isSharedArrayBufferSupported) { + const sharedBuffer = new SharedArrayBuffer(dataArray.length); + const view = new Uint8Array(sharedBuffer); + view.set(dataArray); + + return { + get data() { + return view; + }, + get buffer() { + return sharedBuffer; + }, + }; + } + const { File } = wasi; + return new File(dataArray); + }; + + const processZigArchive = async (response: Response): Promise => { + const { Directory } = wasi; + + const arrayBuffer = await response.arrayBuffer(); + const entries = await untar(arrayBuffer); + const fsMap = new Map(); + + for (const entry of entries) { + if (!entry.name.startsWith('lib/') || entry.type !== '0' || !entry.buffer) continue; + + const path = entry.name.substring(4).split('/'); + let current = fsMap; + + for (let i = 0; i < path.length - 1; i++) { + if (!current.has(path[i])) current.set(path[i], new Map()); + current = current.get(path[i]); + } + + const fileData = createFile(entry.buffer); + + current.set(path[path.length - 1], fileData); + } + + const buildDirectory = (map: Map): any => + new Directory( + Array.from(map.entries()).map(([key, value]) => [ + key, + value instanceof Map ? buildDirectory(value) : value, + ]), + ); + + return buildDirectory(fsMap); + }; + + const doInit = async (): Promise => { + await ensureDependencies(); + + const response = await fetch(`${zigWasmBaseUrl}zig.tar.gz`); + if (!response.ok) throw new Error(`Failed to fetch zig.tar.gz: ${response.statusText}`); + + const arrayBuffer = await response.arrayBuffer(); + + const decompressed = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(arrayBuffer)); + controller.close(); + }, + }).pipeThrough(new DecompressionStream('gzip')), + ); + + zigStdLib = await processZigArchive(decompressed); + ready = true; + }; + + const init = async (): Promise => { + if (ready) return; + if (!initPromise) { + initPromise = doInit().catch((err) => { + initPromise = null; + throw err; + }); + } + return initPromise; + }; + + // compile the user code to a WebAssembly module (cached for identical source, + // e.g. interactive re-runs that only provide new stdin) + const compileZig = async (code: string): Promise => { + const hash = createHash(code); + const cached = compiledModules.get(hash); + if (cached && cached.source === code) return cached.module; + + const { WASI, OpenFile, PreopenDirectory, ConsoleStdout } = wasi; + const errors: string[] = []; + const stderr = ConsoleStdout.lineBuffered((line: string) => errors.push(line)); + + const codeFile = createFile(new TextEncoder().encode(code)); + + const compileWasi = new WASI( + [ + 'zig.wasm', + 'build-exe', + 'main.zig', + '-target', + 'wasm32-wasi', + '-fno-entry', + '-O', + 'ReleaseSmall', + ], + [], + [ + new OpenFile(createFile(new TextEncoder().encode(''))), + createFile(new TextEncoder().encode('')), + stderr, + new PreopenDirectory('.', new Map([['main.zig', codeFile]])), + new PreopenDirectory('/lib', zigStdLib.contents), + new PreopenDirectory('/cache', new Map()), + ], + { debug: false }, + ); + + const wasmResponse = await fetch(`${zigWasmBaseUrl}zig.wasm`); + const wasmArrayBuffer = await wasmResponse.arrayBuffer(); + + let compilerModule: WebAssembly.Module; + if (isSharedArrayBufferSupported) { + const sharedWasmBuffer = new SharedArrayBuffer(wasmArrayBuffer.byteLength); + const sharedWasmView = new Uint8Array(sharedWasmBuffer); + sharedWasmView.set(new Uint8Array(wasmArrayBuffer)); + compilerModule = await WebAssembly.compile(sharedWasmView as any); + } else { + compilerModule = await WebAssembly.compile(wasmArrayBuffer); + } + + const instance = await WebAssembly.instantiate(compilerModule, { + wasi_snapshot_preview1: compileWasi.wasiImport, + }); + + const exitCode = compileWasi.start(instance); + if (exitCode !== 0) { + throw new Error(`Compilation failed: ${errors.join('\n')}`); + } + + const mainWasm = (compileWasi.fds[3] as any).dir.contents.get('main.wasm'); + if (!mainWasm?.data) throw new Error('Compilation output not found'); + + const wasmData = + mainWasm.data instanceof Uint8Array ? mainWasm.data : new Uint8Array(mainWasm.data); + + const module = await WebAssembly.compile(wasmData); + if (compiledModules.size >= MAX_CACHED_MODULES) { + compiledModules.clear(); + } + compiledModules.set(hash, { source: code, module }); + return module; + }; + + const runZig = async (code: string, input = ''): Promise => { + const output: string[] = []; + const errors: string[] = []; + + try { + await init(); + if (!zigStdLib) { + return { output: null, error: 'Zig environment not ready' }; + } + + const { WASI, OpenFile, PreopenDirectory, ConsoleStdout } = wasi; + + const stdout = ConsoleStdout.lineBuffered((line: string) => output.push(line)); + const stderr = ConsoleStdout.lineBuffered((line: string) => errors.push(line)); + + const module = await compileZig(code); + + const inputFile = createFile(new TextEncoder().encode(input)); + + const executeWasi = new WASI( + ['main.wasm'], + [], + [new OpenFile(inputFile), stdout, stderr, new PreopenDirectory('.', new Map())], + { debug: false }, + ); + + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: executeWasi.wasiImport, + }); + + executeWasi.start(instance); + const outputStr = output.join('\n') || null; + const errorStr = errors.join('\n') || null; + + return { output: outputStr, error: errorStr }; + } catch (err) { + compiledModules.clear(); + const error = 'Error: ' + getErrorMessage(err); + return { output: null, error }; + } + }; + + const run = (source: string, input?: string): Promise => runZig(source, input ?? ''); + + return { + init, + run, + get ready() { + return ready; + }, + }; +} + +// ---- worker runner ------------------------------------------------------- +function createWorkerRunner(): ZigRunner { + let worker: Worker | null = null; + let readyPromise: Promise | null = null; + let resolveReady: (() => void) | null = null; + let rejectReady: ((error?: Error) => void) | null = null; + let pending: { + [id: number]: { + resolve: (result: ZigResult) => void; + reject: (error: Error) => void; + timer: ReturnType; + }; + } = {}; + let nextId = 1; + + function onMessage(e: MessageEvent) { + const msg = e.data || {}; + if (msg.type === 'ready') { + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + } else if (msg.type === 'fatal') { + // Boot failed — tear down and reject init so the caller falls back. + if (worker) { + worker.terminate(); + worker = null; + } + if (rejectReady) { + rejectReady(new Error('Zig worker failed to start: ' + msg.message)); + resolveReady = null; + rejectReady = null; + } + } else if (msg.type === 'result') { + const p = pending[msg.id]; + if (p) { + delete pending[msg.id]; + clearTimeout(p.timer); + p.resolve(JSON.parse(msg.json)); + } + } else if (msg.type === 'error') { + const pe = pending[msg.id]; + if (pe) { + delete pending[msg.id]; + clearTimeout(pe.timer); + pe.reject(new Error(msg.message)); + } + } + } + + function onError(err: Error) { + rejectAll(new Error('Zig worker crashed: ' + (err && err.message ? err.message : 'unknown'))); + if (worker) { + worker.terminate(); + worker = null; + } + } + + function rejectAll(err: Error) { + for (const id in pending) { + if (Object.prototype.hasOwnProperty.call(pending, id)) { + const p = pending[id]; + clearTimeout(p.timer); + p.reject(err); + } + } + pending = {}; + } + + function spawn() { + readyPromise = new Promise(function (resolve, reject) { + resolveReady = resolve; + rejectReady = reject; + }); + try { + const toDataUrl = (content: string, type = 'text/javascript') => + `data:${type};charset=UTF-8;base64,` + btoa(content); + + const workerUrl = toDataUrl( + `self.zigWasmBaseUrl = "${zigWasmBaseUrl}"; self.wasiShimUrl = "${wasiShimUrl}";\n\n${workerContent}`, + ); + worker = new Worker(workerUrl); + worker.onmessage = onMessage; + worker.onerror = onError as any; + } catch (err) { + // Workers unavailable in this context (e.g. sandbox restrictions). + worker = null; + readyPromise = null; + resolveReady = null; + rejectReady = null; + throw err; + } + } + + function ensureWorker() { + if (!worker) spawn(); + } + + function init(): Promise { + try { + ensureWorker(); + } catch (err) { + return Promise.reject(err); + } + return new Promise(function (resolve, reject) { + const timer = setTimeout(function () { + if (worker) { + worker.terminate(); + worker = null; + } + reject(new Error('Zig worker failed to start (timeout)')); + }, BOOT_TIMEOUT_MS); + readyPromise?.then( + function () { + clearTimeout(timer); + resolve(); + }, + function (err) { + clearTimeout(timer); + reject(err); + }, + ); + }); + } + + function run(source: string, input?: string): Promise { + try { + ensureWorker(); + } catch (err) { + return Promise.reject(err); + } + if (!readyPromise) return Promise.reject(new Error('Zig worker is not ready')); + return readyPromise.then(function () { + if (!worker) { + // the worker died while we were waiting (boot failure); respawn it + spawn(); + return readyPromise!.then(() => runNow(source, input)); + } + return runNow(source, input); + }); + } + + function runNow(source: string, input?: string): Promise { + const id = nextId++; + return new Promise(function (resolve, reject) { + const timer = setTimeout(function () { + // the worker hung — kill it and let the next run respawn + delete pending[id]; + if (worker) { + worker.terminate(); + worker = null; + } + reject(new Error('Zig compile timed out; restarted the compiler.')); + }, RUN_TIMEOUT_MS); + pending[id] = { resolve, reject, timer }; + worker?.postMessage({ type: 'compile', source, stdin: input ?? '', id }); + }); + } + + return { + init, + run, + get ready() { + return !!worker && resolveReady === null; + }, + }; +} + +// ---- choose runner -------------------------------------------------------- +function isNode() { + return ( + typeof process !== 'undefined' && process.versions != null && process.versions.node != null + ); +} + +function createRunner(): ZigRunner { + const useWorker = !isNode() && typeof Worker === 'function'; + let runner: ZigRunner | null = useWorker ? createWorkerRunner() : null; + const mainRunner = createMainThreadRunner(); + let fellBack = false; + + function init(): Promise { + if (runner && !fellBack) { + return runner.init().catch(function (err) { + // eslint-disable-next-line no-console + console.error('Zig worker unavailable, falling back to main thread:', getErrorMessage(err)); + fellBack = true; + runner = null; + return mainRunner.init(); + }); + } + return mainRunner.init(); + } + + function run(source: string, input?: string): Promise { + return init().then(function () { + return (runner && !fellBack ? runner : mainRunner).run(source, input); + }); + } + + return { + init, + run, + get ready() { + return runner && !fellBack ? runner.ready : mainRunner.ready; + }, + }; +} + +// ---- LiveCodes integration ---------------------------------------------- +window.livecodes.zig ??= {}; + +const livecodesApi = window.livecodes.zig; +livecodesApi.ready = false; +livecodesApi.runner ??= createRunner(); + +livecodesApi.init ??= (function () { + if (livecodesApi.ready) return; + parent.postMessage({ type: 'loading', payload: true }, '*'); + return livecodesApi.runner + ?.init() + .then(function () { + parent.postMessage({ type: 'loading', payload: false }, '*'); + }) + .catch(function (err) { + parent.postMessage({ type: 'loading', payload: false }, '*'); + livecodesApi.ready = false; + livecodesApi.init = null; + livecodesApi.failed = true; + livecodesApi.error = getErrorMessage(err); + // eslint-disable-next-line no-console + console.error('Failed to initialize Zig environment:', err); + throw err; + }); +})(); + +livecodesApi.run = async function (input: string | undefined) { + let code = ''; + const scripts = document.querySelectorAll('script[type="text/zig-wasm"]'); + scripts.forEach(function (script) { + code += script.innerHTML + '\n'; + }); + if (!code.trim()) return { output: null, error: null, exitCode: 0 }; + + livecodesApi.input = input; + livecodesApi.output = null; + livecodesApi.ready = false; + try { + await livecodesApi.init; + const result = await livecodesApi.runner?.run(code, input); + if (!result) throw new Error('Zig runner is not available'); + const error = result.error; + livecodesApi.output = result.output; + livecodesApi.error = error; + livecodesApi.exitCode = error ? 1 : 0; + livecodesApi.ready = true; + if (error != null) { + // eslint-disable-next-line no-console + console.error(error); + } else if (result.output != null) { + // eslint-disable-next-line no-console + console.log(result.output); + } + return { output: result.output, error, exitCode: error ? 1 : 0 }; + } catch (err: any) { + const msg = 'Error: ' + (err && err.message ? err.message : String(err)); + livecodesApi.output = null; + livecodesApi.error = msg; + livecodesApi.exitCode = 1; + livecodesApi.ready = true; + // eslint-disable-next-line no-console + console.error(msg); + return { output: null, error: msg, exitCode: 1 }; + } +}; + +livecodesApi.loaded = new Promise((resolve, reject) => { + const interval = setInterval(function () { + if (livecodesApi.failed) { + clearInterval(interval); + reject(new Error(livecodesApi.error || 'Failed to initialize the Zig environment')); + } else if (livecodesApi.ready) { + clearInterval(interval); + resolve(); + } + }, 50); +}); + +window.addEventListener('load', async function () { + parent.postMessage({ type: 'loading', payload: true }, '*'); + await livecodesApi.run?.(livecodesApi.input); + parent.postMessage({ type: 'loading', payload: false }, '*'); +}); diff --git a/src/livecodes/languages/zig-wasm/lang-zig-wasm-worker.raw.js b/src/livecodes/languages/zig-wasm/lang-zig-wasm-worker.raw.js new file mode 100644 index 000000000..e65199997 --- /dev/null +++ b/src/livecodes/languages/zig-wasm/lang-zig-wasm-worker.raw.js @@ -0,0 +1,284 @@ +// @ts-nocheck +(async function () { + let ready = false; + let wasi = null; + let zigStdLib = null; + let zigCompilerModule = null; + /** @type {any[]} */ + const queue = []; + /** @type {Map} */ + const compiledModules = new Map(); + // compiled user modules are small; bound the map to avoid unbounded growth + // when many distinct sources are compiled over a long-lived worker + const MAX_CACHED_MODULES = 16; + + // injected by the main thread when the worker is created + const { wasiShimUrl, zigWasmBaseUrl } = self; + const isSharedArrayBufferSupported = typeof SharedArrayBuffer !== 'undefined'; + + // hashing function for cache validation + const createHash = (str) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash * 31 + char) % 0x7fffffff; + } + return hash; + }; + + const getErrorMessage = (err) => (err && err.message) || String(err); + + const createFile = (data) => { + const dataArray = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + + if (isSharedArrayBufferSupported) { + const sharedBuffer = new SharedArrayBuffer(dataArray.length); + const view = new Uint8Array(sharedBuffer); + view.set(dataArray); + + return { + get data() { + return view; + }, + get buffer() { + return sharedBuffer; + }, + }; + } + return new wasi.File(dataArray); + }; + + const ensureDependencies = async () => { + if (!wasi) { + wasi = await import(wasiShimUrl); + } + }; + + // Parse the (plain ustar) stdlib archive. js-untar cannot be used inside a + // worker (it references `window`, and cross-origin importScripts is blocked + // for data-URL workers), so extract the entries we need directly. + const parseTar = (buffer) => { + const decoder = new TextDecoder(); + const entries = []; + const readString = (offset, length) => { + let end = offset + length; + for (let i = offset; i < offset + length; i++) { + if (buffer[i] === 0) { + end = i; + break; + } + } + return decoder.decode(buffer.subarray(offset, end)); + }; + + let offset = 0; + while (offset + 512 <= buffer.length) { + const name = readString(offset, 100); + if (!name) break; // end-of-archive marker (zero block) + + const size = parseInt(readString(offset + 124, 12).trim(), 8) || 0; + const type = String.fromCharCode(buffer[offset + 156]); + const prefix = readString(offset + 345, 155); + const dataStart = offset + 512; + + entries.push({ + name: prefix ? prefix + '/' + name : name, + type, + data: buffer.subarray(dataStart, dataStart + size), + }); + + offset = dataStart + Math.ceil(size / 512) * 512; + } + return entries; + }; + + const processZigArchive = async (response) => { + const arrayBuffer = await response.arrayBuffer(); + const entries = parseTar(new Uint8Array(arrayBuffer)); + const fsMap = new Map(); + + for (const entry of entries) { + if (!entry.name.startsWith('lib/') || entry.type !== '0') continue; + + const path = entry.name.substring(4).split('/'); + let current = fsMap; + + for (let i = 0; i < path.length - 1; i++) { + if (!current.has(path[i])) current.set(path[i], new Map()); + current = current.get(path[i]); + } + + // copy the bytes so each file has its own buffer + const fileData = createFile(entry.data.slice()); + + current.set(path[path.length - 1], fileData); + } + + const buildDirectory = (map) => + new wasi.Directory( + Array.from(map.entries()).map(([key, value]) => [ + key, + value instanceof Map ? buildDirectory(value) : value, + ]), + ); + + return buildDirectory(fsMap); + }; + + const fetchArrayBuffer = async (url) => { + const response = await fetch(url); + if (!response.ok) throw new Error('Failed to fetch ' + url + ': ' + response.statusText); + return response.arrayBuffer(); + }; + + const loadToolchain = async () => { + const archiveData = await fetchArrayBuffer(zigWasmBaseUrl + 'zig.tar.gz'); + + const decompressed = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(archiveData)); + controller.close(); + }, + }).pipeThrough(new DecompressionStream('gzip')), + ); + + zigStdLib = await processZigArchive(decompressed); + + // pre-compile the compiler once; it is instantiated for every compilation + const compilerData = await fetchArrayBuffer(zigWasmBaseUrl + 'zig.wasm'); + zigCompilerModule = await WebAssembly.compile(compilerData); + }; + + // compile the user code to a WebAssembly module (cached for identical source, + // e.g. interactive re-runs that only provide new stdin) + const compileZig = async (code) => { + const hash = createHash(code); + const cached = compiledModules.get(hash); + if (cached && cached.source === code) return cached.module; + + const errors = []; + const stderr = wasi.ConsoleStdout.lineBuffered((line) => errors.push(line)); + + const codeFile = createFile(new TextEncoder().encode(code)); + + const compileWasi = new wasi.WASI( + [ + 'zig.wasm', + 'build-exe', + 'main.zig', + '-target', + 'wasm32-wasi', + '-fno-entry', + '-O', + 'ReleaseSmall', + ], + [], + [ + new wasi.OpenFile(createFile(new TextEncoder().encode(''))), + createFile(new TextEncoder().encode('')), + stderr, + new wasi.PreopenDirectory('.', new Map([['main.zig', codeFile]])), + new wasi.PreopenDirectory('/lib', zigStdLib.contents), + new wasi.PreopenDirectory('/cache', new Map()), + ], + { debug: false }, + ); + + const instance = await WebAssembly.instantiate(zigCompilerModule, { + wasi_snapshot_preview1: compileWasi.wasiImport, + }); + + const exitCode = compileWasi.start(instance); + if (exitCode !== 0) throw new Error('Compilation failed: ' + errors.join('\n')); + + const mainWasm = compileWasi.fds[3].dir.contents.get('main.wasm'); + if (!mainWasm || !mainWasm.data) throw new Error('Compilation output not found'); + + const wasmData = + mainWasm.data instanceof Uint8Array ? mainWasm.data : new Uint8Array(mainWasm.data); + const module = await WebAssembly.compile(wasmData); + + if (compiledModules.size >= MAX_CACHED_MODULES) { + compiledModules.clear(); + } + compiledModules.set(hash, { source: code, module }); + return module; + }; + + // compile and run the user code, returning the same result shape the + // main-thread implementation used to produce + const runZig = async (code, stdin) => { + const output = []; + const errors = []; + + try { + const module = await compileZig(code); + + const stdout = wasi.ConsoleStdout.lineBuffered((line) => output.push(line)); + const stderr = wasi.ConsoleStdout.lineBuffered((line) => errors.push(line)); + + const inputFile = createFile(new TextEncoder().encode(stdin || '')); + + const executeWasi = new wasi.WASI( + ['main.wasm'], + [], + [new wasi.OpenFile(inputFile), stdout, stderr, new wasi.PreopenDirectory('.', new Map())], + { debug: false }, + ); + + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: executeWasi.wasiImport, + }); + + executeWasi.start(instance); + } catch (err) { + compiledModules.clear(); + return { output: null, error: 'Error: ' + getErrorMessage(err) }; + } + + return { + output: output.join('\n') || null, + error: errors.join('\n') || null, + }; + }; + + self.onmessage = function (e) { + const data = e.data || {}; + if (data.type !== 'compile') return; + if (!ready || !zigStdLib || !zigCompilerModule) queue.push(data); + else compile(data); + }; + + /** @param {{ source: string; stdin?: string; id: number; }} msg */ + async function compile(msg) { + try { + const result = await runZig(msg.source, msg.stdin); + self.postMessage({ type: 'result', id: msg.id, json: JSON.stringify(result) }); + } catch (err) { + // unexpected failure (runZig handles expected compile/run errors) + self.postMessage({ + type: 'error', + id: msg.id, + message: String((err && err.stack) || err), + }); + } + } + + async function boot() { + try { + await ensureDependencies(); + await loadToolchain(); + ready = true; + queue.splice(0).forEach(compile); + self.postMessage({ type: 'ready' }); + } catch (err) { + self.postMessage({ + type: 'fatal', + message: String((err && err.stack) || err), + }); + } + } + + boot(); +})(); diff --git a/src/livecodes/languages/zig-wasm/lang-zig-wasm.ts b/src/livecodes/languages/zig-wasm/lang-zig-wasm.ts new file mode 100644 index 000000000..1ce25fdff --- /dev/null +++ b/src/livecodes/languages/zig-wasm/lang-zig-wasm.ts @@ -0,0 +1,26 @@ +import { codemirrorLegacy } from '../../editor/codemirror/utils'; +import type { LanguageSpecs } from '../../models'; +import { codeMirrorBaseUrl, monacoLanguagesBaseUrl } from '../../vendors'; + +export const zigWasm: LanguageSpecs = { + name: 'zig-wasm', + title: 'Zig (Wasm)', + compiler: { + factory: () => async (code) => code, + scripts: ({ baseUrl }) => [baseUrl + '{{hash:lang-zig-wasm-script.js}}'], + scriptType: 'text/zig-wasm', + compiledCodeLanguage: 'zig', + liveReload: true, + }, + extensions: ['zig'], + editor: 'script', + editorSupport: { + monaco: { languageSupport: monacoLanguagesBaseUrl + 'zig.js', language: 'zig' }, + codemirror: { + languageSupport: async () => + codemirrorLegacy((await import(codeMirrorBaseUrl + 'codemirror-lang-rust.js')).rust), + }, + codejar: { language: 'zig' }, + }, + largeDownload: true, +}; diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index 7fe856507..1910f2808 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -204,6 +204,7 @@ export interface Compiler { | 'text/prolog' | 'text/minizinc' | 'text/go-wasm' + | 'text/zig-wasm' | 'application/json' | 'application/lua' | 'text/fennel' diff --git a/src/livecodes/templates/starter/index.ts b/src/livecodes/templates/starter/index.ts index 9b6813144..7ffaa6b4a 100644 --- a/src/livecodes/templates/starter/index.ts +++ b/src/livecodes/templates/starter/index.ts @@ -71,6 +71,7 @@ import { typescriptStarter } from './typescript-starter'; import { vueSfcStarter } from './vue-sfc-starter'; import { vue2Starter } from './vue2-starter'; import { watStarter } from './wat-starter'; +import { zigWasmStarter } from './zig-wasm-starter'; export const starterTemplates = [ blank, @@ -120,6 +121,7 @@ export const starterTemplates = [ phpWasmStarter, cppStarter, cppWasmStarter, + zigWasmStarter, javaStarter, csharpWasmStarter, fsharpStarter, diff --git a/src/livecodes/templates/starter/zig-wasm-starter.ts b/src/livecodes/templates/starter/zig-wasm-starter.ts new file mode 100644 index 000000000..59b33f79f --- /dev/null +++ b/src/livecodes/templates/starter/zig-wasm-starter.ts @@ -0,0 +1,91 @@ +import type { Template } from '../../models'; + +export const zigWasmStarter: Template = { + name: 'zig-wasm', + title: window.deps.translateString('templates.starter.zig-wasm', 'Zig (Wasm) Starter'), + thumbnail: 'assets/templates/zig.svg', + activeEditor: 'script', + markup: { + language: 'html', + content: ` +
    +

    Hello, World!

    + +

    You clicked 0 times.

    + +
    + + +`.trimStart(), + }, + style: { + language: 'css', + content: ` +.container, +.container button { + text-align: center; + font: 1em sans-serif; +} +.logo { + width: 150px; +} +`.trimStart(), + }, + script: { + language: 'zig-wasm', + content: ` +const std = @import("std"); + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + const stdin = std.io.getStdIn().reader(); + + const title = "Zig"; + try stdout.print("{s}\\n", .{title}); + + var buf: [100]u8 = undefined; + const input = try stdin.readUntilDelimiterOrEof(&buf, '\\n'); + const count = try std.fmt.parseInt(i32, input.?, 10); + try stdout.print("{}\\n", .{count + 1}); +} +`.trimStart(), + }, +}; diff --git a/src/livecodes/vendors.ts b/src/livecodes/vendors.ts index e8cfb1903..ccc60da6a 100644 --- a/src/livecodes/vendors.ts +++ b/src/livecodes/vendors.ts @@ -279,6 +279,8 @@ export const jsclUrl = /* @__PURE__ */ getUrl( 'gh:jscl-project/jscl-project.github.io@058adc599f0d012718ef3ad28e704a92c4dd741e/jscl.js', ); +export const jsUntarUrl = /* @__PURE__ */ getUrl('js-untar@2.0.0/build/dist/untar.js'); + export const jsZipUrl = /* @__PURE__ */ getUrl('jszip@3.10.1/dist/jszip.js'); export const juliaWasmBaseUrl = /* @__PURE__ */ getUrl('@chriskoch/julia-wasm@1.0.4'); @@ -488,8 +490,12 @@ export const vueSfcLoaderCdnBaseUrl = /* @__PURE__ */ getUrl('vue3-sfc-loader@0. export const wabtjsUrl = /* @__PURE__ */ getUrl('wabt@1.0.35/index.js'); +export const wasiShimUrl = /* @__PURE__ */ getUrl('@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); + export const wasmoonUrl = /* @__PURE__ */ getUrl('wasmoon@1.16.0/dist/index.js'); export const waveDromBaseUrl = /* @__PURE__ */ getUrl('wavedrom@3.2.0/'); export const webRBaseUrl = /* @__PURE__ */ getUrl('webr@0.4.0/dist/'); + +export const zigWasmBaseUrl = /* @__PURE__ */ getUrl('@seth0x41/zig-wasm@1.0.0/'); diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 720a98fca..069569807 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -213,6 +213,9 @@ export type Language = | 'edn' | 'gleam' | 'swift' + | 'rust' + | 'zig' + | 'zig-wasm' | 'rescript' | 'res' | 'resi' @@ -403,6 +406,7 @@ export type TemplateName = | 'csharp-wasm' | 'fsharp' | 'fsharp-wasm' + | 'zig-wasm' | 'perl' | 'lua' | 'lua-wasm' @@ -803,6 +807,7 @@ export type CustomSettings = Partial< | 'text/prolog' | 'text/minizinc' | 'text/go-wasm' + | 'text/zig-wasm' | 'application/json' | 'application/lua' | 'text/fennel'