From 4566d0a5d8f66c71a1487f0eb769c63064e84ef0 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 31 Aug 2026 21:09:35 +0900 Subject: [PATCH] feat: package WebAssembly widget assets --- README.ja.md | 19 ++++ README.ko.md | 18 ++++ README.md | 20 ++++ src/glendix/cmd_ffi.mjs | 198 +++++++++++++++++++++++++++++++++++++- test/glendix_test.gleam | 64 ++++++++++++ test/glendix_test_ffi.mjs | 125 +++++++++++++++++++++++- 6 files changed, 440 insertions(+), 4 deletions(-) diff --git a/README.ja.md b/README.ja.md index f7f3cb8..74447af 100644 --- a/README.ja.md +++ b/README.ja.md @@ -89,6 +89,25 @@ pub fn pie_chart( npm バインディングは Glendix 単体で動作します。 +## WebAssembly 依存関係 + +Glendix は、ブラウザ toolchain が使用する次の標準的な静的 URL 形式の +WebAssembly module を自動的に package 化します。 + +```javascript +new URL("./engine_bg.wasm", import.meta.url) +``` + +生成された Rollup 設定は、各 binary を決定的な content hash 名で widget の +`assets/` directory にコピーします。AMD と ES module の出力には、それぞれ +正しい Mendix runtime path を使用し、query string と fragment を保持します。 +同じ binary を繰り返し参照しても、asset は一度だけ生成されます。 + +自動処理の対象は静的な相対 `.wasm` 参照だけです。参照先が存在しない場合は、 +module と解決済み path を含む error で build が失敗します。Glendix が生成する +`rollup.config.mjs` を置き換える project は、custom 設定で同等の asset 処理を +構成する必要があります。 + ## Marketplace ウィジェットとの組み合わせ ```toml diff --git a/README.ko.md b/README.ko.md index c1afadd..e608298 100644 --- a/README.ko.md +++ b/README.ko.md @@ -93,6 +93,24 @@ pub fn pie_chart( npm 바인딩은 Glendix만으로 동작한다. `binding.element_`와 `binding.void_element`도 제공한다. +## WebAssembly 의존성 + +Glendix는 브라우저 도구가 사용하는 다음 표준 정적 URL 형식의 WebAssembly +모듈을 자동으로 패키징한다. + +```javascript +new URL("./engine_bg.wasm", import.meta.url) +``` + +생성된 Rollup 설정은 각 바이너리를 결정적인 콘텐츠 해시 이름으로 위젯의 +`assets/` 디렉터리에 복사한다. AMD와 ES module 출력에는 각각 올바른 Mendix +런타임 경로를 사용하며 query string과 fragment를 보존한다. 같은 바이너리를 +반복해서 참조해도 자산은 한 번만 생성된다. + +정적인 상대 `.wasm` 참조만 자동 처리한다. 참조 파일이 없으면 module과 해석된 +경로를 포함한 오류로 빌드가 실패한다. Glendix가 생성한 `rollup.config.mjs`를 +교체하는 프로젝트는 custom 설정에서 동등한 자산 처리를 구성해야 한다. + ## Marketplace 위젯과 조합 ```toml diff --git a/README.md b/README.md index a9ff1a3..c28925c 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,26 @@ pub fn pie_chart( `binding.element_` creates an element with children only, and `binding.void_element` creates one without children. +## WebAssembly dependencies + +Glendix automatically packages browser WebAssembly modules referenced with the +standard static URL form used by browser toolchains: + +```javascript +new URL("./engine_bg.wasm", import.meta.url) +``` + +The generated Rollup configuration copies each binary into the widget +`assets/` directory with a deterministic content hash. It rewrites the runtime +URL to the correct Mendix route for both AMD and ES module outputs, so the same +MPK works in classic and modern web clients. Query strings and fragments are +preserved, and repeated references to one binary emit a single asset. + +Only static relative `.wasm` references can be packaged automatically. A +missing referenced file fails the build with its module and resolved path. +Projects that replace Glendix's generated `rollup.config.mjs` must compose +equivalent asset handling in their custom configuration. + ## Installed Marketplace widgets Package acquisition is a separate step owned by mxpak: diff --git a/src/glendix/cmd_ffi.mjs b/src/glendix/cmd_ffi.mjs index f441a7c..47a1fd6 100644 --- a/src/glendix/cmd_ffi.mjs +++ b/src/glendix/cmd_ffi.mjs @@ -1,4 +1,5 @@ import { execSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, existsSync, @@ -13,7 +14,7 @@ import { } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { basename, delimiter, dirname, extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Some, None } from "../../gleam_stdlib/gleam/option.mjs"; import { Ok, Error as GleamError } from "../gleam.mjs"; @@ -266,6 +267,186 @@ export function generate_bindings() { } } +function splitWasmSpecifier(specifier) { + const suffixIndex = specifier.search(/[?#]/); + if (suffixIndex === -1) return { path: specifier, suffix: "" }; + return { + path: specifier.slice(0, suffixIndex), + suffix: specifier.slice(suffixIndex), + }; +} + +function isRelativeWasmSpecifier(specifier) { + const { path } = splitWasmSpecifier(specifier); + return ( + /\.wasm$/i.test(path) && + path !== "" && + !path.startsWith("/") && + !path.startsWith("\\") && + !/^[A-Za-z][A-Za-z\d+.-]*:/.test(path) + ); +} + +function staticStringValue(node) { + if (node?.type === "Literal" && typeof node.value === "string") { + return node.value; + } + if ( + node?.type === "TemplateLiteral" && + node.expressions?.length === 0 && + node.quasis?.length === 1 + ) { + return node.quasis[0].value.cooked; + } + return null; +} + +function isImportMetaUrl(node) { + return ( + node?.type === "MemberExpression" && + node.computed === false && + node.property?.type === "Identifier" && + node.property.name === "url" && + node.object?.type === "MetaProperty" && + node.object.meta?.name === "import" && + node.object.property?.name === "meta" + ); +} + +function collectWasmUrlExpressions(node, expressions) { + if (node === null || node === undefined || typeof node !== "object") return; + if (Array.isArray(node)) { + for (const child of node) collectWasmUrlExpressions(child, expressions); + return; + } + if ( + node.type === "NewExpression" && + node.callee?.type === "Identifier" && + node.callee.name === "URL" && + node.arguments?.length === 2 && + isImportMetaUrl(node.arguments[1]) + ) { + const specifier = staticStringValue(node.arguments[0]); + if (specifier !== null && isRelativeWasmSpecifier(specifier)) { + expressions.push({ start: node.start, end: node.end, specifier }); + } + } + for (const [key, child] of Object.entries(node)) { + if (key !== "parent") collectWasmUrlExpressions(child, expressions); + } +} + +function wasmPublicDirectory(outputFormat, outputFile) { + if (outputFormat !== "amd" && outputFormat !== "es") return null; + const normalized = outputFile.replaceAll("\\", "/"); + const match = normalized.match( + /(?:^|\/)dist\/tmp\/widgets\/(.+)\/[^/]+$/, + ); + if (!match) return null; + const runtimeDirectory = outputFormat === "es" ? "dist" : "widgets"; + return `/${runtimeDirectory}/${match[1]}/assets/`; +} + +function decodedWasmPath(path) { + return decodeURIComponent(path); +} + +function wasmAssetFileName(sourcePath, source) { + const extension = extname(sourcePath); + const name = basename(sourcePath, extension) + .replace(/[^A-Za-z0-9._-]/g, "-") + .replace(/^-+|-+$/g, "") || "module"; + const hash = createHash("sha256").update(source).digest("hex").slice(0, 16); + return `assets/${name}-${hash}${extension.toLowerCase()}`; +} + +export function create_wasm_asset_plugin(outputFormat, outputFile) { + const publicDirectory = wasmPublicDirectory(outputFormat, outputFile); + if (publicDirectory === null) return null; + const emittedSources = new Map(); + const emittedFiles = new Set(); + + return { + name: "glendix-wasm-assets", + transform(code, id) { + if (id.startsWith("\0")) return null; + if (!code.includes("import.meta.url") || !/\.wasm/i.test(code)) { + return null; + } + const expressions = []; + collectWasmUrlExpressions(this.parse(code), expressions); + if (expressions.length === 0) return null; + + const modulePath = id.split(/[?#]/, 1)[0]; + const replacements = []; + for (const expression of expressions) { + const { path, suffix } = splitWasmSpecifier(expression.specifier); + let decodedPath; + try { + decodedPath = decodedWasmPath(path); + } catch (error) { + this.error( + `Could not decode WebAssembly asset path "${path}" referenced by ` + + `"${id}": ${errorMessage(error)}`, + ); + } + const sourcePath = resolve(dirname(modulePath), decodedPath); + let asset = emittedSources.get(sourcePath); + if (asset === undefined) { + let source; + try { + source = readFileSync(sourcePath); + } catch (error) { + this.error( + `Could not package WebAssembly asset "${expression.specifier}" ` + + `referenced by "${id}" at "${sourcePath}": ${errorMessage(error)}`, + ); + } + const fileName = wasmAssetFileName(sourcePath, source); + if (!emittedFiles.has(fileName)) { + this.emitFile({ type: "asset", fileName, source }); + emittedFiles.add(fileName); + } + asset = { fileName }; + emittedSources.set(sourcePath, asset); + } + const runtimeUrl = publicDirectory + asset.fileName.slice("assets/".length) + suffix; + replacements.push({ + start: expression.start, + end: expression.end, + value: `new URL(${JSON.stringify(runtimeUrl)}, document.baseURI)`, + }); + } + + let transformed = code; + replacements + .sort((left, right) => right.start - left.start) + .forEach(replacement => { + transformed = + transformed.slice(0, replacement.start) + + replacement.value + + transformed.slice(replacement.end); + }); + return { code: transformed, map: null }; + }, + }; +} + +const wasmAssetRollupHelper = [ + errorMessage, + splitWasmSpecifier, + isRelativeWasmSpecifier, + staticStringValue, + isImportMetaUrl, + collectWasmUrlExpressions, + wasmPublicDirectory, + decodedWasmPath, + wasmAssetFileName, + create_wasm_asset_plugin, +] + .map(helper => helper.toString()) + .join("\n\n") + "\n\n"; + const forceCloseRollupHelper = `function closeAfterBuild(configs) {\n` + ` if (configs.length === 0) return configs;\n` + @@ -290,15 +471,20 @@ const forceCloseRollupHelper = export function render_rollup_config(secondaryWidgets) { if (secondaryWidgets.length > 0) { - return `import { readFileSync } from "node:fs";\n\n` + + return `import { createHash } from "node:crypto";\n` + + `import { readFileSync } from "node:fs";\n` + + `import { basename, dirname, extname, resolve } from "node:path";\n\n` + + wasmAssetRollupHelper + forceCloseRollupHelper + `export default args => {\n` + ` const configs = args.configDefaultConfig;\n` + ` const secondaryWidgets = ${JSON.stringify(secondaryWidgets)};\n\n` + ` function patchConfig(config) {\n` + ` const origExternal = config.external;\n` + + ` const wasmPlugin = create_wasm_asset_plugin(config.output?.format, config.output?.file ?? "");\n` + ` return {\n` + ` ...config,\n` + + ` plugins: wasmPlugin ? [wasmPlugin, ...(config.plugins ?? [])] : config.plugins,\n` + ` external(id) {\n` + ` if (/^react(-dom)?($|\\/)/.test(id)) return true;\n` + ` if (typeof origExternal === "function") return origExternal(id);\n` + @@ -336,13 +522,19 @@ export function render_rollup_config(secondaryWidgets) { `};\n`; } - return forceCloseRollupHelper + + return `import { createHash } from "node:crypto";\n` + + `import { readFileSync } from "node:fs";\n` + + `import { basename, dirname, extname, resolve } from "node:path";\n\n` + + wasmAssetRollupHelper + + forceCloseRollupHelper + `export default args => {\n` + ` const configs = args.configDefaultConfig;\n` + ` const result = configs.map(config => {\n` + ` const origExternal = config.external;\n` + + ` const wasmPlugin = create_wasm_asset_plugin(config.output?.format, config.output?.file ?? "");\n` + ` return {\n` + ` ...config,\n` + + ` plugins: wasmPlugin ? [wasmPlugin, ...(config.plugins ?? [])] : config.plugins,\n` + ` external(id) {\n` + ` if (/^react(-dom)?($|\\/)/.test(id)) return true;\n` + ` if (typeof origExternal === "function") return origExternal(id);\n` + diff --git a/test/glendix_test.gleam b/test/glendix_test.gleam index fc5e073..d932dd0 100644 --- a/test/glendix_test.gleam +++ b/test/glendix_test.gleam @@ -84,6 +84,58 @@ pub fn cmd_generated_rollup_config_force_closes_test() -> Nil { }) } +/// Verifies generated Rollup configurations install WebAssembly asset support. +pub fn cmd_generated_rollup_config_includes_wasm_assets_test() -> Nil { + [False, True] + |> list.each(fn(with_secondary_widget) { + let source = generated_rollup_config_source(with_secondary_widget) + source + |> string.contains("glendix-wasm-assets") + |> should.be_true + source + |> string.contains("create_wasm_asset_plugin") + |> should.be_true + }) +} + +/// Verifies ES bundles emit one asset and preserve URL suffixes. +pub fn cmd_wasm_asset_es_transform_contract_test() -> Nil { + let summary = wasm_asset_es_transform_summary() + summary + |> string.starts_with("1\nassets/engine-") + |> should.be_true + summary + |> string.contains("/dist/example/widget/assets/engine-") + |> should.be_true + summary + |> string.contains("?cache=1#ready") + |> should.be_true +} + +/// Verifies AMD bundles use the Mendix widget asset route. +pub fn cmd_wasm_asset_amd_transform_contract_test() -> Nil { + wasm_asset_amd_transform_summary() + |> string.contains("/widgets/example/widget/assets/engine-") + |> should.be_true +} + +/// Verifies modules without static WebAssembly references remain unchanged. +pub fn cmd_wasm_asset_noop_contract_test() -> Nil { + wasm_asset_noop_contract() + |> should.be_true +} + +/// Verifies missing WebAssembly files fail with source and module context. +pub fn cmd_wasm_asset_missing_file_contract_test() -> Nil { + let message = wasm_asset_missing_error() + message + |> string.contains("missing.wasm") + |> should.be_true + message + |> string.contains("module.mjs") + |> should.be_true +} + /// Verifies command reporting marks the JavaScript process as failed. pub fn cmd_report_marks_process_failure_test() -> Nil { cmd.report(Error(cmd.CommandFailed("test operation", "test reason"))) @@ -205,6 +257,18 @@ fn test_component() -> binding.JsComponent @external(javascript, "./glendix_test_ffi.mjs", "generated_rollup_config_source") fn generated_rollup_config_source(with_secondary_widget: Bool) -> String +@external(javascript, "./glendix_test_ffi.mjs", "wasm_asset_es_transform_summary") +fn wasm_asset_es_transform_summary() -> String + +@external(javascript, "./glendix_test_ffi.mjs", "wasm_asset_amd_transform_summary") +fn wasm_asset_amd_transform_summary() -> String + +@external(javascript, "./glendix_test_ffi.mjs", "wasm_asset_noop_contract") +fn wasm_asset_noop_contract() -> Bool + +@external(javascript, "./glendix_test_ffi.mjs", "wasm_asset_missing_error") +fn wasm_asset_missing_error() -> String + @external(javascript, "./glendix_test_ffi.mjs", "process_exit_code") fn process_exit_code() -> Int diff --git a/test/glendix_test_ffi.mjs b/test/glendix_test_ffi.mjs index 7db45bd..9f1d81d 100644 --- a/test/glendix_test_ffi.mjs +++ b/test/glendix_test_ffi.mjs @@ -1,5 +1,11 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { toList } from "./gleam.mjs"; -import { render_rollup_config } from "./glendix/cmd_ffi.mjs"; +import { + create_wasm_asset_plugin, + render_rollup_config, +} from "./glendix/cmd_ffi.mjs"; function clone_list(list, clone) { return toList(list.toArray().map(clone)); @@ -62,6 +68,123 @@ export function generated_rollup_config_source(withSecondaryWidget) { return render_rollup_config(withSecondaryWidget ? ["SecondaryWidget"] : []); } +function wasmAst(source, specifiers) { + return { + type: "Program", + body: specifiers.map(specifier => { + const expression = `new URL(${JSON.stringify(specifier)}, import.meta.url)`; + const start = source.indexOf(expression); + if (start === -1) throw new Error(`Missing test expression: ${expression}`); + return { + type: "ExpressionStatement", + expression: { + type: "NewExpression", + start, + end: start + expression.length, + callee: { type: "Identifier", name: "URL" }, + arguments: [ + { type: "Literal", value: specifier }, + { + type: "MemberExpression", + computed: false, + object: { + type: "MetaProperty", + meta: { name: "import" }, + property: { name: "meta" }, + }, + property: { type: "Identifier", name: "url" }, + }, + ], + }, + }; + }), + }; +} + +function transformWasmFixture(outputFormat, specifiers) { + const directory = mkdtempSync(join(tmpdir(), "glendix-wasm-test-")); + try { + const modulePath = join(directory, "module.mjs"); + writeFileSync(join(directory, "engine.wasm"), Buffer.from([0, 97, 115, 109])); + const source = specifiers + .map(specifier => `new URL(${JSON.stringify(specifier)}, import.meta.url)`) + .join(";\n"); + const emitted = []; + const plugin = create_wasm_asset_plugin( + outputFormat, + `/workspace/dist/tmp/widgets/example/widget/Widget.${outputFormat === "es" ? "mjs" : "js"}`, + ); + const result = plugin.transform.call( + { + parse: () => wasmAst(source, specifiers), + emitFile: asset => emitted.push(asset), + error: message => { + throw new Error(message); + }, + }, + source, + modulePath, + ); + return `${emitted.length}\n${emitted[0]?.fileName ?? ""}\n${result?.code ?? ""}`; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +export function wasm_asset_es_transform_summary() { + return transformWasmFixture("es", ["engine.wasm", "engine.wasm?cache=1#ready"]); +} + +export function wasm_asset_amd_transform_summary() { + return transformWasmFixture("amd", ["./engine.wasm"]); +} + +export function wasm_asset_noop_contract() { + const plugin = create_wasm_asset_plugin( + "es", + "/workspace/dist/tmp/widgets/example/widget/Widget.mjs", + ); + return plugin.transform.call( + { + parse: () => ({ type: "Program", body: [] }), + emitFile: () => { + throw new Error("No asset should be emitted"); + }, + }, + "const value = 1;", + "/workspace/module.mjs", + ) === null; +} + +export function wasm_asset_missing_error() { + const directory = mkdtempSync(join(tmpdir(), "glendix-wasm-test-")); + try { + const source = 'new URL("missing.wasm", import.meta.url)'; + const plugin = create_wasm_asset_plugin( + "es", + "/workspace/dist/tmp/widgets/example/widget/Widget.mjs", + ); + try { + plugin.transform.call( + { + parse: () => wasmAst(source, ["missing.wasm"]), + emitFile: () => undefined, + error: message => { + throw new Error(message); + }, + }, + source, + join(directory, "module.mjs"), + ); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + export function process_exit_code() { return process.exitCode ?? 0; }