feat(@astrojs/netlify): edge middleware support (#7632)
Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com>
This commit is contained in:
parent
cc8e9de881
commit
4c93bd8154
25 changed files with 411 additions and 161 deletions
10
.changeset/great-days-judge.md
Normal file
10
.changeset/great-days-judge.md
Normal file
|
@ -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
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -136,7 +136,7 @@ jobs:
|
||||||
- name: Use Deno
|
- name: Use Deno
|
||||||
uses: denoland/setup-deno@v1
|
uses: denoland/setup-deno@v1
|
||||||
with:
|
with:
|
||||||
deno-version: v1.34.1
|
deno-version: v1.35.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
|
@ -115,6 +115,65 @@ Once you run `astro build` there will be a `dist/_redirects` file. Netlify will
|
||||||
> **Note**
|
> **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.
|
> 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
|
## Usage
|
||||||
|
|
||||||
[Read the full deployment guide here.](https://docs.astro.build/en/guides/deploy/netlify/)
|
[Read the full deployment guide here.](https://docs.astro.build/en/guides/deploy/netlify/)
|
||||||
|
|
|
@ -33,8 +33,8 @@
|
||||||
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
||||||
"dev": "astro-scripts dev \"src/**/*.ts\"",
|
"dev": "astro-scripts dev \"src/**/*.ts\"",
|
||||||
"test-fn": "mocha --exit --timeout 20000 --file \"./test/setup.js\" test/functions/",
|
"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-edge": "deno test --allow-run --allow-read --allow-net --allow-env --allow-write ./test/edge-functions/",
|
||||||
"test": "npm run test-fn"
|
"test": "pnpm test-fn"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/underscore-redirects": "^0.2.0",
|
"@astrojs/underscore-redirects": "^0.2.0",
|
||||||
|
|
|
@ -1,21 +1,10 @@
|
||||||
import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
|
import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
|
||||||
import esbuild from 'esbuild';
|
import {
|
||||||
import * as fs from 'fs';
|
bundleServerEntry,
|
||||||
import * as npath from 'path';
|
createEdgeManifest,
|
||||||
import { fileURLToPath } from 'url';
|
createRedirects,
|
||||||
import { createRedirects } from './shared.js';
|
type NetlifyEdgeFunctionsOptions,
|
||||||
|
} from './shared.js';
|
||||||
interface BuildConfig {
|
|
||||||
server: URL;
|
|
||||||
client: URL;
|
|
||||||
serverEntry: string;
|
|
||||||
assets: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHIM = `globalThis.process = {
|
|
||||||
argv: [],
|
|
||||||
env: Deno.env.toObject(),
|
|
||||||
};`;
|
|
||||||
|
|
||||||
export function getAdapter(): AstroAdapter {
|
export function getAdapter(): AstroAdapter {
|
||||||
return {
|
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 {
|
export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}): AstroIntegration {
|
||||||
let _config: AstroConfig;
|
let _config: AstroConfig;
|
||||||
let entryFile: string;
|
let entryFile: string;
|
||||||
let _buildConfig: BuildConfig;
|
let _buildConfig: AstroConfig['build'];
|
||||||
let _vite: any;
|
let _vite: any;
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/netlify/edge-functions',
|
name: '@astrojs/netlify/edge-functions',
|
||||||
|
@ -164,7 +71,8 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'astro:build:done': async ({ routes, dir }) => {
|
'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);
|
await createEdgeManifest(routes, entryFile, _config.root);
|
||||||
const dynamicTarget = `/.netlify/edge-functions/${entryFile}`;
|
const dynamicTarget = `/.netlify/edge-functions/${entryFile}`;
|
||||||
const map: [RouteData, string][] = routes.map((route) => {
|
const map: [RouteData, string][] = routes.map((route) => {
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
|
import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
|
||||||
import { extname } from 'node:path';
|
import { extname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import type { Args } from './netlify-functions.js';
|
import type { Args } from './netlify-functions.js';
|
||||||
import { createRedirects } from './shared.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 {
|
export function getAdapter(args: Args = {}): AstroAdapter {
|
||||||
return {
|
return {
|
||||||
|
@ -27,6 +31,7 @@ function netlifyFunctions({
|
||||||
let _config: AstroConfig;
|
let _config: AstroConfig;
|
||||||
let _entryPoints: Map<RouteData, URL>;
|
let _entryPoints: Map<RouteData, URL>;
|
||||||
let ssrEntryFile: string;
|
let ssrEntryFile: string;
|
||||||
|
let _middlewareEntryPoint: URL;
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/netlify',
|
name: '@astrojs/netlify',
|
||||||
hooks: {
|
hooks: {
|
||||||
|
@ -40,7 +45,10 @@ function netlifyFunctions({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
'astro:build:ssr': ({ entryPoints }) => {
|
'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => {
|
||||||
|
if (middlewareEntryPoint) {
|
||||||
|
_middlewareEntryPoint = middlewareEntryPoint;
|
||||||
|
}
|
||||||
_entryPoints = entryPoints;
|
_entryPoints = entryPoints;
|
||||||
},
|
},
|
||||||
'astro:config:done': ({ config, setAdapter }) => {
|
'astro:config:done': ({ config, setAdapter }) => {
|
||||||
|
@ -85,6 +93,18 @@ function netlifyFunctions({
|
||||||
|
|
||||||
await createRedirects(_config, routeToDynamicTargetMap, dir);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
75
packages/integrations/netlify/src/middleware.ts
Normal file
75
packages/integrations/netlify/src/middleware.ts
Normal file
|
@ -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: "/*"
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { polyfill } from '@astrojs/webapi';
|
||||||
import { builder, type Handler } from '@netlify/functions';
|
import { builder, type Handler } from '@netlify/functions';
|
||||||
import type { SSRManifest } from 'astro';
|
import type { SSRManifest } from 'astro';
|
||||||
import { App } from 'astro/app';
|
import { App } from 'astro/app';
|
||||||
|
import { ASTRO_LOCALS_HEADER } from './integration-functions.js';
|
||||||
|
|
||||||
polyfill(globalThis, {
|
polyfill(globalThis, {
|
||||||
exclude: 'window document',
|
exclude: 'window document',
|
||||||
|
@ -80,8 +81,14 @@ export const createExports = (manifest: SSRManifest, args: Args) => {
|
||||||
|
|
||||||
const ip = headers['x-nf-client-connection-ip'];
|
const ip = headers['x-nf-client-connection-ip'];
|
||||||
Reflect.set(request, clientAddressSymbol, ip);
|
Reflect.set(request, clientAddressSymbol, ip);
|
||||||
|
let locals = {};
|
||||||
const response: Response = await app.render(request, routeData);
|
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 responseHeaders = Object.fromEntries(response.headers.entries());
|
||||||
|
|
||||||
const responseContentType = parseContentType(responseHeaders['content-type']);
|
const responseContentType = parseContentType(responseHeaders['content-type']);
|
||||||
|
|
|
@ -1,6 +1,37 @@
|
||||||
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
|
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
|
||||||
import type { AstroConfig, RouteData } from 'astro';
|
import type { AstroConfig, RouteData } from 'astro';
|
||||||
import fs from 'node:fs';
|
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(
|
export async function createRedirects(
|
||||||
config: AstroConfig,
|
config: AstroConfig,
|
||||||
|
@ -21,3 +52,63 @@ export async function createRedirects(
|
||||||
// If the file does not exist yet, appendFile() automatically creates it.
|
// If the file does not exist yet, appendFile() automatically creates it.
|
||||||
await fs.promises.appendFile(_redirectsURL, content, 'utf-8');
|
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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ export {
|
||||||
assert,
|
assert,
|
||||||
assertExists,
|
assertExists,
|
||||||
} from 'https://deno.land/std@0.132.0/testing/asserts.ts';
|
} 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 * from 'https://deno.land/std@0.142.0/streams/conversion.ts';
|
||||||
export * as cheerio from 'https://cdn.skypack.dev/cheerio?dts';
|
export * as cheerio from 'https://cdn.skypack.dev/cheerio?dts';
|
||||||
export * as fs from 'https://deno.land/std/fs/mod.ts';
|
export * as fs from 'https://deno.land/std/fs/mod.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';
|
import { assertEquals, assert, DOMParser } from './deps.ts';
|
||||||
|
|
||||||
Deno.test({
|
Deno.test({
|
||||||
name: 'Dynamic imports',
|
name: 'Dynamic imports',
|
||||||
|
permissions: 'inherit',
|
||||||
async fn() {
|
async fn() {
|
||||||
await runBuild('./fixtures/dynimport/');
|
const { runApp, runBuild } = await loadFixture('./fixtures/dynimport/');
|
||||||
|
await runBuild();
|
||||||
const stop = await runApp('./fixtures/dynimport/prod.js');
|
const stop = await runApp('./fixtures/dynimport/prod.js');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -14,8 +16,10 @@ Deno.test({
|
||||||
|
|
||||||
assert(html, 'got some html');
|
assert(html, 'got some html');
|
||||||
const doc = new DOMParser().parseFromString(html, `text/html`);
|
const doc = new DOMParser().parseFromString(html, `text/html`);
|
||||||
|
if (doc) {
|
||||||
const div = doc.querySelector('#thing');
|
const div = doc.querySelector('#thing');
|
||||||
assert(div, 'div exists');
|
assert(div, 'div exists');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
|
@ -3,15 +3,16 @@ import { assertEquals, assert, DOMParser } from './deps.ts';
|
||||||
|
|
||||||
Deno.env.set('SECRET_STUFF', 'secret');
|
Deno.env.set('SECRET_STUFF', 'secret');
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
Deno.test({
|
Deno.test({
|
||||||
// TODO: debug why build cannot be found in "await import"
|
|
||||||
ignore: true,
|
ignore: true,
|
||||||
name: 'Edge Basics',
|
name: 'Edge Basics',
|
||||||
skip: true,
|
permissions: 'inherit',
|
||||||
async fn() {
|
async fn(t) {
|
||||||
const fixture = loadFixture('./fixtures/edge-basic/');
|
const fixture = loadFixture('./fixtures/edge-basic/');
|
||||||
|
await t.step('Run the build', async () => {
|
||||||
await fixture.runBuild();
|
await fixture.runBuild();
|
||||||
|
});
|
||||||
|
await t.step('Should correctly render the response', async () => {
|
||||||
const { default: handler } = await import(
|
const { default: handler } = await import(
|
||||||
'./fixtures/edge-basic/.netlify/edge-functions/entry.js'
|
'./fixtures/edge-basic/.netlify/edge-functions/entry.js'
|
||||||
);
|
);
|
||||||
|
@ -26,7 +27,10 @@ Deno.test({
|
||||||
|
|
||||||
const envDiv = doc.querySelector('#env');
|
const envDiv = doc.querySelector('#env');
|
||||||
assertEquals(envDiv?.innerText, 'secret');
|
assertEquals(envDiv?.innerText, 'secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step('Clean up', async () => {
|
||||||
await fixture.cleanup();
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import { netlifyEdgeFunctions } from '@astrojs/netlify';
|
import { netlifyEdgeFunctions } from '@astrojs/netlify';
|
||||||
import react from "@astrojs/react";
|
|
||||||
|
|
||||||
// test env var
|
// test env var
|
||||||
process.env.SECRET_STUFF = 'secret'
|
process.env.SECRET_STUFF = 'secret'
|
||||||
|
@ -9,6 +8,5 @@ export default defineConfig({
|
||||||
adapter: netlifyEdgeFunctions({
|
adapter: netlifyEdgeFunctions({
|
||||||
dist: new URL('./dist/', import.meta.url),
|
dist: new URL('./dist/', import.meta.url),
|
||||||
}),
|
}),
|
||||||
integrations: [react()],
|
|
||||||
output: 'server',
|
output: 'server',
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,8 +5,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "workspace:*",
|
"astro": "workspace:*",
|
||||||
"@astrojs/react": "workspace:*",
|
"@astrojs/react": "workspace:*",
|
||||||
"@astrojs/netlify": "workspace:*",
|
"@astrojs/netlify": "workspace:*"
|
||||||
"react": "^18.1.0",
|
|
||||||
"react-dom": "^18.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function() {
|
|
||||||
return (
|
|
||||||
<div id="react">testing</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,6 +1,3 @@
|
||||||
---
|
|
||||||
import ReactComponent from '../components/React.jsx';
|
|
||||||
---
|
|
||||||
<html>
|
<html>
|
||||||
<head><title>Testing</title></head>
|
<head><title>Testing</title></head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -9,7 +6,6 @@ import ReactComponent from '../components/React.jsx';
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/two/">Two</a></li>
|
<li><a href="/two/">Two</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ReactComponent />
|
|
||||||
<div id="env">{import.meta.env.SECRET_STUFF}</div>
|
<div id="env">{import.meta.env.SECRET_STUFF}</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -3,12 +3,16 @@ import { assertEquals, assertExists, cheerio, fs } from './deps.ts';
|
||||||
|
|
||||||
Deno.test({
|
Deno.test({
|
||||||
name: 'Prerender',
|
name: 'Prerender',
|
||||||
|
permissions: 'inherit',
|
||||||
async fn(t) {
|
async fn(t) {
|
||||||
const environmentVariables = {
|
const environmentVariables = {
|
||||||
PRERENDER: 'true',
|
PRERENDER: 'true',
|
||||||
};
|
};
|
||||||
const fixture = loadFixture('./fixtures/prerender/', environmentVariables);
|
const { runBuild, cleanup } = loadFixture('./fixtures/prerender/', environmentVariables);
|
||||||
await fixture.runBuild();
|
|
||||||
|
await t.step('Run the build', async () => {
|
||||||
|
await runBuild();
|
||||||
|
});
|
||||||
|
|
||||||
await t.step('Handler can process requests to non-existing routes', async () => {
|
await t.step('Handler can process requests to non-existing routes', async () => {
|
||||||
const { default: handler } = await import(
|
const { default: handler } = await import(
|
||||||
|
@ -16,7 +20,7 @@ Deno.test({
|
||||||
);
|
);
|
||||||
assertExists(handler);
|
assertExists(handler);
|
||||||
const response = await handler(new Request('http://example.com/index.html'));
|
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 () => {
|
await t.step('Prerendered route exists', async () => {
|
||||||
|
@ -31,22 +35,28 @@ Deno.test({
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.env.delete('PRERENDER');
|
Deno.env.delete('PRERENDER');
|
||||||
await fixture.cleanup();
|
await cleanup();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test({
|
Deno.test({
|
||||||
name: 'Hybrid rendering',
|
name: 'Hybrid rendering',
|
||||||
|
permissions: 'inherit',
|
||||||
async fn(t) {
|
async fn(t) {
|
||||||
const environmentVariables = {
|
const environmentVariables = {
|
||||||
PRERENDER: 'false',
|
PRERENDER: 'false',
|
||||||
};
|
};
|
||||||
const fixture = loadFixture('./fixtures/prerender/', environmentVariables);
|
const fixture = loadFixture('./fixtures/prerender/', environmentVariables);
|
||||||
|
await t.step('Run the build', async () => {
|
||||||
await fixture.runBuild();
|
await fixture.runBuild();
|
||||||
|
});
|
||||||
|
|
||||||
const stop = await fixture.runApp('./fixtures/prerender/prod.js');
|
const stop = await fixture.runApp('./fixtures/prerender/prod.js');
|
||||||
await t.step('Can fetch server route', async () => {
|
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);
|
assertEquals(response.status, 200);
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
@ -60,7 +70,7 @@ Deno.test({
|
||||||
'./fixtures/prerender/.netlify/edge-functions/entry.js'
|
'./fixtures/prerender/.netlify/edge-functions/entry.js'
|
||||||
);
|
);
|
||||||
const response = await handler(new Request('http://example.com/index.html'));
|
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 () => {
|
await t.step('Has no prerendered route', async () => {
|
||||||
|
|
|
@ -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;
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const onRequest = (context, next) => {
|
||||||
|
context.locals.title = 'Middleware';
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default function ({ request, context }) {
|
||||||
|
return {
|
||||||
|
title: 'Hello world',
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
const title = Astro.locals.title;
|
||||||
|
---
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const onRequest = (context, next) => {
|
||||||
|
context.locals.title = 'Middleware';
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
const title = Astro.locals.title;
|
||||||
|
---
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -6,6 +6,7 @@
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"typeRoots": ["node_modules/@types", "node_modules/@netlify"]
|
"typeRoots": ["node_modules/@types", "node_modules/@netlify"],
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -4464,12 +4464,6 @@ importers:
|
||||||
astro:
|
astro:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../../../astro
|
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:
|
packages/integrations/netlify/test/edge-functions/fixtures/prerender:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Add table
Reference in a new issue