diff options
Diffstat (limited to 'packages/integrations/vercel/src/lib')
-rw-r--r-- | packages/integrations/vercel/src/lib/nft.ts | 83 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/redirects.ts | 135 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/searchRoot.ts | 101 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/web-analytics.ts | 30 |
4 files changed, 349 insertions, 0 deletions
diff --git a/packages/integrations/vercel/src/lib/nft.ts b/packages/integrations/vercel/src/lib/nft.ts new file mode 100644 index 000000000..4381943bb --- /dev/null +++ b/packages/integrations/vercel/src/lib/nft.ts @@ -0,0 +1,83 @@ +import { relative as relativePath } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { copyFilesToFolder } from '@astrojs/internal-helpers/fs'; +import { appendForwardSlash } from '@astrojs/internal-helpers/path'; +import type { AstroIntegrationLogger } from 'astro'; +import { searchForWorkspaceRoot } from './searchRoot.js'; + +export async function copyDependenciesToFunction( + { + entry, + outDir, + includeFiles, + excludeFiles, + logger, + root, + }: { + entry: URL; + outDir: URL; + includeFiles: URL[]; + excludeFiles: URL[]; + logger: AstroIntegrationLogger; + root: URL; + }, + // we want to pass the caching by reference, and not by value + cache: object, +): Promise<{ handler: string }> { + const entryPath = fileURLToPath(entry); + logger.info(`Bundling function ${relativePath(fileURLToPath(outDir), entryPath)}`); + + // Set the base to the workspace root + const base = pathToFileURL(appendForwardSlash(searchForWorkspaceRoot(fileURLToPath(root)))); + + // 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), + cache, + }); + + for (const error of result.warnings) { + if (error.message.startsWith('Failed to resolve dependency')) { + const [, module, file] = /Cannot find module '(.+?)' loaded from (.+)/.exec(error.message)!; + + // The import(astroRemark) sometimes fails to resolve, but it's not a problem + if (module === '@astrojs/') continue; + + // Sharp is always external and won't be able to be resolved, but that's also not a problem + if (module === 'sharp') continue; + + if (entryPath === file) { + logger.debug( + `[@astrojs/vercel] The module "${module}" couldn't be resolved. This may not be a problem, but it's worth checking.`, + ); + } else { + logger.debug( + `[@astrojs/vercel] The module "${module}" inside the file "${file}" couldn't be resolved. This may not be a problem, but it's worth checking.`, + ); + } + } + // parse errors are likely not js and can safely be ignored, + // such as this html file in "main" meant for nw instead of node: + // https://github.com/vercel/nft/issues/311 + else if (error.message.startsWith('Failed to parse')) { + continue; + } else { + throw error; + } + } + + const commonAncestor = await copyFilesToFolder( + [...result.fileList].map((file) => new URL(file, base)).concat(includeFiles), + outDir, + excludeFiles, + ); + + return { + // serverEntry location inside the outDir + handler: relativePath(commonAncestor, entryPath), + }; +} diff --git a/packages/integrations/vercel/src/lib/redirects.ts b/packages/integrations/vercel/src/lib/redirects.ts new file mode 100644 index 000000000..612c8e917 --- /dev/null +++ b/packages/integrations/vercel/src/lib/redirects.ts @@ -0,0 +1,135 @@ +import nodePath from 'node:path'; +import { isRemotePath, removeLeadingForwardSlash } from '@astrojs/internal-helpers/path'; +import type { AstroConfig, IntegrationResolvedRoute, RoutePart } from 'astro'; + +import type { Redirect } from '@vercel/routing-utils'; + +const pathJoin = nodePath.posix.join; + +// Copied from astro/packages/astro/src/core/routing/manifest/create.ts +// Disable eslint as we're not sure how to improve this regex yet +// eslint-disable-next-line regexp/no-super-linear-backtracking +const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/; +const ROUTE_SPREAD = /^\.{3}.+$/; +function getParts(part: string, file: string) { + const result: RoutePart[] = []; + part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => { + if (!str) return; + const dynamic = i % 2 === 1; + + const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; + + if (!content || (dynamic && !/^(?:\.\.\.)?[\w$]+$/.test(content))) { + throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`); + } + + result.push({ + content, + dynamic, + spread: dynamic && ROUTE_SPREAD.test(content), + }); + }); + + return result; +} +/** + * Convert Astro routes into Vercel path-to-regexp syntax, which are the input for getTransformedRoutes + */ +function getMatchPattern(segments: RoutePart[][]) { + return segments + .map((segment) => { + return segment + .map((part) => { + if (part.spread) { + // Extract parameter name from spread syntax (e.g., "...slug" -> "slug") + const paramName = part.content.startsWith('...') ? part.content.slice(3) : part.content; + return `:${paramName}*`; + } + if (part.dynamic) { + return `:${part.content}`; + } + return part.content; + }) + .join(''); + }) + .join('/'); +} + +// Copied from /home/juanm04/dev/misc/astro/packages/astro/src/core/routing/manifest/create.ts +// 2022-04-26 +function getMatchRegex(segments: RoutePart[][]) { + return segments + .map((segment, segmentIndex) => { + return segment.length === 1 && segment[0].spread + ? '(?:\\/(.*?))?' + : // Omit leading slash if segment is a spread. + // This is handled using a regex in Astro core. + // To avoid complex data massaging, we handle in-place here. + (segmentIndex === 0 ? '' : '/') + + segment + .map((part) => { + if (part) + return part.spread + ? '(.*?)' + : part.dynamic + ? '([^/]+?)' + : part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + }) + .join(''); +} + +function getRedirectLocation(route: IntegrationResolvedRoute, config: AstroConfig): string { + if (route.redirectRoute) { + const pattern = getMatchPattern(route.redirectRoute.segments); + return pathJoin(config.base, pattern); + } + + const destination = + typeof route.redirect === 'object' ? route.redirect.destination : (route.redirect ?? ''); + + if (isRemotePath(destination)) { + return destination; + } + + return pathJoin(config.base, destination); +} + +function getRedirectStatus(route: IntegrationResolvedRoute): number { + if (typeof route.redirect === 'object') { + return route.redirect.status; + } + return 301; +} + +export function escapeRegex(content: string) { + const segments = removeLeadingForwardSlash(content) + .split(nodePath.posix.sep) + .filter(Boolean) + .map((s: string) => { + return getParts(s, content); + }); + return `^/${getMatchRegex(segments)}$`; +} + +export function getRedirects(routes: IntegrationResolvedRoute[], config: AstroConfig): Redirect[] { + const redirects: Redirect[] = []; + + for (const route of routes) { + if (route.type === 'redirect') { + redirects.push({ + source: config.base + getMatchPattern(route.segments), + destination: getRedirectLocation(route, config), + statusCode: getRedirectStatus(route), + }); + } + } + return redirects; +} diff --git a/packages/integrations/vercel/src/lib/searchRoot.ts b/packages/integrations/vercel/src/lib/searchRoot.ts new file mode 100644 index 000000000..c770fddb4 --- /dev/null +++ b/packages/integrations/vercel/src/lib/searchRoot.ts @@ -0,0 +1,101 @@ +// Taken from: https://github.com/vitejs/vite/blob/1a76300cd16827f0640924fdc21747ce140c35fb/packages/vite/src/node/server/searchRoot.ts +// MIT license +// See https://github.com/vitejs/vite/blob/1a76300cd16827f0640924fdc21747ce140c35fb/LICENSE +import fs from 'node:fs'; +import { dirname, join } from 'node:path'; + +// https://github.com/vitejs/vite/issues/2820#issuecomment-812495079 +const ROOT_FILES = [ + // '.git', + + // https://pnpm.io/workspaces/ + 'pnpm-workspace.yaml', + + // https://rushjs.io/pages/advanced/config_files/ + // 'rush.json', + + // https://nx.dev/latest/react/getting-started/nx-setup + // 'workspace.json', + // 'nx.json', + + // https://github.com/lerna/lerna#lernajson + 'lerna.json', +]; + +function tryStatSync(file: string): fs.Stats | undefined { + try { + // The "throwIfNoEntry" is a performance optimization for cases where the file does not exist + return fs.statSync(file, { throwIfNoEntry: false }); + } catch { + // Ignore errors + } +} + +function isFileReadable(filename: string): boolean { + if (!tryStatSync(filename)) { + return false; + } + + try { + // Check if current process has read permission to the file + fs.accessSync(filename, fs.constants.R_OK); + + return true; + } catch { + return false; + } +} + +// npm: https://docs.npmjs.com/cli/v7/using-npm/workspaces#installing-workspaces +// yarn: https://classic.yarnpkg.com/en/docs/workspaces/#toc-how-to-use-it +function hasWorkspacePackageJSON(root: string): boolean { + const path = join(root, 'package.json'); + if (!isFileReadable(path)) { + return false; + } + try { + const content = JSON.parse(fs.readFileSync(path, 'utf-8')) || {}; + return !!content.workspaces; + } catch { + return false; + } +} + +function hasRootFile(root: string): boolean { + return ROOT_FILES.some((file) => fs.existsSync(join(root, file))); +} + +function hasPackageJSON(root: string) { + const path = join(root, 'package.json'); + return fs.existsSync(path); +} + +/** + * Search up for the nearest `package.json` + */ +function searchForPackageRoot(current: string, root = current): string { + if (hasPackageJSON(current)) return current; + + const dir = dirname(current); + // reach the fs root + if (!dir || dir === current) return root; + + return searchForPackageRoot(dir, root); +} + +/** + * Search up for the nearest workspace root + */ +export function searchForWorkspaceRoot( + current: string, + root = searchForPackageRoot(current), +): string { + if (hasRootFile(current)) return current; + if (hasWorkspacePackageJSON(current)) return current; + + const dir = dirname(current); + // reach the fs root + if (!dir || dir === current) return root; + + return searchForWorkspaceRoot(dir, root); +} diff --git a/packages/integrations/vercel/src/lib/web-analytics.ts b/packages/integrations/vercel/src/lib/web-analytics.ts new file mode 100644 index 000000000..d6ee4d78d --- /dev/null +++ b/packages/integrations/vercel/src/lib/web-analytics.ts @@ -0,0 +1,30 @@ +export type VercelWebAnalyticsConfig = { + enabled: boolean; +}; + +export async function getInjectableWebAnalyticsContent({ + mode, +}: { + mode: 'development' | 'production'; +}) { + const base = `window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };`; + + if (mode === 'development') { + return ` + ${base} + var script = document.createElement('script'); + script.defer = true; + script.src = 'https://cdn.vercel-insights.com/v1/script.debug.js'; + var head = document.querySelector('head'); + head.appendChild(script); + `; + } + + return `${base} + var script = document.createElement('script'); + script.defer = true; + script.src = '/_vercel/insights/script.js'; + var head = document.querySelector('head'); + head.appendChild(script); + `; +} |