Skip to content

Commit

Permalink
fix: removed imports from @astrojs/vercel/dist
Browse files Browse the repository at this point in the history
  • Loading branch information
alexvuka1 committed Sep 2, 2023
1 parent 7997b27 commit de467bd
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 11 deletions.
7 changes: 0 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,6 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"edge": [
"dist/adapter.d.ts"
]
}
},
"directories": {
"dist": "./dist",
"src": "./src"
Expand Down
8 changes: 4 additions & 4 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import { build } from 'esbuild';
import { relative as relativePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import { exposeEnv } from '@astrojs/vercel/dist/lib/env';
import { copyFilesToFunction, getFilesFromFolder, getVercelOutput, removeDir, writeJson } from '@astrojs/vercel/dist/lib/fs';
import { getRedirects } from '@astrojs/vercel/dist/lib/redirects';
import { type VercelImageConfig, defaultImageConfig, getImageConfig } from '@astrojs/vercel/dist/image/shared';
import { getVercelOutput, getFilesFromFolder, copyFilesToFunction, removeDir, writeJson } from './lib/fs';
import { type VercelImageConfig, getImageConfig, defaultImageConfig } from './lib/image';
import { exposeEnv } from './lib/env';
import { getRedirects } from './lib/redirects';

const PACKAGE_NAME = 'astro-vercel-edge-adapter';

Expand Down
15 changes: 15 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* While Vercel adds the `PUBLIC_` prefix for their `VERCEL_` env vars by default, some env vars
* like `VERCEL_ANALYTICS_ID` aren't, so handle them here so that it works correctly in runtime.
*/
export function exposeEnv(envs: string[]): Record<string, unknown> {
const mapped: Record<string, unknown> = {};

envs
.filter((env) => process.env[env])
.forEach((env) => {
mapped[`import.meta.env.PUBLIC_${env}`] = JSON.stringify(process.env[env]);
});

return mapped;
}
92 changes: 92 additions & 0 deletions src/lib/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { PathLike } from 'node:fs';
import * as fs from 'node:fs/promises';
import nodePath from 'node:path';
import { fileURLToPath } from 'node:url';

export async function writeJson<T>(path: PathLike, data: T) {
await fs.writeFile(path, JSON.stringify(data, null, '\t'), { encoding: 'utf-8' });
}

export async function removeDir(dir: PathLike) {
await fs.rm(dir, { recursive: true, force: true, maxRetries: 3 });
}

export async function emptyDir(dir: PathLike): Promise<void> {
await removeDir(dir);
await fs.mkdir(dir, { recursive: true });
}

export async function getFilesFromFolder(dir: URL) {
const data = await fs.readdir(dir, { withFileTypes: true });
let files: URL[] = [];
for (const item of data) {
if (item.isDirectory()) {
const moreFiles = await getFilesFromFolder(new URL(`./${item.name}/`, dir));
files = files.concat(moreFiles);
} else {
files.push(new URL(`./${item.name}`, dir));
}
}
return files;
}

export const getVercelOutput = (root: URL) => new URL('./.vercel/output/', root);

/**
* Copies files into a folder keeping the folder structure intact.
* The resulting file tree will start at the common ancestor.
*
* @param {URL[]} files A list of files to copy (absolute path).
* @param {URL} outDir Destination folder where to copy the files to (absolute path).
* @param {URL[]} [exclude] A list of files to exclude (absolute path).
* @returns {Promise<string>} The common ancestor of the copied files.
*/
export async function copyFilesToFunction(
files: URL[],
outDir: URL,
exclude: URL[] = []
): Promise<string> {
const excludeList = exclude.map(fileURLToPath);
const fileList = files.map(fileURLToPath).filter((f) => !excludeList.includes(f));

if (files.length === 0) throw new Error('[@astrojs/vercel] No files found to copy');

let commonAncestor = nodePath.dirname(fileList[0]);
for (const file of fileList.slice(1)) {
while (!file.startsWith(commonAncestor)) {
commonAncestor = nodePath.dirname(commonAncestor);
}
}

for (const origin of fileList) {
const dest = new URL(nodePath.relative(commonAncestor, origin), outDir);

const realpath = await fs.realpath(origin);
const isSymlink = realpath !== origin;
const isDir = (await fs.stat(origin)).isDirectory();

// Create directories recursively
if (isDir && !isSymlink) {
await fs.mkdir(new URL('..', dest), { recursive: true });
} else {
await fs.mkdir(new URL('.', dest), { recursive: true });
}

if (isSymlink) {
const realdest = fileURLToPath(new URL(nodePath.relative(commonAncestor, realpath), outDir));
await fs.symlink(
nodePath.relative(fileURLToPath(new URL('.', dest)), realdest),
dest,
isDir ? 'dir' : 'file'
);
} else if (!isDir) {
await fs.copyFile(origin, dest);
}
}

return commonAncestor;
}

export async function writeFile(path: PathLike, content: string) {
await fs.writeFile(path, content, { encoding: 'utf-8' });
}
142 changes: 142 additions & 0 deletions src/lib/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { ImageMetadata, ImageQualityPreset, ImageTransform } from 'astro';

export const defaultImageConfig: VercelImageConfig = {
sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
domains: [],
};

export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
return typeof src === 'object';
}
// https://vercel.com/docs/build-output-api/v3/configuration#images
type ImageFormat = 'image/avif' | 'image/webp';

type RemotePattern = {
protocol?: 'http' | 'https';
hostname: string;
port?: string;
pathname?: string;
};

export type VercelImageConfig = {
/**
* Supported image widths.
*/
sizes: number[];
/**
* Allowed external domains that can use Image Optimization. Leave empty for only allowing the deployment domain to use Image Optimization.
*/
domains: string[];
/**
* Allowed external patterns that can use Image Optimization. Similar to `domains` but provides more control with RegExp.
*/
remotePatterns?: RemotePattern[];
/**
* Cache duration (in seconds) for the optimized images.
*/
minimumCacheTTL?: number;
/**
* Supported output image formats
*/
formats?: ImageFormat[];
/**
* Allow SVG input image URLs. This is disabled by default for security purposes.
*/
dangerouslyAllowSVG?: boolean;
/**
* Change the [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) of the optimized images.
*/
contentSecurityPolicy?: string;
};

export const qualityTable: Record<ImageQualityPreset, number> = {
low: 25,
mid: 50,
high: 80,
max: 100,
};

export function getImageConfig(
images: boolean | undefined,
imagesConfig: VercelImageConfig | undefined,
command: string
) {
if (images) {
return {
image: {
service: {
entrypoint:
command === 'dev'
? '@astrojs/vercel/dev-image-service'
: '@astrojs/vercel/build-image-service',
config: imagesConfig ? imagesConfig : defaultImageConfig,
},
},
};
}

return {};
}

export function sharedValidateOptions(
options: ImageTransform,
serviceConfig: Record<string, any>,
mode: 'development' | 'production'
) {
const vercelImageOptions = serviceConfig as VercelImageConfig;

if (
mode === 'development' &&
(!vercelImageOptions.sizes || vercelImageOptions.sizes.length === 0)
) {
throw new Error('Vercel Image Optimization requires at least one size to be configured.');
}

const configuredWidths = vercelImageOptions.sizes.sort((a, b) => a - b);

// The logic for finding the perfect width is a bit confusing, here it goes:
// For images where no width has been specified:
// - For local, imported images, fallback to nearest width we can find in our configured
// - For remote images, that's an error, width is always required.
// For images where a width has been specified:
// - If the width that the user asked for isn't in `sizes`, then fallback to the nearest one, but save the width
// the user asked for so we can put it on the `img` tag later.
// - Otherwise, just use as-is.
// The end goal is:
// - The size on the page is always the one the user asked for or the base image's size
// - The actual size of the image file is always one of `sizes`, either the one the user asked for or the nearest to it
if (!options.width) {
const src = options.src;
if (isESMImportedImage(src)) {
const nearestWidth = configuredWidths.reduce((prev, curr) => {
return Math.abs(curr - src.width) < Math.abs(prev - src.width) ? curr : prev;
});

// Use the image's base width to inform the `width` and `height` on the `img` tag
options.inputtedWidth = src.width;
options.width = nearestWidth;
} else {
throw new Error(`Missing \`width\` parameter for remote image ${options.src}`);
}
} else {
if (!configuredWidths.includes(options.width)) {
const nearestWidth = configuredWidths.reduce((prev, curr) => {
return Math.abs(curr - options.width!) < Math.abs(prev - options.width!) ? curr : prev;
});

// Save the width the user asked for to inform the `width` and `height` on the `img` tag
options.inputtedWidth = options.width;
options.width = nearestWidth;
}
}

if (options.quality && typeof options.quality === 'string') {
options.quality = options.quality in qualityTable ? qualityTable[options.quality] : undefined;
}

if (!options.quality) {
options.quality = 100;
}

return options;
}
108 changes: 108 additions & 0 deletions src/lib/redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
import type { AstroConfig, RouteData, RoutePart } from 'astro';
import nodePath from 'node:path';

const pathJoin = nodePath.posix.join;

// https://vercel.com/docs/project-configuration#legacy/routes
interface VercelRoute {
src: string;
methods?: string[];
dest?: string;
headers?: Record<string, string>;
status?: number;
continue?: boolean;
}

// Copied from /home/juanm04/dev/misc/astro/packages/astro/src/core/routing/manifest/create.ts
// 2022-04-26
function getMatchPattern(segments: RoutePart[][]) {
return segments
.map((segment) => {
return segment[0].spread
? '(?:\\/(.*?))?'
: '\\/' +
segment
.map((part) => {
if (part)
return part.dynamic
? '([^/]+?)'
: part.content
.normalize()
.replace(/\?/g, '%3F')
.replace(/#/g, '%23')
.replace(/%5B/g, '[')
.replace(/%5D/g, ']')
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('');
})
.join('');
}

function getReplacePattern(segments: RoutePart[][]) {
let n = 0;
let result = '';

for (const segment of segments) {
for (const part of segment) {
if (part.dynamic) result += '$' + ++n;
else result += part.content;
}
result += '/';
}

// Remove trailing slash
result = result.slice(0, -1);

return result;
}

function getRedirectLocation(route: RouteData, config: AstroConfig): string {
if (route.redirectRoute) {
const pattern = getReplacePattern(route.redirectRoute.segments);
const path = config.trailingSlash === 'always' ? appendForwardSlash(pattern) : pattern;
return pathJoin(config.base, path);
} else if (typeof route.redirect === 'object') {
return pathJoin(config.base, route.redirect.destination);
} else {
return pathJoin(config.base, route.redirect || '');
}
}

function getRedirectStatus(route: RouteData): number {
if (typeof route.redirect === 'object') {
return route.redirect.status;
}
return 301;
}

export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRoute[] {
let redirects: VercelRoute[] = [];

for (const route of routes) {
if (route.type === 'redirect') {
redirects.push({
src: config.base + getMatchPattern(route.segments),
headers: { Location: getRedirectLocation(route, config) },
status: getRedirectStatus(route),
});
} else if (route.type === 'page' && route.route !== '/') {
if (config.trailingSlash === 'always') {
redirects.push({
src: config.base + getMatchPattern(route.segments),
headers: { Location: config.base + getReplacePattern(route.segments) + '/' },
status: 308,
});
} else if (config.trailingSlash === 'never') {
redirects.push({
src: config.base + getMatchPattern(route.segments) + '/',
headers: { Location: config.base + getReplacePattern(route.segments) },
status: 308,
});
}
}
}

return redirects;
}

0 comments on commit de467bd

Please sign in to comment.