From e9fd8f518998672392e10847205f4fa1dca105a0 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 22:07:34 +0800 Subject: [PATCH] fix(docker): serve x2t WASM as gzip on plain HTTP --- CHANGELOG.md | 2 + CLAUDE.md | 8 +++- Dockerfile | 5 ++- bin/prepare-docker-wasm.mjs | 18 +++++++++ .../2026-10-14-docker-http-wasm-encoding.md | 37 +++++++++++++++++++ docs/test-matrix.md | 2 +- packages/converter/src/document-converter.ts | 12 +++--- playwright.docker.config.ts | 5 +++ public/sdkjs/common/wasm/x2t/x2t_helper.js | 9 ++--- sws.toml | 10 +++-- test/e2e/docker-cache-headers.spec.ts | 28 ++++++++++++++ test/unit/docker-wasm.test.ts | 31 ++++++++++++++++ test/unit/hosting-contract.test.ts | 12 +++--- 13 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 bin/prepare-docker-wasm.mjs create mode 100644 docs/explorations/2026-10-14-docker-http-wasm-encoding.md create mode 100644 test/unit/docker-wasm.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 795b8f10d..206da67f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Docker deployments accessed over ordinary HTTP can load the document converter + in Chrome without a WASM content-decoding error ([#267](https://github.com/ranuts/document/issues/267)). - Stopping a browser-local assistant response no longer prevents the next question from being answered. - Cancelling a save keeps the unsaved-changes warning and recovery status intact. diff --git a/CLAUDE.md b/CLAUDE.md index 001a077d6..4cc998b0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -905,8 +905,12 @@ v7 代码分支(OO_VARIANT、页面级 x2t 打开转换、empty_bin 模板、v `x2t.wasm.br`(6,898,179 字节,`brotli -q 11`;对比最好的 gzip(zopfli `--i15`) 9,483,006、裸 42,111,200——全站最大的那个下载省掉 2.58 MB。brotli 不是仓库依赖, vendor 升级后手动重跑 `brotli -q 11 -c x2t.wasm > x2t.wasm.br`,`vendor-contract` - 的尺寸门会提醒)。三处托管各自声明 `Content-Encoding: br`(`public/_headers` / - `sws.toml` / `vite.config.ts` 的 `precompressedAssets` 中间件),**浏览器在网络层 + 的尺寸门会提醒)。Pages 与 Vite 声明 `Content-Encoding: br`(`public/_headers` / + `vite.config.ts` 的 `precompressedAssets` 中间件)。Docker 在构建容器中运行 + `bin/prepare-docker-wasm.mjs public`,把同一 URL 的文件转换为 gzip,`sws.toml` + 对应声明 `Content-Encoding: gzip`:Chrome 不解码普通 HTTP 非 localhost 来源的 + Brotli(#267)。转换必须在 `pnpm run build` 前做,使 SW 指纹对应实际投递的字节; + 不改仓库里的 Brotli 文件或 Pages/Vite 的响应头。**浏览器在网络层 就解压完了,我们这边一行解压代码都没有**。 **`.br` 这个扩展名和 `_headers` 里"没有 Content-Type 规则"两件事都是承重的**: `wrangler pages dev`(`e2e-pages` 跑的那个)会按可压缩性**重新压一遍并覆盖掉 diff --git a/Dockerfile b/Dockerfile index 68c28917a..63ed619b0 100755 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,9 @@ RUN pnpm install --frozen-lockfile --ignore-scripts # Copy source code COPY . . -# Build the workspace packages (their prepare scripts), then the app -RUN pnpm -r run prepare && pnpm run build +# Build the workspace packages, encode WASM for plain HTTP, then build the app. +# Do this before build.sh hashes the vendor tree, so sw.js names the served bytes. +RUN pnpm -r run prepare && node bin/prepare-docker-wasm.mjs public && pnpm run build #FROM nginxinc/nginx-unprivileged:stable-alpine #COPY --from=builder /app/dist /usr/share/nginx/html diff --git a/bin/prepare-docker-wasm.mjs b/bin/prepare-docker-wasm.mjs new file mode 100644 index 000000000..dc946b3cd --- /dev/null +++ b/bin/prepare-docker-wasm.mjs @@ -0,0 +1,18 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { brotliDecompressSync, gzipSync } from 'node:zlib'; + +// Docker supports ordinary http://, where Chrome will not decode +// Brotli. Re-encode only the image builder's copy; HTTPS Pages keeps Brotli. +// The loader URL stays stable and opaque: Content-Encoding describes the +// transport, not the .br suffix. sws.toml must declare gzip for this payload. +// Run before Vite copies public and build.sh fingerprints the vendor tree. +const root = process.argv[2]; +if (!root) throw new Error('Usage: node bin/prepare-docker-wasm.mjs '); +const path = resolve(root, 'sdkjs/common/wasm/x2t/x2t.wasm.br'); +const wasm = brotliDecompressSync(readFileSync(path)); +const magic = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]); +if (!wasm.subarray(0, 8).equals(magic)) throw new Error('x2t payload is not a WASM module'); +const packed = gzipSync(wasm, { level: 9 }); +writeFileSync(path, packed); +console.log(`[docker] Encoded x2t WASM as gzip (${packed.length} bytes)`); diff --git a/docs/explorations/2026-10-14-docker-http-wasm-encoding.md b/docs/explorations/2026-10-14-docker-http-wasm-encoding.md new file mode 100644 index 000000000..09a347166 --- /dev/null +++ b/docs/explorations/2026-10-14-docker-http-wasm-encoding.md @@ -0,0 +1,37 @@ +# Docker WASM decoding on ordinary HTTP (#267) + +## Cause + +Chrome decodes `Content-Encoding: br` on trustworthy origins, including HTTPS +and loopback HTTP. On ordinary `http://`, the same response fails +with `ERR_CONTENT_DECODING_FAILED` and the converter reports `Failed to fetch`. +The vendored file is valid Brotli: it decompresses to the existing 42,111,200-byte +WASM module. Neither a corrupt binary nor double compression caused this case. + +A probe using static-web-server 2.42.0 and the repository's original `sws.toml` +loaded the module on loopback HTTP and failed on a non-loopback HTTP origin. +The old Docker E2E base URL was loopback and therefore hid the failure. + +## Change + +`bin/prepare-docker-wasm.mjs public` re-encodes the image builder's copy as gzip +before Vite copies it and `bin/build.sh` hashes the served vendor tree for the +service worker. `sws.toml` declares `Content-Encoding: gzip`. The loader URL +retains its opaque `.br` suffix; the response header describes the actual bytes. +Pages and Vite keep the repository's Brotli file and header. The browser still +decodes the response natively, including the streaming-instantiation path. +Gzip costs 10,058,136 transport bytes versus Brotli's 6,898,179. + +## Regression coverage + +- `docker-wasm.test.ts` runs the packaging script on the real vendor file and + checks gzip framing, identical decoded bytes, and unchanged source bytes. +- `hosting-contract.test.ts` pins the gzip response header and build ordering. +- `docker-cache-headers.spec.ts` fetches and compiles the real module from + `http://document-http.test`, mapped to the container by Chromium's resolver. + It asserts `isSecureContext === false`; loopback HTTP cannot substitute here. + This test failed with `TypeError: Failed to fetch` before the fix and passed + against the re-encoded payload served by static-web-server 2.42.0. + +This addresses WASM transport only. Secure-context features such as service +workers and cross-origin isolation retain their existing browser requirements. diff --git a/docs/test-matrix.md b/docs/test-matrix.md index 9810f3ab2..df292ce4b 100644 --- a/docs/test-matrix.md +++ b/docs/test-matrix.md @@ -94,7 +94,7 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec | standalone 主站 | AS(加载 / manifest)+ `MT` = main-site.spec(hero 打开本地 docx → 打字 → Ctrl+S 下载;hero 新建 xlsx → Ctrl+S) | | 入口路径:`?file=` / embed `document:open-url` / 落地页 `?open=local`(IndexedDB 交接) | `EP` = entry-paths.spec(另起本地 HTTP 源做跨域真实 fetch) | | embed(iframe) | ER / EA / 其余全部 | -| Docker 镜像 | 同套 E2E(`test:e2e:docker`) | +| Docker 镜像 | 同套 E2E(`test:e2e:docker`);`docker-cache-headers.spec.ts` 另测非 localhost 的普通 HTTP 下 x2t 下载、解码与编译(#267),`docker-wasm.test.ts` 钉住镜像 gzip 与源 Brotli 解压后的字节一致 | | **线上站(edit.chaxus.com)** | `playwright.prod.config.ts` + `.github/workflows/prod-smoke.yml`(**每次 push main 等部署上线后即冒烟** + 每日 04:47 + 手动;核心 spec,1 worker);也可 `E2E_BASE_URL=<站点>` 跑任意 spec;首跑发现慢链路首存超时与线上 PDF 打不开(均已修) | | **托管语义(CF Pages 模拟)** | `playwright.pages.config.ts` + CI job `e2e-pages`:`bin/build.sh` 真部署构建 + `wrangler pages dev`(复现 index.html→目录 308、`_headers`、`_redirects`),全套 PR 档 spec;托管契约单测 `test/unit/hosting-contract.test.ts` | | 字体替换(渲染 / 导出) | `FS` = font-substitution.spec:被替换的名字(Arial / Times / Calibri / SimSun / 微软雅黑)与背后真正的开源 family 指着同一个 catalog 位置,两次渲染必须逐像素相同(健康 ≤0.044%,错位时 0.56~0.62%,阈值 0.3%);`PC` = pdf-cjk-export.spec:纯中文文档导出 PDF 再打开,页面墨迹必须还在(CFF 字体会让它掉到 0.02%)。文本跨过 U+A0,用 `pluginMethod_PasteHtml` 灌入(键盘会丢字符) | diff --git a/packages/converter/src/document-converter.ts b/packages/converter/src/document-converter.ts index 5599c65d2..3f252ce5e 100644 --- a/packages/converter/src/document-converter.ts +++ b/packages/converter/src/document-converter.ts @@ -69,10 +69,10 @@ export class X2TConverter { * handing Emscripten the bytes via `Module.wasmBinary` (see * prepareWasmBinary), so x2t.js never fetches the module itself either way. * - * The file behind that URL holds brotli bytes and every host we deploy to - * declares `Content-Encoding: br` for it, so the browser decodes it at the - * network layer: 6.6 MB on the wire, under Cloudflare Pages' 25 MiB-per-file - * deploy limit, and nothing here decompresses anything. + * HTTPS Pages serves Brotli (6.6 MB, under its 25 MiB-per-file limit). + * Docker re-encodes the same URL as gzip to support ordinary HTTP (#267). + * Each host declares the matching Content-Encoding, so the browser decodes + * it at the network layer and nothing here decompresses anything. * * Kept in step with the same two paths in the vendor-side loader * (public/sdkjs/common/wasm/x2t/x2t_helper.js), which is what the editor @@ -181,8 +181,8 @@ export class X2TConverter { // Fetched through fetchWasmResponse so this path survives the same // transient CDN answers the streaming one does. The response body is - // already the decoded module: the host declares `Content-Encoding: br` - // for this URL and the browser undoes it. + // already the decoded module: the host declares the matching + // Content-Encoding for this URL and the browser undoes it. const response = await fetchWasmResponse(this.WASM_PATH); const wasmBinary = await response.arrayBuffer(); diff --git a/playwright.docker.config.ts b/playwright.docker.config.ts index 51af5b3e0..a6a6366d6 100644 --- a/playwright.docker.config.ts +++ b/playwright.docker.config.ts @@ -29,6 +29,11 @@ export default defineConfig({ reporter: [['list'], ['html', { outputFolder: 'playwright-report-docker', open: 'never' }]], use: { baseURL: 'http://127.0.0.1:8090', + // Exercise an untrustworthy HTTP origin too: localhost would conceal + // Chrome's refusal to decode Brotli on plain HTTP self-hosting (#267). + launchOptions: { + args: ['--host-resolver-rules=MAP document-http.test 127.0.0.1', '--no-proxy-server'], + }, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', diff --git a/public/sdkjs/common/wasm/x2t/x2t_helper.js b/public/sdkjs/common/wasm/x2t/x2t_helper.js index 725373978..db22ea3bd 100644 --- a/public/sdkjs/common/wasm/x2t/x2t_helper.js +++ b/public/sdkjs/common/wasm/x2t/x2t_helper.js @@ -382,11 +382,10 @@ this.wasmInstantiateError = null; this.onWasmInstantiateError = null; this.SCRIPT_PATH = '../../../../sdkjs/common/wasm/x2t/x2t.js' - // The file under this name holds brotli bytes (6.6 MB, against 42.1 MB - // raw -- over Cloudflare Pages' 25 MiB per-file deploy limit). Every - // server we deploy to declares `Content-Encoding: br` for it - // (public/_headers, sws.toml, vite.config.ts), so the browser decodes - // it at the network layer and nothing here decompresses anything. + // HTTPS Pages serves Brotli (6.6 MB, under its 25 MiB-per-file limit). + // Docker re-encodes the same URL as gzip for ordinary HTTP (#267). + // Each host declares the matching Content-Encoding, so the browser + // decodes it at the network layer; nothing here decompresses anything. this.WASM_PATH = '../../../../sdkjs/common/wasm/x2t/x2t.wasm.br' // Total tries for that fetch, and the step of the linear backoff // between them (0.5 s, then 1 s). Small on purpose: a CDN blip is over diff --git a/sws.toml b/sws.toml index 59b10d0b6..76409c6e0 100644 --- a/sws.toml +++ b/sws.toml @@ -72,13 +72,15 @@ Content-Type = "font/ttf" X-Robots-Tag = "noindex" # The x2t WASM: the single largest download, vendor-versioned. The file holds -# brotli bytes; Content-Encoding is what makes the browser decode them (see the -# same rule in public/_headers). Without it the editor gets an opaque blob and -# never loads. +# gzip bytes in the Docker image (bin/prepare-docker-wasm.mjs re-encodes the +# builder's copy before build.sh hashes it). Chrome will not decode Brotli on +# ordinary http:// origins (#267), so only HTTPS Pages keeps Brotli. +# The loader URL keeps its .br suffix; Content-Encoding describes the actual +# transport bytes. Leave Content-Type as octet-stream to avoid recompression. [[advanced.headers]] source = "/sdkjs/common/wasm/x2t/x2t.wasm.br" [advanced.headers.headers] -Content-Encoding = "br" +Content-Encoding = "gzip" Cache-Control = "public, max-age=31536000, immutable" # Same narrow model-Worker policy as Pages and Vite. diff --git a/test/e2e/docker-cache-headers.spec.ts b/test/e2e/docker-cache-headers.spec.ts index 6372a7d26..194cc7546 100644 --- a/test/e2e/docker-cache-headers.spec.ts +++ b/test/e2e/docker-cache-headers.spec.ts @@ -48,4 +48,32 @@ test.describe('self-hosted image cache headers', () => { const second = await request.get('/editor.html', { headers: { 'If-Modified-Since': lastModified } }); expect(second.status()).toBe(304); }); + + test('x2t decodes and compiles on ordinary HTTP outside localhost', async ({ page, baseURL, l0 }) => { + // Mapped to the container by playwright.docker.config.ts. Loopback URLs + // are trustworthy in Chrome and would hide the Brotli failure (#267). + const origin = new URL(baseURL!); + origin.hostname = 'document-http.test'; + // Isolation headers are intentionally ignored on insecure HTTP; this + // test probes WASM transport, which does not require shared memory. + l0.allowConsole(/Cross-Origin-Opener-Policy header has been ignored/); + await page.goto(origin.href); + expect(await page.evaluate(() => window.isSecureContext)).toBe(false); + const result = await page.evaluate(async () => { + const response = await fetch('/sdkjs/common/wasm/x2t/x2t.wasm.br'); + const encoding = response.headers.get('content-encoding'); + const bytes = await response.arrayBuffer(); + const module = await WebAssembly.compile(bytes); + return { + status: response.status, + encoding, + magic: Array.from(new Uint8Array(bytes).subarray(0, 8)), + exports: WebAssembly.Module.exports(module).length, + }; + }); + expect(result.status).toBe(200); + expect(result.encoding).toBe('gzip'); + expect(result.magic).toEqual([0, 97, 115, 109, 1, 0, 0, 0]); + expect(result.exports).toBeGreaterThan(0); + }); }); diff --git a/test/unit/docker-wasm.test.ts b/test/unit/docker-wasm.test.ts new file mode 100644 index 000000000..b382f3b1d --- /dev/null +++ b/test/unit/docker-wasm.test.ts @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { brotliDecompressSync, gunzipSync } from 'node:zlib'; +import { expect, it } from 'vitest'; + +const ROOT = resolve(__dirname, '../..'); +const WASM_PATH = 'sdkjs/common/wasm/x2t/x2t.wasm.br'; + +it('packages the real vendor WASM as gzip without changing the decoded module or source', () => { + const root = mkdtempSync(resolve(tmpdir(), 'document-docker-wasm-')); + try { + const source = readFileSync(resolve(ROOT, 'public', WASM_PATH)); + const target = resolve(root, WASM_PATH); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source); + const result = spawnSync(process.execPath, [resolve(ROOT, 'bin/prepare-docker-wasm.mjs'), root], { + encoding: 'utf8', + }); + expect(result.status, result.stderr).toBe(0); + const packed = readFileSync(target); + expect([...packed.subarray(0, 2)]).toEqual([0x1f, 0x8b]); + expect(gunzipSync(packed).equals(brotliDecompressSync(source))).toBe(true); + expect(readFileSync(resolve(ROOT, 'public', WASM_PATH)).equals(source)).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + // Re-compressing the real 42 MB module needs more than Vitest's default + // five-second budget on a shared CI runner. +}, 60_000); diff --git a/test/unit/hosting-contract.test.ts b/test/unit/hosting-contract.test.ts index 29d90f8fb..a7d21dd10 100644 --- a/test/unit/hosting-contract.test.ts +++ b/test/unit/hosting-contract.test.ts @@ -160,13 +160,11 @@ describe('sws.toml (self-hosted Docker)', () => { expect(cc('**')).toBe('no-cache'); }); - it('declares the x2t wasm pre-encoded, the same as _headers', () => { - // Same reason as the Pages rule: the file holds brotli bytes under its - // plain name, so without this the self-hosted image serves an opaque blob - // and no document opens. test/e2e/docker-cache-headers.spec.ts checks the - // header on a running container. + it('declares gzip for x2t so ordinary HTTP self-hosting can decode it', () => { + // Chrome only decodes Brotli on trustworthy origins. Docker must serve + // gzip for http://, while HTTPS Pages keeps its Brotli payload. const rule = rules.find((entry) => entry.source === WASM_PATH); - expect(rule?.headers['content-encoding']).toBe('br'); + expect(rule?.headers['content-encoding']).toBe('gzip'); }); it('pins the same immutable set as _headers (hashed assets, font catalog, x2t wasm)', () => { @@ -192,6 +190,8 @@ describe('sws.toml (self-hosted Docker)', () => { const dockerfile = read('Dockerfile'); expect(dockerfile).toMatch(/COPY sws\.toml \/sws\.toml/); expect(dockerfile).toMatch(/ENV SERVER_CONFIG_FILE=\/sws\.toml/); + // Re-encode before the build hashes the served vendor tree for sw.js. + expect(dockerfile).toMatch(/node bin\/prepare-docker-wasm\.mjs public\s*&&\s*pnpm run build/); }); });