Skip to content

Commit bfb6129

Browse files
committed
fix(file-parsers): read officeparser's entry point across module systems
`officeparser` is CommonJS — `main: officeParser.js`, no `type`, no `exports` map — so what `await import('officeparser')` yields depends on who built the code. Node and webpack synthesize named exports from `module.exports`, so `.parseOfficeAsync` is there. esbuild, which builds the Trigger.dev worker bundle, puts `module.exports` on `.default` and leaves the named export undefined, and the package is in neither `build.external` nor `additionalPackages`, so it is bundled. Reading the named export directly therefore worked everywhere except the worker, where calling it threw `TypeError: parseOfficeAsync is not a function`. All four parsers treat that as "the library failed" and answer with a scrape of the archive, which returns `degraded: true`, and the document pipeline rejects a degraded parse outright. The visible result was every `.pptx` and legacy `.doc` reporting "No text could be extracted from this file — it may be scanned, image-only, or password-protected", naming a cause that had nothing to do with the fault. 118 pptx and 14 doc failures landed in a single burst when one connector's sync first succeeded after ten consecutive crashes. Resolved in one shared loader rather than per bundler: externalizing the package has to be repeated in every build config this code runs under and regresses silently the day one is missed. The shape handling is split into a pure `resolveParseOfficeAsync` because the failing shape cannot be reproduced by mocking the specifier — Vitest's module-namespace proxy throws on a missing export rather than yielding the `undefined` a real bundle produces, so a test going through `import` can only assert the shape that already worked. That is also why the existing parser suites never caught this: each mocks `officeparser` with a fabricated named export, which presupposes the interop being broken here.
1 parent 5fb52a4 commit bfb6129

6 files changed

Lines changed: 106 additions & 7 deletions

File tree

