diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8328bf44..792bc281 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -22,6 +22,11 @@ jobs: node-version: lts/* cache: 'npm' + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: 2.2.4 + - name: Install dependencies run: npm ci --no-audit diff --git a/.github/workflows/publint.yaml b/.github/workflows/publint.yaml index 79e0d7e1..86ee1001 100644 --- a/.github/workflows/publint.yaml +++ b/.github/workflows/publint.yaml @@ -22,6 +22,10 @@ jobs: with: node-version: lts/* cache: 'npm' + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: 2.2.4 - name: Install dependencies run: npm ci - name: Build diff --git a/eslint.config.js b/eslint.config.js index 79818a4b..a224fe7f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,7 +30,7 @@ export default tseslint.config( // TODO: Move this to `edge-functions` package. { - ignores: ['packages/**/deno'], + ignores: ['packages/**/deno', 'packages/edge-functions/bootstrap-bundle.mjs'], }, // JavaScript-specific rules diff --git a/package-lock.json b/package-lock.json index c1c0881b..9dc0636b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5624,7 +5624,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", @@ -11231,6 +11230,7 @@ }, "devDependencies": { "@netlify/types": "2.0.2", + "execa": "^8.0.1", "tsup": "^8.0.0", "vitest": "^3.0.0" }, diff --git a/packages/edge-functions/.gitignore b/packages/edge-functions/.gitignore index 7b4a69ce..aa2750f1 100644 --- a/packages/edge-functions/.gitignore +++ b/packages/edge-functions/.gitignore @@ -1,2 +1,3 @@ dist -dist-dev \ No newline at end of file +dist-dev +dev/deno/bootstrap.mjs diff --git a/packages/edge-functions/bootstrap-bundle.mjs b/packages/edge-functions/bootstrap-bundle.mjs new file mode 100644 index 00000000..5039d1d6 --- /dev/null +++ b/packages/edge-functions/bootstrap-bundle.mjs @@ -0,0 +1,15 @@ +import * as esbuild from 'npm:esbuild' +import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@^0.11.1' + +const [entryPoint, outfile] = Deno.args + +await esbuild.build({ + bundle: true, + entryPoints: [entryPoint], + format: 'esm', + minify: true, + outfile, + plugins: denoPlugins(), +}) + +await esbuild.stop() diff --git a/packages/edge-functions/dev/deno/invoke.mjs b/packages/edge-functions/dev/deno/invoke.mjs index 72d0bc96..a6ab3115 100644 --- a/packages/edge-functions/dev/deno/invoke.mjs +++ b/packages/edge-functions/dev/deno/invoke.mjs @@ -1,7 +1,7 @@ // @ts-check /** - * @typedef {import('./workers/types.js').Message} Message + * @typedef {import('./workers/types.ts').Message} Message */ /** @@ -10,11 +10,10 @@ * construct a `Response`. * * @param {Request} req - * @param {string} bootstrapURL * @param {Record} functions * @param {number} requestTimeout */ -export function invoke(req, bootstrapURL, functions, requestTimeout) { +export function invoke(req, functions, requestTimeout) { return new Promise((resolve, reject) => { const worker = new Worker(new URL('./workers/runner.mjs', import.meta.url).href, { type: 'module', @@ -43,7 +42,6 @@ export function invoke(req, bootstrapURL, functions, requestTimeout) { type: 'request', data: { body: await req.arrayBuffer(), - bootstrapURL, functions, headers: Object.fromEntries(req.headers.entries()), method: req.method, diff --git a/packages/edge-functions/dev/deno/server.mjs b/packages/edge-functions/dev/deno/server.mjs index cd935401..8c3868b7 100644 --- a/packages/edge-functions/dev/deno/server.mjs +++ b/packages/edge-functions/dev/deno/server.mjs @@ -15,7 +15,7 @@ import { invoke } from './invoke.mjs' * * @param {RunOptions} options */ -export const serveLocal = ({ bootstrapURL, denoPort: port, requestTimeout }) => { +export const serveLocal = ({ denoPort: port, requestTimeout }) => { const serveOptions = { // Adding a no-op listener to avoid the default one, which prints a message // we don't want. @@ -37,11 +37,11 @@ export const serveLocal = ({ bootstrapURL, denoPort: port, requestTimeout }) => // the Deno server take a list of functions, import them, and return their // configs. if (method === 'NETLIFYCONFIG') { + const functionsParam = url.searchParams.get('functions') + // This is the list of all the functions found in the project. /** @type {Record} */ - const availableFunctions = url.searchParams.has('functions') - ? JSON.parse(decodeURIComponent(url.searchParams.get('functions'))) - : {} + const availableFunctions = functionsParam ? JSON.parse(decodeURIComponent(functionsParam)) : {} functions = availableFunctions @@ -59,7 +59,7 @@ export const serveLocal = ({ bootstrapURL, denoPort: port, requestTimeout }) => } try { - return await invoke(request, bootstrapURL, functions, requestTimeout) + return await invoke(request, functions, requestTimeout) } catch (error) { return getErrorResponse(error) } diff --git a/packages/edge-functions/dev/deno/workers/config.mjs b/packages/edge-functions/dev/deno/workers/config.mjs index 1a75fb3a..eed100cd 100644 --- a/packages/edge-functions/dev/deno/workers/config.mjs +++ b/packages/edge-functions/dev/deno/workers/config.mjs @@ -1,12 +1,17 @@ // @ts-check +/// /** * @typedef {import('../../shared/types.ts').SerializedError} SerializedError - * @typedef {import('./types.js').ConfigResponseMessage} ConfigResponseMessage + * @typedef {import('./types.ts').ConfigResponseMessage} ConfigResponseMessage * @typedef {import('./types.ts').Message} Message */ -self.onmessage = async (e) => { +/** @type {DedicatedWorkerGlobalScope} */ +// @ts-ignore We are inside a worker, so the global scope is `DedicatedWorkerGlobalScope`. +const worker = globalThis + +worker.addEventListener('message', async (e) => { const message = /** @type {Message} */ (e.data) if (message.type === 'configRequest') { @@ -38,10 +43,10 @@ self.onmessage = async (e) => { await Promise.allSettled(imports) - self.postMessage(/** @type {ConfigResponseMessage} */ ({ type: 'configResponse', data: { configs, errors } })) + worker.postMessage(/** @type {ConfigResponseMessage} */ ({ type: 'configResponse', data: { configs, errors } })) return } throw new Error('Unsupported message') -} +}) diff --git a/packages/edge-functions/dev/deno/workers/runner.mjs b/packages/edge-functions/dev/deno/workers/runner.mjs index 4a3c6955..7f4c4738 100644 --- a/packages/edge-functions/dev/deno/workers/runner.mjs +++ b/packages/edge-functions/dev/deno/workers/runner.mjs @@ -1,21 +1,26 @@ // @ts-check +/// +import { handleRequest } from '../bootstrap.mjs' /** - * @typedef {import('./types.js').Message} Message - * @typedef {import('./types.js').RunResponseStartMessage} RunResponseStartMessage - * @typedef {import('./types.js').RunResponseChunkMessage} RunResponseChunkMessage - * @typedef {import('./types.js').RunResponseEndMessage} RunResponseEndMessage + * @typedef {import('./types.ts').Message} Message + * @typedef {import('./types.ts').RunResponseStartMessage} RunResponseStartMessage + * @typedef {import('./types.ts').RunResponseChunkMessage} RunResponseChunkMessage + * @typedef {import('./types.ts').RunResponseEndMessage} RunResponseEndMessage */ const consoleLog = globalThis.console.log /** @type {Map} */ const fetchRewrites = new Map() -self.onmessage = async (e) => { +/** @type {DedicatedWorkerGlobalScope} */ +// @ts-ignore We are inside a worker, so the global scope is `DedicatedWorkerGlobalScope`. +const worker = globalThis + +worker.addEventListener('message', async (e) => { const message = /** @type {Message} */ (e.data) if (message.type === 'request') { - const { handleRequest } = await import(message.data.bootstrapURL) const body = message.data.method === 'GET' || message.data.method === 'HEAD' ? undefined : message.data.body const req = new Request(message.data.url, { body, @@ -35,12 +40,14 @@ self.onmessage = async (e) => { await Promise.allSettled(imports) const res = await handleRequest(req, functions, { + // @ts-ignore TODO: Figure out why `fetchRewrites` is not being picked up + // as part of the type. fetchRewrites, rawLogger: consoleLog, requestTimeout: message.data.timeout, }) - self.postMessage( + worker.postMessage( /** @type {RunResponseStartMessage} */ ({ type: 'responseStart', data: { @@ -58,7 +65,8 @@ self.onmessage = async (e) => { break } - self.postMessage( + // @ts-expect-error TODO: Figure out type mismatch. + worker.postMessage( /** @type {RunResponseChunkMessage} */ ({ type: 'responseChunk', data: { chunk: value }, @@ -68,10 +76,10 @@ self.onmessage = async (e) => { } } - self.postMessage(/** @type {RunResponseEndMessage} */ ({ type: 'responseEnd' })) + worker.postMessage(/** @type {RunResponseEndMessage} */ ({ type: 'responseEnd' })) return } throw new Error('Unsupported message') -} +}) diff --git a/packages/edge-functions/dev/deno/workers/types.ts b/packages/edge-functions/dev/deno/workers/types.ts index 1081663d..70e79ddd 100644 --- a/packages/edge-functions/dev/deno/workers/types.ts +++ b/packages/edge-functions/dev/deno/workers/types.ts @@ -31,7 +31,6 @@ export interface RunRequestMessage { type: 'request' data: { body: ArrayBuffer - bootstrapURL: string functions: Record headers: Record method: string diff --git a/packages/edge-functions/dev/node/main.ts b/packages/edge-functions/dev/node/main.ts index 580b7d5f..bf54293a 100644 --- a/packages/edge-functions/dev/node/main.ts +++ b/packages/edge-functions/dev/node/main.ts @@ -11,7 +11,6 @@ import { EdgeFunction, FunctionConfig, } from '@netlify/edge-bundler' -import { getURL as getBootstrapURL } from '@netlify/edge-functions-bootstrap/version' import { base64Encode } from '@netlify/runtime-utils' import getAvailablePort from 'get-port' @@ -272,7 +271,6 @@ export class EdgeFunctionsHandler { versionRange: '^2.2.4', }) const runOptions: RunOptions = { - bootstrapURL: await getBootstrapURL(), denoPort, requestTimeout: this.requestTimeout, } diff --git a/packages/edge-functions/dev/shared/types.ts b/packages/edge-functions/dev/shared/types.ts index e60c8847..47526010 100644 --- a/packages/edge-functions/dev/shared/types.ts +++ b/packages/edge-functions/dev/shared/types.ts @@ -1,5 +1,4 @@ export interface RunOptions { - bootstrapURL: string denoPort: number requestTimeout: number } diff --git a/packages/edge-functions/package.json b/packages/edge-functions/package.json index b830a693..c60e476c 100644 --- a/packages/edge-functions/package.json +++ b/packages/edge-functions/package.json @@ -51,6 +51,7 @@ }, "devDependencies": { "@netlify/types": "2.0.2", + "execa": "^8.0.1", "tsup": "^8.0.0", "vitest": "^3.0.0" }, diff --git a/packages/edge-functions/tsup.config.ts b/packages/edge-functions/tsup.config.ts index 51cccdd2..a0d9f2c5 100644 --- a/packages/edge-functions/tsup.config.ts +++ b/packages/edge-functions/tsup.config.ts @@ -3,10 +3,14 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { argv } from 'node:process' +import { getURL } from '@netlify/edge-functions-bootstrap/version' +import { execa } from 'execa' import { defineConfig } from 'tsup' const __filename = fileURLToPath(import.meta.url) +const BOOTSTRAP_FILENAME = 'bootstrap.mjs' + export default defineConfig([ { clean: true, @@ -45,10 +49,29 @@ export default defineConfig([ // preserve the original structure, so that the relative path to the worker // files is consistent. onSuccess: async () => { + const bootstrapURL = await getURL() const denoPath = path.resolve(path.dirname(__filename), 'dev', 'deno') const distPath = path.resolve(path.dirname(__filename), 'dist-dev') await fs.cp(denoPath, path.resolve(distPath, 'deno'), { recursive: true }) + + // We need to bundle the bootstrap layer with the package because Deno + // does not support HTTP imports when inside a `node_modukes` directory. + const distBootstrapPath = path.resolve(distPath, 'deno', BOOTSTRAP_FILENAME) + await execa( + 'deno', + ['run', '--allow-all', '--no-lock', 'bootstrap-bundle.mjs', bootstrapURL, distBootstrapPath], + { + stdio: 'inherit', + }, + ) + + // In addition to putting the bootstrap file in `dist-dev`, we must also + // put it in the source directory so that the reference to the bootstrap + // file still works in tests and local development. This is not great. At + // least we're gitignoring the file so that it doesn't end up in version + // control. + await fs.cp(distBootstrapPath, path.resolve(denoPath, BOOTSTRAP_FILENAME)) }, }, ])