Skip to content

Commit 3d2a644

Browse files
authored
fix: handle TsTypeAssertion that conflicts with JSX handling for tarball bundling (#7049)
1 parent 0e2b212 commit 3d2a644

7 files changed

Lines changed: 184 additions & 20 deletions

File tree

.github/workflows/workflow.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ jobs:
5353
matrix:
5454
os: [ubuntu-24.04, macos-14, windows-2025]
5555
node-version: ['22']
56-
# Must include the minimum deno version from the `DENO_VERSION_RANGE` constant in `node/bridge.ts`.
57-
deno-version: ['v2.4.2']
56+
# Maximum supported deno version from the `DENO_VERSION_RANGE` constant in `node/bridge.ts`.
57+
# If there is no upper band - this should be set to whatever is the latest deno version at the time of updating this workflow.
58+
deno-version: ['v2.8.0']
5859
include:
5960
- os: ubuntu-24.04
6061
# Earliest supported version
6162
node-version: '18.14.0'
63+
# Minimum supported deno version from the `DENO_VERSION_RANGE` constant in `node/bridge.ts`.
6264
deno-version: 'v2.4.2'
6365
fail-fast: false
6466
steps:

packages/edge-bundler/node/bundler.test.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,12 @@ test('Adds a custom error property to user errors during bundling', async () =>
108108
const messageBeforeStack = (error as BundleError).message
109109
expect(
110110
messageBeforeStack
111-
.replace(/file:\/\/\/(.*?\/)(build\/packages\/edge-bundler\/deno\/vendor\/deno\.land\/x\/eszip.*)/, 'file://$2')
112111
// eslint-disable-next-line no-control-regex
113-
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''),
112+
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
113+
.replace(
114+
/file:\/\/\/(.*?\/)(build\/packages\/edge-bundler\/deno\/vendor\/deno\.land\/x\/eszip.*)/,
115+
'file://$2',
116+
),
114117
).toMatchSnapshot()
115118
expect((error as BundleError).customErrorInfo).toEqual({
116119
location: {
@@ -1309,6 +1312,63 @@ describe.skipIf(lt(denoVersion, '2.4.2'))(
13091312

13101313
await cleanup()
13111314
})
1315+
1316+
test('Importing a remote module that imports a WebAssembly binary (deno_dom)', async () => {
1317+
// Deno <2.6 vendors `.wasm` imports under a `.d.mts` extension even though
1318+
// the content is the raw WASM binary. The rewriter must detect this by
1319+
// magic bytes and copy the file through untouched instead of attempting
1320+
// to parse it as UTF-8 source.
1321+
const { basePath, cleanup, distPath } = await useFixture('imports_deno_dom_wasm', { copyDirectory: true })
1322+
const declarations: Declaration[] = [
1323+
{
1324+
function: 'func1',
1325+
path: '/func1',
1326+
},
1327+
]
1328+
await bundle([join(basePath, 'netlify/edge-functions')], distPath, declarations, {
1329+
basePath,
1330+
featureFlags: {
1331+
edge_bundler_generate_tarball: true,
1332+
},
1333+
})
1334+
const expectedOutput = {
1335+
func1: 'hello from deno_dom',
1336+
}
1337+
1338+
const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
1339+
const manifest = JSON.parse(manifestFile)
1340+
1341+
const tarballPath = join(distPath, manifest.bundles[0].asset)
1342+
const tarballResult = await runTarball(tarballPath)
1343+
expect(tarballResult).toStrictEqual(expectedOutput)
1344+
1345+
const entries: string[] = []
1346+
await tar.list({
1347+
file: tarballPath,
1348+
onReadEntry: (entry) => {
1349+
entries.push(entry.path)
1350+
},
1351+
})
1352+
1353+
expect(entries).toContain('./___netlify-edge-functions.json')
1354+
expect(entries).toContain('./deno.json')
1355+
expect(entries).toContain('./func1.ts')
1356+
1357+
// The vendored deno_dom WASM payload must be present in the tarball.
1358+
// Deno <2.6 vendors `.wasm` imports under a `.d.mts` extension (with a
1359+
// content-hash suffix); 2.6+ keeps the original `.wasm` extension.
1360+
const denoDomVendorPrefix = './vendor/deno.land/x/deno_dom@v0.1.56/build/deno-wasm/'
1361+
const expectedWasmEntry = lt(denoVersion, '2.6.0')
1362+
? `${denoDomVendorPrefix}#deno-wasm_bg.wasm_d2792.d.mts`
1363+
: `${denoDomVendorPrefix}deno-wasm_bg.wasm`
1364+
expect(entries).toContain(expectedWasmEntry)
1365+
1366+
const eszipPath = join(distPath, manifest.bundles[1].asset)
1367+
const eszipResult = await runESZIP(eszipPath)
1368+
expect(eszipResult).toStrictEqual(expectedOutput)
1369+
1370+
await cleanup()
1371+
})
13121372
},
1313-
10_000,
1373+
50_000,
13141374
)

packages/edge-bundler/node/formats/tarball.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,19 +269,61 @@ async function getRequiredSourceFiles(
269269
return localFiles
270270
}
271271

272+
// WebAssembly binary magic bytes: `\0asm` (0x00 0x61 0x73 0x6d).
273+
const WASM_MAGIC = Buffer.from([0x00, 0x61, 0x73, 0x6d])
274+
275+
/**
276+
* Detects whether a file is a raw WebAssembly module by its magic bytes.
277+
* Deno <2.6 vendors imported `.wasm` modules under a `.d.mts` extension even
278+
* though the content is the raw WASM binary, so we cannot rely on extension
279+
* alone to decide whether a file is safe to read as UTF-8 and rewrite.
280+
*/
281+
async function isWasm(sourceFile: string): Promise<boolean> {
282+
const fd = await fs.open(sourceFile, 'r')
283+
try {
284+
const buf = Buffer.alloc(WASM_MAGIC.length)
285+
const { bytesRead } = await fd.read(buf, 0, buf.length, 0)
286+
return bytesRead === WASM_MAGIC.length && buf.equals(WASM_MAGIC)
287+
} finally {
288+
await fd.close()
289+
}
290+
}
291+
292+
/**
293+
* Decides whether a source file should be parsed and rewritten. Cheap extension
294+
* check first; only if it passes do we open the file to rule out raw WebAssembly
295+
* binaries (Deno <2.6 vendors `.wasm` imports under `.d.mts`) — reading those
296+
* as UTF-8 would corrupt the binary on round-trip.
297+
*/
298+
async function shouldRewrite(sourceFile: string): Promise<boolean> {
299+
if (!REWRITABLE_EXTENSIONS.has(path.extname(sourceFile))) {
300+
return false
301+
}
302+
303+
if (await isWasm(sourceFile)) {
304+
return false
305+
}
306+
307+
return true
308+
}
309+
272310
/**
273311
* Rewrites import assert into import with in the bundle directory
274312
* Defaults to copying the file in its current form
275313
*/
276314
export async function rewriteImportAssertions(sourceFile: string, destPath: string): Promise<void> {
277-
if (!REWRITABLE_EXTENSIONS.has(path.extname(sourceFile))) {
315+
if (!(await shouldRewrite(sourceFile))) {
278316
if (sourceFile !== destPath) {
279317
await fs.copyFile(sourceFile, destPath)
280318
}
281319
return
282320
}
283321

284-
const source = await fs.readFile(sourceFile, 'utf-8')
285-
const modified = rewriteSourceImportAssertions(source)
286-
await fs.writeFile(destPath, modified)
322+
try {
323+
const source = await fs.readFile(sourceFile, 'utf-8')
324+
const modified = rewriteSourceImportAssertions(source)
325+
await fs.writeFile(destPath, modified)
326+
} catch (error) {
327+
throw new Error(`Failed to rewrite import assertions in ${sourceFile}`, { cause: error })
328+
}
287329
}

packages/edge-bundler/node/utils/import_attributes.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,30 @@ import data2 from './data.json' with { type: 'json' };
153153
expect(result).toEqual(expectedResult)
154154
})
155155

