import * as fs from "node:fs"; import * as path from "node:path"; import { rolldown } from "rolldown"; import type { AppManifest } from "../manifest.js"; import { APPS_WORKSPACE_RESOURCE_ID, type PageIntent } from "../notion-as-code/intents.js"; import { createProvisioningArtifact, type RecordedProvisioning, } from "../notion-as-code/provisioning.js"; import { clearRegisteredPacers } from "../pacer.js"; import { parseAppPage } from "./app-page.js"; import { generateEntry, generateMetadataEntry, generateBlocksEntry } from "./codegen.js"; import { discoverCapabilities, type DiscoveredCapability } from "./discover.js"; import { extractManifest, extractProvisioning, extractBlockConfigs } from "./emit-manifest.js"; import { validateCustomBlockReferences } from "./validate-custom-block-references.js"; import { packageSecretDeclarations } from "./secrets.js"; const APP_MD_RESOURCE_ID = "__notion_apps_app_md__"; export type BuildResult = { manifest: AppManifest; bundlePath: string; manifestPath: string; }; /** * Build an app project: discover capability files by convention, generate * the Worker and build-only metadata entries, bundle the Worker, and emit * the Worker manifest plus any recorded Notion-as-Code provisioning artifact. */ export async function buildApp(projectRoot: string): Promise { let packageJson: unknown; try { packageJson = JSON.parse( await fs.promises.readFile(path.join(projectRoot, "package.json"), "utf8"), ); } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; } const secrets = packageSecretDeclarations(packageJson); const capabilities = await discoverCapabilities(projectRoot); if (capabilities.length === 0) { throw new Error( `No capabilities found in ${projectRoot}. ` + `Add a workflow under src/workflows/ or a sync under src/syncs/.`, ); } const entryPath = path.join(projectRoot, ".notion", "entry.ts"); await fs.promises.mkdir(path.dirname(entryPath), { recursive: true }); await fs.promises.writeFile(entryPath, generateEntry(capabilities)); const bundlePath = path.join(projectRoot, "dist", "worker.js"); // Bundle only the app's own code. Bare imports (npm packages) stay // external and resolve from node_modules at runtime, so packages behave // normally — including native addons, which cannot be inlined. const bundle = await rolldown({ input: entryPath, platform: "node", external: (id) => !id.startsWith(".") && !path.isAbsolute(id), }); try { await bundle.write({ format: "esm", file: bundlePath }); } finally { await bundle.close(); } const blockConfigs = await buildBlockDeclarations(projectRoot, capabilities); const entityProvisioning = await buildMetadata(projectRoot, capabilities); const manifest = await extractManifest( bundlePath, capabilities, blockConfigs, entityProvisioning, secrets, ); const manifestPath = path.join(projectRoot, "dist", "manifest.json"); await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`); await emitProvisioningArtifact(projectRoot, entityProvisioning, capabilities); return { manifest, bundlePath, manifestPath }; } /** Emit the Notion-as-Code artifact recorded by the build-only metadata bundle. */ async function emitProvisioningArtifact( projectRoot: string, entityProvisioning: RecordedProvisioning, capabilities: readonly DiscoveredCapability[], ): Promise { const provisioningPath = path.join(projectRoot, "dist", "provisioning.json"); const appPage = await readAppPage(projectRoot); if (entityProvisioning.intents.length === 0 && !appPage) { await fs.promises.rm(provisioningPath, { force: true }); return; } const artifact = createProvisioningArtifact([ ...entityProvisioning.intents, ...(appPage ? [appPage] : []), ]); validateCustomBlockReferences( artifact.intents, new Set( capabilities .filter((capability) => capability.type === "custom_block") .map((capability) => capability.key), ), ); await fs.promises.writeFile(provisioningPath, `${JSON.stringify(artifact, null, "\t")}\n`); } async function readAppPage( projectRoot: string, ): Promise<({ type: "page" } & PageIntent) | undefined> { let content: string; try { content = await fs.promises.readFile(path.join(projectRoot, "APP.md"), "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return undefined; } throw error; } return { type: "page", resourceId: APP_MD_RESOURCE_ID, parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, properties: { title: [["APP.md"]] }, ...parseAppPage(content), }; } /** Bundle capabilities separately to record their Notion-as-Code declarations without shipping them. */ async function buildMetadata( projectRoot: string, capabilities: readonly DiscoveredCapability[], ): Promise { const metadataEntryPath = path.join(projectRoot, ".notion", "metadata-entry.ts"); const metadataBundlePath = path.join(projectRoot, ".notion", "metadata.js"); await fs.promises.writeFile(metadataEntryPath, generateMetadataEntry(capabilities)); // Metadata evaluation imports capability modules; clear their process-global pacer registrations first. clearRegisteredPacers(); try { const metadataBundle = await rolldown({ input: metadataEntryPath, platform: "node", // Keep SDK packages external so the metadata bundle resolves the app's installed SDK. external: (id) => !id.startsWith(".") && !path.isAbsolute(id), }); try { await metadataBundle.write({ format: "esm", file: metadataBundlePath, codeSplitting: false, }); } finally { await metadataBundle.close(); } return await extractProvisioning(metadataBundlePath); } finally { clearRegisteredPacers(); await fs.promises.rm(metadataBundlePath, { force: true }); } } async function buildBlockDeclarations( projectRoot: string, capabilities: readonly DiscoveredCapability[], ): Promise> { if (!capabilities.some((capability) => capability.runtime === "build")) return {}; const entryPath = path.join(projectRoot, ".notion", "blocks-entry.ts"); const bundlePath = path.join(projectRoot, ".notion", "blocks.js"); await fs.promises.writeFile(entryPath, generateBlocksEntry(capabilities)); try { const bundle = await rolldown({ input: entryPath, platform: "node", external: (id) => !id.startsWith(".") && !path.isAbsolute(id), }); try { await bundle.write({ format: "esm", file: bundlePath, codeSplitting: false }); } finally { await bundle.close(); } return await extractBlockConfigs(bundlePath); } catch (error) { throw new Error( `Failed to read custom block declarations (${capabilities .filter((c) => c.runtime === "build") .map((c) => c.sourcePath) .join(", ")}): ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } finally { await fs.promises.rm(bundlePath, { force: true }); } }