From 9e5fafa2b25b5128084c7072aa282642fcfbb14b Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 5 Jul 2023 16:45:58 +0100 Subject: [PATCH] feat: vercel edge middleware support (#7532) Co-authored-by: Bjorn Lu Co-authored-by: Sarah Rainsberger --- .changeset/brown-shrimps-hug.md | 11 ++ .changeset/chilly-pants-fix.md | 24 +++++ .changeset/cool-kids-grin.md | 5 + .changeset/good-pigs-fetch.md | 11 ++ .changeset/long-geckos-battle.md | 7 ++ .changeset/strong-years-travel.md | 20 ++++ packages/astro/src/@types/astro.ts | 25 +++++ packages/astro/src/core/build/index.ts | 22 +++- packages/astro/src/core/build/internal.ts | 1 + .../astro/src/core/build/plugins/index.ts | 2 +- .../core/build/plugins/plugin-middleware.ts | 33 +++++- .../src/core/build/plugins/plugin-pages.ts | 11 +- .../src/core/build/plugins/plugin-ssr.ts | 2 + packages/astro/src/core/build/static-build.ts | 3 - packages/astro/src/core/config/schema.ts | 9 ++ packages/astro/src/core/endpoint/index.ts | 21 ++-- packages/astro/src/core/middleware/index.ts | 102 +++++++++++++++++- packages/astro/src/integrations/index.ts | 33 +++--- packages/astro/test/middleware.test.js | 49 ++++++++- .../astro/test/ssr-split-manifest.test.js | 4 +- packages/astro/test/test-adapter.js | 3 +- packages/integrations/vercel/README.md | 74 ++++++++++++- packages/integrations/vercel/package.json | 5 +- packages/integrations/vercel/src/lib/fs.ts | 4 + packages/integrations/vercel/src/lib/nft.ts | 7 +- .../vercel/src/serverless/adapter.ts | 35 +++++- .../vercel/src/serverless/entrypoint.ts | 10 +- .../vercel/src/serverless/middleware.ts | 81 ++++++++++++++ .../vercel/test/edge-middleware.test.js | 30 ++++++ .../vercel/test/edge-middleware.test.js.snap | 40 +++++++ .../test/fixtures/middleware/astro.config.mjs | 10 ++ .../test/fixtures/middleware/package.json | 9 ++ .../fixtures/middleware/src/middleware.js | 8 ++ .../fixtures/middleware/src/pages/index.astro | 0 .../middleware/src/vercel-edge-middleware.js | 5 + pnpm-lock.yaml | 92 ++++++++++++++++ 36 files changed, 758 insertions(+), 50 deletions(-) create mode 100644 .changeset/brown-shrimps-hug.md create mode 100644 .changeset/chilly-pants-fix.md create mode 100644 .changeset/cool-kids-grin.md create mode 100644 .changeset/good-pigs-fetch.md create mode 100644 .changeset/long-geckos-battle.md create mode 100644 .changeset/strong-years-travel.md create mode 100644 packages/integrations/vercel/src/serverless/middleware.ts create mode 100644 packages/integrations/vercel/test/edge-middleware.test.js create mode 100644 packages/integrations/vercel/test/edge-middleware.test.js.snap create mode 100644 packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs create mode 100644 packages/integrations/vercel/test/fixtures/middleware/package.json create mode 100644 packages/integrations/vercel/test/fixtures/middleware/src/middleware.js create mode 100644 packages/integrations/vercel/test/fixtures/middleware/src/pages/index.astro create mode 100644 packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js diff --git a/.changeset/brown-shrimps-hug.md b/.changeset/brown-shrimps-hug.md new file mode 100644 index 0000000000..1c40fc3809 --- /dev/null +++ b/.changeset/brown-shrimps-hug.md @@ -0,0 +1,11 @@ +--- +'astro': minor +--- + +The `astro/middleware` module exports a new utility called `trySerializeLocals`. + +This utility can be used by adapters to validate their `locals` before sending it +to the Astro middleware. + +This function will throw a runtime error if the value passed is not serializable, so +consumers will need to handle that error. diff --git a/.changeset/chilly-pants-fix.md b/.changeset/chilly-pants-fix.md new file mode 100644 index 0000000000..c862a15dcb --- /dev/null +++ b/.changeset/chilly-pants-fix.md @@ -0,0 +1,24 @@ +--- +'astro': minor +--- + +Astro exposes the middleware file path to the integrations in the hook `astro:build:ssr` + +```ts +// myIntegration.js +import type { AstroIntegration } from 'astro'; +function integration(): AstroIntegration { + return { + name: "fancy-astro-integration", + hooks: { + 'astro:build:ssr': ({ middlewareEntryPoint }) => { + if (middlewareEntryPoint) { + // do some operations + } + } + } + } +} +``` + +The `middlewareEntryPoint` is only defined if the user has created an Astro middleware. diff --git a/.changeset/cool-kids-grin.md b/.changeset/cool-kids-grin.md new file mode 100644 index 0000000000..190e5eee9d --- /dev/null +++ b/.changeset/cool-kids-grin.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Correctly track the middleware during the SSR build. diff --git a/.changeset/good-pigs-fetch.md b/.changeset/good-pigs-fetch.md new file mode 100644 index 0000000000..4a463044e5 --- /dev/null +++ b/.changeset/good-pigs-fetch.md @@ -0,0 +1,11 @@ +--- +'@astrojs/vercel': minor +--- + +Support for Vercel Edge Middleware via Astro middleware. + +When a project uses the new option Astro `build.excludeMiddleware`, the +`@astrojs/vercel/serverless` adapter will automatically create a Vercel Edge Middleware +that will automatically communicate with the Astro Middleware. + +Check the [documentation](https://github.com/withastro/astro/blob/main/packages/integrations/vercel/README.md##vercel-edge-middleware-with-astro-middleware) for more details. diff --git a/.changeset/long-geckos-battle.md b/.changeset/long-geckos-battle.md new file mode 100644 index 0000000000..3c1a993bec --- /dev/null +++ b/.changeset/long-geckos-battle.md @@ -0,0 +1,7 @@ +--- +'astro': minor +--- + +The `astro/middleware` module exports a new API called `createContext`. + +This a low-level API that adapters can use to create a context that can be consumed by middleware functions. diff --git a/.changeset/strong-years-travel.md b/.changeset/strong-years-travel.md new file mode 100644 index 0000000000..3067e01b48 --- /dev/null +++ b/.changeset/strong-years-travel.md @@ -0,0 +1,20 @@ +--- +'astro': minor +--- + +Introduced a new build option for SSR, called `build.excludeMiddleware`. + +```js +// astro.config.mjs +import {defineConfig} from "astro/config"; + +export default defineConfig({ + build: { + excludeMiddleware: true + } +}) +``` + +When enabled, the code that belongs to be middleware **won't** be imported +by the final pages/entry points. The user is responsible for importing it and +calling it manually. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 185401a898..12f309f1ab 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -849,6 +849,27 @@ export interface AstroUserConfig { * ``` */ split?: boolean; + + /** + * @docs + * @name build.excludeMiddleware + * @type {boolean} + * @default {false} + * @version 2.8.0 + * @description + * Defines whether or not any SSR middleware code will be bundled when built. + * + * When enabled, middleware code is not bundled and imported by all pages during the build. To instead execute and import middleware code manually, set `build.excludeMiddleware: true`: + * + * ```js + * { + * build: { + * excludeMiddleware: true + * } + * } + * ``` + */ + excludeMiddleware?: boolean; }; /** @@ -1842,6 +1863,10 @@ export interface AstroIntegration { * the physical file you should import. */ entryPoints: Map; + /** + * File path of the emitted middleware + */ + middlewareEntryPoint: URL | undefined; }) => void | Promise; 'astro:build:start'?: () => void | Promise; 'astro:build:setup'?: (options: { diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 037c462fde..11e2b1fa9f 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -1,5 +1,4 @@ import type { AstroConfig, AstroSettings, ManifestData, RuntimeMode } from '../../@types/astro'; - import fs from 'fs'; import * as colors from 'kleur/colors'; import { performance } from 'perf_hooks'; @@ -12,7 +11,7 @@ import { runHookConfigSetup, } from '../../integrations/index.js'; import { createVite } from '../create-vite.js'; -import { debug, info, levels, timerMessage, type LogOptions } from '../logger/core.js'; +import { debug, info, warn, levels, timerMessage, type LogOptions } from '../logger/core.js'; import { printHelp } from '../messages.js'; import { apply as applyPolyfill } from '../polyfill.js'; import { RouteCache } from '../render/route-cache.js'; @@ -211,6 +210,25 @@ class AstroBuilder { `the outDir cannot be the root folder. Please build to a folder such as dist.` ); } + + if (config.build.split === true) { + if (config.output === 'static') { + warn( + this.logging, + 'configuration', + 'The option `build.split` won\'t take effect, because `output` is not `"server"` or `"hybrid"`.' + ); + } + } + if (config.build.excludeMiddleware === true) { + if (config.output === 'static') { + warn( + this.logging, + 'configuration', + 'The option `build.excludeMiddleware` won\'t take effect, because `output` is not `"server"` or `"hybrid"`.' + ); + } + } } /** Stats */ diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 28d15d874d..5dff6f3ddb 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -88,6 +88,7 @@ export interface BuildInternals { entryPoints: Map; ssrSplitEntryChunks: Map; componentMetadata: SSRResult['componentMetadata']; + middlewareEntryPoint?: URL; } /** diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index 160e18fdd8..3a44824d61 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -19,7 +19,7 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP register(pluginAnalyzer(internals)); register(pluginInternals(internals)); register(pluginRenderers(options)); - register(pluginMiddleware(options)); + register(pluginMiddleware(options, internals)); register(pluginPages(options, internals)); register(pluginCSS(options, internals)); register(astroHeadBuildPlugin(internals)); diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts index dee73d2f8b..6db39733ef 100644 --- a/packages/astro/src/core/build/plugins/plugin-middleware.ts +++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts @@ -3,12 +3,17 @@ import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../../constants.js'; import { addRollupInput } from '../add-rollup-input.js'; import type { AstroBuildPlugin } from '../plugin'; import type { StaticBuildOptions } from '../types'; +import type { BuildInternals } from '../internal'; export const MIDDLEWARE_MODULE_ID = '@astro-middleware'; const EMPTY_MIDDLEWARE = '\0empty-middleware'; -export function vitePluginMiddleware(opts: StaticBuildOptions): VitePlugin { +export function vitePluginMiddleware( + opts: StaticBuildOptions, + internals: BuildInternals +): VitePlugin { + let resolvedMiddlewareId: string; return { name: '@astro/plugin-middleware', @@ -22,6 +27,7 @@ export function vitePluginMiddleware(opts: StaticBuildOptions): VitePlugin { `${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}` ); if (middlewareId) { + resolvedMiddlewareId = middlewareId.id; return middlewareId.id; } else { return EMPTY_MIDDLEWARE; @@ -35,18 +41,39 @@ export function vitePluginMiddleware(opts: StaticBuildOptions): VitePlugin { load(id) { if (id === EMPTY_MIDDLEWARE) { return 'export const onRequest = undefined'; + } else if (id === resolvedMiddlewareId) { + this.emitFile({ + type: 'chunk', + preserveSignature: 'strict', + fileName: 'middleware.mjs', + id, + }); + } + }, + + writeBundle(_, bundle) { + for (const [chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + continue; + } + if (chunk.fileName === 'middleware.mjs') { + internals.middlewareEntryPoint = new URL(chunkName, opts.settings.config.build.server); + } } }, }; } -export function pluginMiddleware(opts: StaticBuildOptions): AstroBuildPlugin { +export function pluginMiddleware( + opts: StaticBuildOptions, + internals: BuildInternals +): AstroBuildPlugin { return { build: 'ssr', hooks: { 'build:before': () => { return { - vitePlugin: vitePluginMiddleware(opts), + vitePlugin: vitePluginMiddleware(opts, internals), }; }, }, diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index cf078f0b5b..2ee438a6a6 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -73,10 +73,13 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`); exports.push(`export { renderers };`); - const middlewareModule = await this.resolve(MIDDLEWARE_MODULE_ID); - if (middlewareModule) { - imports.push(`import { onRequest } from "${middlewareModule.id}";`); - exports.push(`export { onRequest };`); + // The middleware should not be imported by the pages + if (!opts.settings.config.build.excludeMiddleware) { + const middlewareModule = await this.resolve(MIDDLEWARE_MODULE_ID); + if (middlewareModule) { + imports.push(`import { onRequest } from "${middlewareModule.id}";`); + exports.push(`export { onRequest };`); + } } return `${imports.join('\n')}${exports.join('\n')}`; diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 41f38a8b25..514fe2409d 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -138,6 +138,7 @@ export function pluginSSR( manifest, logging: options.logging, entryPoints: internals.entryPoints, + middlewareEntryPoint: internals.middlewareEntryPoint, }); const code = injectManifest(manifest, internals.ssrEntryChunk); mutate(internals.ssrEntryChunk, 'server', code); @@ -260,6 +261,7 @@ export function pluginSSRSplit( manifest, logging: options.logging, entryPoints: internals.entryPoints, + middlewareEntryPoint: internals.middlewareEntryPoint, }); for (const [, chunk] of internals.ssrSplitEntryChunks) { const code = injectManifest(manifest, chunk); diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 59a42db344..9bef0d6817 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -26,7 +26,6 @@ import { generatePages } from './generate.js'; import { trackPageData } from './internal.js'; import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.js'; import { registerAllPlugins } from './plugins/index.js'; -import { MIDDLEWARE_MODULE_ID } from './plugins/plugin-middleware.js'; import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js'; import { RESOLVED_SPLIT_MODULE_ID, SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; @@ -183,8 +182,6 @@ async function ssrBuild( ); } else if (chunkInfo.facadeModuleId?.startsWith(RESOLVED_SPLIT_MODULE_ID)) { return makeSplitEntryPointFileName(chunkInfo.facadeModuleId, routes); - } else if (chunkInfo.facadeModuleId === MIDDLEWARE_MODULE_ID) { - return 'middleware.mjs'; } else if (chunkInfo.facadeModuleId === SSR_VIRTUAL_MODULE_ID) { return opts.settings.config.build.serverEntry; } else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) { diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 7410df470e..ae681a5434 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -25,6 +25,7 @@ const ASTRO_CONFIG_DEFAULTS = { redirects: true, inlineStylesheets: 'never', split: false, + excludeMiddleware: false, }, compressHTML: false, server: { @@ -122,6 +123,10 @@ export const AstroConfigSchema = z.object({ .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets), split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split), + excludeMiddleware: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.build.excludeMiddleware), }) .optional() .default({}), @@ -283,6 +288,10 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) { .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets), split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split), + excludeMiddleware: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.build.excludeMiddleware), }) .optional() .default({}), diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index dde07cd9c6..33cb113a2d 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -31,19 +31,26 @@ type EndpointCallResult = response: Response; }; +type CreateAPIContext = { + request: Request; + params: Params; + site?: string; + props: Record; + adapterName?: string; +}; + +/** + * Creates a context that holds all the information needed to handle an Astro endpoint. + * + * @param {CreateAPIContext} payload + */ export function createAPIContext({ request, params, site, props, adapterName, -}: { - request: Request; - params: Params; - site?: string; - props: Record; - adapterName?: string; -}): APIContext { +}: CreateAPIContext): APIContext { const context = { cookies: new AstroCookies(request), request, diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index f9fb07bd4d..47127c6748 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -1,9 +1,107 @@ -import type { MiddlewareResponseHandler } from '../../@types/astro'; +import type { MiddlewareResponseHandler, Params } from '../../@types/astro'; import { sequence } from './sequence.js'; +import { createAPIContext } from '../endpoint/index.js'; function defineMiddleware(fn: MiddlewareResponseHandler) { return fn; } +/** + * Payload for creating a context to be passed to Astro middleware + */ +export type CreateContext = { + /** + * The incoming request + */ + request: Request; + /** + * Optional parameters + */ + params?: Params; +}; + +/** + * Creates a context to be passed to Astro middleware `onRequest` function. + */ +function createContext({ request, params }: CreateContext) { + return createAPIContext({ + request, + params: params ?? {}, + props: {}, + site: undefined, + }); +} + +/** + * Checks whether the passed `value` is serializable. + * + * A serializable value contains plain values. For example, `Proxy`, `Set`, `Map`, functions, etc. + * are not accepted because they can't be serialized. + */ +function isLocalsSerializable(value: unknown): boolean { + let type = typeof value; + let plainObject = true; + if (type === 'object' && isPlainObject(value)) { + for (const [, nestedValue] of Object.entries(value)) { + if (!isLocalsSerializable(nestedValue)) { + plainObject = false; + break; + } + } + } else { + plainObject = false; + } + let result = + value === null || + type === 'string' || + type === 'number' || + type === 'boolean' || + Array.isArray(value) || + plainObject; + + return result; +} + +/** + * + * From [redux-toolkit](https://github.com/reduxjs/redux-toolkit/blob/master/packages/toolkit/src/isPlainObject.ts) + * + * Returns true if the passed value is "plain" object, i.e. an object whose + * prototype is the root `Object.prototype`. This includes objects created + * using object literals, but not for instance for class instances. + */ +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false; + + let proto = Object.getPrototypeOf(value); + if (proto === null) return true; + + let baseProto = proto; + while (Object.getPrototypeOf(baseProto) !== null) { + baseProto = Object.getPrototypeOf(baseProto); + } + + return proto === baseProto; +} + +/** + * It attempts to serialize `value` and return it as a string. + * + * ## Errors + * If the `value` is not serializable if the function will throw a runtime error. + * + * Something is **not serializable** when it contains properties/values like functions, `Map`, `Set`, `Date`, + * and other types that can't be made a string. + * + * @param value + */ +function trySerializeLocals(value: unknown) { + if (isLocalsSerializable(value)) { + return JSON.stringify(value); + } else { + throw new Error("The passed value can't be serialized."); + } +} + // NOTE: this export must export only the functions that will be exposed to user-land as officials APIs -export { sequence, defineMiddleware }; +export { sequence, defineMiddleware, createContext, trySerializeLocals }; diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index eaf4b21d1f..b243ba9794 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -298,22 +298,30 @@ export async function runHookBuildSetup({ return updatedConfig; } +type RunHookBuildSsr = { + config: AstroConfig; + manifest: SerializedSSRManifest; + logging: LogOptions; + entryPoints: Map; + middlewareEntryPoint: URL | undefined; +}; + export async function runHookBuildSsr({ config, manifest, logging, entryPoints, -}: { - config: AstroConfig; - manifest: SerializedSSRManifest; - logging: LogOptions; - entryPoints: Map; -}) { + middlewareEntryPoint, +}: RunHookBuildSsr) { for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:ssr']) { await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:build:ssr']({ manifest, entryPoints }), + hookResult: integration.hooks['astro:build:ssr']({ + manifest, + entryPoints, + middlewareEntryPoint, + }), logging, }); } @@ -340,17 +348,14 @@ export async function runHookBuildGenerated({ } } -export async function runHookBuildDone({ - config, - pages, - routes, - logging, -}: { +type RunHookBuildDone = { config: AstroConfig; pages: string[]; routes: RouteData[]; logging: LogOptions; -}) { +}; + +export async function runHookBuildDone({ config, pages, routes, logging }: RunHookBuildDone) { const dir = isServerLikeOutput(config) ? config.build.client : config.outDir; await fs.promises.mkdir(dir, { recursive: true }); diff --git a/packages/astro/test/middleware.test.js b/packages/astro/test/middleware.test.js index e2c57bafb9..9e22131464 100644 --- a/packages/astro/test/middleware.test.js +++ b/packages/astro/test/middleware.test.js @@ -2,6 +2,8 @@ import { loadFixture } from './test-utils.js'; import { expect } from 'chai'; import * as cheerio from 'cheerio'; import testAdapter from './test-adapter.js'; +import { fileURLToPath } from 'node:url'; +import { readFileSync, existsSync } from 'node:fs'; describe('Middleware in DEV mode', () => { /** @type {import('./test-utils').Fixture} */ @@ -104,12 +106,19 @@ describe('Middleware in PROD mode, SSG', () => { describe('Middleware API in PROD mode, SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; + let middlewarePath; before(async () => { fixture = await loadFixture({ root: './fixtures/middleware-dev/', output: 'server', - adapter: testAdapter({}), + adapter: testAdapter({ + setEntryPoints(entryPointsOrMiddleware) { + if (entryPointsOrMiddleware instanceof URL) { + middlewarePath = entryPointsOrMiddleware; + } + }, + }), }); await fixture.build(); }); @@ -201,6 +210,18 @@ describe('Middleware API in PROD mode, SSR', () => { const text = await response.text(); expect(text.includes('REDACTED')).to.be.true; }); + + it('the integration should receive the path to the middleware', async () => { + expect(middlewarePath).to.not.be.undefined; + try { + const path = fileURLToPath(middlewarePath); + expect(existsSync(path)).to.be.true; + const content = readFileSync(fileURLToPath(middlewarePath), 'utf-8'); + expect(content.length).to.be.greaterThan(0); + } catch (e) { + throw e; + } + }); }); describe('Middleware with tailwind', () => { @@ -224,3 +245,29 @@ describe('Middleware with tailwind', () => { expect(bundledCSS.includes('--tw-content')).to.be.true; }); }); + +describe('Middleware, split middleware option', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/middleware-dev/', + output: 'server', + build: { + excludeMiddleware: true, + }, + adapter: testAdapter({}), + }); + await fixture.build(); + }); + + it('should not render locals data because the page does not export it', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('p').html()).to.not.equal('bar'); + }); +}); diff --git a/packages/astro/test/ssr-split-manifest.test.js b/packages/astro/test/ssr-split-manifest.test.js index 9e8a0981ed..394740395c 100644 --- a/packages/astro/test/ssr-split-manifest.test.js +++ b/packages/astro/test/ssr-split-manifest.test.js @@ -18,7 +18,9 @@ describe('astro:ssr-manifest, split', () => { output: 'server', adapter: testAdapter({ setEntryPoints(entries) { - entryPoints = entries; + if (entries) { + entryPoints = entries; + } }, setRoutes(routes) { currentRoutes = routes; diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index af5a7777b8..ed79e5f210 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -74,9 +74,10 @@ export default function ( ...extendAdapter, }); }, - 'astro:build:ssr': ({ entryPoints }) => { + 'astro:build:ssr': ({ entryPoints, middlewareEntryPoint }) => { if (setEntryPoints) { setEntryPoints(entryPoints); + setEntryPoints(middlewareEntryPoint); } }, 'astro:build:done': ({ routes }) => { diff --git a/packages/integrations/vercel/README.md b/packages/integrations/vercel/README.md index 41a5591dcb..19d841a2f1 100644 --- a/packages/integrations/vercel/README.md +++ b/packages/integrations/vercel/README.md @@ -233,9 +233,9 @@ export default defineConfig({ }); ``` -### Vercel Middleware +### Vercel Edge Middleware -You can use Vercel middleware to intercept a request and redirect before sending a response. Vercel middleware can run for Edge, SSR, and Static deployments. You don't need to install `@vercel/edge` to write middleware, but you do need to install it to use features such as geolocation. For more information see [Vercel’s middleware documentation](https://vercel.com/docs/concepts/functions/edge-middleware). +You can use Vercel Edge middleware to intercept a request and redirect before sending a response. Vercel middleware can run for Edge, SSR, and Static deployments. You may not need to install this package for your middleware. `@vercel/edge` is only required to use some middleware features such as geolocation. For more information see [Vercel’s middleware documentation](https://vercel.com/docs/concepts/functions/edge-middleware). 1. Add a `middleware.js` file to the root of your project: @@ -262,6 +262,76 @@ You can use Vercel middleware to intercept a request and redirect before sending > **Warning** > **Trying to rewrite?** Currently rewriting a request with middleware only works for static files. +### Vercel Edge Middleware with Astro middleware + +The `@astrojs/vercel/serverless` adapter can automatically create the Vercel 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 vercel from "@astrojs/vercel"; +export default defineConfig({ + output: "server", + adapter: vercel(), + build: { + excludeMiddleware: true + } +}) +``` + +Optionally, you can create a file recognized by the adapter named `vercel-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 requires the [`@vercel/edge`](https://www.npmjs.com/package/@vercel/edge) package. + +```js +// src/vercel-edge-middleware.js +/** + * + * @param options.request {Request} + * @param options.context {import("@vercel/edge").RequestContext} + * @returns {object} + */ +export default function({ request, context }) { + // do something with request and context + return { + title: "Spider-man's blog" + } +} +``` + +If you use TypeScript, you can type the function as follows: + +```ts +// src/vercel-edge-middleware.ts +import type {RequestContext} from "@vercel/edge"; + +export default function ({request, context}: { request: Request, context: RequestContext }) { + // 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 [`RequestContext`](https://vercel.com/docs/concepts/functions/edge-functions/vercel-edge-package#requestcontext); + +#### Limitations and constraints + +When you opt in to this feature, there are few constraints to note: +- The Vercel Edge middleware will always be the **first** function to receive the `Request` and the last function to receive `Response`. This an architectural constraint that follows the [boundaries set by Vercel](https://vercel.com/docs/concepts/functions/edge-middleware). +- 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. + + ## Troubleshooting **A few known complex packages (example: [puppeteer](https://github.com/puppeteer/puppeteer)) do not support bundling and therefore will not work properly with this adapter.** By default, Vercel doesn't include npm installed files & packages from your project's `./node_modules` folder. To address this, the `@astrojs/vercel` adapter automatically bundles your final build output using `esbuild`. diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 3899b9069e..a039ee5a87 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -64,10 +64,13 @@ }, "devDependencies": { "@types/set-cookie-parser": "^2.4.2", + "@vercel/edge": "^0.3.4", "astro": "workspace:*", "astro-scripts": "workspace:*", "chai": "^4.3.7", + "chai-jest-snapshot": "^2.0.0", "cheerio": "1.0.0-rc.12", - "mocha": "^9.2.2" + "mocha": "^9.2.2", + "rollup": "^3.20.1" } } diff --git a/packages/integrations/vercel/src/lib/fs.ts b/packages/integrations/vercel/src/lib/fs.ts index 18fbe85d29..51b12d52fd 100644 --- a/packages/integrations/vercel/src/lib/fs.ts +++ b/packages/integrations/vercel/src/lib/fs.ts @@ -86,3 +86,7 @@ export async function copyFilesToFunction( return commonAncestor; } + +export async function writeFile(path: PathLike, content: string) { + await fs.writeFile(path, content, { encoding: 'utf-8' }); +} diff --git a/packages/integrations/vercel/src/lib/nft.ts b/packages/integrations/vercel/src/lib/nft.ts index 46604db902..752f87251b 100644 --- a/packages/integrations/vercel/src/lib/nft.ts +++ b/packages/integrations/vercel/src/lib/nft.ts @@ -1,7 +1,5 @@ -import { nodeFileTrace } from '@vercel/nft'; import { relative as relativePath } from 'node:path'; import { fileURLToPath } from 'node:url'; - import { copyFilesToFunction } from './fs.js'; export async function copyDependenciesToFunction({ @@ -23,6 +21,11 @@ export async function copyDependenciesToFunction({ base = new URL('../', base); } + // The Vite bundle includes an import to `@vercel/nft` for some reason, + // and that trips up `@vercel/nft` itself during the adapter build. Using a + // dynamic import helps prevent the issue. + // TODO: investigate why + const { nodeFileTrace } = await import('@vercel/nft'); const result = await nodeFileTrace([entryPath], { base: fileURLToPath(base), }); diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 007fb85374..9d799a7bf5 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -13,8 +13,12 @@ import { exposeEnv } from '../lib/env.js'; import { getVercelOutput, removeDir, writeJson } from '../lib/fs.js'; import { copyDependenciesToFunction } from '../lib/nft.js'; import { getRedirects } from '../lib/redirects.js'; +import { generateEdgeMiddleware } from './middleware.js'; +import { fileURLToPath } from 'node:url'; const PACKAGE_NAME = '@astrojs/vercel/serverless'; +export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; +export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware'; function getAdapter(): AstroAdapter { return { @@ -70,6 +74,8 @@ export default function vercelServerless({ }); } + const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || []; + return { name: PACKAGE_NAME, hooks: { @@ -106,17 +112,32 @@ export default function vercelServerless({ `); } }, - 'astro:build:ssr': async ({ entryPoints }) => { + + 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { _entryPoints = entryPoints; + if (middlewareEntryPoint) { + const outPath = fileURLToPath(buildTempFolder); + const vercelEdgeMiddlewareHandlerPath = new URL( + VERCEL_EDGE_MIDDLEWARE_FILE, + _config.srcDir + ); + const bundledMiddlewarePath = await generateEdgeMiddleware( + middlewareEntryPoint, + outPath, + vercelEdgeMiddlewareHandlerPath + ); + // let's tell the adapter that we need to save this file + filesToInclude.push(bundledMiddlewarePath); + } }, + 'astro:build:done': async ({ routes }) => { // Merge any includes from `vite.assetsInclude - const inc = includeFiles?.map((file) => new URL(file, _config.root)) || []; if (_config.vite.assetsInclude) { const mergeGlobbedIncludes = (globPattern: unknown) => { if (typeof globPattern === 'string') { const entries = glob.sync(globPattern).map((p) => pathToFileURL(p)); - inc.push(...entries); + filesToInclude.push(...entries); } else if (Array.isArray(globPattern)) { for (const pattern of globPattern) { mergeGlobbedIncludes(pattern); @@ -133,14 +154,18 @@ export default function vercelServerless({ if (_entryPoints.size) { for (const [route, entryFile] of _entryPoints) { const func = basename(entryFile.toString()).replace(/\.mjs$/, ''); - await createFunctionFolder(func, entryFile, inc); + await createFunctionFolder(func, entryFile, filesToInclude); routeDefinitions.push({ src: route.pattern.source, dest: func, }); } } else { - await createFunctionFolder('render', new URL(serverEntry, buildTempFolder), inc); + await createFunctionFolder( + 'render', + new URL(serverEntry, buildTempFolder), + filesToInclude + ); routeDefinitions.push({ src: '/.*', dest: 'render' }); } diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 71ad2bfaef..3c0e22a28f 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -4,6 +4,7 @@ import { App } from 'astro/app'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequest, setResponse } from './request-transform'; +import { ASTRO_LOCALS_HEADER } from './adapter'; polyfill(globalThis, { exclude: 'window document', @@ -28,7 +29,14 @@ export const createExports = (manifest: SSRManifest) => { return res.end('Not found'); } - await setResponse(app, res, 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); + } + } + await setResponse(app, res, await app.render(request, routeData, locals)); }; return { default: handler }; diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts new file mode 100644 index 0000000000..2f05756c61 --- /dev/null +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -0,0 +1,81 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { join } from 'node:path'; +import { ASTRO_LOCALS_HEADER } from './adapter.js'; +import { existsSync } from 'fs'; + +/** + * It generates the Vercel Edge Middleware file. + * + * It creates a temporary file, the edge middleware, with some dynamic info. + * + * Then this file gets bundled with esbuild. The bundle phase will inline the Astro middleware code. + * + * @param astroMiddlewareEntryPoint + * @param outPath + * @returns {Promise} The path to the bundled file + */ +export async function generateEdgeMiddleware( + astroMiddlewareEntryPointPath: URL, + outPath: string, + vercelEdgeMiddlewareHandlerPath: URL +): Promise { + const entryPointPathURLAsString = JSON.stringify( + fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') + ); + + const code = edgeMiddlewareTemplate(entryPointPathURLAsString, vercelEdgeMiddlewareHandlerPath); + // https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware + const bundledFilePath = join(outPath, 'middleware.mjs'); + const esbuild = await import('esbuild'); + await esbuild.build({ + stdin: { + contents: code, + resolveDir: process.cwd(), + }, + target: 'es2020', + platform: 'browser', + // https://runtime-keys.proposal.wintercg.org/#edge-light + conditions: ['edge-light', 'worker', 'browser'], + external: ['astro/middleware'], + outfile: bundledFilePath, + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: false, + }); + return pathToFileURL(bundledFilePath); +} + +function edgeMiddlewareTemplate(middlewarePath: string, vercelEdgeMiddlewareHandlerPath: URL) { + const filePathEdgeMiddleware = fileURLToPath(vercelEdgeMiddlewareHandlerPath); + 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 () => { + const response = await fetch(url, { + headers: { + ${JSON.stringify(ASTRO_LOCALS_HEADER)}: trySerializeLocals(ctx.locals) + } + }); + return response; + }; + + return onRequest(ctx, next); +}`; +} diff --git a/packages/integrations/vercel/test/edge-middleware.test.js b/packages/integrations/vercel/test/edge-middleware.test.js new file mode 100644 index 0000000000..dd4b25b677 --- /dev/null +++ b/packages/integrations/vercel/test/edge-middleware.test.js @@ -0,0 +1,30 @@ +import { loadFixture } from './test-utils.js'; +import { expect, use } from 'chai'; +import chaiJestSnapshot from 'chai-jest-snapshot'; + +use(chaiJestSnapshot); + +describe('Serverless prerender', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + beforeEach(function () { + chaiJestSnapshot.configureUsingMochaContext(this); + }); + + before(async () => { + chaiJestSnapshot.resetSnapshotRegistry(); + fixture = await loadFixture({ + root: './fixtures/middleware/', + }); + }); + + it('build successfully the middleware edge file', async () => { + await fixture.build(); + const contents = await fixture.readFile( + // this is abysmal... + '../.vercel/output/functions/render.func/packages/integrations/vercel/test/fixtures/middleware/dist/middleware.mjs' + ); + expect(contents).to.matchSnapshot(); + }); +}); diff --git a/packages/integrations/vercel/test/edge-middleware.test.js.snap b/packages/integrations/vercel/test/edge-middleware.test.js.snap new file mode 100644 index 0000000000..fe82ccff93 --- /dev/null +++ b/packages/integrations/vercel/test/edge-middleware.test.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Serverless prerender build successfully the middleware edge file 1`] = ` +"// test/fixtures/middleware/src/vercel-edge-middleware.js +function vercel_edge_middleware_default({ request, context }) { + return { + title: \\"Hello world\\" + }; +} + +// test/fixtures/middleware/dist/middleware2.mjs +var onRequest = async (context, next) => { + const response = await next(); + return response; +}; + +// +import { createContext, trySerializeLocals } from \\"astro/middleware\\"; +async function middleware(request, context) { + const url = new URL(request.url); + const ctx = createContext({ + request, + params: {} + }); + ctx.locals = vercel_edge_middleware_default({ request, context }); + const next = async () => { + const response = await fetch(url, { + headers: { + \\"x-astro-locals\\": trySerializeLocals(ctx.locals) + } + }); + return response; + }; + return onRequest(ctx, next); +} +export { + middleware as default +}; +" +`; diff --git a/packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs b/packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs new file mode 100644 index 0000000000..321a8bde3e --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs @@ -0,0 +1,10 @@ +import {defineConfig} from "astro/config"; +import vercel from "@astrojs/vercel/serverless"; + +export default defineConfig({ + adapter: vercel(), + build: { + excludeMiddleware: true + }, + output: 'server' +}); \ No newline at end of file diff --git a/packages/integrations/vercel/test/fixtures/middleware/package.json b/packages/integrations/vercel/test/fixtures/middleware/package.json new file mode 100644 index 0000000000..9ba60852d4 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/vercel-edge-middleware", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/vercel": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/vercel/test/fixtures/middleware/src/middleware.js b/packages/integrations/vercel/test/fixtures/middleware/src/middleware.js new file mode 100644 index 0000000000..349a0aa795 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/src/middleware.js @@ -0,0 +1,8 @@ +/** + * @type {import("astro").MiddlewareResponseHandler} + */ +export const onRequest = async (context, next) => { + const test = 'something'; + const response = await next(); + return response; +}; diff --git a/packages/integrations/vercel/test/fixtures/middleware/src/pages/index.astro b/packages/integrations/vercel/test/fixtures/middleware/src/pages/index.astro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js b/packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js new file mode 100644 index 0000000000..bf69edb3e8 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js @@ -0,0 +1,5 @@ +export default function ({ request, context }) { + return { + title: 'Hello world', + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4922d35c4..0ee9cf6744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4898,6 +4898,9 @@ importers: '@types/set-cookie-parser': specifier: ^2.4.2 version: 2.4.2 + '@vercel/edge': + specifier: ^0.3.4 + version: 0.3.4 astro: specifier: workspace:* version: link:../../astro @@ -4907,12 +4910,18 @@ importers: chai: specifier: ^4.3.7 version: 4.3.7 + chai-jest-snapshot: + specifier: ^2.0.0 + version: 2.0.0(chai@4.3.7) cheerio: specifier: 1.0.0-rc.12 version: 1.0.0-rc.12 mocha: specifier: ^9.2.2 version: 9.2.2 + rollup: + specifier: ^3.20.1 + version: 3.25.1 packages/integrations/vercel/test/fixtures/basic: dependencies: @@ -4932,6 +4941,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/middleware: + dependencies: + '@astrojs/vercel': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/no-output: dependencies: '@astrojs/vercel': @@ -9015,6 +9033,10 @@ packages: optional: true dev: false + /@vercel/edge@0.3.4: + resolution: {integrity: sha512-dFU+yAUDQRwpuRGxRDlEO1LMq0y1LGsBgkyryQWe4w15/Fy2/lCnpvdIoAhHl3QvIGAxCLHzwRHsqfLRdpxgJQ==} + dev: true + /@vercel/nft@0.22.6: resolution: {integrity: sha512-gTsFnnT4mGxodr4AUlW3/urY+8JKKB452LwF3m477RFUJTAaDmcz2JqFuInzvdybYIeyIv1sSONEJxsxnbQ5JQ==} engines: {node: '>=14'} @@ -9355,6 +9377,11 @@ packages: type-fest: 1.4.0 dev: false + /ansi-regex@3.0.1: + resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} + engines: {node: '>=4'} + dev: true + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -9885,6 +9912,16 @@ packages: check-error: 1.0.2 dev: true + /chai-jest-snapshot@2.0.0(chai@4.3.7): + resolution: {integrity: sha512-u8jZZjw/0G1t5A8wDfH6K7DAVfMg3g0dsw9wKQURNUyrZX96VojHNrFMmLirq1m0kOvC5icgL/Qh/fu1MZyvUw==} + peerDependencies: + chai: '>=1.9.0' + dependencies: + chai: 4.3.7 + jest-snapshot: 21.2.1 + lodash.values: 4.3.0 + dev: true + /chai-xml@0.4.1(chai@4.3.7): resolution: {integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw==} engines: {node: '>= 0.8.0'} @@ -10594,6 +10631,11 @@ packages: /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + /diff@3.5.0: + resolution: {integrity: sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==} + engines: {node: '>=0.3.1'} + dev: true + /diff@5.0.0: resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==} engines: {node: '>=0.3.1'} @@ -12837,6 +12879,38 @@ packages: minimatch: 3.1.2 dev: false + /jest-diff@21.2.1: + resolution: {integrity: sha512-E5fu6r7PvvPr5qAWE1RaUwIh/k6Zx/3OOkZ4rk5dBJkEWRrUuSgbMt2EO8IUTPTd6DOqU3LW6uTIwX5FRvXoFA==} + dependencies: + chalk: 2.4.2 + diff: 3.5.0 + jest-get-type: 21.2.0 + pretty-format: 21.2.1 + dev: true + + /jest-get-type@21.2.0: + resolution: {integrity: sha512-y2fFw3C+D0yjNSDp7ab1kcd6NUYfy3waPTlD8yWkAtiocJdBRQqNoRqVfMNxgj+IjT0V5cBIHJO0z9vuSSZ43Q==} + dev: true + + /jest-matcher-utils@21.2.1: + resolution: {integrity: sha512-kn56My+sekD43dwQPrXBl9Zn9tAqwoy25xxe7/iY4u+mG8P3ALj5IK7MLHZ4Mi3xW7uWVCjGY8cm4PqgbsqMCg==} + dependencies: + chalk: 2.4.2 + jest-get-type: 21.2.0 + pretty-format: 21.2.1 + dev: true + + /jest-snapshot@21.2.1: + resolution: {integrity: sha512-bpaeBnDpdqaRTzN8tWg0DqOTo2DvD3StOemxn67CUd1p1Po+BUpvePAp44jdJ7Pxcjfg+42o4NHw1SxdCA2rvg==} + dependencies: + chalk: 2.4.2 + jest-diff: 21.2.1 + jest-matcher-utils: 21.2.1 + mkdirp: 0.5.6 + natural-compare: 1.4.0 + pretty-format: 21.2.1 + dev: true + /jest-worker@26.6.2: resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} engines: {node: '>= 10.13.0'} @@ -13134,6 +13208,10 @@ packages: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} dev: true + /lodash.values@4.3.0: + resolution: {integrity: sha512-r0RwvdCv8id9TUblb/O7rYPwVy6lerCbcawrfdo9iC/1t1wsNMJknO79WNBgwkH0hIeJ08jmvvESbFpNb4jH0Q==} + dev: true + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: false @@ -14018,6 +14096,13 @@ packages: /mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -15221,6 +15306,13 @@ packages: engines: {node: ^14.13.1 || >=16.0.0} dev: false + /pretty-format@21.2.1: + resolution: {integrity: sha512-ZdWPGYAnYfcVP8yKA3zFjCn8s4/17TeYH28MXuC8vTp0o21eXjbFGcOAXZEaDaOFJjc3h2qa7HQNHNshhvoh2A==} + dependencies: + ansi-regex: 3.0.1 + ansi-styles: 3.2.1 + dev: true + /pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}