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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 跑的那个)会按可压缩性**重新压一遍并覆盖掉
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions bin/prepare-docker-wasm.mjs
Original file line number Diff line number Diff line change
@@ -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://<server-ip>, 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 <public-directory>');
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)`);
37 changes: 37 additions & 0 deletions docs/explorations/2026-10-14-docker-http-wasm-encoding.md
Original file line number Diff line number Diff line change
@@ -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://<server-ip>`, 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.
2 changes: 1 addition & 1 deletion docs/test-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<url>` / 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` 灌入(键盘会丢字符) |
Expand Down
12 changes: 6 additions & 6 deletions packages/converter/src/document-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down
5 changes: 5 additions & 0 deletions playwright.docker.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 4 additions & 5 deletions public/sdkjs/common/wasm/x2t/x2t_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions sws.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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://<server-ip> 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.
Expand Down
28 changes: 28 additions & 0 deletions test/e2e/docker-cache-headers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
31 changes: 31 additions & 0 deletions test/unit/docker-wasm.test.ts
Original file line number Diff line number Diff line change
@@ -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);
12 changes: 6 additions & 6 deletions test/unit/hosting-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<server-ip>, 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)', () => {
Expand All @@ -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/);
});
});

Expand Down
Loading