Skip to content

Commit

Permalink
feat(@astrojs/netlify): edge middleware support (#7632)
Browse files Browse the repository at this point in the history
Co-authored-by: Bjorn Lu <[email protected]>
Co-authored-by: Yan Thomas <[email protected]>
  • Loading branch information
3 people authored Jul 17, 2023
1 parent cc8e9de commit 4c93bd8
Show file tree
Hide file tree
Showing 25 changed files with 411 additions and 161 deletions.
10 changes: 10 additions & 0 deletions .changeset/great-days-judge.md
Original file line number Diff line number Diff line change
@@ -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.

2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions packages/integrations/netlify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
4 changes: 2 additions & 2 deletions packages/integrations/netlify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 9 additions & 101 deletions packages/integrations/netlify/src/integration-edge-functions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
Expand Down Expand Up @@ -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) => {
Expand Down
24 changes: 22 additions & 2 deletions packages/integrations/netlify/src/integration-functions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -27,6 +31,7 @@ function netlifyFunctions({
let _config: AstroConfig;
let _entryPoints: Map<RouteData, URL>;
let ssrEntryFile: string;
let _middlewareEntryPoint: URL;
return {
name: '@astrojs/netlify',
hooks: {
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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
);
}
},
},
};
Expand Down
75 changes: 75 additions & 0 deletions packages/integrations/netlify/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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<URL> {
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: "/*"
}
`;
}
11 changes: 9 additions & 2 deletions packages/integrations/netlify/src/netlify-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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']);
Expand Down
Loading

0 comments on commit 4c93bd8

Please sign in to comment.