156+
test('handles TsTypeAssertion (handling of syntax not supported in JSX mode)', () => {
157+
// edge case, because "<Params> inputs" is valid JSX syntax (a component with name "Params" and children "inputs"),
158+
// but also valid TypeScript syntax (type assertion). In this case, the acorn-jsx parser will parse it as JSX,
159+
// but then throw an error when it encounters the "assert" keyword in the import assertion.
160+
// We want to make sure we can handle this case and still rewrite the import assertions correctly.
161+
const source = `
162+
import data3 from './data.json' assert { type: 'json' };
163+
const params = <Params> inputs;
164+
const [,foo]=[1,2]
165+
import data2 from './data.json' assert { type: 'json' };
166+
`
167+
const expectedResult = `
168+
import data3 from './data.json' with { type: 'json' };
169+
const params = <Params> inputs;
170+
const [,foo]=[1,2]
171+
import data2 from './data.json' with { type: 'json' };
172+
`
173+
174+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
175+
const result = rewriteSourceImportAssertions(source)
176+
177+
expect(result).toEqual(expectedResult)
178+
})
179+
156180
test('complex JSX import assertion case', () => {
157181
const source = `<><Component prop={() => import('./foo.json', { assert: { type: 'json' } })} /></>`
158182
const expectedResult = `<><Component prop={() => import('./foo.json', { with: { type: 'json' } })} /></>`

packages/edge-bundler/node/utils/import_attributes.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
import { Parser, Node } from 'acorn'
2-
import type { ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, ImportExpression } from 'acorn'
2+
import type {
3+
ExportAllDeclaration,
4+
ExportNamedDeclaration,
5+
ImportDeclaration,
6+
ImportExpression,
7+
Options as AcornOptions,
8+
Program,
9+
} from 'acorn'
310
import { tsPlugin } from '@sveltejs/acorn-typescript'
411

5-
const acorn = Parser.extend(tsPlugin({ jsx: true }))
12+
const acornNoJSX = Parser.extend(tsPlugin({ jsx: false }))
13+
const acornJSX = Parser.extend(tsPlugin({ jsx: true }))
14+
15+
const parseOptions: AcornOptions = {
16+
ecmaVersion: 'latest',
17+
sourceType: 'module',
18+
locations: true,
19+
}
20+
21+
const parseAST = (source: string): Program => {
22+
try {
23+
return acornJSX.parse(source, parseOptions)
24+
} catch (error) {
25+
// for non-jsx typescript casting to type via "<type> value" (normally done with "value as type") will throw an "Unexpected token" error in acorn-jsx,
26+
// but is valid syntax in TypeScript. In this case, we can retry parsing with the non-jsx parser.
27+
if (error instanceof SyntaxError) {
28+
return acornNoJSX.parse(source, parseOptions)
29+
}
30+
throw error
31+
}
32+
}
633

734
/**
835
* Given source code rewrites import assert into import with
@@ -15,11 +42,7 @@ export function rewriteSourceImportAssertions(source: string): string {
1542
let modified = source
1643

1744
try {
18-
const parsedAST = acorn.parse(source, {
19-
ecmaVersion: 'latest',
20-
sourceType: 'module',
21-
locations: true,
22-
})
45+
const parsedAST = parseAST(source)
2346

2447
const statements = collectImportAssertions(source, parsedAST.body)
2548

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts'
2+
3+
export default async () => {
4+
const doc = new DOMParser().parseFromString('<h1>hello from deno_dom</h1>', 'text/html')
5+
const text = doc?.querySelector('h1')?.textContent ?? 'no heading'
6+
return new Response(text)
7+
}
8+
9+
export const config = {
10+
path: '/func1',
11+
}

packages/edge-bundler/test/util.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,18 @@ const inspectESZIPFunction = (path: string) => `
6565
`
6666

6767
const inspectTarballFunction = () => `
68-
import path from "node:path";
69-
import { pathToFileURL } from "node:url";
7068
import manifest from "./___netlify-edge-functions.json" with { type: "json" };
7169
7270
const responses = {};
7371
7472
for (const functionName in manifest.functions) {
7573
const req = new Request("https://test.netlify");
76-
const entrypoint = path.resolve(manifest.functions[functionName]);
77-
const func = await import(pathToFileURL(entrypoint))
74+
// Import via a relative specifier so Deno resolves the module URL itself,
75+
// keeping its encoding consistent with the import map base (both derived from
76+
// cwd). Pre-building an absolute file:// URL on the Node side encodes paths
77+
// differently from Deno (e.g. '~' in Windows 8.3 short names), which breaks
78+
// import map prefix matching on Deno 2.8+.
79+
const func = await import("./" + manifest.functions[functionName]);
7880
const res = await func.default(req);
7981
8082
responses[functionName] = await res.text();

0 commit comments

Comments
 (0)