Skip to content

Commit 9e483b5

Browse files
committed
feat: support import assertions with Deno 2
1 parent 8b061d4 commit 9e483b5

File tree

16 files changed

+819
-118
lines changed

16 files changed

+819
-118
lines changed

deno.lock

Lines changed: 447 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { loadESZIP } from 'https://deno.land/x/[email protected]/eszip.ts'
2+
3+
const [functionPath, destPath] = Deno.args
4+
5+
const eszip = await loadESZIP(functionPath)
6+
7+
await eszip.extract(destPath)

packages/edge-bundler/deno/lib/stage2.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ const stage2Loader = (
105105
}
106106
}
107107

108-
const writeStage2 = async ({
108+
export const writeStage2 = async ({
109109
basePath,
110110
destPath,
111111
externals,
@@ -122,5 +122,3 @@ const writeStage2 = async ({
122122

123123
return await Deno.writeFile(destPath, bytes)
124124
}
125-
126-
export { writeStage2 }
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// CLI utility to build/list/extract/run ESZIPs
2+
3+
import { build, Parser } from "./mod.ts";
4+
import { dirname, join } from "https://deno.land/[email protected]/path/mod.ts";
5+
6+
function hasV2Header(bytes: Uint8Array) {
7+
const magicV2 = new TextDecoder().decode(bytes.slice(0, 8));
8+
return magicV2 === "ESZIP_V2";
9+
}
10+
11+
interface ESZIP {
12+
extract(dest: string): Promise<void>;
13+
list(): string[];
14+
}
15+
16+
interface V1Entry {
17+
Source: {
18+
source: string;
19+
transpiled: string;
20+
};
21+
}
22+
23+
class V1 {
24+
inner: Record<string, V1Entry>;
25+
26+
constructor(bytes: Uint8Array) {
27+
const json = new TextDecoder().decode(bytes);
28+
const eszip = JSON.parse(json);
29+
this.inner = eszip;
30+
}
31+
32+
static load(bytes: Uint8Array) {
33+
return Promise.resolve(new V1(bytes));
34+
}
35+
36+
*entries() {
37+
for (
38+
const [
39+
url,
40+
{
41+
Source: { source, transpiled },
42+
},
43+
] of Object.entries(this.inner.modules)
44+
) {
45+
yield { url, source, transpiled };
46+
}
47+
}
48+
49+
async extract(dest: string) {
50+
for (const { url, source, transpiled } of this.entries()) {
51+
await write(join(dest, "source", url2path(url)), source);
52+
await write(
53+
join(dest, "transpiled", url2path(url)),
54+
transpiled ?? source,
55+
);
56+
}
57+
}
58+
59+
list() {
60+
return Array.from(this.entries()).map((e) => e.url);
61+
}
62+
}
63+
64+
class V2 {
65+
parser: Parser;
66+
specifiers: string[];
67+
68+
constructor(parser: Parser, specifiers: string[]) {
69+
this.parser = parser;
70+
this.specifiers = specifiers;
71+
}
72+
73+
static async load(bytes: Uint8Array) {
74+
const parser = await Parser.createInstance();
75+
const specifiers = await parser.parseBytes(bytes);
76+
await parser.load();
77+
return new V2(parser, specifiers as string[]);
78+
}
79+
80+
async extract(dest: string) {
81+
const imports: Record<string, string> = {};
82+
83+
for (const specifier of this.specifiers) {
84+
const module = await this.parser.getModuleSource(specifier);
85+
await write(join(dest, "source", url2path(specifier)), module);
86+
// Track import
87+
imports[specifier] = `./${url2path(specifier)}`;
88+
}
89+
// Write import map
90+
const importMap = JSON.stringify({ imports }, null, 2);
91+
await Deno.writeTextFile(
92+
join(dest, "source", "import_map.json"),
93+
importMap,
94+
);
95+
}
96+
97+
list() {
98+
return this.specifiers;
99+
}
100+
}
101+
102+
export async function loadESZIP(filename: string): Promise<ESZIP> {
103+
const bytes = await Deno.readFile(filename);
104+
if (hasV2Header(bytes)) {
105+
return await V2.load(bytes);
106+
}
107+
return await V1.load(bytes);
108+
}
109+
110+
function url2path(url: string) {
111+
return join(...(new URL(url).pathname.split("/").filter(Boolean)));
112+
}
113+
114+
async function write(path: string, content: string) {
115+
await Deno.mkdir(dirname(path), { recursive: true });
116+
await Deno.writeTextFile(path, content);
117+
}
118+
119+
async function run(eszip: ESZIP, specifier: string) {
120+
// Extract to tmp directory
121+
const tmpDir = await Deno.makeTempDir({ prefix: "esz" });
122+
try {
123+
// Extract
124+
await eszip.extract(tmpDir);
125+
const importMap = join(tmpDir, "source", "import_map.json");
126+
// Run
127+
const p = new Deno.Command("deno", {
128+
args: [
129+
"run",
130+
"-A",
131+
"--no-check",
132+
"--import-map",
133+
importMap,
134+
specifier,
135+
],
136+
});
137+
await p.output();
138+
} finally {
139+
// Cleanup
140+
await Deno.remove(tmpDir, { recursive: true });
141+
}
142+
}
143+
144+
// Main
145+
async function main() {
146+
const args = Deno.args;
147+
const [subcmd, filename, ...rest] = args;
148+
149+
if (subcmd === "help") {
150+
return console.log("TODO");
151+
}
152+
153+
switch (subcmd) {
154+
case "build":
155+
case "b": {
156+
const eszip = await build([filename]);
157+
let out = rest[0];
158+
if (!out) {
159+
// Create outfile name from url filename
160+
out = new URL(filename).pathname.split("/").pop() || "out";
161+
}
162+
console.log(`${out}.eszip: ${eszip.length} bytes`);
163+
await Deno.writeFile(`${out}.eszip`, eszip);
164+
return;
165+
}
166+
case "x":
167+
case "extract": {
168+
const eszip = await loadESZIP(filename);
169+
return await eszip.extract(rest[0] ?? Deno.cwd());
170+
}
171+
case "l":
172+
case "ls":
173+
case "list": {
174+
const eszip = await loadESZIP(filename);
175+
return console.log(eszip.list().join("\n"));
176+
}
177+
case "r":
178+
case "run": {
179+
const eszip = await loadESZIP(filename);
180+
const specifier = rest[0];
181+
if (!specifier) {
182+
return console.error("Please provide a specifier to run");
183+
}
184+
return await run(eszip, specifier);
185+
}
186+
}
187+
}
188+
189+
await main();

packages/edge-bundler/node/bridge.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ const DENO_VERSION_FILE = 'version.txt'
1717
// When updating DENO_VERSION_RANGE, ensure that the deno version
1818
// on the netlify/buildbot build image satisfies this range!
1919
// https://github.com/netlify/buildbot/blob/f9c03c9dcb091d6570e9d0778381560d469e78ad/build-image/noble/Dockerfile#L410
20-
export const DENO_VERSION_RANGE = '1.39.0 - 2.2.4'
21-
22-
const NEXT_DENO_VERSION_RANGE = '^2.4.2'
20+
export const DENO_VERSION_RANGE = '^2.4.2'
2321

2422
export type OnBeforeDownloadHook = () => void | Promise<void>
2523
export type OnAfterDownloadHook = (error?: Error) => void | Promise<void>
@@ -69,9 +67,7 @@ export class DenoBridge {
6967
this.onAfterDownload = options.onAfterDownload
7068
this.onBeforeDownload = options.onBeforeDownload
7169
this.useGlobal = options.useGlobal ?? true
72-
this.versionRange =
73-
options.versionRange ??
74-
(options.featureFlags?.edge_bundler_generate_tarball ? NEXT_DENO_VERSION_RANGE : DENO_VERSION_RANGE)
70+
this.versionRange = options.versionRange ?? DENO_VERSION_RANGE
7571
}
7672

7773
private async downloadBinary() {

packages/edge-bundler/node/bundle_error.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { ExecaError } from 'execa'
22

33
interface BundleErrorOptions {
4-
format: string
4+
cause?: unknown
5+
format?: string
56
}
67

78
const getCustomErrorInfo = (options?: BundleErrorOptions) => ({
@@ -12,11 +13,11 @@ const getCustomErrorInfo = (options?: BundleErrorOptions) => ({
1213
type: 'functionsBundling',
1314
})
1415

15-
class BundleError extends Error {
16+
export class BundleError extends Error {
1617
customErrorInfo: ReturnType<typeof getCustomErrorInfo>
1718

1819
constructor(originalError: Error, options?: BundleErrorOptions) {
19-
super(originalError.message)
20+
super(originalError.message, { cause: options?.cause })
2021

2122
this.customErrorInfo = getCustomErrorInfo(options)
2223
this.name = 'BundleError'
@@ -30,7 +31,7 @@ class BundleError extends Error {
3031
/**
3132
* BundleErrors are treated as user-error, so Netlify Team is not alerted about them.
3233
*/
33-
const wrapBundleError = (input: unknown, options?: BundleErrorOptions) => {
34+
export const wrapBundleError = (input: unknown, options?: BundleErrorOptions) => {
3435
if (input instanceof Error) {
3536
if (input.message.includes("The module's source code could not be parsed")) {
3637
input.message = (input as ExecaError).stderr
@@ -41,5 +42,3 @@ const wrapBundleError = (input: unknown, options?: BundleErrorOptions) => {
4142

4243
return input
4344
}
44-
45-
export { BundleError, wrapBundleError }

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

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -626,44 +626,37 @@ test('Loads JSON modules with `with` attribute', async () => {
626626
await rm(vendorDirectory.path, { force: true, recursive: true })
627627
})
628628

629-
// We can't run this on versions above 2.0.0 because the bundling will fail
630-
// entirely, and what we're asserting here is that we emit a system log when
631-
// import assertions are detected on successful builds. Also, running it on
632-
// earlier versions won't work either, since those won't even show a warning.
633-
test.skipIf(lt(denoVersion, '1.46.3') || gte(denoVersion, '2.0.0'))(
634-
'Emits a system log when import assertions are used',
635-
async () => {
636-
const { basePath, cleanup, distPath } = await useFixture('with_import_assert')
637-
const sourceDirectory = join(basePath, 'functions')
638-
const declarations: Declaration[] = [
639-
{
640-
function: 'func1',
641-
path: '/func1',
642-
},
643-
]
644-
const vendorDirectory = await tmp.dir()
645-
const systemLogger = vi.fn()
629+
test('Emits a system log when import assertions are used', async () => {
630+
const { basePath, cleanup, distPath } = await useFixture('with_import_assert')
631+
const sourceDirectory = join(basePath, 'functions')
632+
const vendorDirectory = await tmp.dir()
633+
const systemLogger = vi.fn()
646634

647-
await bundle([sourceDirectory], distPath, declarations, {
648-
basePath,
649-
systemLogger,
650-
vendorDirectory: vendorDirectory.path,
651-
})
635+
await bundle([sourceDirectory], distPath, [], {
636+
basePath,
637+
systemLogger,
638+
vendorDirectory: vendorDirectory.path,
639+
})
652640

653-
const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
654-
const manifest = JSON.parse(manifestFile)
655-
const bundlePath = join(distPath, manifest.bundles[0].asset)
656-
const { func1 } = await runESZIP(bundlePath, vendorDirectory.path)
641+
const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
642+
const manifest = JSON.parse(manifestFile)
643+
const bundlePath = join(distPath, manifest.bundles[0].asset)
644+
const { func1 } = await runESZIP(bundlePath, vendorDirectory.path)
657645

658-
expect(func1).toBe(`{"foo":"bar"}`)
659-
expect(systemLogger).toHaveBeenCalledWith(
660-
`Edge function uses import assertions: ${join(sourceDirectory, 'func1.ts')}`,
661-
)
646+
expect(func1).toBe(`{"foo":"bar"}`)
647+
expect(systemLogger).toHaveBeenCalledWith(
648+
`Edge function uses import assertions: ${join(sourceDirectory, 'func1.ts')}`,
649+
)
650+
expect(manifest.routes[0]).toEqual({
651+
function: 'func1',
652+
pattern: '^/with-import-assert/?$',
653+
excluded_patterns: [],
654+
path: '/with-import-assert',
655+
})
662656

663-
await cleanup()
664-
await rm(vendorDirectory.path, { force: true, recursive: true })
665-
},
666-
)
657+
await cleanup()
658+
await rm(vendorDirectory.path, { force: true, recursive: true })
659+
})
667660

668661
test('Supports TSX and process.env', async () => {
669662
const { basePath, cleanup, distPath } = await useFixture('tsx')

0 commit comments

Comments
 (0)