Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
198 changes: 195 additions & 3 deletions src/glendix/cmd_ffi.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
Expand All @@ -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";
Expand Down Expand Up @@ -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` +
Expand All @@ -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` +
Expand Down Expand Up @@ -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` +
Expand Down
Loading
Loading