apps/sim/lib/file-parsers/doc-parser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync } from 'fs'
22
import { readFile } from 'fs/promises'
33
import { createLogger } from '@sim/logger'
4+
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
45
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
56
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
67
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
@@ -41,8 +42,8 @@ export class DocParser implements FileParser {
4142
assertOoxmlArchiveWithinLimits(buffer)
4243

4344
try {
44-
const officeParser = await import('officeparser')
45-
const result = await officeParser.parseOfficeAsync(buffer)
45+
const parseOfficeAsync = await loadParseOfficeAsync()
46+
const result = await parseOfficeAsync(buffer)
4647

4748
if (result) {
4849
const resultString = typeof result === 'string' ? result : String(result)

apps/sim/lib/file-parsers/docx-parser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile } from 'fs/promises'
22
import { createLogger } from '@sim/logger'
33
import mammoth from 'mammoth'
4+
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
45
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
56
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
67
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
@@ -65,8 +66,8 @@ export class DocxParser implements FileParser {
6566
}
6667

6768
try {
68-
const officeParser = await import('officeparser')
69-
const result = await officeParser.parseOfficeAsync(buffer)
69+
const parseOfficeAsync = await loadParseOfficeAsync()
70+
const result = await parseOfficeAsync(buffer)
7071

7172
if (result) {
7273
const resultString = typeof result === 'string' ? result : String(result)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { resolveParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
6+
7+
const parse = async () => 'slide text'
8+
9+
describe('resolveParseOfficeAsync', () => {
10+
/**
11+
* Node and webpack synthesize named exports from `officeparser`'s CommonJS
12+
* `module.exports`, so this is the shape the app server sees — and the only
13+
* one the code used to handle.
14+
*/
15+
it('resolves the named export when the bundler synthesizes one', () => {
16+
expect(resolveParseOfficeAsync({ parseOfficeAsync: parse })).toBe(parse)
17+
})
18+
19+
/**
20+
* esbuild — which builds the Trigger.dev worker — puts `module.exports` on
21+
* `default` and leaves the named export undefined. Reading the named export
22+
* directly yielded `undefined` there, and calling it threw
23+
* `TypeError: parseOfficeAsync is not a function`, which every parser treats
24+
* as a library failure and answers with a `degraded` scrape that the document
25+
* pipeline then rejects. This is the shape that broke production: every
26+
* `.pptx` and legacy `.doc` from a connector reported "No text could be
27+
* extracted" while the same files parsed fine through the app.
28+
*/
29+
it('resolves through default when the bundler namespaces the CommonJS exports', () => {
30+
expect(resolveParseOfficeAsync({ default: { parseOfficeAsync: parse } })).toBe(parse)
31+
})
32+
33+
/** A CommonJS module whose `module.exports` IS the function. */
34+
it('resolves a default export that is itself callable', () => {
35+
expect(resolveParseOfficeAsync({ default: parse })).toBe(parse)
36+
})
37+
38+
/**
39+
* Fails loudly rather than handing back `undefined` for a caller to invoke —
40+
* the undefined call is what produced a misleading "no text could be
41+
* extracted" report instead of naming the real fault.
42+
*/
43+
it('throws when no shape exposes the entry point', () => {
44+
expect(() => resolveParseOfficeAsync({})).toThrow('did not expose parseOfficeAsync')
45+
})
46+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/** `officeparser`'s single entry point, as every parser here calls it. */
2+
type ParseOfficeAsync = (input: Buffer) => Promise<string>
3+
4+
interface OfficeParserModule {
5+
parseOfficeAsync?: ParseOfficeAsync
6+
default?: { parseOfficeAsync?: ParseOfficeAsync } | ParseOfficeAsync
7+
}
8+
9+
/**
10+
* Resolves `officeparser`'s entry point across module systems.
11+
*
12+
* `officeparser` is CommonJS — `main: officeParser.js`, no `type` and no
13+
* `exports` map — so what `await import('officeparser')` yields depends on who
14+
* built the code. Node and webpack synthesize named exports from the CJS
15+
* `module.exports`, so `.parseOfficeAsync` is there. esbuild, which builds the
16+
* Trigger.dev worker bundle, puts `module.exports` on `.default` and leaves the
17+
* named export undefined.
18+
*
19+
* Reading the named export directly therefore worked everywhere except the
20+
* worker, where `parseOfficeAsync` was `undefined` and calling it threw
21+
* `TypeError: parseOfficeAsync is not a function`. Every parser here treats that
22+
* as "the library failed" and falls back to scraping the archive, which returns
23+
* `degraded: true` — and the document pipeline rejects a degraded parse outright.
24+
* The visible result was every `.pptx` and legacy `.doc` from a connector
25+
* failing as "No text could be extracted", while the same files parsed fine
26+
* through the app.
27+
*
28+
* Reading both shapes fixes it at the source rather than per bundler: the
29+
* alternative is externalizing the package in each build config, which has to be
30+
* repeated for every bundler this code runs under and silently regresses the day
31+
* one is missed.
32+
*/
33+
export function resolveParseOfficeAsync(mod: OfficeParserModule): ParseOfficeAsync {
34+
if (typeof mod.parseOfficeAsync === 'function') return mod.parseOfficeAsync
35+
if (typeof mod.default === 'function') return mod.default
36+
if (typeof mod.default?.parseOfficeAsync === 'function') return mod.default.parseOfficeAsync
37+
38+
throw new Error('officeparser did not expose parseOfficeAsync')
39+
}
40+
41+
/**
42+
* Split from {@link resolveParseOfficeAsync} so the shape handling is testable.
43+
* The failing shape cannot be reproduced by mocking the specifier — Vitest's
44+
* module-namespace proxy throws on a missing export rather than yielding the
45+
* `undefined` the real bundle produces — so a test that goes through `import`
46+
* can only assert the shape that already worked.
47+
*/
48+
export async function loadParseOfficeAsync(): Promise<ParseOfficeAsync> {
49+
return resolveParseOfficeAsync((await import('officeparser')) as OfficeParserModule)
50+
}

apps/sim/lib/file-parsers/opendocument-parser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync } from 'fs'
22
import { readFile } from 'fs/promises'
33
import { createLogger } from '@sim/logger'
4+
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
45
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
56
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
67
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
@@ -46,7 +47,7 @@ export class OpenDocumentParser implements FileParser {
4647
*/
4748
assertOoxmlArchiveWithinLimits(buffer)
4849

49-
const { parseOfficeAsync } = await import('officeparser')
50+
const parseOfficeAsync = await loadParseOfficeAsync()
5051

5152
let extracted: string
5253
try {

apps/sim/lib/file-parsers/pptx-parser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync } from 'fs'
22
import { readFile } from 'fs/promises'
33
import { createLogger } from '@sim/logger'
4+
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
45
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
56
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
67
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
@@ -40,8 +41,7 @@ export class PptxParser implements FileParser {
4041

4142
let parseOfficeAsync
4243
try {
43-
const officeParser = await import('officeparser')
44-
parseOfficeAsync = officeParser.parseOfficeAsync
44+
parseOfficeAsync = await loadParseOfficeAsync()
4545
} catch (importError) {
4646
logger.warn('officeparser not available, using fallback extraction')
4747
return this.fallbackExtraction(buffer)

0 commit comments

Comments
 (0)