UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

276 lines (244 loc) 8.32 kB
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, ), ); } }