Skip to content

Commit cc22724

Browse files
authored
chore(deps): upgrade docx to 9.15.2 (#9895)
* chore(deps): upgrade docx to 9.15.2 docx 9.15 packs with fflate and encodes base64 with the global btoa, which the isolated-vm sandbox does not provide, so the docx-generate finalize (Packer.toBase64String + hand-rolled decode) threw ReferenceError. Pack with Packer.toArrayBuffer instead, like the pptx/pdf tasks, and cover the real bundle + bootstrap + finalize in a bare isolate-shaped context. docx now drops XML-1.0-illegal control characters itself, so the Word block's pre-strip before Markdown parsing is removed. Regenerated the docx sandbox bundle. * fix(word): keep stripping control characters before Markdown parsing * fix(sandbox): provide btoa and atob to sandbox tasks docx 9.15 encodes base64 through the global btoa/atob, so in the task isolate Packer.toBase64String and patchDocument's base64 output (which worked on 9.7.1) and password protection threw ReferenceError. The worker now bridges btoa/atob to Node's native WHATWG implementations, like the text codecs, and the bare verification context mirrors them.
1 parent d93bfa4 commit cc22724

11 files changed

Lines changed: 130 additions & 99 deletions

File tree

‎apps/sim/lib/execution/isolated-vm-worker.cjs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,17 @@ async function executeTask(request, executionId) {
740740
releaseables.push(textDecodeCallback)
741741
await jail.set('__textDecode', textDecodeCallback)
742742

743+
// btoa / atob delegate to Node's native WHATWG implementations, so
744+
// libraries that encode base64 through them (docx's Packer.toBase64String,
745+
// document protection hashes, data-URI images) behave as in a browser.
746+
const btoaCallback = new ivm.Callback((data) => btoa(data))
747+
releaseables.push(btoaCallback)
748+
await jail.set('__btoa', btoaCallback)
749+
750+
const atobCallback = new ivm.Callback((data) => atob(data))
751+
releaseables.push(atobCallback)
752+
await jail.set('__atob', atobCallback)
753+
743754
// Delegate timers to Node's real timer heap via ivm.Reference (the pattern
744755
// recommended by laverdet/isolated-vm#136). Host-side bookkeeping was
745756
// hoisted to the function scope above so the finally can always sweep.
@@ -890,6 +901,8 @@ async function executeTask(request, executionId) {
890901
const __error = globalThis.__error;
891902
const __textEncode = globalThis.__textEncode;
892903
const __textDecode = globalThis.__textDecode;
904+
const __btoa = globalThis.__btoa;
905+
const __atob = globalThis.__atob;
893906
const __setTimeoutRef = globalThis.__setTimeoutRef;
894907
const __clearTimeoutRef = globalThis.__clearTimeoutRef;
895908
const __setIntervalRef = globalThis.__setIntervalRef;
@@ -927,6 +940,9 @@ async function executeTask(request, executionId) {
927940
}
928941
};
929942
943+
globalThis.btoa = function btoa(data) { return __btoa(String(data)); };
944+
globalThis.atob = function atob(data) { return __atob(String(data)); };
945+
930946
// setTimeout / setInterval delegate to Node's real timer heap via
931947
// ivm.Reference. The \`ms\` arg is honored (host clamps to the script
932948
// timeout window); \`clearTimeout\` / \`clearInterval\` actually cancel.
@@ -1039,7 +1055,7 @@ async function executeTask(request, executionId) {
10391055
const undefined_globals = [
10401056
'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference',
10411057
'ExternalCopy', '__dirname', '__filename', '__brokerRef',
1042-
'__log', '__error', '__textEncode', '__textDecode',
1058+
'__log', '__error', '__textEncode', '__textDecode', '__btoa', '__atob',
10431059
'__setTimeoutRef', '__clearTimeoutRef', '__setIntervalRef'
10441060
];
10451061
for (const name of undefined_globals) {

‎apps/sim/lib/execution/sandbox/bundles/build.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*
1010
* Every bundle is evaluated in a bare context before it is written: the
1111
* bundler can emit a reference to a runtime helper it never defines (Bun does
12-
* this for docx's inlined CommonJS shim), and nothing else loads these files
12+
* this for an inlined CommonJS shim), and nothing else loads these files
1313
* before a production document generation does.
1414
*
1515
* Run via: `bun run build:sandbox-bundles`.

‎apps/sim/lib/execution/sandbox/bundles/docx.cjs‎

Lines changed: 9 additions & 39 deletions
Large diffs are not rendered by default.

‎apps/sim/lib/execution/sandbox/bundles/isolate-polyfills.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* Beyond aliasing `global -> globalThis` for UMD-style fallbacks inside the
1111
* bundles, this file only answers the one name the bundler can leave dangling
1212
* (see below). All other runtime surface (`console`, `TextEncoder`,
13-
* `TextDecoder`, timers) is installed by the worker via `ivm.Callback` /
14-
* `ivm.Reference` bridges to Node's native implementations — no hand-rolled
15-
* polyfill logic lives in the isolate.
13+
* `TextDecoder`, `btoa`, `atob`, timers) is installed by the worker via
14+
* `ivm.Callback` / `ivm.Reference` bridges to Node's native implementations —
15+
* no hand-rolled polyfill logic lives in the isolate.
1616
*/
1717

1818
const g: typeof globalThis & {
@@ -24,11 +24,10 @@ if (typeof g.global === 'undefined') g.global = globalThis
2424

2525
/**
2626
* A library that inlines a CommonJS dependency ships esbuild's `__require`
27-
* helper around it (docx >= 9.7.1 does this for JSZip's UMD build). Bun's
28-
* browser/iife build rewrites the bare `require` references inside that helper
29-
* to its own `__require` runtime helper and then never emits it, so the bundle
30-
* throws `ReferenceError: __require is not defined` while it is still being
31-
* evaluated. The isolate has no `require` at all, so the only correct answer
27+
* helper around it. Bun's browser/iife build rewrites the bare `require`
28+
* references inside that helper to its own `__require` runtime helper and
29+
* then never emits it, so the bundle throws `ReferenceError: __require is not
30+
* defined` while it is still being evaluated. The isolate has no `require` at all, so the only correct answer
3231
* to a dynamic require is the one esbuild's helper gives when `require` is
3332
* absent: throw. Defining it here keeps every bundle self-contained; `build.ts`
3433
* evaluates each bundle in a bare context so a new variant of the defect fails

‎apps/sim/lib/execution/sandbox/bundles/verify.test.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { readFileSync } from 'node:fs'
2+
import vm from 'node:vm'
3+
import JSZip from 'jszip'
24
import { describe, expect, it } from 'vitest'
3-
import { evaluateSandboxBundle } from '@/lib/execution/sandbox/bundles/verify'
5+
import {
6+
createBareSandboxContext,
7+
evaluateSandboxBundle,
8+
} from '@/lib/execution/sandbox/bundles/verify'
49
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'
10+
import { docxGenerateTask } from '@/sandboxes/docx-generate'
511

612
function loadCheckedInBundle(name: SandboxBundleName): Record<string, unknown> {
713
const source = readFileSync(new URL(`./${name}.cjs`, import.meta.url), 'utf-8')
@@ -23,6 +29,57 @@ describe('sandbox bundles', () => {
2329
expect(typeof docx.Paragraph).toBe('function')
2430
})
2531

32+
it('docx-generate packs a document with the checked-in bundle in a bare context', async () => {
33+
const context = createBareSandboxContext()
34+
evaluateSandboxBundle(
35+
readFileSync(new URL('./docx.cjs', import.meta.url), 'utf-8'),
36+
'docx',
37+
context
38+
)
39+
const runAsync = (body: string) => vm.runInContext(`(async () => { ${body} })()`, context)
40+
41+
await runAsync(docxGenerateTask.bootstrap)
42+
await runAsync(
43+
"addSection({ children: [new docx.Paragraph({ children: [new docx.TextRun('R&D <ok>')] })] })"
44+
)
45+
const bytes: unknown = await runAsync(docxGenerateTask.finalize)
46+
47+
expect(ArrayBuffer.isView(bytes)).toBe(true)
48+
const zip = await JSZip.loadAsync(bytes as Uint8Array)
49+
expect(await zip.file('word/document.xml')?.async('string')).toContain('R&amp;D &lt;ok&gt;')
50+
})
51+
52+
it('docx encodes and decodes base64 through the btoa/atob the worker provides', async () => {
53+
const context = createBareSandboxContext()
54+
evaluateSandboxBundle(
55+
readFileSync(new URL('./docx.cjs', import.meta.url), 'utf-8'),
56+
'docx',
57+
context
58+
)
59+
const pngBase64 =
60+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
61+
62+
const packed: unknown = await vm.runInContext(
63+
`(async () => {
64+
const docx = globalThis.__bundles.docx;
65+
const doc = new docx.Document({
66+
protection: { type: 'readOnly', password: 'secret' },
67+
sections: [{ children: [new docx.Paragraph({ children: [
68+
new docx.ImageRun({ type: 'png', data: 'data:image/png;base64,${pngBase64}', transformation: { width: 10, height: 10 } }),
69+
] })] }],
70+
});
71+
return docx.Packer.toBase64String(doc);
72+
})()`,
73+
context
74+
)
75+
76+
const zip = await JSZip.loadAsync(packed as string, { base64: true })
77+
expect(await zip.file('word/settings.xml')?.async('string')).toMatch(
78+
/<w:documentProtection[^>]*w:hash="[^"]+"/
79+
)
80+
expect(zip.file('word/media/image1.png')).not.toBeNull()
81+
})
82+
2683
it('pdf-lib evaluates in a bare context and exposes the pdf-generate surface', () => {
2784
const pdfLib = loadCheckedInBundle('pdf-lib')
2885
expect(typeof pdfLib.PDFDocument).toBe('function')

‎apps/sim/lib/execution/sandbox/bundles/verify.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@ import vm from 'node:vm'
22
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'
33

44
/**
5-
* Evaluates a built sandbox bundle the way the isolated-vm worker will: as a
6-
* classic script in a context that has timers, `console`, and the text codecs
7-
* but no `require`, `process`, or `Buffer` of its own. Returns the export the
8-
* bundle registered on `globalThis.__bundles`, or throws with the bundle's own
9-
* error, so a bundle that references a helper the bundler never emitted fails
10-
* at build time and in the test suite instead of on the first document
11-
* generated in production.
5+
* A context shaped like the isolated-vm worker's task mode: timers, `console`,
6+
* the text codecs, and `btoa`/`atob`, but no `require`, `process`, or `Buffer`
7+
* of its own. Code that reaches for any other Node or browser global fails here
8+
* the same way it fails inside the isolate.
129
*/
13-
export function evaluateSandboxBundle(source: string, name: SandboxBundleName): unknown {
10+
export function createBareSandboxContext(): vm.Context {
1411
const context: Record<string, unknown> = {
1512
setTimeout,
1613
clearTimeout,
@@ -20,9 +17,26 @@ export function evaluateSandboxBundle(source: string, name: SandboxBundleName):
2017
console,
2118
TextEncoder,
2219
TextDecoder,
20+
btoa,
21+
atob,
2322
}
2423
context.globalThis = context
25-
vm.createContext(context)
24+
return vm.createContext(context)
25+
}
26+
27+
/**
28+
* Evaluates a built sandbox bundle the way the isolated-vm worker will: as a
29+
* classic script in a {@link createBareSandboxContext} context. Returns the
30+
* export the bundle registered on `globalThis.__bundles`, or throws with the
31+
* bundle's own error, so a bundle that references a helper the bundler never
32+
* emitted fails at build time and in the test suite instead of on the first
33+
* document generated in production.
34+
*/
35+
export function evaluateSandboxBundle(
36+
source: string,
37+
name: SandboxBundleName,
38+
context: vm.Context = createBareSandboxContext()
39+
): unknown {
2640
vm.runInContext(source, context, { filename: `sandbox/${name}.cjs` })
2741

2842
const bundles = context.__bundles

‎apps/sim/lib/microsoft-word/document.server.test.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,6 @@ describe('buildDocxFromContent', () => {
9191
})
9292

9393
it('strips control characters that would make the package unopenable', async () => {
94-
// The docx package writes run text into the XML verbatim, so a forbidden
95-
// character reaching it produces invalid XML 1.0 rather than a rendering bug.
9694
const buffer = await buildDocxFromContent(`before${String.fromCharCode(8)}after`)
9795
const xml = await readDocumentXml(buffer)
9896

‎apps/sim/lib/microsoft-word/document.server.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,10 @@ function parseInlineRuns(line: string): ContentRun[] {
8383
function parseContentBlocks(content: string): ContentBlock[] {
8484
const blocks: ContentBlock[] = []
8585

86-
// Stripped once here rather than per run: the `docx` package writes run text
87-
// into the XML verbatim, so a stray control character from an upstream block
88-
// would produce a package Word refuses to open. Tab, newline, and carriage
89-
// return are legal XML and are outside the class, so the split below is
90-
// unaffected.
86+
// Stripped before the Markdown patterns run, so a stray control character
87+
// from an upstream block can't hide a heading or bullet marker. Tab, newline,
88+
// and carriage return are legal XML and outside the class, so the split below
89+
// is unaffected.
9190
for (const rawLine of stripInvalidXmlChars(content).replace(/\r\n?/g, '\n').split('\n')) {
9291
const line = rawLine.trimEnd()
9392
if (line.trim().length === 0) continue

‎apps/sim/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@
211211
"date-fns": "4.4.0",
212212
"decimal.js": "10.6.0",
213213
"diff": "9.0.0",
214-
"docx": "^9.6.1",
214+
"docx": "^9.15.2",
215215
"docx-preview": "^0.4.1",
216216
"drizzle-orm": "^0.45.4",
217217
"echarts": "6.1.0",

‎apps/sim/sandboxes/docx-generate.ts‎

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ export const docxGenerateTask = defineSandboxTask<SandboxTaskInput>({
7878
}));
7979
};
8080
`,
81-
// JSZip's browser build doesn't support nodebuffer output, so we go through
82-
// base64 and decode back to bytes inside the isolate (avoids DataURL / Blob).
8381
finalize: `
8482
let doc = globalThis.doc;
8583
if (!doc && globalThis.__docxSections.length > 0) {
@@ -91,23 +89,7 @@ export const docxGenerateTask = defineSandboxTask<SandboxTaskInput>({
9189
if (!doc) {
9290
throw new Error('No document created. Use addSection({ children: [...] }) for chunked writes, or set globalThis.doc = new docx.Document({...}) for a single write.');
9391
}
94-
const b64 = await globalThis.docx.Packer.toBase64String(doc);
95-
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
96-
const lookup = new Uint8Array(128);
97-
for (let i = 0; i < alphabet.length; i++) lookup[alphabet.charCodeAt(i)] = i;
98-
const clean = b64.replace(/=+$/, '');
99-
const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
100-
let pos = 0;
101-
for (let i = 0; i < clean.length; i += 4) {
102-
const c0 = lookup[clean.charCodeAt(i)];
103-
const c1 = lookup[clean.charCodeAt(i + 1)];
104-
const c2 = lookup[clean.charCodeAt(i + 2)];
105-
const c3 = lookup[clean.charCodeAt(i + 3)];
106-
out[pos++] = (c0 << 2) | (c1 >> 4);
107-
if (i + 2 < clean.length) out[pos++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
108-
if (i + 3 < clean.length) out[pos++] = ((c2 & 0x03) << 6) | c3;
109-
}
110-
return out.subarray(0, pos);
92+
return new Uint8Array(await globalThis.docx.Packer.toArrayBuffer(doc));
11193
`,
11294
toResult: (bytes) => Buffer.from(bytes),
11395
})

0 commit comments

Comments
 (0)