Skip to content
Merged
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
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,23 @@ jobs:
# files before their tests are registered.
- run: bun run test:ci

startup:
name: startup (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun test app/tests/build-cache.test.ts

python-harness:
name: python harness regressions
runs-on: ubuntu-latest
Expand Down Expand Up @@ -492,7 +509,7 @@ jobs:
name: verify
runs-on: ubuntu-latest
if: always()
needs: [static, deployables, chart, test, python-harness, build, migrations, image, component-dockerfiles]
needs: [static, deployables, chart, test, startup, python-harness, build, migrations, image, component-dockerfiles]
steps:
- name: Require every check
env:
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"build": "bun --bun node_modules/vite/bin/vite.js build",
"dev": "bun --bun node_modules/vite/bin/vite.js",
"serve": "bun run build && bun serve.ts",
"serve": "bun scripts/serve-or-build.ts && bun serve.ts",
"prebuild": "bun run --cwd .. generate:app-config",
"predev": "bun run --cwd .. generate:app-config",
"pretypecheck": "bun run --cwd .. generate:app-config",
Expand Down
265 changes: 265 additions & 0 deletions app/scripts/build-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, join, relative, resolve, sep } from "node:path";

const MANIFEST_VERSION = 1;
const MANIFEST_NAME = ".openbot-build-cache.json";

export type BuildCachePaths = {
rootDir: string;
appDir: string;
};

type BuildCacheEnv = Record<string, string | undefined>;

type BuildCacheManifest = {
version: number;
key: string;
};

type InputFile = {
path: string;
absolutePath: string;
};

const sourceExtensions = new Set([
".css",
".html",
".js",
".jsx",
".json",
".mjs",
".cjs",
".ts",
".tsx",
".yaml",
".yml",
]);

export function buildCacheManifestPath({ appDir }: BuildCachePaths): string {
return join(appDir, "dist", MANIFEST_NAME);
}

function slashPath(path: string): string {
return path.split(sep).join("/");
}

function hasSourceExtension(path: string): boolean {
const name = basename(path);
if (name === "bun.lock") return true;
const dot = name.lastIndexOf(".");
return dot >= 0 && sourceExtensions.has(name.slice(dot));
}

async function existingFile(path: string): Promise<boolean> {
try {
const info = await stat(path);
return info.isFile();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}

async function readJson(path: string): Promise<unknown> {
return JSON.parse(await readFile(path, "utf8"));
}

function versionFromPackageJson(json: unknown): string {
if (
json &&
typeof json === "object" &&
"version" in json &&
typeof json.version === "string"
) {
return json.version;
}
return "";
}

async function collectFiles(
base: string,
options: {
prefix: string;
include: (path: string, info: Stats) => boolean;
skipDirectory?: (path: string) => boolean;
},
): Promise<InputFile[]> {
const files: InputFile[] = [];

async function visit(directory: string) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
if (!options.skipDirectory?.(absolutePath)) await visit(absolutePath);
continue;
}
if (!entry.isFile()) continue;
const info = await stat(absolutePath);
if (!options.include(absolutePath, info)) continue;
files.push({
path: `${options.prefix}/${slashPath(relative(base, absolutePath))}`,
absolutePath,
});
}
}

try {
await visit(base);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
return files;
}

function tenantPackageDir(rootDir: string, env: BuildCacheEnv): string {
const configured = env.TENANT_PACKAGE_DIR;
if (!configured) return join(rootDir, "examples", "fintech");
return resolve(rootDir, "server", configured);
}

function viteEnvironment(env: BuildCacheEnv): Record<string, string> {
const values: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (
value !== undefined &&
(key === "BUN_ENV" ||
key === "MODE" ||
key === "NODE_ENV" ||
key === "TENANT_PACKAGE_DIR" ||
key.startsWith("VITE_"))
) {
values[key] = value;
}
}
return values;
}

async function collectBuildInputs(
{ rootDir, appDir }: BuildCachePaths,
env: BuildCacheEnv,
): Promise<InputFile[]> {
const resolvedRoot = resolve(rootDir);
const resolvedApp = resolve(appDir);
const tenantDir = tenantPackageDir(resolvedRoot, env);

const explicit = [
join(resolvedRoot, "bun.lock"),
join(resolvedRoot, "package.json"),
join(resolvedApp, "package.json"),
join(resolvedApp, "vite.config.ts"),
join(resolvedApp, "index.html"),
];
const explicitFiles = (
await Promise.all(
explicit.map(async (absolutePath) =>
(await existingFile(absolutePath))
? {
path: slashPath(relative(resolvedRoot, absolutePath)),
absolutePath,
}
: null,
),
)
).filter((file): file is InputFile => file !== null);

const sourceFiles = await collectFiles(join(resolvedApp, "src"), {
prefix: "app/src",
include: (path) => hasSourceExtension(path),
skipDirectory: (path) => basename(path) === "node_modules",
});
const tenantFiles = await collectFiles(tenantDir, {
prefix: `tenant/${slashPath(relative(resolvedRoot, tenantDir))}`,
include: (path) => hasSourceExtension(path),
skipDirectory: (path) => basename(path) === "node_modules",
});

return [...explicitFiles, ...sourceFiles, ...tenantFiles].sort(
(left, right) => left.path.localeCompare(right.path),
);
}

