From 0ba52951318593970db1f8d671461ceea40dd9bd Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:58:30 +0000 Subject: [PATCH] feat: add swipl-web-no-data build variant (external wasm, no data) Adds the missing cell of the build matrix: an external, streamable .wasm (like swipl-web) with no .data file and no base64 embedding (like swipl-bundle-no-data). Boots from a saved state only. - docker/swipl-web-no-data.cmake: new Emscripten link target appended to swipl-devel's EmscriptenTargets.cmake at image build time; guarded so it no-ops if swipl-devel ships the target upstream. - package.json: extract the new artifacts from the build container. - dist/loadImageWeb.ts: loadImageDefault-style helper for the variant. - tests: node + puppeteer smoke tests that boot from a qsave image and assert only the .wasm is fetched (never a .data). - nodejs.yml: cache key now also hashes docker/*, so Docker build changes (including this one) invalidate cached artifacts. Co-Authored-By: Claude Fable 5 --- .github/workflows/nodejs.yml | 5 +++- .gitignore | 4 +++ README.md | 39 +++++++++++++++++++++++++ dist/loadImageWeb.ts | 6 ++++ dist/swipl/swipl-web-no-data.d.ts | 2 ++ docker/Dockerfile | 8 ++++++ docker/swipl-web-no-data.cmake | 27 +++++++++++++++++ examples/browser-no-data.html | 30 +++++++++++++++++++ package.json | 2 ++ tests/browser.js | 48 +++++++++++++++++++++++++++++++ tests/node.js | 40 ++++++++++++++++++++++++++ 11 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 dist/loadImageWeb.ts create mode 100644 dist/swipl/swipl-web-no-data.d.ts create mode 100644 docker/swipl-web-no-data.cmake create mode 100644 examples/browser-no-data.html diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 18c1ee2d..02d025be 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -28,7 +28,10 @@ jobs: with: node-version: "24.15" - run: npm ci - - run: echo "CACHE_KEY=$(cat ./build-config.json | jq '[.[]] | map(.version, .commit) | @tsv')" >> $GITHUB_ENV + # The cache key covers everything that determines the WASM artifacts: + # the pinned dependency versions AND the Docker build definition, so + # that changes to docker/ invalidate previously cached artifacts. + - run: echo "CACHE_KEY=$(cat ./build-config.json | jq '[.[]] | map(.version, .commit) | @tsv')-$(cat docker/* | sha256sum | cut -c1-16)" >> $GITHUB_ENV - name: Restore cached build id: cache diff --git a/.gitignore b/.gitignore index 8ce380fc..0b597446 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules dist/swipl/*.js dist/swipl/swipl-web.data dist/swipl/swipl-web.wasm +dist/swipl/swipl-web-no-data.wasm +examples/no-data-image.pvm examples/webpack/node_modules examples/webpack/public/*.data examples/webpack/public/*.wasm @@ -12,6 +14,8 @@ dist/loadImage.js dist/loadImage.d.ts dist/loadImageDefault.js dist/loadImageDefault.d.ts +dist/loadImageWeb.js +dist/loadImageWeb.d.ts dist/generateImage.js dist/generateImage.d.ts dist/strToBuffer.js diff --git a/README.md b/README.md index f61803ce..95ce30c6 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,45 @@ the `.js` file instead. ``` +## Using the No-Data Web Build + +The build matrix also contains `swipl-web-no-data.js`: like `swipl-web.js` +the WebAssembly stays in an external `swipl-web-no-data.wasm` file (so +browsers can compile it with `WebAssembly.instantiateStreaming` and cache it +independently of your application code), but like `swipl-bundle-no-data.js` +there is no `.data` file at all. + +Because the standard Prolog library is not shipped, this build must be +booted from a saved state (see "Generating an Image" below): + +```html +
+ + +``` + +This is the smallest way to deliver SWI-Prolog over the wire: roughly the +brotli-compressed size of the `.wasm` plus a small JS glue, instead of +additionally downloading the `.data` file (`swipl-web`) or downloading and +parsing a single large JavaScript file with the WebAssembly embedded in +base64 (`swipl-bundle-no-data`). + +You can run this example by executing `npm run test:serve-http` and +visiting (the test +suite generates the saved state used by that page). + ## Generating an Image Often you will want to bundle a pre-built image of your Prolog file. The easiest way to do this is using the `swipl-generate` command to generate the image. For example, in `./examples/generation`, the script `npx swipl-generate ./max.pl ./dist/max.ts` will generate a file `./dist/max.ts` which contains the image of `./max.pl`. This file can then be imported into your project and used as follows: diff --git a/dist/loadImageWeb.ts b/dist/loadImageWeb.ts new file mode 100644 index 00000000..ee0a3aae --- /dev/null +++ b/dist/loadImageWeb.ts @@ -0,0 +1,6 @@ +import SWIPL from './swipl/swipl-web-no-data'; +import { loadImage } from './loadImage' + +export default function(image: string | Buffer | Uint8Array) { + return loadImage(image, SWIPL); +} diff --git a/dist/swipl/swipl-web-no-data.d.ts b/dist/swipl/swipl-web-no-data.d.ts new file mode 100644 index 00000000..e5007941 --- /dev/null +++ b/dist/swipl/swipl-web-no-data.d.ts @@ -0,0 +1,2 @@ +import SWIPL = require("../common"); +export = SWIPL; diff --git a/docker/Dockerfile b/docker/Dockerfile index b8ec0143..bae846a4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -38,6 +38,14 @@ RUN git submodule update --init --depth 1 -j 100 \ packages/nlp packages/pcre packages/plunit packages/sgml packages/RDF \ packages/semweb packages/zlib +# Add the swipl-web-no-data link target: an external (streamable) .wasm +# with no .data preload and no base64 embedding. The snippet is +# appended (rather than applied as a context patch) so it keeps working +# across SWIPL_COMMIT bumps, and it is guarded to become a no-op if +# swipl-devel ever ships the target itself. +COPY swipl-web-no-data.cmake /tmp/swipl-web-no-data.cmake +RUN cat /tmp/swipl-web-no-data.cmake >> /swipl-devel/cmake/EmscriptenTargets.cmake + # Build SWIPL WORKDIR /swipl-devel/build.wasm RUN mkdir -p /swipl-devel/build.wasm diff --git a/docker/swipl-web-no-data.cmake b/docker/swipl-web-no-data.cmake new file mode 100644 index 00000000..c09ae4fc --- /dev/null +++ b/docker/swipl-web-no-data.cmake @@ -0,0 +1,27 @@ + +# Create swipl-web-no-data.js + swipl-web-no-data.wasm +# +# Same packaging as swipl-web (external, streamable .wasm and a small +# MODULARIZE'd JS glue), but without `--preload-file`, so no +# swipl-web-no-data.data is produced and the glue never fetches one. +# Like swipl-bundle-no-data, the result cannot load the standard +# Prolog library and must be booted from a saved state +# (e.g. `-x image.pvm`). +# +# This file is appended to cmake/EmscriptenTargets.cmake of swipl-devel +# by the npm-swipl-wasm Docker build. It reuses WASM_DIST_LINK_FLAGS, +# SWIPL_SRC, PREJS and POSTJS defined earlier in that file. The block +# is guarded so it becomes a no-op if a future swipl-devel ships the +# target itself. +if(NOT TARGET swipl-web-no-data) + set(WASM_NO_DATA_WEB_LINK_FLAGS + -Wno-unused-main) + join_list(WASM_NO_DATA_WEB_LINK_FLAGS_STRING " " + ${WASM_NO_DATA_WEB_LINK_FLAGS} ${WASM_DIST_LINK_FLAGS}) + add_executable(swipl-web-no-data ${SWIPL_SRC}) + set_target_properties(swipl-web-no-data PROPERTIES + LINK_FLAGS "${WASM_NO_DATA_WEB_LINK_FLAGS_STRING}") + target_link_libraries(swipl-web-no-data libswipl) + set_property(TARGET swipl-web-no-data PROPERTY LINK_DEPENDS + ${POSTJS} ${PREJS}) +endif() diff --git a/examples/browser-no-data.html b/examples/browser-no-data.html new file mode 100644 index 00000000..8001f6f2 --- /dev/null +++ b/examples/browser-no-data.html @@ -0,0 +1,30 @@ + + + + + SWI-Prolog WebAssembly no-data browser test + + +
+ + + + diff --git a/package.json b/package.json index bb0ac079..1913137d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "build:wasm-docker:extract:data": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-web.data dist/swipl/swipl-web.data", "build:wasm-docker:extract:wasm": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-web.wasm dist/swipl/swipl-web.wasm", "build:wasm-docker:extract:web": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-web.js dist/swipl/swipl-web.js", + "build:wasm-docker:extract:webNoData": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-web-no-data.js dist/swipl/swipl-web-no-data.js", + "build:wasm-docker:extract:webNoDataWasm": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-web-no-data.wasm dist/swipl/swipl-web-no-data.wasm", "build:wasm-docker:extract:bundle": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-bundle.js dist/swipl/swipl-bundle.js", "build:wasm-docker:extract:bundleNoData": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-bundle-no-data.js dist/swipl/swipl-bundle-no-data.js", "build:wasm-docker:extract:node": "docker cp swipl-wasm:/swipl-devel/build.wasm/src/swipl-web.js dist/swipl/swipl.js", diff --git a/tests/browser.js b/tests/browser.js index ffe1dc04..8a36bfe5 100644 --- a/tests/browser.js +++ b/tests/browser.js @@ -1,3 +1,5 @@ +const assert = require("assert"); +const fs = require("fs"); const http = require("http"); const path = require("path"); const static = require("node-static"); @@ -47,4 +49,50 @@ describe("SWI-Prolog WebAssembly on Browser", () => { server.close(); } }); + + it("should run the no-data web build from a saved state fetching only the wasm", async () => { + // The no-data builds do not ship the Prolog library, so the page + // boots from a saved state that we generate up front. + const imagePath = path.join(__dirname, "..", "examples", "no-data-image.pvm"); + const SWIPL_BUNDLE = require("../dist/swipl/swipl-bundle"); + const bundle = await SWIPL_BUNDLE({ + arguments: ["-q", "-f", "prolog.pl"], + preRun: [(module) => module.FS.writeFile("prolog.pl", "hello(world).\n")], + }); + bundle.prolog.query('qsave_program("image.pvm")').once(); + fs.writeFileSync(imagePath, bundle.FS.readFile("/image.pvm")); + + const server = await createServer(); + try { + const browser = await puppeteer.launch({ headless: "new" }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(1_5000); + page.setDefaultNavigationTimeout(1_5000); + const requestedPaths = []; + page.on("request", (request) => { + requestedPaths.push(new URL(request.url()).pathname); + }); + await page.goto("http://localhost:8080/examples/browser-no-data.html"); + await page.waitForSelector("#solution"); + await page.waitForFunction(() => { + const solutionElement = document.querySelector("#solution"); + return solutionElement.textContent === "world"; + }); + assert.ok( + requestedPaths.some((p) => p.endsWith("/swipl-web-no-data.wasm")), + "the external wasm must be fetched" + ); + assert.ok( + requestedPaths.every((p) => !p.endsWith(".data")), + "no .data file may be fetched" + ); + } finally { + await browser.close(); + } + } finally { + server.close(); + fs.rmSync(imagePath, { force: true }); + } + }); }); diff --git a/tests/node.js b/tests/node.js index cf5a7d21..ad433c73 100644 --- a/tests/node.js +++ b/tests/node.js @@ -1,4 +1,5 @@ const assert = require("assert"); +const fs = require("fs"); const path = require("path"); describe("SWI-Prolog WebAssembly on Node.js", () => { @@ -189,3 +190,42 @@ describe("SWI-Prolog WebAssembly on Node.js", () => { }); } }); + +describe("SWI-Prolog WebAssembly no-data web build", () => { + const SWIPL_WEB_NO_DATA = require("../dist/swipl/swipl-web-no-data"); + const SWIPL_BUNDLE = require("../dist/swipl/swipl-bundle"); + const swiplDir = path.join(__dirname, "..", "dist", "swipl"); + + // Build a saved state to boot from; the no-data builds do not ship + // the Prolog library, so they can only start from an image. + async function generateImage() { + const bundle = await SWIPL_BUNDLE({ + arguments: ["-q", "-f", "prolog.pl"], + preRun: [(module) => module.FS.writeFile("prolog.pl", "hello(world).\n")], + }); + bundle.prolog.query('qsave_program("image.pvm")').once(); + return bundle.FS.readFile("/image.pvm"); + } + + it("should ship an external wasm and no data preload", () => { + const glue = fs.readFileSync(path.join(swiplDir, "swipl-web-no-data.js"), "utf8"); + assert.ok(glue.includes("swipl-web-no-data.wasm"), "glue must reference the external wasm"); + assert.ok(!glue.includes("swipl-web-no-data.data"), "glue must not reference a data preload"); + assert.ok(!fs.existsSync(path.join(swiplDir, "swipl-web-no-data.data")), "no data file may be produced"); + }); + + it("should boot from a saved state fetching only the wasm", async () => { + const image = await generateImage(); + const located = []; + const swipl = await SWIPL_WEB_NO_DATA({ + arguments: ["-q", "-x", "image.pvm"], + preRun: [(module) => module.FS.writeFile("image.pvm", image)], + locateFile: (name) => { + located.push(name); + return path.join(swiplDir, name); + }, + }); + assert.strictEqual(swipl.prolog.query("hello(X).").once().X, "world"); + assert.ok(located.every((name) => !name.endsWith(".data")), "no .data file may be requested"); + }); +});