blob: f1806909e0f1854ffa7ecf487dbe547ffc61fd19 [file]
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// airflow-ts-pack: bundle a TypeScript entrypoint into the single artifact NodeCoordinator consumes.
// `bundle.min.mjs` carries the metadata, the entrypoint source, and an integrity layout descriptor
// in JavaScript comments.
//
// Build first, then run the built bundle with --airflow-metadata so the manifest comes from the
// bundle's own Dag registry and schema version, never from a hand-written sidecar.
import { execFileSync } from "node:child_process";
import { readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { readFile as readFileAsync } from "node:fs/promises";
import path from "node:path";
import {
AIRFLOW_METADATA_FLAG,
AIRFLOW_METADATA_SENTINEL,
type BundleManifest,
} from "../coordinator/manifest.js";
import { MODULE_SOURCE_SLOT_KEY } from "../sdk/module-source.js";
import { encodeBundle } from "./bundle-encoder.js";
import { warnOnSuspiciousIds } from "./validate.js";
// NodeCoordinator discovers bundles by this suffix, so keep the two in step.
const BUNDLE_FILENAME = "bundle.min.mjs";
// Write the bundle only after the build and manifest checks succeed, so a failed pack
// cannot leave a partial artifact.
const STAGING_FILENAME = "bundle.pack-staging.mjs";
const MANIFEST_TIMEOUT_MS = 60_000;
const MANIFEST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
const USAGE = `Usage: airflow-ts-pack <entry> [--outdir <dir> | --outfile <path>]
Bundles <entry> into a minified ${BUNDLE_FILENAME} with esbuild and embeds the
airflow metadata generated from the bundle's served Dags, plus each Dag-defining
source file verbatim so Airflow has readable text to display per Dag.
Options:
--outdir <dir> Output directory, holding ${BUNDLE_FILENAME} (default: dist)
--outfile <path> Exact output path; its name must end in .min.mjs
`;
/** A bundle written without this suffix is invisible to NodeCoordinator. */
const REQUIRED_OUTFILE_SUFFIX = ".min.mjs";
export interface PackArgs {
entry: string;
outfile: string;
}
function usageError(message: string): Error {
return new Error(`${message}\n\n${USAGE}`);
}
export function parsePackArgs(argv: readonly string[]): PackArgs {
let entry: string | null = null;
let outdir: string | null = null;
let outfile: string | null = null;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--outdir" || arg === "--outfile") {
const value = argv[i + 1];
if (!value) throw usageError(`${arg} requires a value`);
if (arg === "--outdir") outdir = value;
else outfile = value;
i += 1;
} else if (arg.startsWith("-")) {
throw usageError(`Unknown option ${arg}`);
} else if (entry) {
throw usageError(`Unexpected argument ${arg}`);
} else {
entry = arg;
}
}
if (!entry) throw usageError("Missing entry file");
// Silently preferring one would write the bundle somewhere the caller did not ask for.
if (outdir !== null && outfile !== null) {
throw usageError("--outdir and --outfile are mutually exclusive");
}
if (outfile !== null && !path.basename(outfile).endsWith(REQUIRED_OUTFILE_SUFFIX)) {
throw usageError(
`--outfile name must end in ${REQUIRED_OUTFILE_SUFFIX}; NodeCoordinator finds bundles by that suffix`,
);
}
return {
entry,
outfile: outfile ?? path.join(outdir ?? "dist", BUNDLE_FILENAME),
};
}
function readSdkVersion(): string {
const packageJsonUrl = new URL("../../package.json", import.meta.url);
const { version } = JSON.parse(readFileSync(packageJsonUrl, "utf-8")) as { version: string };
return version;
}
function readBundleManifest(bundlePath: string): BundleManifest {
let stdout: string;
try {
stdout = execFileSync(process.execPath, [bundlePath, AIRFLOW_METADATA_FLAG], {
encoding: "utf-8",
timeout: MANIFEST_TIMEOUT_MS,
maxBuffer: MANIFEST_MAX_BUFFER_BYTES,
});
} catch (error) {
const stderr = (error as { stderr?: string }).stderr ?? "";
const reported = stderr
.split("\n")
.reverse()
.find((line) => /^\w*Error: /.test(line.trim()))
?.trim();
throw new Error(
reported ?? `Running the bundle with ${AIRFLOW_METADATA_FLAG} failed: ${String(error)}`,
{ cause: error },
);
}
// Import-time logging from user code lands on stdout too; pick the sentinel line.
const line = stdout
.split("\n")
.reverse()
.find((candidate) => candidate.startsWith(AIRFLOW_METADATA_SENTINEL));
if (line === undefined) {
throw new Error(`Bundle produced no ${AIRFLOW_METADATA_FLAG} output`);
}
let parsed: unknown;
try {
parsed = JSON.parse(line.slice(AIRFLOW_METADATA_SENTINEL.length));
} catch (error) {
throw new Error(`Bundle produced invalid ${AIRFLOW_METADATA_FLAG} output: ${String(error)}`, {
cause: error,
});
}
if (!isBundleManifest(parsed)) {
throw new Error(`Bundle produced incomplete ${AIRFLOW_METADATA_FLAG} output`);
}
const manifest = parsed;
// The line is whatever the bundle printed and nothing downstream re-validates
// it, so check each Dag entry down to the task-id element.
for (const [dagId, dag] of Object.entries(manifest.task_handlers)) {
if (dag == null || !isTaskIdList(dag.tasks)) {
throw new Error(
`Bundle produced ${AIRFLOW_METADATA_FLAG} output with a malformed entry for Dag "${dagId}"`,
);
}
}
return manifest;
}
// The document is checked before anything is read off it: JSON.parse also yields
// null and primitives, and `null.supervisor_schema_version` would surface as a
// raw TypeError rather than a report about the bundle.
function isBundleManifest(value: unknown): value is BundleManifest {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const {
supervisor_schema_version: version,
task_handlers: taskHandlers,
dag_source_paths: dagSourcePaths,
} = value as Partial<BundleManifest>;
return (
// Rendered into the manifest verbatim, where the schema requires a non-empty
// string, so a truthy number or boolean would travel to Airflow as-is.
typeof version === "string" &&
version.length > 0 &&
typeof taskHandlers === "object" &&
taskHandlers !== null &&
// An array would pass the typeof check and yield Dags named "0", "1", ...
!Array.isArray(taskHandlers) &&
isSourcePathMap(dagSourcePaths)
);
}
function isSourcePathMap(value: unknown): value is Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
for (const entry of Object.values(value)) {
if (typeof entry !== "string" || entry.length === 0) return false;
}
return true;
}
function isTaskIdList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
}
async function loadEsbuild(): Promise<typeof import("esbuild")> {
try {
return await import("esbuild");
} catch (error) {
throw new Error(
"airflow-ts-pack needs esbuild; install it alongside the SDK (e.g. `npm i -D esbuild`)",
{ cause: error },
);
}
}
/** Author source files esbuild's onLoad tags with their path. Same filter set the previous
* task-id plugin used, which matched every language a Dag can be declared in. */
const AUTHOR_SOURCE_FILTER = /\.[cm]?[jt]sx?$/;
/** Skip anything under node_modules: an installed dependency is not a Dag file, and tagging it
* would only cost bundle bytes while overwriting the slot with the wrong path. */
const DEPENDENCY_PATH = /[\\/]node_modules[\\/]/;
/**
* esbuild plugin: prepend a single line to each author-owned source file that
* writes the file's path into the SDK's module-source slot. `Dag`'s
* constructor reads that slot, so a Dag declared in `src/dags/reports.ts`
* carries that path even though esbuild will soon inline every module into
* one bundle.
*
* The prepend is text-only — no parsing, no AST — so it can never mis-identify
* a construction site. ES modules hoist imports above non-import statements
* at execution, so the tag runs *after* imported modules have written their
* own slots and *before* this module's own top-level statements, which is
* exactly when its `new Dag(...)` calls fire.
*/
function moduleSourceTagPlugin(cwd: string): import("esbuild").Plugin {
const slotKey = JSON.stringify(MODULE_SOURCE_SLOT_KEY);
return {
name: "airflow-module-source-tag",
setup(build) {
build.onLoad({ filter: AUTHOR_SOURCE_FILTER }, async ({ path: file, namespace }) => {
// Only the `file` namespace has a path on disk to attribute a Dag to;
// a virtual module from another plugin has nothing to tag.
if (namespace !== "file" || DEPENDENCY_PATH.test(file)) return undefined;
const source = await readFileAsync(file, "utf-8");
// Project-relative so the bundle is portable and readable (no host
// filesystem prefix), matching how esbuild's own metafile keys sources.
const relative = path.relative(cwd, file);
// Save the previous slot value, set ours, restore at the end of the
// module. Nested imports (this module imports another that also gets
// tagged) then leave the slot back on this module's path after the
// import returns, so a `new Dag(...)` after the import still sees this
// file — not whichever was imported last.
const slot = `globalThis[Symbol.for(${slotKey})]`;
const localVar = `__airflow_prev_source_${randomLocalSuffix()}`;
const openTag = `const ${localVar}=${slot};${slot}=${JSON.stringify(relative)};\n`;
const closeTag = `\n;${slot}=${localVar};`;
return {
contents: withOpenAndCloseTags(source, openTag, closeTag),
loader: loaderFor(file),
};
});
},
};
}
/** Distinguish nested tags in the bundled output — a redeclared `const`
* would be a fatal SyntaxError when esbuild inlines several modules. */
function randomLocalSuffix(): string {
return Math.random().toString(36).slice(2, 10);
}
/** A shebang has to be the file's first bytes; the open tag goes after it. */
function withOpenAndCloseTags(source: string, openTag: string, closeTag: string): string {
if (!source.startsWith("#!")) return `${openTag}${source}${closeTag}`;
const newline = source.indexOf("\n");
if (newline === -1) return `${source}\n${openTag}${closeTag}`;
return `${source.slice(0, newline + 1)}${openTag}${source.slice(newline + 1)}${closeTag}`;
}
function loaderFor(file: string): "ts" | "tsx" | "js" | "jsx" {
if (/\.[cm]?tsx$/.test(file)) return "tsx";
if (/\.[cm]?ts$/.test(file)) return "ts";
if (/\.[cm]?jsx$/.test(file)) return "jsx";
return "js";
}
/** Reads every unique source path into a map the encoder embeds: one file per native Dag, plus the
* entrypoint so a reader always has a fallback source. Files imported by these but declaring no Dag
* stay out — the Code tab shows what defines each Dag, not what it depends on. */
function readSourceFiles(relativePaths: Iterable<string>, cwd: string): Record<string, string> {
const sources: Record<string, string> = {};
for (const relative of new Set(relativePaths)) {
sources[relative] = readFileSync(path.resolve(cwd, relative), "utf-8");
}
return sources;
}
export async function runPack(argv: readonly string[]): Promise<void> {
const args = parsePackArgs(argv);
const bundlePath = args.outfile;
const stagingPath = path.join(path.dirname(bundlePath), STAGING_FILENAME);
const { build } = await loadEsbuild();
const cwd = process.cwd();
try {
await build({
entryPoints: [args.entry],
bundle: true,
platform: "node",
format: "esm",
target: "node22",
// A digest is only worth taking over an artifact nobody reads or edits in place.
minify: true,
// A task id comes from the handler's name, and bundling renames a symbol
// that two modules both declare, so the name the SDK reads is pinned to
// the one the author wrote.
keepNames: true,
// Tags each author-owned file with its own path before its `new Dag(...)`
// constructor runs, so the encoder gets one Dag-defining file per source
// region.
plugins: [moduleSourceTagPlugin(cwd)],
// The manifest is read by running the staged bundle, so the metadata describes what ships.
outfile: stagingPath,
});
const manifest = readBundleManifest(stagingPath);
const dagEntries = Object.entries(manifest.task_handlers);
if (dagEntries.length === 0) {
throw new Error(
`${args.entry} served nothing; register Dags or task handlers with bundle.register(...)`,
);
}
// Warn rather than fail, as airflow-go-pack does: the shared schema allows a
// Dag with no tasks.
for (const [dagId, dag] of dagEntries) {
if (dag.tasks.length === 0) {
process.stderr.write(`warning: dag ${JSON.stringify(dagId)} has no tasks\n`);
}
}
warnOnSuspiciousIds(manifest.task_handlers);
// Project-relative, matching how the module-source tag keys `dag_source_paths`: esbuild resolves
// the entry through its real path, so realpath here too or a symlinked entry (e.g. a macOS
// tmpdir) would not coincide with its own Dag region and would duplicate it.
const entrypointPath = path.relative(cwd, realpathSync(path.resolve(cwd, args.entry)));
const bundle = encodeBundle({
bundleManifest: manifest,
sdkVersion: readSdkVersion(),
entrypointPath,
// One source file per native Dag, plus the entrypoint: a reader falls back to it for a
// native Dag the packer could not attribute to a file (one built dynamically in a callback).
sourceFiles: readSourceFiles(
[entrypointPath, ...Object.values(manifest.dag_source_paths)],
cwd,
),
executable: readFileSync(stagingPath),
});
writeFileSync(bundlePath, bundle);
} finally {
rmSync(stagingPath, { force: true });
}
console.log(`Wrote ${bundlePath} (airflow metadata, source, and integrity embedded)`);
}