export async function buildCacheKey(
paths: BuildCachePaths,
env: BuildCacheEnv,
): Promise<string> {
const hash = createHash("sha256");
const rootPackage = await readJson(join(paths.rootDir, "package.json")).catch(
() => null,
);
const appPackage = await readJson(join(paths.appDir, "package.json")).catch(
() => null,
);

hash.update(
JSON.stringify({
manifestVersion: MANIFEST_VERSION,
rootVersion: versionFromPackageJson(rootPackage),
appVersion: versionFromPackageJson(appPackage),
env: viteEnvironment(env),
}),
);

for (const input of await collectBuildInputs(paths, env)) {
hash.update("\0");
hash.update(input.path);
hash.update("\0");
hash.update(await readFile(input.absolutePath));
}

return hash.digest("hex");
}

export async function readBuildCacheManifest(
paths: BuildCachePaths,
): Promise<BuildCacheManifest | null> {
try {
const raw = JSON.parse(
await readFile(buildCacheManifestPath(paths), "utf8"),
);
if (
raw &&
typeof raw === "object" &&
raw.version === MANIFEST_VERSION &&
typeof raw.key === "string"
) {
return raw;
}
return null;
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === "ENOENT" ||
error instanceof SyntaxError
) {
return null;
}
throw error;
}
}

export async function writeBuildCacheManifest(
paths: BuildCachePaths,
env: BuildCacheEnv,
): Promise<void> {
await writeFile(
buildCacheManifestPath(paths),
`${JSON.stringify({
version: MANIFEST_VERSION,
key: await buildCacheKey(paths, env),
})}\n`,
);
}

export async function isReusableBuild(
paths: BuildCachePaths,
env: BuildCacheEnv,
): Promise<boolean> {
if (!(await existingFile(join(paths.appDir, "dist", "index.html")))) {
return false;
}
const manifest = await readBuildCacheManifest(paths);
return manifest?.key === (await buildCacheKey(paths, env));
}
70 changes: 70 additions & 0 deletions app/scripts/serve-or-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
isReusableBuild,
writeBuildCacheManifest,
type BuildCachePaths,
} from "./build-cache";

type RunCommand = (command: string[], cwd: string) => Promise<void>;

const scriptDir = dirname(fileURLToPath(import.meta.url));
const defaultAppDir = resolve(scriptDir, "..");
const defaultRootDir = resolve(defaultAppDir, "..");

function pathsFromEnvironment(): BuildCachePaths {
return {
rootDir: process.env.OPENBOT_BUILD_CACHE_ROOT_DIR
? resolve(process.env.OPENBOT_BUILD_CACHE_ROOT_DIR)
: defaultRootDir,
appDir: process.env.OPENBOT_BUILD_CACHE_APP_DIR
? resolve(process.env.OPENBOT_BUILD_CACHE_APP_DIR)
: defaultAppDir,
};
}

async function run(command: string[], cwd: string) {
const child = Bun.spawn({
cmd: command,
cwd,
env: process.env,
stdout: "inherit",
stderr: "inherit",
});
const exitCode = await child.exited;
if (exitCode !== 0) {
throw new Error(`${command.join(" ")} exited with ${exitCode}`);
}
}

export async function prepareProductionBuild(
paths: BuildCachePaths,
runCommand: RunCommand = run,
): Promise<"reused" | "rebuilt"> {
await runCommand(
[process.execPath, "run", "--cwd", paths.rootDir, "generate:app-config"],
paths.rootDir,
);

if (await isReusableBuild(paths, process.env)) {
console.log("Reusing app/dist from build cache");
return "reused";
}

console.log("Building app/dist because the build cache is stale or missing");
await runCommand(
[
process.execPath,
"--bun",
join("node_modules", "vite", "bin", "vite.js"),
"build",
],
paths.appDir,
);
await writeBuildCacheManifest(paths, process.env);
return "rebuilt";
}

if (import.meta.main) {
await prepareProductionBuild(pathsFromEnvironment());
}
Loading