alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
276 lines (244 loc) • 8.32 kB
text/typescript
import { $inject } from "alepha";
import type { CronProvider } from "alepha/scheduler";
import { FileSystemProvider } from "alepha/system";
import { BuildTask, type BuildTaskContext } from "./BuildTask.ts";
/**
* Generate Vercel Build Output API v3 deployment configuration.
*
* Creates:
* - .vercel/output/config.json with routes and version
* - .vercel/output/static/ (moved from dist/public/)
* - .vercel/output/functions/index.func/ with handler, index.js, server/, package.json
* - .vercel/project.json if projectId and orgId are set
*/
export class BuildVercelTask extends BuildTask {
protected readonly fs = $inject(FileSystemProvider);
protected readonly warningComment =
"// This file was automatically generated. DO NOT MODIFY.\n" +
"// Changes to this file will be lost when the code is regenerated.\n";
async run(ctx: BuildTaskContext): Promise<void> {
if (ctx.options.target !== "vercel") {
return;
}
const distDir = ctx.options.output?.dist ?? "dist";
await ctx.run({
name: "generate deploy config (vercel)",
handler: async () => {
await this.generateVercel(ctx, distDir);
},
});
}
protected async generateVercel(
ctx: BuildTaskContext,
distDir: string,
): Promise<void> {
const root = ctx.root;
const dist = this.fs.join(root, distDir);
const outputDir = this.fs.join(dist, ".vercel", "output");
const funcDir = this.fs.join(outputDir, "functions", "index.func");
const staticDir = this.fs.join(outputDir, "static");
await this.fs.mkdir(funcDir);
await this.fs.mkdir(staticDir);
await this.writeOutputConfig(
outputDir,
ctx.options.vercel?.config,
this.collectCronJobs(ctx),
);
await this.writeVcConfig(funcDir);
await this.writeHandler(funcDir);
await this.copyServerBundle(dist, funcDir);
await this.copyStaticAssets(dist, staticDir);
await this.writeProjectConfig(ctx, dist);
}
/**
* Collect every registered `$scheduler` / `$job({ cron })` and turn it
* into a Vercel Cron entry. Vercel hits `path` on the configured
* `schedule`; the entry-point handler routes that to a `serverless:cron`
* event so `CronProvider` runs the matching job in-process.
*
* Mirrors the equivalent in `BuildCloudflareTask.enhanceCron`, except
* Vercel needs one path per job (it doesn't dispatch by cron expression
* the way Cloudflare does).
*/
protected collectCronJobs(
ctx: BuildTaskContext,
): { path: string; schedule: string }[] {
if (ctx.alepha.primitives("scheduler").length === 0) {
// `$job` registers cron jobs through the same provider, so the
// primitives count covers both `$scheduler` and `$job({ cron })`.
}
let cronProvider: CronProvider | undefined;
try {
cronProvider = ctx.alepha.inject("CronProvider") as CronProvider;
} catch {}
const jobs = cronProvider?.getCronJobs();
if (!jobs || jobs.length === 0) {
return [];
}
return jobs.map((job) => ({
path: `/_alepha/cron/${encodeURIComponent(job.name)}`,
schedule: job.expression,
}));
}
/**
* Write .vercel/output/config.json with Build Output API v3 format.
*
* `userCrons` are merged with auto-collected `$scheduler` / `$job({ cron })`
* entries — explicit user config wins on conflicting paths.
*/
protected async writeOutputConfig(
outputDir: string,
config?: { crons?: { path: string; schedule: string }[] },
autoCrons: { path: string; schedule: string }[] = [],
): Promise<void> {
const outputConfig: Record<string, any> = {
version: 3,
routes: [{ handle: "filesystem" }, { src: "/(.*)", dest: "/index" }],
};
const merged = new Map<string, { path: string; schedule: string }>();
for (const c of autoCrons) merged.set(c.path, c);
for (const c of config?.crons ?? []) merged.set(c.path, c);
const crons = [...merged.values()];
if (crons.length > 0) {
outputConfig.crons = crons;
}
await this.fs.writeFile(
this.fs.join(outputDir, "config.json"),
JSON.stringify(outputConfig, null, 2),
);
}
/**
* Write .vc-config.json for the serverless function.
*/
protected async writeVcConfig(funcDir: string): Promise<void> {
await this.fs.writeFile(
this.fs.join(funcDir, ".vc-config.json"),
JSON.stringify(
{
runtime: "nodejs22.x",
handler: "handler.js",
launcherType: "Nodejs",
},
null,
2,
),
);
}
/**
* Write the handler.js entry point that uses node:request event.
*/
protected async writeHandler(funcDir: string): Promise<void> {
const handlerCode = `
import "./index.js";
const CRON_PREFIX = "/_alepha/cron/";
export default async (req, res) => {
try {
await __alepha.start();
} catch (err) {
__alepha.log.error("Failed to start Alepha for request", err);
res.writeHead(500, { "content-type": "text/plain" });
res.end("Internal Server Error");
return;
}
// Vercel Cron triggers are HTTP GETs to a configured path. We route them
// to the CronProvider via an event so the same job declaration works on
// Vercel, Cloudflare, and long-running Node alike. Vercel signs cron
// requests with \`Authorization: Bearer \${CRON_SECRET}\`; the secret is
// available as \`process.env.CRON_SECRET\`. When set, we require the
// header to match before triggering.
const url = req.url || "";
if (url.startsWith(CRON_PREFIX)) {
const expected = process.env.CRON_SECRET;
if (expected) {
const got = (req.headers?.authorization || "").replace(/^Bearer\\s+/i, "");
if (got !== expected) {
res.writeHead(401, { "content-type": "text/plain" });
res.end("Unauthorized");
return;
}
}
const name = decodeURIComponent(url.slice(CRON_PREFIX.length).split("?")[0]);
try {
await __alepha.events.emit("serverless:cron", { name });
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
} catch (err) {
__alepha.log.error(\`Cron trigger '\${name}' failed\`, err);
res.writeHead(500, { "content-type": "text/plain" });
res.end("cron failed");
}
return;
}
await __alepha.events.emit("node:request", { req, res });
};
`.trim();
await this.fs.writeFile(
this.fs.join(funcDir, "handler.js"),
`${this.warningComment}\n${handlerCode}`,
);
}
/**
* Copy the server bundle (index.js, server/, package.json) into the function directory.
*/
protected async copyServerBundle(
dist: string,
funcDir: string,
): Promise<void> {
await this.fs.cp(
this.fs.join(dist, "index.js"),
this.fs.join(funcDir, "index.js"),
);
await this.fs.cp(
this.fs.join(dist, "package.json"),
this.fs.join(funcDir, "package.json"),
);
const serverDir = this.fs.join(dist, "server");
if (await this.fs.exists(serverDir)) {
await this.fs.cp(serverDir, this.fs.join(funcDir, "server"));
}
}
/**
* Move static assets from dist/public/ to .vercel/output/static/.
*/
protected async copyStaticAssets(
dist: string,
staticDir: string,
): Promise<void> {
const publicDir = this.fs.join(dist, "public");
if (await this.fs.exists(publicDir)) {
await this.fs.cp(publicDir, staticDir);
} else {
await this.fs.writeFile(this.fs.join(staticDir, ".keep"), "");
}
}
/**
* Write .vercel/project.json if projectId and orgId are available.
*/
protected async writeProjectConfig(
ctx: BuildTaskContext,
dist: string,
): Promise<void> {
const projectId =
process.env.VERCEL_PROJECT_ID ?? ctx.options.vercel?.projectId;
const projectName =
process.env.VERCEL_PROJECT_NAME ?? ctx.options.vercel?.projectName;
const orgId = process.env.VERCEL_ORG_ID ?? ctx.options.vercel?.orgId;
if (!projectId || !orgId) {
return;
}
const vercelDir = this.fs.join(dist, ".vercel");
await this.fs.mkdir(vercelDir);
await this.fs.writeFile(
this.fs.join(vercelDir, "project.json"),
JSON.stringify(
{
projectId,
projectName,
orgId,
},
null,
2,
),
);
}
}