From 4c93bd8154c210ebce6ad2889bd8bfdf4c349a78 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 17 Jul 2023 15:53:10 +0100 Subject: [PATCH] feat(@astrojs/netlify): edge middleware support (#7632) Co-authored-by: Bjorn Lu Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com> --- .changeset/great-days-judge.md | 10 ++ .github/workflows/ci.yml | 2 +- packages/integrations/netlify/README.md | 59 ++++++++++ packages/integrations/netlify/package.json | 4 +- .../netlify/src/integration-edge-functions.ts | 110 ++---------------- .../netlify/src/integration-functions.ts | 24 +++- .../integrations/netlify/src/middleware.ts | 75 ++++++++++++ .../netlify/src/netlify-functions.ts | 11 +- packages/integrations/netlify/src/shared.ts | 91 +++++++++++++++ .../netlify/test/edge-functions/deps.ts | 2 +- ...-import.test.js => dynamic-import.test.ts} | 12 +- .../test/edge-functions/edge-basic.test.ts | 40 ++++--- .../fixtures/edge-basic/astro.config.mjs | 2 - .../fixtures/edge-basic/package.json | 4 +- .../edge-basic/src/components/React.jsx | 7 -- .../fixtures/edge-basic/src/pages/index.astro | 4 - .../test/edge-functions/prerender.test.ts | 24 ++-- .../test/functions/edge-middleware.test.js | 43 +++++++ .../src/middleware.ts | 5 + .../src/netlify-edge-middleware.js | 5 + .../src/pages/index.astro | 12 ++ .../src/middleware.ts | 5 + .../src/pages/index.astro | 12 ++ packages/integrations/netlify/tsconfig.json | 3 +- pnpm-lock.yaml | 6 - 25 files changed, 411 insertions(+), 161 deletions(-) create mode 100644 .changeset/great-days-judge.md create mode 100644 packages/integrations/netlify/src/middleware.ts rename packages/integrations/netlify/test/edge-functions/{dynamic-import.test.js => dynamic-import.test.ts} (65%) delete mode 100644 packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx create mode 100644 packages/integrations/netlify/test/functions/edge-middleware.test.js create mode 100644 packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/middleware.ts create mode 100644 packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/netlify-edge-middleware.js create mode 100644 packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/pages/index.astro create mode 100644 packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/middleware.ts create mode 100644 packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/pages/index.astro diff --git a/.changeset/great-days-judge.md b/.changeset/great-days-judge.md new file mode 100644 index 000000000000..5c08417fe6af --- /dev/null +++ b/.changeset/great-days-judge.md @@ -0,0 +1,10 @@ +--- +'@astrojs/netlify': minor +--- + +When a project uses the new option Astro `build.excludeMiddleware`, the +`@astrojs/netlify/functions` adapter will automatically create an Edge Middleware +that will automatically communicate with the Astro Middleware. + +Check the [documentation](https://github.com/withastro/astro/blob/main/packages/integrations/netlify/README.md#edge-middleware-with-astro-middleware) for more details. + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c94e8d4d9e8..329835b8035e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: - name: Use Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.34.1 + deno-version: v1.35.0 - name: Install dependencies run: pnpm install diff --git a/packages/integrations/netlify/README.md b/packages/integrations/netlify/README.md index 5e7895217643..31337b97d0a4 100644 --- a/packages/integrations/netlify/README.md +++ b/packages/integrations/netlify/README.md @@ -115,6 +115,65 @@ Once you run `astro build` there will be a `dist/_redirects` file. Netlify will > **Note** > You can still include a `public/_redirects` file for manual redirects. Any redirects you specify in the redirects config are appended to the end of your own. + +### Edge Middleware with Astro middleware + +The `@astrojs/netlify/functions` adapter can automatically create an edge function that will act as "Edge Middleware", from an Astro middleware in your code base. + +This is an opt-in feature and the `build.excludeMiddleware` option needs to be set to `true`: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import netlify from '@astrojs/netlify/functions'; +export default defineConfig({ + output: 'server', + adapter: netlify(), + build: { + excludeMiddleware: true, + }, +}); +``` + +Optionally, you can create a file recognized by the adapter named `netlify-edge-middleware.(js|ts)` in the [`srcDir`](https://docs.astro.build/en/reference/configuration-reference/#srcdir) folder to create [`Astro.locals`](https://docs.astro.build/en/reference/api-reference/#astrolocals). + +Typings require the [`https://edge.netlify.com`](https://docs.netlify.com/edge-functions/api/#reference) types. + +> Netlify edge functions run in a Deno environment, so you would need to import types using URLs. +> +> You can find more in the [Netlify documentation page](https://docs.netlify.com/edge-functions/api/#runtime-environment) + +```ts +// src/netlify-edge-middleware.ts +import type { Context } from "https://edge.netlify.com"; + +export default function ({ request, context }: { request: Request, context: Context }): object { + // do something with request and context + return { + title: "Spider-man's blog", + }; +} +``` + +The data returned by this function will be passed to Astro middleware. + +The function: + +- must export a **default** function; +- must **return** an `object`; +- accepts an object with a `request` and `context` as properties; +- `request` is typed as [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request); +- `context` is typed as [`Context`](https://docs.netlify.com/edge-functions/api/#edge-function-types); + +#### Limitations and constraints + +When you opt-in to this feature, there are a few constraints to note: + +- The Edge middleware will always be the **first** function to receive the `Request` and the last function to receive `Response`. This is an architectural constraint that follows the [boundaries set by Netlify](https://docs.netlify.com/edge-functions/overview/#use-cases). +- Only `request` and `context` may be used to produce an `Astro.locals` object. Operations like redirects, etc. should be delegated to Astro middleware. +- `Astro.locals` **must be serializable**. Failing to do so will result in a **runtime error**. This means that you **cannot** store complex types like `Map`, `function`, `Set`, etc. + + ## Usage [Read the full deployment guide here.](https://docs.astro.build/en/guides/deploy/netlify/) diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index 0b3ada36bb89..632e5af198a9 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -33,8 +33,8 @@ "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", "test-fn": "mocha --exit --timeout 20000 --file \"./test/setup.js\" test/functions/", - "test-edge": "deno test --allow-run --allow-read --allow-net --allow-env ./test/edge-functions/", - "test": "npm run test-fn" + "test-edge": "deno test --allow-run --allow-read --allow-net --allow-env --allow-write ./test/edge-functions/", + "test": "pnpm test-fn" }, "dependencies": { "@astrojs/underscore-redirects": "^0.2.0", diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts index 72721bd592d1..ac7c124fb93e 100644 --- a/packages/integrations/netlify/src/integration-edge-functions.ts +++ b/packages/integrations/netlify/src/integration-edge-functions.ts @@ -1,21 +1,10 @@ import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; -import esbuild from 'esbuild'; -import * as fs from 'fs'; -import * as npath from 'path'; -import { fileURLToPath } from 'url'; -import { createRedirects } from './shared.js'; - -interface BuildConfig { - server: URL; - client: URL; - serverEntry: string; - assets: string; -} - -const SHIM = `globalThis.process = { - argv: [], - env: Deno.env.toObject(), -};`; +import { + bundleServerEntry, + createEdgeManifest, + createRedirects, + type NetlifyEdgeFunctionsOptions, +} from './shared.js'; export function getAdapter(): AstroAdapter { return { @@ -25,92 +14,10 @@ export function getAdapter(): AstroAdapter { }; } -interface NetlifyEdgeFunctionsOptions { - dist?: URL; -} - -interface NetlifyEdgeFunctionManifestFunctionPath { - function: string; - path: string; -} - -interface NetlifyEdgeFunctionManifestFunctionPattern { - function: string; - pattern: string; -} - -type NetlifyEdgeFunctionManifestFunction = - | NetlifyEdgeFunctionManifestFunctionPath - | NetlifyEdgeFunctionManifestFunctionPattern; - -interface NetlifyEdgeFunctionManifest { - functions: NetlifyEdgeFunctionManifestFunction[]; - version: 1; -} - -async function createEdgeManifest(routes: RouteData[], entryFile: string, dir: URL) { - const functions: NetlifyEdgeFunctionManifestFunction[] = []; - for (const route of routes) { - if (route.pathname) { - functions.push({ - function: entryFile, - path: route.pathname, - }); - } else { - functions.push({ - function: entryFile, - // Make route pattern serializable to match expected - // Netlify Edge validation format. Mirrors Netlify's own edge bundler: - // https://github.com/netlify/edge-bundler/blob/main/src/manifest.ts#L34 - pattern: route.pattern.source.replace(/\\\//g, '/').toString(), - }); - } - } - - const manifest: NetlifyEdgeFunctionManifest = { - functions, - version: 1, - }; - - const baseDir = new URL('./.netlify/edge-functions/', dir); - await fs.promises.mkdir(baseDir, { recursive: true }); - - const manifestURL = new URL('./manifest.json', baseDir); - const _manifest = JSON.stringify(manifest, null, ' '); - await fs.promises.writeFile(manifestURL, _manifest, 'utf-8'); -} - -async function bundleServerEntry({ serverEntry, server }: BuildConfig, vite: any) { - const entryUrl = new URL(serverEntry, server); - const pth = fileURLToPath(entryUrl); - await esbuild.build({ - target: 'es2020', - platform: 'browser', - entryPoints: [pth], - outfile: pth, - allowOverwrite: true, - format: 'esm', - bundle: true, - external: ['@astrojs/markdown-remark'], - banner: { - js: SHIM, - }, - }); - - // Remove chunks, if they exist. Since we have bundled via esbuild these chunks are trash. - try { - const chunkFileNames = - vite?.build?.rollupOptions?.output?.chunkFileNames ?? `chunks/chunk.[hash].mjs`; - const chunkPath = npath.dirname(chunkFileNames); - const chunksDirUrl = new URL(chunkPath + '/', server); - await fs.promises.rm(chunksDirUrl, { recursive: true, force: true }); - } catch {} -} - export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}): AstroIntegration { let _config: AstroConfig; let entryFile: string; - let _buildConfig: BuildConfig; + let _buildConfig: AstroConfig['build']; let _vite: any; return { name: '@astrojs/netlify/edge-functions', @@ -164,7 +71,8 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}) } }, 'astro:build:done': async ({ routes, dir }) => { - await bundleServerEntry(_buildConfig, _vite); + const entryUrl = new URL(_buildConfig.serverEntry, _buildConfig.server); + await bundleServerEntry(entryUrl, _buildConfig.server, _vite); await createEdgeManifest(routes, entryFile, _config.root); const dynamicTarget = `/.netlify/edge-functions/${entryFile}`; const map: [RouteData, string][] = routes.map((route) => { diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index 3e8c476e583b..64bdbb2037fd 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -1,8 +1,12 @@ import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; import { extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Args } from './netlify-functions.js'; import { createRedirects } from './shared.js'; +import { fileURLToPath } from 'node:url'; +import { generateEdgeMiddleware } from './middleware.js'; + +export const NETLIFY_EDGE_MIDDLEWARE_FILE = 'netlify-edge-middleware'; +export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; export function getAdapter(args: Args = {}): AstroAdapter { return { @@ -27,6 +31,7 @@ function netlifyFunctions({ let _config: AstroConfig; let _entryPoints: Map; let ssrEntryFile: string; + let _middlewareEntryPoint: URL; return { name: '@astrojs/netlify', hooks: { @@ -40,7 +45,10 @@ function netlifyFunctions({ }, }); }, - 'astro:build:ssr': ({ entryPoints }) => { + 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { + if (middlewareEntryPoint) { + _middlewareEntryPoint = middlewareEntryPoint; + } _entryPoints = entryPoints; }, 'astro:config:done': ({ config, setAdapter }) => { @@ -85,6 +93,18 @@ function netlifyFunctions({ await createRedirects(_config, routeToDynamicTargetMap, dir); } + if (_middlewareEntryPoint) { + const outPath = fileURLToPath(new URL('./.netlify/edge-functions/', _config.root)); + const netlifyEdgeMiddlewareHandlerPath = new URL( + NETLIFY_EDGE_MIDDLEWARE_FILE, + _config.srcDir + ); + await generateEdgeMiddleware( + _middlewareEntryPoint, + outPath, + netlifyEdgeMiddlewareHandlerPath + ); + } }, }, }; diff --git a/packages/integrations/netlify/src/middleware.ts b/packages/integrations/netlify/src/middleware.ts new file mode 100644 index 000000000000..a53d4fbde343 --- /dev/null +++ b/packages/integrations/netlify/src/middleware.ts @@ -0,0 +1,75 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { ASTRO_LOCALS_HEADER } from './integration-functions.js'; +import { DENO_SHIM } from './shared.js'; + +/** + * It generates a Netlify edge function. + * + */ +export async function generateEdgeMiddleware( + astroMiddlewareEntryPointPath: URL, + outPath: string, + netlifyEdgeMiddlewareHandlerPath: URL +): Promise { + const entryPointPathURLAsString = JSON.stringify( + fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') + ); + + const code = edgeMiddlewareTemplate(entryPointPathURLAsString, netlifyEdgeMiddlewareHandlerPath); + const bundledFilePath = join(outPath, 'edgeMiddleware.js'); + const esbuild = await import('esbuild'); + await esbuild.build({ + stdin: { + contents: code, + resolveDir: process.cwd(), + }, + target: 'es2020', + platform: 'browser', + outfile: bundledFilePath, + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: false, + banner: { + js: DENO_SHIM, + }, + }); + return pathToFileURL(bundledFilePath); +} + +function edgeMiddlewareTemplate(middlewarePath: string, netlifyEdgeMiddlewareHandlerPath: URL) { + const filePathEdgeMiddleware = fileURLToPath(netlifyEdgeMiddlewareHandlerPath); + let handlerTemplateImport = ''; + let handlerTemplateCall = '{}'; + if (existsSync(filePathEdgeMiddleware + '.js') || existsSync(filePathEdgeMiddleware + '.ts')) { + const stringified = JSON.stringify(filePathEdgeMiddleware.replace(/\\/g, '/')); + handlerTemplateImport = `import handler from ${stringified}`; + handlerTemplateCall = `handler({ request, context })`; + } else { + } + return ` + ${handlerTemplateImport} +import { onRequest } from ${middlewarePath}; +import { createContext, trySerializeLocals } from 'astro/middleware'; +export default async function middleware(request, context) { + const url = new URL(request.url); + const ctx = createContext({ + request, + params: {} + }); + ctx.locals = ${handlerTemplateCall}; + const next = async () => { + request.headers.set(${JSON.stringify(ASTRO_LOCALS_HEADER)}, trySerializeLocals(ctx.locals)); + return await context.next(); + }; + + return onRequest(ctx, next); +} + +export const config = { + path: "/*" +} +`; +} diff --git a/packages/integrations/netlify/src/netlify-functions.ts b/packages/integrations/netlify/src/netlify-functions.ts index 915f72955105..8d0196d5e459 100644 --- a/packages/integrations/netlify/src/netlify-functions.ts +++ b/packages/integrations/netlify/src/netlify-functions.ts @@ -2,6 +2,7 @@ import { polyfill } from '@astrojs/webapi'; import { builder, type Handler } from '@netlify/functions'; import type { SSRManifest } from 'astro'; import { App } from 'astro/app'; +import { ASTRO_LOCALS_HEADER } from './integration-functions.js'; polyfill(globalThis, { exclude: 'window document', @@ -80,8 +81,14 @@ export const createExports = (manifest: SSRManifest, args: Args) => { const ip = headers['x-nf-client-connection-ip']; Reflect.set(request, clientAddressSymbol, ip); - - const response: Response = await app.render(request, routeData); + let locals = {}; + if (request.headers.has(ASTRO_LOCALS_HEADER)) { + let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER); + if (localsAsString) { + locals = JSON.parse(localsAsString); + } + } + const response: Response = await app.render(request, routeData, locals); const responseHeaders = Object.fromEntries(response.headers.entries()); const responseContentType = parseContentType(responseHeaders['content-type']); diff --git a/packages/integrations/netlify/src/shared.ts b/packages/integrations/netlify/src/shared.ts index ca45dc752c35..c0ed9ec17a29 100644 --- a/packages/integrations/netlify/src/shared.ts +++ b/packages/integrations/netlify/src/shared.ts @@ -1,6 +1,37 @@ import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; import type { AstroConfig, RouteData } from 'astro'; import fs from 'node:fs'; +import { fileURLToPath } from 'url'; +import esbuild from 'esbuild'; +import npath from 'path'; + +export const DENO_SHIM = `globalThis.process = { + argv: [], + env: Deno.env.toObject(), +};`; + +export interface NetlifyEdgeFunctionsOptions { + dist?: URL; +} + +export interface NetlifyEdgeFunctionManifestFunctionPath { + function: string; + path: string; +} + +export interface NetlifyEdgeFunctionManifestFunctionPattern { + function: string; + pattern: string; +} + +export type NetlifyEdgeFunctionManifestFunction = + | NetlifyEdgeFunctionManifestFunctionPath + | NetlifyEdgeFunctionManifestFunctionPattern; + +export interface NetlifyEdgeFunctionManifest { + functions: NetlifyEdgeFunctionManifestFunction[]; + version: 1; +} export async function createRedirects( config: AstroConfig, @@ -21,3 +52,63 @@ export async function createRedirects( // If the file does not exist yet, appendFile() automatically creates it. await fs.promises.appendFile(_redirectsURL, content, 'utf-8'); } + +export async function createEdgeManifest(routes: RouteData[], entryFile: string, dir: URL) { + const functions: NetlifyEdgeFunctionManifestFunction[] = []; + for (const route of routes) { + if (route.pathname) { + functions.push({ + function: entryFile, + path: route.pathname, + }); + } else { + functions.push({ + function: entryFile, + // Make route pattern serializable to match expected + // Netlify Edge validation format. Mirrors Netlify's own edge bundler: + // https://github.com/netlify/edge-bundler/blob/main/src/manifest.ts#L34 + pattern: route.pattern.source.replace(/\\\//g, '/').toString(), + }); + } + } + + const manifest: NetlifyEdgeFunctionManifest = { + functions, + version: 1, + }; + + const baseDir = new URL('./.netlify/edge-functions/', dir); + await fs.promises.mkdir(baseDir, { recursive: true }); + + const manifestURL = new URL('./manifest.json', baseDir); + const _manifest = JSON.stringify(manifest, null, ' '); + await fs.promises.writeFile(manifestURL, _manifest, 'utf-8'); +} + +export async function bundleServerEntry(entryUrl: URL, serverUrl?: URL, vite?: any | undefined) { + const pth = fileURLToPath(entryUrl); + await esbuild.build({ + target: 'es2020', + platform: 'browser', + entryPoints: [pth], + outfile: pth, + allowOverwrite: true, + format: 'esm', + bundle: true, + external: ['@astrojs/markdown-remark', 'astro/middleware'], + banner: { + js: DENO_SHIM, + }, + }); + + // Remove chunks, if they exist. Since we have bundled via esbuild these chunks are trash. + if (vite && serverUrl) { + try { + const chunkFileNames = + vite?.build?.rollupOptions?.output?.chunkFileNames ?? `chunks/chunk.[hash].mjs`; + const chunkPath = npath.dirname(chunkFileNames); + const chunksDirUrl = new URL(chunkPath + '/', serverUrl); + await fs.promises.rm(chunksDirUrl, { recursive: true, force: true }); + } catch {} + } +} diff --git a/packages/integrations/netlify/test/edge-functions/deps.ts b/packages/integrations/netlify/test/edge-functions/deps.ts index c6ced8814fac..6d729970d099 100644 --- a/packages/integrations/netlify/test/edge-functions/deps.ts +++ b/packages/integrations/netlify/test/edge-functions/deps.ts @@ -5,7 +5,7 @@ export { assert, assertExists, } from 'https://deno.land/std@0.132.0/testing/asserts.ts'; -export * from 'https://deno.land/x/deno_dom/deno-dom-wasm.ts'; +export { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.35-alpha/deno-dom-wasm.ts'; export * from 'https://deno.land/std@0.142.0/streams/conversion.ts'; export * as cheerio from 'https://cdn.skypack.dev/cheerio?dts'; export * as fs from 'https://deno.land/std/fs/mod.ts'; diff --git a/packages/integrations/netlify/test/edge-functions/dynamic-import.test.js b/packages/integrations/netlify/test/edge-functions/dynamic-import.test.ts similarity index 65% rename from packages/integrations/netlify/test/edge-functions/dynamic-import.test.js rename to packages/integrations/netlify/test/edge-functions/dynamic-import.test.ts index bfa895290d5e..89a640b0b856 100644 --- a/packages/integrations/netlify/test/edge-functions/dynamic-import.test.js +++ b/packages/integrations/netlify/test/edge-functions/dynamic-import.test.ts @@ -1,10 +1,12 @@ -import { runBuild, runApp } from './test-utils.ts'; +import { loadFixture } from './test-utils.ts'; import { assertEquals, assert, DOMParser } from './deps.ts'; Deno.test({ name: 'Dynamic imports', + permissions: 'inherit', async fn() { - await runBuild('./fixtures/dynimport/'); + const { runApp, runBuild } = await loadFixture('./fixtures/dynimport/'); + await runBuild(); const stop = await runApp('./fixtures/dynimport/prod.js'); try { @@ -14,8 +16,10 @@ Deno.test({ assert(html, 'got some html'); const doc = new DOMParser().parseFromString(html, `text/html`); - const div = doc.querySelector('#thing'); - assert(div, 'div exists'); + if (doc) { + const div = doc.querySelector('#thing'); + assert(div, 'div exists'); + } } catch (err) { console.error(err); } finally { diff --git a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts index 9f2a7bde3ac7..699ab0014a64 100644 --- a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts +++ b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts @@ -3,30 +3,34 @@ import { assertEquals, assert, DOMParser } from './deps.ts'; Deno.env.set('SECRET_STUFF', 'secret'); -// @ts-expect-error Deno.test({ - // TODO: debug why build cannot be found in "await import" ignore: true, name: 'Edge Basics', - skip: true, - async fn() { + permissions: 'inherit', + async fn(t) { const fixture = loadFixture('./fixtures/edge-basic/'); - await fixture.runBuild(); - const { default: handler } = await import( - './fixtures/edge-basic/.netlify/edge-functions/entry.js' - ); - const response = await handler(new Request('http://example.com/')); - assertEquals(response.status, 200); - const html = await response.text(); - assert(html, 'got some html'); + await t.step('Run the build', async () => { + await fixture.runBuild(); + }); + await t.step('Should correctly render the response', async () => { + const { default: handler } = await import( + './fixtures/edge-basic/.netlify/edge-functions/entry.js' + ); + const response = await handler(new Request('http://example.com/')); + assertEquals(response.status, 200); + const html = await response.text(); + assert(html, 'got some html'); - const doc = new DOMParser().parseFromString(html, `text/html`)!; - const div = doc.querySelector('#react'); - assert(div, 'div exists'); + const doc = new DOMParser().parseFromString(html, `text/html`)!; + const div = doc.querySelector('#react'); + assert(div, 'div exists'); - const envDiv = doc.querySelector('#env'); - assertEquals(envDiv?.innerText, 'secret'); + const envDiv = doc.querySelector('#env'); + assertEquals(envDiv?.innerText, 'secret'); + }); - await fixture.cleanup(); + await t.step('Clean up', async () => { + await fixture.cleanup(); + }); }, }); diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs index 310088c88618..ac15ad4e9c49 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs @@ -1,6 +1,5 @@ import { defineConfig } from 'astro/config'; import { netlifyEdgeFunctions } from '@astrojs/netlify'; -import react from "@astrojs/react"; // test env var process.env.SECRET_STUFF = 'secret' @@ -9,6 +8,5 @@ export default defineConfig({ adapter: netlifyEdgeFunctions({ dist: new URL('./dist/', import.meta.url), }), - integrations: [react()], output: 'server', }) diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json index e84f1d5f371a..16ff300883d2 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json @@ -5,8 +5,6 @@ "dependencies": { "astro": "workspace:*", "@astrojs/react": "workspace:*", - "@astrojs/netlify": "workspace:*", - "react": "^18.1.0", - "react-dom": "^18.1.0" + "@astrojs/netlify": "workspace:*" } } diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx deleted file mode 100644 index c6cf39aa55a8..000000000000 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -export default function() { - return ( -
testing
- ) -} diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro index 1247ba8f6403..a480cf46ce55 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro @@ -1,6 +1,3 @@ ---- -import ReactComponent from '../components/React.jsx'; ---- Testing @@ -9,7 +6,6 @@ import ReactComponent from '../components/React.jsx'; -
{import.meta.env.SECRET_STUFF}
diff --git a/packages/integrations/netlify/test/edge-functions/prerender.test.ts b/packages/integrations/netlify/test/edge-functions/prerender.test.ts index 4d4dfc9c6914..2c066b9b8764 100644 --- a/packages/integrations/netlify/test/edge-functions/prerender.test.ts +++ b/packages/integrations/netlify/test/edge-functions/prerender.test.ts @@ -3,12 +3,16 @@ import { assertEquals, assertExists, cheerio, fs } from './deps.ts'; Deno.test({ name: 'Prerender', + permissions: 'inherit', async fn(t) { const environmentVariables = { PRERENDER: 'true', }; - const fixture = loadFixture('./fixtures/prerender/', environmentVariables); - await fixture.runBuild(); + const { runBuild, cleanup } = loadFixture('./fixtures/prerender/', environmentVariables); + + await t.step('Run the build', async () => { + await runBuild(); + }); await t.step('Handler can process requests to non-existing routes', async () => { const { default: handler } = await import( @@ -16,7 +20,7 @@ Deno.test({ ); assertExists(handler); const response = await handler(new Request('http://example.com/index.html')); - assertEquals(response, undefined, "No response because this route doesn't exist"); + assertEquals(response.status, 404, "No response because this route doesn't exist"); }); await t.step('Prerendered route exists', async () => { @@ -31,22 +35,28 @@ Deno.test({ }); Deno.env.delete('PRERENDER'); - await fixture.cleanup(); + await cleanup(); }, }); Deno.test({ name: 'Hybrid rendering', + permissions: 'inherit', async fn(t) { const environmentVariables = { PRERENDER: 'false', }; const fixture = loadFixture('./fixtures/prerender/', environmentVariables); - await fixture.runBuild(); + await t.step('Run the build', async () => { + await fixture.runBuild(); + }); const stop = await fixture.runApp('./fixtures/prerender/prod.js'); await t.step('Can fetch server route', async () => { - const response = await fetch('http://127.0.0.1:8085/'); + const { default: handler } = await import( + './fixtures/prerender/.netlify/edge-functions/entry.js' + ); + const response = await handler(new Request('http://example.com/')); assertEquals(response.status, 200); const html = await response.text(); @@ -60,7 +70,7 @@ Deno.test({ './fixtures/prerender/.netlify/edge-functions/entry.js' ); const response = await handler(new Request('http://example.com/index.html')); - assertEquals(response, undefined, "No response because this route doesn't exist"); + assertEquals(response.status, 404, "No response because this route doesn't exist"); }); await t.step('Has no prerendered route', async () => { diff --git a/packages/integrations/netlify/test/functions/edge-middleware.test.js b/packages/integrations/netlify/test/functions/edge-middleware.test.js new file mode 100644 index 000000000000..219fd1ced80c --- /dev/null +++ b/packages/integrations/netlify/test/functions/edge-middleware.test.js @@ -0,0 +1,43 @@ +import netlifyAdapter from '../../dist/index.js'; +import { testIntegration, loadFixture } from './test-utils.js'; +import { expect } from 'chai'; + +describe('Middleware', () => { + it('with edge handle file, should successfully build the middleware', async () => { + /** @type {import('./test-utils').Fixture} */ + const fixture = await loadFixture({ + root: new URL('./fixtures/middleware-with-handler-file/', import.meta.url).toString(), + output: 'server', + adapter: netlifyAdapter({ + dist: new URL('./fixtures/middleware-with-handler-file/dist/', import.meta.url), + }), + site: `http://example.com`, + integrations: [testIntegration()], + build: { + excludeMiddleware: true, + }, + }); + await fixture.build(); + const contents = await fixture.readFile('../.netlify/edge-functions/edgeMiddleware.js'); + expect(contents.includes('"Hello world"')).to.be.true; + }); + + it('without edge handle file, should successfully build the middleware', async () => { + /** @type {import('./test-utils').Fixture} */ + const fixture = await loadFixture({ + root: new URL('./fixtures/middleware-without-handler-file/', import.meta.url).toString(), + output: 'server', + adapter: netlifyAdapter({ + dist: new URL('./fixtures/middleware-without-handler-file/dist/', import.meta.url), + }), + site: `http://example.com`, + integrations: [testIntegration()], + build: { + excludeMiddleware: true, + }, + }); + await fixture.build(); + const contents = await fixture.readFile('../.netlify/edge-functions/edgeMiddleware.js'); + expect(contents.includes('"Hello world"')).to.be.false; + }); +}); diff --git a/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/middleware.ts b/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/middleware.ts new file mode 100644 index 000000000000..8cab418c1c15 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/middleware.ts @@ -0,0 +1,5 @@ +export const onRequest = (context, next) => { + context.locals.title = 'Middleware'; + + return next(); +}; diff --git a/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/netlify-edge-middleware.js b/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/netlify-edge-middleware.js new file mode 100644 index 000000000000..bf69edb3e8bf --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/netlify-edge-middleware.js @@ -0,0 +1,5 @@ +export default function ({ request, context }) { + return { + title: 'Hello world', + }; +} diff --git a/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/pages/index.astro b/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/pages/index.astro new file mode 100644 index 000000000000..d97f7069897a --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/middleware-with-handler-file/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +const title = Astro.locals.title; +--- + + + + {title} + + +

{title}

+ + diff --git a/packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/middleware.ts b/packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/middleware.ts new file mode 100644 index 000000000000..8cab418c1c15 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/middleware.ts @@ -0,0 +1,5 @@ +export const onRequest = (context, next) => { + context.locals.title = 'Middleware'; + + return next(); +}; diff --git a/packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/pages/index.astro b/packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/pages/index.astro new file mode 100644 index 000000000000..d97f7069897a --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/middleware-without-handler-file/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +const title = Astro.locals.title; +--- + + + + {title} + + +

{title}

+ + diff --git a/packages/integrations/netlify/tsconfig.json b/packages/integrations/netlify/tsconfig.json index 9f96de7cd0ed..4442d4c3670a 100644 --- a/packages/integrations/netlify/tsconfig.json +++ b/packages/integrations/netlify/tsconfig.json @@ -6,6 +6,7 @@ "module": "ES2022", "outDir": "./dist", "target": "ES2021", - "typeRoots": ["node_modules/@types", "node_modules/@netlify"] + "typeRoots": ["node_modules/@types", "node_modules/@netlify"], + "allowImportingTsExtensions": true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 036b745f20b5..4e9db8a0f6ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4464,12 +4464,6 @@ importers: astro: specifier: workspace:* version: link:../../../../../../astro - react: - specifier: ^18.1.0 - version: 18.2.0 - react-dom: - specifier: ^18.1.0 - version: 18.2.0(react@18.2.0) packages/integrations/netlify/test/edge-functions/fixtures/prerender: dependencies: