Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,8 @@ jobs:
done
fi

- name: Run tests
run: npm test

- name: Build all templates
run: npm run build
136 changes: 88 additions & 48 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { readdir, readFile, cp, rm, mkdir, stat, writeFile } from "node:fs/promi
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { availableParallelism } from "node:os";
import { fileURLToPath } from "node:url";
import { emitNodeFunctionsProject, copyNodeFunctionsArtifacts } from "./build-node-functions.mjs";

function run(cmd, opts = {}) {
Expand All @@ -26,15 +28,44 @@ function run(cmd, opts = {}) {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "", stderr = "";
// Guard against settling twice and handle spawn errors — without an `error`
// handler a failed spawn (e.g. missing `sh`, ENOMEM) leaves the promise
// pending forever and hangs the whole build.
let settled = false;
const settle = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));
child.on("error", (err) => settle(reject, new Error(`spawn failed for "${cmd}": ${err.message}`)));
child.on("close", (code) => {
if (code !== 0) reject(new Error(`exit ${code}\n${stderr || stdout}`));
else resolve({ stdout, stderr });
if (code !== 0) settle(reject, new Error(`exit ${code}\n${stderr || stdout}`));
else settle(resolve, { stdout, stderr });
});
});
}

// Bounded-concurrency map returning Promise.allSettled-shaped results, so a
// large hub doesn't fire one install/build per project all at once.
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const idx = next++;
try {
results[idx] = { status: "fulfilled", value: await fn(items[idx], idx) };
} catch (reason) {
results[idx] = { status: "rejected", reason };
}
}
};
await Promise.all(
Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker)
);
return results;
}

const BUILD_CONCURRENCY = Math.max(1, availableParallelism() - 1);

const ROOT = resolve(import.meta.dirname);
const PROJECTS_DIR = join(ROOT, "projects");
const DIST_DIR = join(ROOT, "dist");
Expand Down Expand Up @@ -108,7 +139,9 @@ async function detectProjectType(projectDir) {
}

function detectInstallCmd(projectDir) {
if (existsSync(join(projectDir, "package-lock.json"))) return "npm install";
// Reproducible installs: when a lockfile exists, use the locked-install form so
// a build can't silently float to a fresh patch within the same semver range.
if (existsSync(join(projectDir, "package-lock.json"))) return "npm ci";
if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
if (existsSync(join(projectDir, "bun.lockb"))) return "bun install --frozen-lockfile";
return "npm install";
Expand Down Expand Up @@ -463,50 +496,49 @@ async function buildLandingPage(projects, externals) {
await writeFile(join(staticDir, "index.html"), html);
}

async function generateVercelConfig(projects, crons = []) {
// Pure: compute the Build Output API v3 config object from the project list.
// Kept side-effect-free so it can be unit-tested (see test/build.test.mjs).
function buildVercelConfig(projects, crons = []) {
const serverProjects = projects.filter((p) => p.config.type === "nuxt-server");

const routes = [];

// Add routes for each server project
// API routes for each server project go to the Nitro __fallback function.
for (const p of serverProjects) {
// API routes go to the Nitro __fallback function
routes.push({
src: `/${p.name}/api/(.*)`,
dest: `/${p.name}/__fallback`,
});
routes.push({ src: `/${p.name}/api/(.*)`, dest: `/${p.name}/__fallback` });
}

// Strip trailing slash from dynamic [param] paths in node-functions projects.
// `trailingSlash: true` 308-redirects /api/reports/UUID -> /api/reports/UUID/,
// and Vercel BOA's filesystem matcher won't pair the trailing-slash form with
// a `[param].func` entry. Rewrite the slash form back to no-slash so the
// dynamic match fires.
// node-functions dynamic routing. `trailingSlash: true` 308-redirects
// /api/x/UUID -> /api/x/UUID/, and Vercel BOA's filesystem matcher won't pair
// the slash form with a `[param].func` entry, so we route dynamic paths to the
// PARENT function and pass the captured segment via query string.
const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const fnProjects = projects.filter((p) => p.config.type === "node-functions");
for (const p of fnProjects) {
const entries = p.config.functions?.entries || [];
const perEntry = p.config.functions?.perEntry || {};
for (const entry of entries) {
// Single-segment dynamic [id] entries: rewrite /<name>/<parent>/<seg> ->
// /<name>/<parent>?subId=<seg>. Vercel BOA's filesystem matcher doesn't
// auto-resolve `[param].func` dynamic routing — the .func directory name
// is treated literally — so we route dynamic paths to the PARENT function
// and pass the captured segment via query string.
// Single trailing dynamic segment: /<name>/<parent>/<seg> -> ?subId=<seg>.
const m = entry.match(/^(.+?)\/\[([^\]]+)\]\.(?:mjs|js)$/);
if (m) {
const [, parentPath] = m;
// Fail fast on multi-segment dynamic paths — they'd silently emit a route
// to a non-existent function and 404 in prod. Use one trailing [id].mjs
// (parent reads ?subId=) or a catchAll entry instead.
if (parentPath.includes("[")) {
throw new Error(
`template.config.json (${p.name}): function entry "${entry}" has multiple dynamic [param] segments, which isn't supported. Use a single trailing [id].mjs or a catchAll entry.`
);
}
routes.push({
src: `^/${reEscape(p.name)}/${reEscape(parentPath)}/([^/]+)/?$`,
dest: `/${p.name}/${parentPath}?subId=$1`,
});
continue;
}
// Catch-all opt-in via template.config.json:
// functions.perEntry["api/proxy.mjs"] = { catchAll: true }
// Routes /<name>/<entry>/<arbitrary multi-segment path> to the function
// with the remainder in ?subPath=. Handy for API proxies that forward
// arbitrary upstream paths (e.g. /v1/resource/.../items).
// Catch-all opt-in: functions.perEntry["api/proxy.mjs"] = { catchAll: true }
// routes /<name>/<entry>/<multi/segment> to the function with the remainder
// in ?subPath= (handy for API proxies forwarding arbitrary upstream paths).
const cleanRel = entry.replace(/\.m?js$/, "");
if (perEntry[entry]?.catchAll) {
routes.push({
Expand All @@ -517,25 +549,25 @@ async function generateVercelConfig(projects, crons = []) {
}
}

// Filesystem fallback (serves static files + node-functions automatically by path)
// Filesystem fallback (serves static files + node-functions automatically).
routes.push({ handle: "filesystem" });

// SPA fallback for server projects — Nitro serves the SPA shell
// SPA fallback for server projects — Nitro serves the SPA shell.
for (const p of serverProjects) {
routes.push({
src: `/${p.name}(?:/((?!api/).*))?`,
dest: `/${p.name}/__fallback`,
});
routes.push({ src: `/${p.name}(?:/((?!api/).*))?`, dest: `/${p.name}/__fallback` });
}

const config = { version: 3, cleanUrls: true, trailingSlash: true, routes };
if (Array.isArray(crons) && crons.length > 0) {
config.crons = crons;
}
return config;
}

async function generateVercelConfig(projects, crons = []) {
await writeFile(
join(VERCEL_OUTPUT, "config.json"),
JSON.stringify(config, null, 2)
JSON.stringify(buildVercelConfig(projects, crons), null, 2)
);
}

Expand Down Expand Up @@ -583,17 +615,15 @@ async function main() {
projects.push({ name, config, dir: projectDir });
}

// Build all projects in parallel
console.log(`Found ${projects.length} project(s) — building in parallel:\n`);
// Build all projects with bounded concurrency
console.log(`Found ${projects.length} project(s) — building (up to ${BUILD_CONCURRENCY} at a time):\n`);
const buildStart = Date.now();

const results = await Promise.allSettled(
projects.map(async (p) => {
const t0 = Date.now();
const { logs, crons } = await buildProject(p.name, p.dir, p.config);
return { ms: Date.now() - t0, logs, crons };
})
);
const results = await mapLimit(projects, BUILD_CONCURRENCY, async (p) => {
const t0 = Date.now();
const { logs, crons } = await buildProject(p.name, p.dir, p.config);
return { ms: Date.now() - t0, logs, crons };
});

const allCrons = [];
let failed = 0;
Expand Down Expand Up @@ -629,10 +659,14 @@ async function main() {
process.exitCode = 1;
}

// Only assemble + index projects that actually built — never copy partial or
// empty output from a failed project into the deployment.
const built = projects.filter((_, i) => results[i].status === "fulfilled");

// Assembly phase: populate .vercel/output/
console.log("Assembling Vercel Build Output API v3 structure...\n");

for (const p of projects) {
for (const p of built) {
const type = p.config.type || "static";

if (type === "nuxt-server") {
Expand Down Expand Up @@ -689,7 +723,7 @@ async function main() {
}

// Generate landing page at .vercel/output/static/index.html
await buildLandingPage(projects, externals);
await buildLandingPage(built, externals);
console.log("\nLanding page generated.");

// Copy favicon if exists
Expand All @@ -699,13 +733,19 @@ async function main() {
}

// Generate .vercel/output/config.json
await generateVercelConfig(projects, allCrons);
await generateVercelConfig(built, allCrons);
console.log(`Vercel config generated${allCrons.length > 0 ? ` (${allCrons.length} cron(s))` : ""}.`);

console.log(`\nBuild complete → .vercel/output/`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
// Exported for unit tests (test/build.test.mjs).
export { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, detectProjectType };

// Only run the build when executed directly (`node build.mjs`), not when imported.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
17 changes: 11 additions & 6 deletions dev.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Local dev server for the prototypes hub
* Local dev server for Runflow Templates
*
* Runs the build, then serves /dist on localhost:3000
* with live reload on file changes.
* Runs the build, then serves .vercel/output/ (with a dist/ fallback) on
* localhost:3000, dispatching node-functions like the Vercel runtime does.
*/

import { createServer } from "node:http";
Expand Down Expand Up @@ -32,9 +32,14 @@ if (DEV_LOAD_DOTENV) {
}
// Apply the fallback whether or not .env existed — clean checkouts shouldn't
// 401 on local cron probes just because they haven't created a .env yet.
// Never apply it in CI/production: those must fail closed on a real secret.
if (!process.env.CRON_SECRET) {
process.env.CRON_SECRET = "local-dev-cron-secret";
console.log("[dev] CRON_SECRET not set — using local fallback 'local-dev-cron-secret'");
if (process.env.CI || process.env.VERCEL || process.env.NODE_ENV === "production") {
console.warn("[dev] CRON_SECRET not set — refusing the dev fallback in CI/production.");
} else {
process.env.CRON_SECRET = "local-dev-cron-secret";
console.log("[dev] CRON_SECRET not set — using local fallback 'local-dev-cron-secret' (dev only)");
}
}
}

Expand Down Expand Up @@ -106,5 +111,5 @@ const server = createServer(async (req, res) => {

server.listen(PORT, () => {
console.log(`Dev server running at http://localhost:${PORT}`);
console.log(`Serving from: ${DIST}\n`);
console.log(`Serving ${join(VERCEL_OUTPUT, "static")} (fallback: ${DIST})\n`);
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"description": "Runflow templates and demos — build many small projects and deploy them together",
"scripts": {
"build": "node build.mjs",
"dev": "node dev.mjs"
"dev": "node dev.mjs",
"test": "node --test"
},
"devDependencies": {
"esbuild": "^0.28.0"
Expand Down
87 changes: 87 additions & 0 deletions test/build.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildVercelConfig, escapeHtml, safeHref, isValidProjectName } from "../build.mjs";
import { collectCronEntries } from "../build-node-functions.mjs";

test("escapeHtml escapes HTML-significant characters", () => {
assert.equal(escapeHtml(`<script>"&'`), "&lt;script&gt;&quot;&amp;&#39;");
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");
});

test("safeHref allows internal paths and http(s), blocks scheme injection", () => {
assert.equal(safeHref("/example-html/"), "/example-html/");
assert.equal(safeHref("https://example.com"), "https://example.com");
assert.equal(safeHref("http://example.com/x"), "http://example.com/x");
assert.equal(safeHref("javascript:alert(1)"), "#");
assert.equal(safeHref("data:text/html,x"), "#");
assert.equal(safeHref("ftp://example.com"), "#");
});

test("isValidProjectName accepts slugs, rejects unsafe names", () => {
assert.ok(isValidProjectName("example-html"));
assert.ok(isValidProjectName("a1"));
assert.ok(!isValidProjectName("Bad_Name")); // uppercase + underscore
assert.ok(!isValidProjectName("../evil")); // traversal
assert.ok(!isValidProjectName("a;rm -rf /")); // shell metachars
assert.ok(!isValidProjectName("-leading")); // must start alnum
assert.ok(!isValidProjectName(""));
});

test("buildVercelConfig: static-only hub is just a filesystem handler", () => {
const cfg = buildVercelConfig([{ name: "a", config: { type: "static" } }], []);
assert.equal(cfg.version, 3);
assert.equal(cfg.cleanUrls, true);
assert.equal(cfg.trailingSlash, true);
assert.deepEqual(cfg.routes, [{ handle: "filesystem" }]);
assert.equal(cfg.crons, undefined);
});

test("buildVercelConfig: node-functions [id] entry emits a subId rewrite before filesystem", () => {
const cfg = buildVercelConfig(
[{ name: "api", config: { type: "node-functions", functions: { entries: ["api/reports/[id].mjs"] } } }],
[]
);
assert.deepEqual(cfg.routes[0], {
src: "^/api/api/reports/([^/]+)/?$",
dest: "/api/api/reports?subId=$1",
});
assert.deepEqual(cfg.routes.at(-1), { handle: "filesystem" });
});

test("buildVercelConfig: catchAll entry emits a subPath rewrite", () => {
const cfg = buildVercelConfig(
[{ name: "p", config: { type: "node-functions", functions: { entries: ["api/proxy.mjs"], perEntry: { "api/proxy.mjs": { catchAll: true } } } } }],
[]
);
assert.ok(cfg.routes.some((r) => r.src === "^/p/api/proxy/(.+?)/?$" && r.dest === "/p/api/proxy?subPath=$1"));
});

test("buildVercelConfig: rejects multi-segment dynamic entries (fail fast)", () => {
assert.throws(
() => buildVercelConfig([{ name: "p", config: { type: "node-functions", functions: { entries: ["api/users/[uid]/posts/[pid].mjs"] } } }], []),
/multiple dynamic/
);
});

test("buildVercelConfig: nuxt-server gets api + SPA fallback routes around the filesystem handler", () => {
const cfg = buildVercelConfig([{ name: "app", config: { type: "nuxt-server" } }], []);
assert.deepEqual(cfg.routes[0], { src: "/app/api/(.*)", dest: "/app/__fallback" });
const fsIdx = cfg.routes.findIndex((r) => r.handle === "filesystem");
const spaIdx = cfg.routes.findIndex((r) => r.src === "/app(?:/((?!api/).*))?");
assert.ok(fsIdx >= 0 && spaIdx > fsIdx, "SPA fallback must come after the filesystem handler");
});

test("buildVercelConfig: crons are attached only when present", () => {
const withCron = buildVercelConfig([{ name: "a", config: { type: "static" } }], [{ path: "/a/api/cron/", schedule: "*/5 * * * *" }]);
assert.equal(withCron.crons.length, 1);
});

test("collectCronEntries prefixes project name and forces a trailing slash", () => {
const crons = collectCronEntries("myproj", { crons: [{ path: "/api/cron/run", schedule: "*/5 * * * *" }] });
assert.deepEqual(crons, [{ path: "/myproj/api/cron/run/", schedule: "*/5 * * * *" }]);
});

test("collectCronEntries throws when a cron is missing path or schedule", () => {
assert.throws(() => collectCronEntries("p", { crons: [{ path: "/x" }] }), /schedule/);
});