blob: b5f2d063bd0c6b4a8fb2edb2ae872bbf579a560a [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.
*/
/**
* Encodes a self-contained TypeScript Dag bundle that remains directly
* executable by Node.
*
* Final byte order:
*
* airflowBundle header
* -> airflowMetadata
* -> airflowSource region, one block per source file, in the order they
* appear in `entrypointSources`
* -> executable JavaScript
*
* The header records each region's byte range and digest, and each source
* region also carries the path it was compiled from. Metadata describes what
* the bundle serves, source regions carry the author's original files, and
* the executable JavaScript runs the task handlers.
*
* This module owns the on-disk encoding. Readers must use the header's named
* byte ranges and paths rather than incidental line positions.
*/
import { createHash } from "node:crypto";
import type { BundleManifest } from "../coordinator/manifest.js";
const AIRFLOW_BUNDLE_METADATA_VERSION = "1.0";
const EMBEDDED_METADATA_MAX_BYTES = 1024 * 1024;
const EMBEDDED_SOURCE_MAX_BYTES = 1024 * 1024;
const OFFSET_HEX_WIDTH = 16;
export const EMBEDDED_METADATA_PREFIX = "//# airflowMetadata=";
export const EMBEDDED_LAYOUT_PREFIX = "//# airflowBundle=";
/** Opens each source block, followed by the region's path and a newline. */
export const EMBEDDED_SOURCE_MARKER = "/*# airflowSource:";
export const EMBEDDED_SOURCE_CLOSE = "\n#*/\n";
export interface BundleEncoderInput {
bundleManifest: BundleManifest;
sdkVersion: string;
/** Project-relative path of the entry file `airflow-ts-pack` bundled. Always present among
* `sourceFiles`, so a reader has a source to fall back to for a Dag with no attributed file
* (dynamically constructed, or a mixed-lang Dag owned by Python). */
entrypointPath: string;
/** Author-owned source file per native Dag, keyed by its path in
* `BundleManifest.dag_source_paths`, plus the entrypoint. */
sourceFiles: Record<string, string>;
executable: Uint8Array;
}
interface BundleMetadata {
airflow_bundle_metadata_version: string;
sdk: { language: string; version: string; supervisor_schema_version: string };
entrypoint_path: string;
dag_source_paths: BundleManifest["dag_source_paths"];
task_handlers: BundleManifest["task_handlers"];
}
interface VerifiedByteRange {
end: string;
sha256: string;
start: string;
}
interface VerifiedSourceRegion extends VerifiedByteRange {
path: string;
}
interface BundleHeader {
code: VerifiedByteRange;
metadata: VerifiedByteRange;
sources: VerifiedSourceRegion[];
}
/** Per-source-region metadata within the concatenated sources buffer. */
interface SourceRegion {
path: string;
payloadStart: number;
payloadEnd: number;
sha256: string;
}
interface EncodedSources {
buffer: Buffer;
regions: SourceRegion[];
}
export function encodeBundle(input: BundleEncoderInput): Buffer {
// The reader falls back to the entrypoint's source, so it must be one of the embedded regions.
if (!Object.hasOwn(input.sourceFiles, input.entrypointPath)) {
throw new Error(
`entrypoint ${JSON.stringify(input.entrypointPath)} must be among the packed source files`,
);
}
const metadata = encodeMetadata(input);
const sources = encodeSources(input.sourceFiles);
const executable = encodeExecutable(input.executable);
const header = encodeHeader({ metadata, sources, executable });
return Buffer.concat([header, metadata, sources.buffer, executable]);
}
function encodeHeader(regions: {
metadata: Buffer;
sources: EncodedSources;
executable: Buffer;
}): Buffer {
// Each digest covers the payload only. The framing markers and newlines are re-derived.
const metadataPayload = regions.metadata.subarray(
Buffer.byteLength(EMBEDDED_METADATA_PREFIX),
-1,
);
const metadataDigest = computeSha256(metadataPayload);
const codeDigest = computeSha256(regions.executable);
const zeroOffset = "0".repeat(OFFSET_HEX_WIDTH);
// Placeholder header with zeroed offsets and real digests, to measure its
// length without recursing. Every source path is present, so the array
// length is what it will be in the final header.
const placeholderHeader = renderHeader({
code: { start: zeroOffset, end: zeroOffset, sha256: codeDigest },
metadata: { start: zeroOffset, end: zeroOffset, sha256: metadataDigest },
sources: regions.sources.regions.map((region) => ({
path: region.path,
start: zeroOffset,
end: zeroOffset,
sha256: region.sha256,
})),
});
const metadataStart = placeholderHeader.length + Buffer.byteLength(EMBEDDED_METADATA_PREFIX);
const metadataEnd = metadataStart + metadataPayload.length;
const sourcesBaseOffset = placeholderHeader.length + regions.metadata.length;
const codeStart = sourcesBaseOffset + regions.sources.buffer.length;
const codeEnd = codeStart + regions.executable.length;
const header = renderHeader({
code: {
start: formatOffset(codeStart),
end: formatOffset(codeEnd),
sha256: codeDigest,
},
metadata: {
start: formatOffset(metadataStart),
end: formatOffset(metadataEnd),
sha256: metadataDigest,
},
sources: regions.sources.regions.map((region) => ({
path: region.path,
start: formatOffset(sourcesBaseOffset + region.payloadStart),
end: formatOffset(sourcesBaseOffset + region.payloadEnd),
sha256: region.sha256,
})),
});
if (header.length !== placeholderHeader.length) {
throw new Error("Bundle header changed length while resolving section offsets");
}
return header;
}
/**
* Wrap each source in its own block comment, one per author-owned Dag file.
*
* A comment terminator would splice the rest of the payload into executable position, so it is
* escaped. `*\\` is escaped too, which keeps the transformation reversible.
*
* The returned regions are keyed by path and hold byte offsets within the
* concatenated sources buffer (not the final bundle); the header adds the
* bundle-relative base offset when it renders.
*/
function encodeSources(sources: Record<string, string>): EncodedSources {
const chunks: Buffer[] = [];
const regions: SourceRegion[] = [];
let offset = 0;
for (const [path, content] of Object.entries(sources)) {
// A `*/` in the path would close the marker's own comment early, so the
// packer refuses rather than trying to escape it — no real filesystem path
// holds one, and rejecting keeps the marker line trivially readable.
if (path.includes("*/") || path.includes("\n")) {
throw new Error(
`Source path ${JSON.stringify(path)} contains a block-comment terminator or newline; ` +
`airflow-ts-pack cannot embed it`,
);
}
const openBytes = Buffer.from(`${EMBEDDED_SOURCE_MARKER}${path}\n`, "utf-8");
const payloadBytes = Buffer.from(escapeBlockComment(content), "utf-8");
const closeBytes = Buffer.from(EMBEDDED_SOURCE_CLOSE, "ascii");
// Per-file cap: one large source cannot drown the others, and the total
// is only bounded by however many files a bundle declares.
if (payloadBytes.length > EMBEDDED_SOURCE_MAX_BYTES) {
throw new Error(
`Embedded source ${JSON.stringify(path)} is ${payloadBytes.length} bytes, ` +
`over the ${EMBEDDED_SOURCE_MAX_BYTES} byte limit; move code out of that file into ` +
`imported modules`,
);
}
chunks.push(openBytes, payloadBytes, closeBytes);
const payloadStart = offset + openBytes.length;
const payloadEnd = payloadStart + payloadBytes.length;
regions.push({
path,
payloadStart,
payloadEnd,
sha256: computeSha256(payloadBytes),
});
offset = payloadEnd + closeBytes.length;
}
return { buffer: Buffer.concat(chunks), regions };
}
function escapeBlockComment(source: string): string {
return source.replaceAll(/\*([\\/])/g, "*\\$1");
}
function encodeMetadata(input: BundleEncoderInput): Buffer {
const payload = Buffer.from(
JSON.stringify(buildBundleMetadata(input))
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029"),
"utf-8",
);
const metadata = Buffer.concat([
Buffer.from(EMBEDDED_METADATA_PREFIX, "ascii"),
payload,
Buffer.from("\n", "ascii"),
]);
if (metadata.length > EMBEDDED_METADATA_MAX_BYTES) {
throw new Error(
`Embedded airflow metadata is ${metadata.length} bytes, ` +
`over the ${EMBEDDED_METADATA_MAX_BYTES} byte limit; reduce the number of registered tasks`,
);
}
return metadata;
}
function encodeExecutable(executable: Uint8Array): Buffer {
const bytes = Buffer.from(executable);
if (bytes[0] !== 0x23 || bytes[1] !== 0x21) return bytes;
const newline = bytes.indexOf(0x0a);
return newline === -1 ? Buffer.alloc(0) : bytes.subarray(newline + 1);
}
function buildBundleMetadata(input: BundleEncoderInput): BundleMetadata {
return {
airflow_bundle_metadata_version: AIRFLOW_BUNDLE_METADATA_VERSION,
sdk: {
language: "typescript",
version: input.sdkVersion,
supervisor_schema_version: input.bundleManifest.supervisor_schema_version,
},
entrypoint_path: input.entrypointPath,
dag_source_paths: input.bundleManifest.dag_source_paths,
task_handlers: input.bundleManifest.task_handlers,
};
}
function renderHeader(header: BundleHeader): Buffer {
// utf-8 rather than ascii because source paths (`sources[i].path`) may hold
// non-ASCII characters — a Latin-1 filename otherwise loses bytes here and
// the metadata-to-region mapping stops round-tripping.
const payload = JSON.stringify(header);
return Buffer.from(`${EMBEDDED_LAYOUT_PREFIX}${payload}\n`, "utf-8");
}
function formatOffset(offset: number): string {
const value = offset.toString(16);
if (value.length > OFFSET_HEX_WIDTH) {
throw new Error(`Bundle offset ${offset} exceeds the 16-digit hexadecimal layout limit`);
}
return value.padStart(OFFSET_HEX_WIDTH, "0");
}
function computeSha256(contents: Uint8Array): string {
return createHash("sha256").update(contents).digest("hex");
}