This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new f3faf90ff69 TS SDK: minify packed bundles and find them by metadata,
not filename (#73126)
f3faf90ff69 is described below
commit f3faf90ff69a92054474de2bc70a4875a7ee36cd
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Fri Sep 18 20:55:51 2026 +0800
TS SDK: minify packed bundles and find them by metadata, not filename
(#73126)
NodeCoordinator looked for exactly `bundle.mjs` in each configured root, so
a
`bundles_root` could hold only one bundle and its name was fixed. That
defeats
the Dag-to-bundle routing the embedded metadata already supports, and it is
out
of step with JavaCoordinator and ExecutableCoordinator, which walk their
roots
and select on metadata. Each root is now searched recursively for
`*.min.mjs`
bundles, so one directory can hold several and each Dag is routed to
whichever
bundle declares it. Roots are searched in configured order and each
directory
in sorted path order, so which bundle wins no longer depends on the order a
filesystem returns entries in. `airflow-ts-pack` gains `--outfile` for
naming
the artifact.
esbuild output is also minified now. An integrity digest over the code
region
is only worth taking when the artifact is not something anyone is expected
to
read or edit in place, which is what `.min` records. Dependencies' license
banners are preserved. Identifier names are not: nothing identifies a Dag, a
task, or a handler by a function name, so there is nothing for minification
to
break, and ADR-0002 already settled that a bundle-wide `keepNames` is not
how
a name reaches the registration that needs it.
The bundle container format is unchanged; minification only changes which
bytes
land inside the already-digested code region.
---
.../language-sdks/typescript.rst | 42 +++++---
.../tests/airflow_e2e_tests/conftest.py | 3 +-
task-sdk/docs/ts-bundle-spec.rst | 116 +++++++++++----------
.../airflow/sdk/coordinators/_bundle_metadata.py | 58 ++++++++++-
.../airflow/sdk/coordinators/node/coordinator.py | 22 ++--
.../coordinators/node/_bundle_test_utils.py | 5 +-
.../coordinators/node/test_bundle_reader.py | 67 ++++++------
.../task_sdk/coordinators/node/test_coordinator.py | 82 ++++++++++++---
ts-sdk/README.md | 25 +++--
ts-sdk/example/README.md | 4 +-
ts-sdk/src/cli/pack.ts | 59 +++++++----
ts-sdk/tests/cli/pack.test.ts | 81 +++++++++++---
12 files changed, 386 insertions(+), 178 deletions(-)
diff --git
a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
index 7aaf6408ac8..bf7c78bc6a7 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -49,8 +49,8 @@ Prerequisites
-------------
* Node.js 22 or later must be available on the Airflow worker nodes.
-* The packed bundle (a single ``bundle.mjs`` file, see
:ref:`typescript-sdk/build`) must be accessible from
- the worker, under a directory the coordinator scans.
+* The packed bundle (a single ``bundle.min.mjs`` file, see
:ref:`typescript-sdk/build`) must be accessible
+ from the worker, under a directory the coordinator scans.
* The ``apache-airflow-task-sdk`` package (installed with Airflow) provides
the coordinator; no additional
Python packages are needed.
* In the TypeScript project, install the ``apache-airflow-ts-sdk`` npm package
to author task handlers:
@@ -163,8 +163,8 @@ task instance.
.. note::
- The coordinator runs inside the Airflow worker, so the ``[sdk]`` config (and
the packed ``bundle.mjs``
- files in ``bundles_root``) only need to be present wherever tasks actually
execute. With
+ The coordinator runs inside the Airflow worker, so the ``[sdk]`` config (and
the packed ``*.min.mjs``
+ bundles in ``bundles_root``) only need to be present wherever tasks actually
execute. With
``CeleryExecutor``, setting them on the Celery workers is sufficient. With
``LocalExecutor``, tasks run
inside the scheduler process, so they must be present where the scheduler
can read them. The API server
and Dag processor do not need them.
@@ -271,10 +271,15 @@ Building and packaging
----------------------
``airflow-ts-pack`` (shipped with the SDK) bundles the entry module and all of
its imports with esbuild into
-a single self-contained ESM file, ``bundle.mjs``, and embeds the manifest (the
``dag_id`` and ``task_id``
-map plus the supervisor schema version) after a leading compact JSON ``//#
airflowBundle=...`` layout header.
-The layout records the byte ranges and SHA-256 digests of the manifest and
executable code, so one file to
-deploy, with no separate manifest or ``node_modules``.
+a single self-contained, minified ESM file, ``bundle.min.mjs``, and embeds the
manifest (the ``dag_id`` and
+``task_id`` map plus the supervisor schema version) after a leading compact
JSON ``//# airflowBundle=...``
+layout header. The layout records the byte ranges and SHA-256 digests of the
manifest and executable code,
+so there is one file to deploy, with no separate manifest or ``node_modules``.
+
+The code is minified because an integrity digest is only worth taking over an
artifact nobody is expected to
+read or edit in place. The ``/*! */`` license banners of bundled dependencies
are kept. Nothing is identified by
+a function name, so minified names are safe: a Dag and a task are named by the
string ids their registration
+states, and a handler is dispatched by reference.
``esbuild`` is an optional peer dependency: packing is build-time only, so the
runtime install of
``apache-airflow-ts-sdk`` skips it, and it must be installed separately before
running ``airflow-ts-pack``.
@@ -284,16 +289,21 @@ deploy, with no separate manifest or ``node_modules``.
npm install --save-dev esbuild
npx airflow-ts-pack src/main.ts --outdir dist
-Use ``--outdir <dir>`` to choose the output directory (default ``dist``) and
``--source <name>`` to set the
-source name displayed in the Airflow UI (default: the entry file's basename).
+Use ``--outdir <dir>`` to choose the output directory (default ``dist``),
``--outfile <path>`` to name the
+artifact exactly, which helps when one ``bundles_root`` holds several bundles,
and ``--source <name>`` to set
+the source name displayed in the Airflow UI (default: the entry file's
basename). ``--outdir`` and
+``--outfile`` are mutually exclusive, and an ``--outfile`` name must end in
``.min.mjs`` so the coordinator
+can find it.
Deploying
~~~~~~~~~
-Copy or mount ``bundle.mjs`` into a directory listed in the coordinator's
``bundles_root``.
-:class:`~airflow.sdk.coordinators.node.NodeCoordinator` searches the
configured directories in order and
-launches the first integrity-verified bundle whose metadata declares the task
instance's Dag. If multiple
-bundles declare the same Dag, the first configured match wins.
+Copy or mount the bundle into a directory listed in the coordinator's
``bundles_root``.
+:class:`~airflow.sdk.coordinators.node.NodeCoordinator` searches the
configured directories in order,
+recursively, and launches the first integrity-verified ``*.min.mjs`` bundle
whose metadata declares the task
+instance's Dag. The artifact's name does not matter beyond that suffix, so one
root can hold several bundles
+and a Dag is routed to whichever declares it. If multiple bundles declare the
same Dag, the first configured
+root wins, and within a root the first in sorted path order.
.. _typescript-sdk/coordinator-config:
@@ -312,8 +322,8 @@ All ``kwargs`` in the ``coordinators`` config entry are
passed to the
- Description
* - ``bundles_root``
- *(required)*
- - One or more directories searched, in order, for an integrity-verified
``bundle.mjs`` that declares
- the requested Dag. Accepts a string, a path, or a list of strings/paths.
+ - One or more directories searched recursively, in order, for an
integrity-verified ``*.min.mjs``
+ bundle that declares the requested Dag. Accepts a string, a path, or a
list of strings/paths.
* - ``node_executable``
- ``"node"``
- Path to the ``node`` binary. Defaults to ``node`` on ``$PATH``.
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 961f367b907..39ff3fd0a64 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -722,7 +722,8 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
# version from the metadata airflow-ts-pack embedded in the bundle.
ts_bundles_dir = tmp_dir / "ts-bundles"
ts_bundles_dir.mkdir()
- copyfile(TS_SDK_EXAMPLE_PATH / "dist" / "bundle.mjs", ts_bundles_dir /
"bundle.mjs")
+ # Deliberately renamed: the coordinator routes on embedded metadata, not
on a fixed name.
+ copyfile(TS_SDK_EXAMPLE_PATH / "dist" / "bundle.min.mjs", ts_bundles_dir /
"example.min.mjs")
# Both of the example bundle's Dags: one bundle.mjs provides for two
dag_ids,
# and the tests check that dispatch tells their same-named tasks apart.
diff --git a/task-sdk/docs/ts-bundle-spec.rst b/task-sdk/docs/ts-bundle-spec.rst
index 9aa46545132..82147f95e5c 100644
--- a/task-sdk/docs/ts-bundle-spec.rst
+++ b/task-sdk/docs/ts-bundle-spec.rst
@@ -18,18 +18,27 @@
TypeScript Bundle Format
========================
-This document specifies the ``bundle.mjs`` format produced by
``airflow-ts-pack`` and consumed by
:class:`~airflow.sdk.coordinators.node.NodeCoordinator`.
+This document specifies the bundle format produced by ``airflow-ts-pack`` and
consumed by
+:class:`~airflow.sdk.coordinators.node.NodeCoordinator`.
+
+Artifact Name
+-------------
+
+A bundle's name must end in ``.min.mjs``. Nothing else about it is
significant: the coordinator searches each
+configured root recursively and routes on embedded metadata, so one root may
hold several differently named bundles.
+``airflow-ts-pack`` writes ``bundle.min.mjs`` by default and accepts
``--outfile`` for any other name ending in
+that suffix.
Container
---------
-The bundle remains an ECMAScript module that runs directly with ``node
bundle.mjs``. It has three regions:
+The bundle remains an ECMAScript module that runs directly with ``node
bundle.min.mjs``. It has three regions:
.. code-block:: text
//# airflowBundle=<compact JSON layout>\n
//# airflowMetadata=<compact JSON>\n
- <bundled ECMAScript code>
+ <minified, bundled ECMAScript code>
The layout comes first so readers can locate and verify the other regions. The
current format has no embedded source region.
@@ -54,32 +63,25 @@ The ``airflowBundle`` payload is a compact UTF-8 JSON
object:
}
}
-Offsets are bytes from the beginning of ``bundle.mjs``. They use exactly 16
-lowercase hexadecimal digits and describe half-open ranges: ``start`` is
-included and ``end`` is excluded.
-
-The layout stays on one line and contains only controlled ASCII field names,
-fixed-width hexadecimal offsets, and SHA-256 digests. It therefore needs no
-additional encoding layer.
-
-The first-line layout is a stable bootstrap descriptor, not a separately
-versioned payload. The ``airflow_bundle_metadata_version`` stored in the
-metadata region versions the entire TypeScript bundle contract, including this
-physical framing and the decoded metadata schema. A reader parses the bounded,
-named ranges before it can locate and verify that version.
-
-The metadata range points to the UTF-8 JSON payload only, excluding the
-JavaScript comment marker and newline. Its digest therefore covers the exact
-JSON bytes stored in that range. The code range covers every byte after the
-metadata line through the end of the file. Its digest covers those raw
-JavaScript bytes.
-
-The file begins with the layout line. The metadata marker immediately follows
-that line, and exactly one newline separates the metadata payload from the code
-range. These prescribed framing bytes are outside the hashed metadata and code
-ranges; no additional bytes are permitted before, between, or after them.
-Post-pack formatters, compressors, source-map injectors, and other tools that
-rewrite ``bundle.mjs`` invalidate the offsets or digests.
+Offsets are bytes from the beginning of the bundle file. They use exactly 16
lowercase hexadecimal digits and
+describe half-open ranges: ``start`` is included and ``end`` is excluded.
+
+The layout stays on one line and contains only controlled ASCII field names,
fixed-width hexadecimal offsets, and
+SHA-256 digests. It therefore needs no additional encoding layer.
+
+The first-line layout is a stable bootstrap descriptor, not a separately
versioned payload. The
+``airflow_bundle_metadata_version`` stored in the metadata region versions the
entire TypeScript bundle contract,
+including this physical framing and the decoded metadata schema. A reader
parses the bounded, named ranges before it
+can locate and verify that version.
+
+The metadata range points to the UTF-8 JSON payload only, excluding the
JavaScript comment marker and newline. Its
+digest therefore covers the exact JSON bytes stored in that range. The code
range covers every byte after the
+metadata line through the end of the file, and its digest covers those raw
JavaScript bytes.
+
+The file begins with the layout line. The metadata marker immediately follows
that line, and exactly one newline
+separates the metadata payload from the code range. These prescribed framing
bytes are outside the hashed metadata
+and code ranges, and no additional bytes are permitted before, between, or
after them. Post-pack formatters,
+compressors, source-map injectors, and other tools that rewrite the bundle
invalidate the offsets or digests.
Metadata
--------
@@ -103,43 +105,43 @@ The ``airflowMetadata`` payload is compact UTF-8 JSON
with this logical shape:
}
}
-The packer serializes this object without insignificant whitespace and escapes
-the ECMAScript line and paragraph separators (U+2028 and U+2029), keeping it in
-one newline-terminated JavaScript comment without a second encoding layer. The
-SHA-256 digest detects changes to the exact serialized bytes.
+The packer serializes this object without insignificant whitespace and escapes
the ECMAScript line and paragraph
+separators (U+2028 and U+2029), keeping it in one newline-terminated
JavaScript comment without a second encoding
+layer. The SHA-256 digest detects changes to the exact serialized bytes.
-The coordinator uses the ``dags`` keys to choose a bundle for a task instance.
-The ``source`` value is a logical authoring name only; it is not embedded
source
-content and is not used to execute the bundle.
+The coordinator uses the ``dags`` keys to choose a bundle for a task instance.
The ``source`` value is a logical
+authoring name only, not embedded source content, and it is not used to
execute the bundle.
Reader and Selection Algorithm
------------------------------
-For each directory in ``bundles_root``, in configured order, the coordinator:
+For each candidate in ``bundles_root``, the coordinator:
-1. Looks for ``bundle.mjs`` and opens it once.
+1. Opens it once. Candidates are the files whose name ends in ``.min.mjs``,
found by walking each root recursively,
+ roots in configured order and each directory's entries in sorted order, so
selection does not depend on the order
+ a filesystem returns entries in. Directories are deduplicated by ``(st_dev,
st_ino)``, so a symlink loop
+ terminates the walk instead of exhausting the interpreter stack.
2. Reads a bounded first line and decodes the named metadata and code ranges.
-3. Reads the bounded metadata line and checks that the declared metadata and
- code ranges exactly match their physical locations and the file size.
+3. Reads the bounded metadata line and checks that the declared metadata and
code ranges exactly match their
+ physical locations and the file size.
4. Computes SHA-256 for both ranges before parsing or using metadata.
-5. Confirms with ``fstat`` that the open file did not change during
- verification.
-6. Parses metadata and requires a supported TypeScript bundle contract major
- version from ``airflow_bundle_metadata_version``.
-7. Skips the verified bundle if its ``dags`` mapping does not contain the
- requested ``dag_id``.
+5. Confirms with ``fstat`` that the open file did not change during
verification.
+6. Parses metadata and requires a supported bundle contract major version from
+ ``airflow_bundle_metadata_version``.
+7. Skips the verified bundle if its ``dags`` mapping does not contain the
requested ``dag_id``.
8. Resolves the supervisor schema version and selects the first usable match.
-A missing, unrelated, unreadable, malformed, corrupt, or incompatible earlier
-candidate does not prevent selection of a later usable match. When more than
one
-usable bundle declares the same Dag, the first configured match wins. If none
-matches, the error identifies the requested Dag, searched roots, and rejected
-candidates.
+A missing, unrelated, unreadable, malformed, corrupt, or incompatible earlier
candidate does not prevent selection
+of a later usable match. When more than one usable bundle declares the same
Dag, the first configured match wins. If
+none matches, the error identifies the requested Dag, searched roots, and
rejected candidates.
+
+Every ``.min.mjs`` file under a root is therefore opened, and one that is not
a usable bundle is named among those
+rejected candidates. ``bundles_root`` names directories of deployed Airflow
bundles, so unrelated minified modules do
+not belong there.
-The coordinator does not cache Dag-to-path routing. It checks root ordering and
-the current deployed files for each task selection. It may reuse section
digests
-from a bounded process-local cache when the open file identity, timestamps,
-size, layout ranges, and declared digests have not changed.
+The coordinator does not cache Dag-to-path routing. It checks root ordering
and the current deployed files for each
+task selection. It may reuse section digests from a bounded process-local
cache when the open file identity,
+timestamps, size, layout ranges, and declared digests have not changed.
Integrity, Authenticity, and Provenance
---------------------------------------
@@ -174,5 +176,5 @@ even when they cannot reach the metadata version. A future
container that
cannot preserve the readable first-line descriptor must use a new marker rather
than reinterpret the current one.
-The TypeScript packing workflow was unreleased when this format was added. The
-coordinator therefore does not accept the earlier metadata-first prototype.
+The TypeScript packing workflow was unreleased when this format was added. The
coordinator therefore does not accept
+the earlier metadata-first prototype.
diff --git a/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
b/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
index e95ffe52396..9a0ee5e5b32 100644
--- a/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
+++ b/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
@@ -21,13 +21,22 @@ from __future__ import annotations
import os
import pathlib
-from typing import Any
+import stat
+from typing import TYPE_CHECKING, Any
import attrs
+import structlog
import yaml
from airflow.sdk.execution_time.schema import get_schema_version_migrator
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable, Iterator
+
+ from structlog.typing import FilteringBoundLogger
+
+log: FilteringBoundLogger = structlog.get_logger(logger_name="coordinators")
+
def convert_roots(
value: None | os.PathLike[str] | pathlib.Path | list[os.PathLike[str] |
pathlib.Path],
@@ -40,6 +49,53 @@ def convert_roots(
return [pathlib.Path(v).expanduser() for v in value]
+def walk_files(
+ roots: Iterable[pathlib.Path], *, match: Callable[[pathlib.Path], bool]
+) -> Iterator[pathlib.Path]:
+ """
+ Yield the regular files under *roots* that satisfy *match*, descending
into directories.
+
+ Roots are visited in order and each directory's entries sorted, so
coordinator selection does
+ not depend on filesystem ordering.
+
+ ``JavaCoordinator`` and ``ExecutableCoordinator`` still carry equivalent
walks and should move
+ onto this one.
+ """
+ yield from _walk_files(roots, match, set())
+
+
+def _walk_files(
+ items: Iterable[pathlib.Path],
+ match: Callable[[pathlib.Path], bool],
+ seen_dirs: set[tuple[int, int]],
+) -> Iterator[pathlib.Path]:
+ for item in items:
+ try:
+ file_info = item.stat()
+ except OSError:
+ # A broken symlink or unreadable parent must not abort the scan.
+ # The caller reports a genuinely missing artifact once every root
is searched.
+ continue
+ if stat.S_ISDIR(file_info.st_mode):
+ # Dedupe by identity so a symlink loop cannot recurse until the
stack is exhausted.
+ key = (file_info.st_dev, file_info.st_ino)
+ if key in seen_dirs:
+ log.debug("Skipping already-visited directory", path=item)
+ continue
+ seen_dirs.add(key)
+ yield from _walk_files(_sorted_children(item), match, seen_dirs)
+ elif stat.S_ISREG(file_info.st_mode) and match(item):
+ yield item
+
+
+def _sorted_children(directory: pathlib.Path) -> list[pathlib.Path]:
+ # iterdir() is lazy, so an unreadable directory raises only once iteration
starts.
+ try:
+ return sorted(directory.iterdir())
+ except OSError:
+ return []
+
+
def validate_schema_version(instance, _, value) -> str:
"""Attrs validator resolving a bundle's supervisor schema version to a
known one."""
return get_schema_version_migrator().resolve_version(str(value))
diff --git a/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
b/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
index 98de32d5761..cd352f3871b 100644
--- a/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
+++ b/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
@@ -26,7 +26,7 @@ from typing import TYPE_CHECKING
import attrs
import structlog
-from airflow.sdk.coordinators._bundle_metadata import ResolvedBundle,
convert_roots
+from airflow.sdk.coordinators._bundle_metadata import ResolvedBundle,
convert_roots, walk_files
from airflow.sdk.coordinators._subprocess import SubprocessCoordinator
from airflow.sdk.coordinators.node._bundle_reader import read_bundle
@@ -40,7 +40,11 @@ if TYPE_CHECKING:
log: FilteringBoundLogger =
structlog.get_logger(logger_name="coordinators.node")
-BUNDLE_FILENAME = "bundle.mjs"
+BUNDLE_SUFFIX = ".min.mjs"
+
+
+def _is_bundle(path: pathlib.Path) -> bool:
+ return path.name.endswith(BUNDLE_SUFFIX)
@attrs.define
@@ -48,18 +52,15 @@ class _Bundle(ResolvedBundle):
@classmethod
def find(cls, bundles_root: Sequence[pathlib.Path], dag_id: str) -> Self:
"""Return the first verified configured bundle that declares
*dag_id*."""
+ log.debug("Finding TypeScript bundles recursively",
roots=bundles_root, dag_id=dag_id)
rejected: list[tuple[pathlib.Path, str]] = []
- for root in bundles_root:
- candidate = root / BUNDLE_FILENAME
+ for candidate in walk_files(bundles_root, match=_is_bundle):
try:
- if not candidate.is_file():
- continue
metadata = read_bundle(candidate)
if dag_id not in metadata.dag_ids:
log.debug(
"TypeScript bundle does not contain requested Dag;
skipping",
path=candidate,
- root=root,
dag_id=dag_id,
)
rejected.append(
@@ -71,13 +72,12 @@ class _Bundle(ResolvedBundle):
log.debug(
"TypeScript bundle rejected; skipping",
path=candidate,
- root=root,
reason=str(exc),
exc_info=True,
)
rejected.append((candidate, str(exc)))
continue
- log.debug("Selected TypeScript bundle", path=candidate, root=root,
dag_id=dag_id)
+ log.debug("Selected TypeScript bundle", path=candidate,
dag_id=dag_id)
return bundle
searched = os.pathsep.join(os.fspath(root) for root in bundles_root)
@@ -110,8 +110,8 @@ class NodeCoordinator(SubprocessCoordinator):
:param node_executable: Path to the ``node`` binary (defaults to
``"node"``, which relies on ``$PATH``).
- :param bundles_root: Ordered list of directories scanned for the first
- verified ``bundle.mjs`` that declares the task instance's Dag.
+ :param bundles_root: Directories searched recursively, in order, for the
first verified
+ ``*.min.mjs`` bundle declaring the task instance's Dag.
:param task_startup_timeout: Maximum time the coordinator waits for a task
process to start, in seconds. The default is 10 seconds.
"""
diff --git a/task-sdk/tests/task_sdk/coordinators/node/_bundle_test_utils.py
b/task-sdk/tests/task_sdk/coordinators/node/_bundle_test_utils.py
index 303c95b77b7..0ec0a13cf7b 100644
--- a/task-sdk/tests/task_sdk/coordinators/node/_bundle_test_utils.py
+++ b/task-sdk/tests/task_sdk/coordinators/node/_bundle_test_utils.py
@@ -23,6 +23,7 @@ import json
import pathlib
SCHEMA_VERSION = "2026-06-16"
+BUNDLE_NAME = "bundle.min.mjs"
LAYOUT_PREFIX = b"//# airflowBundle="
METADATA_PREFIX = b"//# airflowMetadata="
OFFSET_WIDTH = 16
@@ -67,6 +68,7 @@ def write_bundle(
schema_version: str = SCHEMA_VERSION,
metadata_version: str | None = "1.0",
metadata_payload: bytes | None = None,
+ name: str = BUNDLE_NAME,
) -> pathlib.Path:
if metadata_payload is None:
metadata_payload = metadata_json(
@@ -92,7 +94,8 @@ def write_bundle(
)
assert len(layout_line) == len(placeholder)
- bundle = root / "bundle.mjs"
+ bundle = root / name
+ bundle.parent.mkdir(parents=True, exist_ok=True)
bundle.write_bytes(layout_line + metadata_line + code)
return bundle
diff --git a/task-sdk/tests/task_sdk/coordinators/node/test_bundle_reader.py
b/task-sdk/tests/task_sdk/coordinators/node/test_bundle_reader.py
index 1ff5808cf6f..3866028b658 100644
--- a/task-sdk/tests/task_sdk/coordinators/node/test_bundle_reader.py
+++ b/task-sdk/tests/task_sdk/coordinators/node/test_bundle_reader.py
@@ -26,6 +26,7 @@ from unittest import mock
import pytest
from task_sdk.coordinators.node._bundle_test_utils import (
+ BUNDLE_NAME,
LAYOUT_PREFIX,
METADATA_PREFIX,
OFFSET_WIDTH,
@@ -61,10 +62,10 @@ class TestBundleReader:
def test_rejects_metadata_first_legacy_bundle(self, tmp_path):
payload = _metadata_json("sales")
- (tmp_path / "bundle.mjs").write_bytes(METADATA_PREFIX + payload +
b"\nexport {};\n")
+ (tmp_path / BUNDLE_NAME).write_bytes(METADATA_PREFIX + payload +
b"\nexport {};\n")
with pytest.raises(ValueError, match="no airflow bundle layout"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize(
"metadata_version",
@@ -74,7 +75,7 @@ class TestBundleReader:
write_bundle(tmp_path, "sales", metadata_version=metadata_version)
with pytest.raises(ValueError, match="unsupported airflow bundle
metadata version"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_accepts_newer_minor_version_with_unknown_optional_fields(self,
tmp_path):
payload = json.loads(_metadata_json("sales", metadata_version="1.7.3"))
@@ -109,7 +110,7 @@ class TestBundleReader:
_replace_layout_payload(bundle, payload)
with pytest.raises(ValueError, match=message):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize("encoding", ["utf-16", "utf-32"])
def test_rejects_layout_that_is_not_utf8(self, tmp_path, encoding):
@@ -126,7 +127,7 @@ class TestBundleReader:
_replace_layout_payload(bundle, b"A" * _reader._MAX_LAYOUT_LINE_BYTES)
with pytest.raises(ValueError, match="bundle layout exceeds"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_unterminated_layout(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
@@ -134,7 +135,7 @@ class TestBundleReader:
bundle.write_bytes(layout_line)
with pytest.raises(ValueError, match="bundle layout is not
newline-terminated"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_requires_metadata_immediately_after_layout(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
@@ -142,7 +143,7 @@ class TestBundleReader:
bundle.write_bytes(contents)
with pytest.raises(ValueError, match="no embedded airflow metadata
after its layout"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_unterminated_metadata(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
@@ -150,7 +151,7 @@ class TestBundleReader:
bundle.write_bytes(layout_line + b"\n" + metadata_line)
with pytest.raises(ValueError, match="embedded airflow metadata is not
newline-terminated"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize(
"metadata_payload",
@@ -175,7 +176,7 @@ class TestBundleReader:
_replace_layout_payload(bundle, json.dumps(layout).encode())
with pytest.raises(ValueError, match=f"missing the {section} section"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_invalid_section_digest(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
@@ -184,7 +185,7 @@ class TestBundleReader:
_rewrite_layout(bundle, layout)
with pytest.raises(ValueError, match="64 lowercase hexadecimal
digits"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_malformed_offset(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
@@ -193,7 +194,7 @@ class TestBundleReader:
_rewrite_layout(bundle, layout)
with pytest.raises(ValueError, match="16-digit lowercase hexadecimal"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize(("start", "end"), [(1, 1), (2, 1)])
def test_rejects_empty_or_reversed_section(self, tmp_path, start, end):
@@ -204,7 +205,7 @@ class TestBundleReader:
_rewrite_layout(bundle, layout)
with pytest.raises(ValueError, match="code section must contain at
least one byte"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_metadata_offset_mismatch(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
@@ -214,7 +215,7 @@ class TestBundleReader:
_rewrite_layout(bundle, layout)
with pytest.raises(ValueError, match="metadata offsets do not match"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize("section", [pytest.param("metadata",
id="metadata-before-decode"), "code"])
def test_rejects_section_digest_mismatch(self, tmp_path, section):
@@ -224,24 +225,24 @@ class TestBundleReader:
_mutate_byte(bundle, int(layout[section]["start"], 16)) # type:
ignore[index, call-overload]
with pytest.raises(ValueError, match=f"{section} SHA-256 mismatch"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_truncated_code(self, tmp_path):
bundle = write_bundle(tmp_path, "sales")
bundle.write_bytes(bundle.read_bytes()[:-1])
with pytest.raises(ValueError, match="code offsets do not match"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_reports_truncation_while_hashing(self, tmp_path):
with pytest.raises(ValueError, match="was truncated while hashing its
code region"):
- _hash_region(io.BytesIO(), start=0, end=1, path=tmp_path /
"bundle.mjs", section="code")
+ _hash_region(io.BytesIO(), start=0, end=1, path=tmp_path /
BUNDLE_NAME, section="code")
def test_rejects_invalid_metadata_json_after_verification(self, tmp_path):
write_bundle(tmp_path, "sales", metadata_payload=b"not-json")
with pytest.raises(ValueError, match="cannot parse embedded airflow
metadata"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize(
"metadata_payload",
@@ -255,13 +256,13 @@ class TestBundleReader:
write_bundle(tmp_path, "sales", metadata_payload=metadata_payload)
with pytest.raises(ValueError, match="cannot parse embedded airflow
metadata"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_metadata_that_is_not_a_mapping(self, tmp_path):
write_bundle(tmp_path, "sales", metadata_payload=b"[]")
with pytest.raises(ValueError, match="embedded airflow metadata must
contain a mapping"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize("dags", [None, []], ids=["missing",
"not-a-mapping"])
def test_rejects_missing_or_malformed_dags(self, tmp_path, dags):
@@ -273,7 +274,7 @@ class TestBundleReader:
write_bundle(tmp_path, metadata_payload=json.dumps(metadata).encode())
with pytest.raises(ValueError, match="metadata must contain a dags
mapping"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_oversized_metadata(self, tmp_path):
write_bundle(
@@ -283,7 +284,7 @@ class TestBundleReader:
)
with pytest.raises(ValueError, match="embedded airflow metadata
exceeds"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_change_during_verification(self, tmp_path, monkeypatch):
bundle = write_bundle(tmp_path, "sales")
@@ -299,7 +300,7 @@ class TestBundleReader:
monkeypatch.setattr(_reader, "_hash_region", hash_then_touch)
with pytest.raises(ValueError, match="changed while its integrity was
being verified"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_rejects_change_after_verification_before_metadata_decode(self,
tmp_path, monkeypatch):
bundle = write_bundle(tmp_path, "sales")
@@ -313,14 +314,14 @@ class TestBundleReader:
monkeypatch.setattr(_digest_cache, "put", put_then_touch)
with pytest.raises(ValueError, match="changed while it was being
read"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
@pytest.mark.parametrize(
("failure_call", "message"),
[
- (1, "cannot read bundle.mjs"),
- (2, "cannot stat bundle.mjs after verification"),
- (3, "cannot stat bundle.mjs after reading"),
+ (1, "cannot read bundle.min.mjs"),
+ (2, "cannot stat bundle.min.mjs after verification"),
+ (3, "cannot stat bundle.min.mjs after reading"),
],
)
def test_translates_fstat_errors(self, tmp_path, monkeypatch,
failure_call, message):
@@ -338,7 +339,7 @@ class TestBundleReader:
monkeypatch.setattr(_reader.os, "fstat", fail_selected_fstat)
with pytest.raises(OSError, match=message):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_translates_metadata_read_error(self, tmp_path, monkeypatch):
bundle = write_bundle(tmp_path, "sales")
@@ -351,16 +352,16 @@ class TestBundleReader:
monkeypatch.setattr(pathlib.Path, "open", path_open)
monkeypatch.setattr(_reader.os, "fstat", lambda _: bundle.stat())
- with pytest.raises(OSError, match="cannot read bundle.mjs"):
- read_bundle(tmp_path / "bundle.mjs")
+ with pytest.raises(OSError, match="cannot read bundle.min.mjs"):
+ read_bundle(tmp_path / BUNDLE_NAME)
@mock.patch.object(_reader, "_hash_region", autospec=True)
def test_reuses_cached_digests_for_unchanged_bundle(self, hash_region,
tmp_path):
write_bundle(tmp_path, "sales")
hash_region.side_effect = _hash_region
- read_bundle(tmp_path / "bundle.mjs")
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
+ read_bundle(tmp_path / BUNDLE_NAME)
assert hash_region.call_count == 2
@@ -383,7 +384,7 @@ class TestBundleReader:
fstat.return_value.st_ctime_ns += 1
with pytest.raises(ValueError, match="code SHA-256 mismatch"):
- read_bundle(tmp_path / "bundle.mjs")
+ read_bundle(tmp_path / BUNDLE_NAME)
def test_digest_cache_evicts_least_recently_used_entry(self):
cache = _reader._BundleDigestCache(maxsize=2)
@@ -392,7 +393,7 @@ class TestBundleReader:
def build_key(inode):
return _reader._DigestCacheKey(
- path="bundle.mjs",
+ path="bundle.min.mjs",
metadata=section,
code=section,
device=1,
diff --git a/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
b/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
index 304de1832b4..492c410c8aa 100644
--- a/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
+++ b/task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py
@@ -24,6 +24,7 @@ from unittest import mock
import pytest
from task_sdk.coordinators.node._bundle_test_utils import (
+ BUNDLE_NAME,
mutate_byte,
read_layout,
write_bundle,
@@ -98,24 +99,33 @@ class TestNodeCoordinatorExecuteTaskCommand:
class TestBundleFind:
- def test_ignores_roots_without_bundle_mjs(self, tmp_path):
- (tmp_path / "tasks.mjs").write_bytes(b"export {};\n")
+ @pytest.mark.parametrize(
+ "name",
+ ["tasks.mjs", "tasks.js", "tasks.min.js", "bundle.min.mjs.bak",
"min.mjs.txt"],
+ ids=["mjs", "js", "min-js", "suffixed", "embedded"],
+ )
+ def test_ignores_files_without_the_bundle_suffix(self, tmp_path, name):
+ # Written as a real bundle, so only the name can exclude it.
+ write_bundle(tmp_path, "sales", name=name)
- with pytest.raises(FileNotFoundError, match="dag_id='sales'"):
+ with pytest.raises(FileNotFoundError, match="dag_id='sales'") as
exc_info:
_Bundle.find([tmp_path], "sales")
+ # Never opened, so it cannot appear among the rejected candidates.
+ assert "rejected candidates" not in str(exc_info.value)
+
def test_reports_unreadable_bundle(self, tmp_path, monkeypatch):
write_bundle(tmp_path, "sales")
original_open = pathlib.Path.open
def raise_os_error(self, *args, **kwargs):
- if self.name == "bundle.mjs":
+ if self.name == BUNDLE_NAME:
raise PermissionError("denied")
return original_open(self, *args, **kwargs)
monkeypatch.setattr(pathlib.Path, "open", raise_os_error)
- with pytest.raises(FileNotFoundError, match="cannot read bundle.mjs"):
+ with pytest.raises(FileNotFoundError, match="cannot read
bundle.min.mjs"):
_Bundle.find([tmp_path], "sales")
def test_skips_root_when_bundle_probe_fails(self, tmp_path, monkeypatch):
@@ -123,21 +133,69 @@ class TestBundleFind:
second = tmp_path / "second"
first.mkdir()
second.mkdir()
- write_bundle(first, "sales")
+ unstattable = write_bundle(first, "sales")
expected = write_bundle(second, "sales")
- original_is_file = pathlib.Path.is_file
+ original_stat = pathlib.Path.stat
- def fail_first_probe(self):
- if self.parent == first:
+ def fail_first_probe(self, *args, **kwargs):
+ if self == unstattable:
raise PermissionError("denied")
- return original_is_file(self)
+ return original_stat(self, *args, **kwargs)
- monkeypatch.setattr(pathlib.Path, "is_file", fail_first_probe)
+ monkeypatch.setattr(pathlib.Path, "stat", fail_first_probe)
found = _Bundle.find([first, second], "sales")
assert found.path == expected
+ def test_finds_bundle_nested_below_a_root(self, tmp_path):
+ expected = write_bundle(tmp_path / "team" / "sales", "sales")
+
+ found = _Bundle.find([tmp_path], "sales")
+
+ assert found.path == expected
+
+ def test_selects_bundle_by_dag_id_within_one_root(self, tmp_path):
+ write_bundle(tmp_path, "inventory", name="inventory.min.mjs")
+ expected = write_bundle(tmp_path, "sales", name="sales.min.mjs")
+
+ found = _Bundle.find([tmp_path], "sales")
+
+ assert found.path == expected
+
+ def test_orders_candidates_in_one_root_by_path(self, tmp_path):
+ # Directory iteration order is filesystem-dependent, so sorted name
decides the winner.
+ expected = write_bundle(tmp_path, "sales", name="a.min.mjs")
+ write_bundle(tmp_path, "sales", name="b.min.mjs")
+ write_bundle(tmp_path / "nested", "sales")
+
+ found = _Bundle.find([tmp_path], "sales")
+
+ assert found.path == expected
+
+ def test_survives_a_directory_symlink_loop(self, tmp_path):
+ expected = write_bundle(tmp_path, "sales")
+ loop = tmp_path / "loop"
+ try:
+ loop.symlink_to(tmp_path, target_is_directory=True)
+ except (OSError, NotImplementedError):
+ pytest.skip("filesystem does not support directory symlinks")
+
+ found = _Bundle.find([tmp_path], "sales")
+
+ assert found.path == expected
+
+ def test_names_unrelated_min_mjs_file_among_rejected_candidates(self,
tmp_path):
+ stray = tmp_path / "vendor.min.mjs"
+ stray.write_bytes(b"export {};\n")
+
+ with pytest.raises(FileNotFoundError) as exc_info:
+ _Bundle.find([tmp_path], "sales")
+
+ message = str(exc_info.value)
+ assert str(stray) in message
+ assert "no airflow bundle layout" in message
+
def test_selects_later_bundle_containing_requested_dag(self, tmp_path):
first = tmp_path / "first"
second = tmp_path / "second"
@@ -236,7 +294,7 @@ class TestBundleFind:
second = tmp_path / "second"
first.mkdir()
second.mkdir()
- (first / "bundle.mjs").write_bytes(b"export {};\n")
+ (first / BUNDLE_NAME).write_bytes(b"export {};\n")
write_bundle(second, "inventory")
with pytest.raises(FileNotFoundError) as exc_info:
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 6e4c44079da..a4af960e622 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -98,9 +98,8 @@ coordinators = {
queue_to_coordinator = {"typescript": "ts"}
```
-Each configured bundle directory must contain a `bundle.mjs` built with
-`airflow-ts-pack` (see [Packing bundles](#packing-bundles)), which embeds the
-Airflow metadata in the bundle itself.
+Each configured bundle directory is searched recursively for `*.min.mjs`
bundles built with `airflow-ts-pack`
+(see [Packing bundles](#packing-bundles)), which embeds the Airflow metadata
in the bundle itself.
TypeScript entrypoint:
@@ -185,20 +184,20 @@ npm install --save-dev esbuild
airflow-ts-pack src/main.ts --outdir dist
```
-It bundles the entrypoint into `dist/bundle.mjs` with esbuild, runs the
-bundle with `--airflow-metadata` so the bundle reports its own registered
-Dag/task pairs and supervisor schema version, and embeds that manifest in the
-bundle as a compact JSON `//# airflowMetadata=...` comment after a leading
-compact JSON `//# airflowBundle=...` layout descriptor. The descriptor records
-fixed-width byte ranges and SHA-256 digests for the metadata and bundled code,
-allowing a coordinator reader to detect corruption before using either region.
-These in-bundle digests do not authenticate who produced the bundle because
-someone who can replace the content can also replace its digests. The result is
-one deployable file with no hand-written metadata sidecar.
+It bundles the entrypoint into a minified `dist/bundle.min.mjs` with esbuild,
then runs that bundle with
+`--airflow-metadata` so it reports its own registered Dag/task pairs and
supervisor schema version. The manifest is
+embedded as a compact JSON `//# airflowMetadata=...` comment after a leading
compact JSON `//# airflowBundle=...`
+layout descriptor. The CLI records the integrity metadata for both regions in
that descriptor, so a coordinator that
+is handed a bundle whose content was replaced fails loudly instead of running
it. The result is one deployable file
+with no hand-written metadata sidecar.
+
+Pass `--outfile <path>` instead of `--outdir` to name the artifact yourself,
so one bundle directory can hold several
+bundles. The name must still end in `.min.mjs`, which is how `NodeCoordinator`
finds bundles.
Options:
- `--outdir <dir>`: output directory (default `dist`)
+- `--outfile <path>`: exact output path, whose name must end in `.min.mjs`
- `--source <name>`: display name of the primary source file shown in the
Airflow UI (default: entry basename)
## TaskClient
diff --git a/ts-sdk/example/README.md b/ts-sdk/example/README.md
index e9a1e43967b..4af4af57471 100644
--- a/ts-sdk/example/README.md
+++ b/ts-sdk/example/README.md
@@ -25,7 +25,7 @@ This example shows the coordinator-mode shape for TypeScript
task handlers:
- `src/main.ts` and `src/taskflow.ts` register a `TaskHandler` per stub task
and start the coordinator runtime.
One bundle provides for both Dags, and both declare a task called
`build_message`.
A handler binds the `(dag_id, task_id)` pair, so the two are different tasks
with different bodies.
-- `dist/bundle.mjs` is the generated Node.js bundle that Airflow launches.
+- `dist/bundle.min.mjs` is the generated Node.js bundle that Airflow launches.
The build uses the SDK's `airflow-ts-pack` tool, which bundles the entrypoint
with esbuild and embeds the Airflow metadata generated from the bundle's
@@ -53,7 +53,7 @@ The coordinator expects this layout:
```text
ts-sdk/example/dist/
- bundle.mjs
+ bundle.min.mjs
```
## Airflow Configuration
diff --git a/ts-sdk/src/cli/pack.ts b/ts-sdk/src/cli/pack.ts
index 08a3aff008b..0403d8047bd 100644
--- a/ts-sdk/src/cli/pack.ts
+++ b/ts-sdk/src/cli/pack.ts
@@ -17,13 +17,11 @@
* under the License.
*/
-// airflow-ts-pack: bundle a TypeScript entrypoint into the single-file
-// artifact NodeCoordinator consumes: `bundle.mjs` with metadata and an
-// integrity layout descriptor embedded in JavaScript comments.
+// airflow-ts-pack: bundle a TypeScript entrypoint into the single artifact
NodeCoordinator consumes.
+// `bundle.min.mjs` carries the metadata 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.
+// 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, rmSync, writeFileSync } from "node:fs";
@@ -37,26 +35,31 @@ import {
import { encodeBundle } from "./bundle-encoder.js";
import { warnOnSuspiciousIds } from "./validate.js";
-const BUNDLE_FILENAME = "bundle.mjs";
-// Write bundle.mjs only after the build and manifest checks succeed, so a
-// failed pack cannot leave a partial final artifact.
+// 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>] [--source
<name>]
+const USAGE = `Usage: airflow-ts-pack <entry> [--outdir <dir> | --outfile
<path>] [--source <name>]
-Bundles <entry> into <outdir>/${BUNDLE_FILENAME} with esbuild and embeds the
+Bundles <entry> into a minified ${BUNDLE_FILENAME} with esbuild and embeds the
airflow metadata generated from the bundle's served Dags.
Options:
- --outdir <dir> Output directory (default: dist)
- --source <name> Display name of the primary source file (default: <entry>
basename)
+ --outdir <dir> Output directory, holding ${BUNDLE_FILENAME} (default:
dist)
+ --outfile <path> Exact output path; its name must end in .min.mjs
+ --source <name> Display name of the primary source file (default: <entry>
basename)
`;
+/** A bundle written without this suffix is invisible to NodeCoordinator. */
+const REQUIRED_OUTFILE_SUFFIX = ".min.mjs";
+
export interface PackArgs {
entry: string;
- outdir: string;
+ outfile: string;
source: string;
}
@@ -66,14 +69,16 @@ function usageError(message: string): Error {
export function parsePackArgs(argv: readonly string[]): PackArgs {
let entry: string | null = null;
- let outdir = "dist";
+ let outdir: string | null = null;
+ let outfile: string | null = null;
let source: string | null = null;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
- if (arg === "--outdir" || arg === "--source") {
+ if (arg === "--outdir" || arg === "--outfile" || arg === "--source") {
const value = argv[i + 1];
if (!value) throw usageError(`${arg} requires a value`);
if (arg === "--outdir") outdir = value;
+ else if (arg === "--outfile") outfile = value;
else source = value;
i += 1;
} else if (arg.startsWith("-")) {
@@ -85,7 +90,20 @@ export function parsePackArgs(argv: readonly string[]):
PackArgs {
}
}
if (!entry) throw usageError("Missing entry file");
- return { entry, outdir, source: source ?? path.basename(entry) };
+ // 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),
+ source: source ?? path.basename(entry),
+ };
}
function readSdkVersion(): string {
@@ -183,8 +201,8 @@ async function loadEsbuild(): Promise<typeof
import("esbuild")> {
export async function runPack(argv: readonly string[]): Promise<void> {
const args = parsePackArgs(argv);
- const bundlePath = path.join(args.outdir, BUNDLE_FILENAME);
- const stagingPath = path.join(args.outdir, STAGING_FILENAME);
+ const bundlePath = args.outfile;
+ const stagingPath = path.join(path.dirname(bundlePath), STAGING_FILENAME);
const { build } = await loadEsbuild();
try {
@@ -194,6 +212,9 @@ export async function runPack(argv: readonly string[]):
Promise<void> {
platform: "node",
format: "esm",
target: "node22",
+ // A digest is only worth taking over an artifact nobody reads or edits
in place.
+ minify: true,
+ // The manifest is read by running the staged bundle, so the metadata
describes what ships.
outfile: stagingPath,
});
diff --git a/ts-sdk/tests/cli/pack.test.ts b/ts-sdk/tests/cli/pack.test.ts
index 34d4b3a3d86..fb45586a254 100644
--- a/ts-sdk/tests/cli/pack.test.ts
+++ b/ts-sdk/tests/cli/pack.test.ts
@@ -58,7 +58,7 @@ describe("parsePackArgs", () => {
it("parses entry with defaults", () => {
expect(parsePackArgs(["src/main.ts"])).toEqual({
entry: "src/main.ts",
- outdir: "dist",
+ outfile: path.join("dist", "bundle.min.mjs"),
source: "main.ts",
});
});
@@ -66,16 +66,33 @@ describe("parsePackArgs", () => {
it("parses --outdir and --source overrides", () => {
expect(parsePackArgs(["src/main.ts", "--outdir", "build", "--source",
"pipeline.ts"])).toEqual({
entry: "src/main.ts",
- outdir: "build",
+ outfile: path.join("build", "bundle.min.mjs"),
source: "pipeline.ts",
});
});
+ it("parses --outfile as the exact output path", () => {
+ expect(parsePackArgs(["src/main.ts", "--outfile",
"out/sales.min.mjs"])).toEqual({
+ entry: "src/main.ts",
+ outfile: "out/sales.min.mjs",
+ source: "main.ts",
+ });
+ });
+
it.each([
[[], "Missing entry file"],
[["--outdir"], "--outdir requires a value"],
+ [["--outfile"], "--outfile requires a value"],
[["a.ts", "b.ts"], "Unexpected argument b.ts"],
[["a.ts", "--bogus"], "Unknown option --bogus"],
+ [
+ ["a.ts", "--outdir", "dist", "--outfile", "dist/sales.min.mjs"],
+ "--outdir and --outfile are mutually exclusive",
+ ],
+ // A bundle written without the suffix would never be found.
+ [["a.ts", "--outfile", "dist/sales.mjs"], "--outfile name must end in
.min.mjs"],
+ [["a.ts", "--outfile", "dist/sales.min.js"], "--outfile name must end in
.min.mjs"],
+ [["a.ts", "--outfile", "dist/min.mjs.txt"], "--outfile name must end in
.min.mjs"],
])("rejects %j", (argv, message) => {
expect(() => parsePackArgs(argv)).toThrow(message);
});
@@ -193,7 +210,7 @@ describe("runPack", () => {
const nested = path.join(outdir, "dist");
await runPack([FIXTURE_ENTRY, "--outdir", nested]);
- const bundlePath = path.join(nested, "bundle.mjs");
+ const bundlePath = path.join(nested, "bundle.min.mjs");
expect(existsSync(path.join(nested, "airflow-metadata.yaml"))).toBe(false);
const [layoutLine, metadataLine] = readFileSync(bundlePath,
"utf-8").split("\n");
@@ -227,7 +244,7 @@ describe("runPack", () => {
outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
await runPack([FIXTURE_ENTRY, "--outdir", outdir]);
- const bundle = readFileSync(path.join(outdir, "bundle.mjs"));
+ const bundle = readFileSync(path.join(outdir, "bundle.min.mjs"));
const firstNewline = bundle.indexOf("\n");
const layoutLine = bundle.subarray(0, firstNewline).toString("utf-8");
expect(layoutLine.startsWith(EMBEDDED_LAYOUT_PREFIX)).toBe(true);
@@ -249,11 +266,51 @@ describe("runPack", () => {
expect(bundle.toString("utf-8")).not.toContain("airflowSource");
});
+ it("minifies the code region and keeps it runnable", async () => {
+ outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+ await runPack([FIXTURE_ENTRY, "--outdir", outdir]);
+
+ const bundlePath = path.join(outdir, "bundle.min.mjs");
+ const bundle = readFileSync(bundlePath);
+ const layout = parseHeader(bundle.subarray(0,
bundle.indexOf("\n")).toString("utf-8"));
+ const code = bundle
+ .subarray(Number.parseInt(layout.code.start, 16),
Number.parseInt(layout.code.end, 16))
+ .toString("utf-8");
+
+ // esbuild indents unminified output; minified output has no indented line.
+ expect(code).not.toContain("\n ");
+ // Minification must not strip the dependencies' license banners.
+ expect(code).toContain("/*!");
+ // A digest over minified bytes only means something if those bytes still
execute.
+ const dumped = execFileSync(process.execPath, [bundlePath,
"--airflow-metadata"], {
+ encoding: "utf-8",
+ });
+ expect(
+
JSON.parse(dumped.slice(AIRFLOW_METADATA_SENTINEL.length)).supervisor_schema_version,
+ ).toBe(SUPERVISOR_API_VERSION);
+ });
+
+ it("writes the bundle to an explicit --outfile", async () => {
+ outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+ const target = path.join(outdir, "nested", "sales.min.mjs");
+
+ await runPack([FIXTURE_ENTRY, "--outfile", target]);
+
+ expect(existsSync(target)).toBe(true);
+ expect(existsSync(path.join(outdir, "nested",
"bundle.min.mjs"))).toBe(false);
+ // Staging is written beside the target and cleaned up there.
+ expect(existsSync(path.join(outdir, "nested",
"bundle.pack-staging.mjs"))).toBe(false);
+ expect(readFileSync(target).subarray(0,
EMBEDDED_LAYOUT_PREFIX.length).toString()).toBe(
+ EMBEDDED_LAYOUT_PREFIX,
+ );
+
expect(JSON.parse(readEmbeddedMetadata(target))).toHaveProperty("dags.fixture_dag");
+ });
+
it("keeps a shebang entry runnable and reads the manifest past import-time
logging", async () => {
outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
await runPack([NOISY_ENTRY, "--outdir", outdir]);
- const bundlePath = path.join(outdir, "bundle.mjs");
+ const bundlePath = path.join(outdir, "bundle.min.mjs");
const bundle = readFileSync(bundlePath, "utf-8");
expect(bundle.startsWith(EMBEDDED_LAYOUT_PREFIX)).toBe(true);
expect(bundle).not.toContain("#!/usr/bin/env node");
@@ -282,7 +339,7 @@ describe("runPack", () => {
await expect(runPack([entry, "--outdir", outdir])).rejects.toThrow(
"over the 1048576 byte limit",
);
- expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
+ expect(existsSync(path.join(outdir, "bundle.min.mjs"))).toBe(false);
expect(existsSync(path.join(outdir,
"bundle.pack-staging.mjs"))).toBe(false);
});
@@ -292,7 +349,7 @@ describe("runPack", () => {
await expect(runPack([EMPTY_ENTRY, "--outdir", outdir])).rejects.toThrow(
"served nothing; register Dags or task handlers",
);
- expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
+ expect(existsSync(path.join(outdir, "bundle.min.mjs"))).toBe(false);
expect(existsSync(path.join(outdir,
"bundle.pack-staging.mjs"))).toBe(false);
});
@@ -328,7 +385,7 @@ describe("runPack", () => {
await runPack([entry, "--outdir", outdir]);
expect(stderr()).toContain(expected);
- expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(true);
+ expect(existsSync(path.join(outdir, "bundle.min.mjs"))).toBe(true);
});
it("reports the last error from a failed bundle", async () => {
@@ -343,7 +400,7 @@ describe("runPack", () => {
"message",
"Error: final failure",
);
- expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
+ expect(existsSync(path.join(outdir, "bundle.min.mjs"))).toBe(false);
expect(existsSync(path.join(outdir,
"bundle.pack-staging.mjs"))).toBe(false);
});
@@ -374,7 +431,7 @@ describe("runPack", () => {
);
await expect(runPack([entry, "--outdir",
outdir])).rejects.toThrow(message);
- expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
+ expect(existsSync(path.join(outdir, "bundle.min.mjs"))).toBe(false);
expect(existsSync(path.join(outdir,
"bundle.pack-staging.mjs"))).toBe(false);
});
@@ -395,7 +452,7 @@ describe("runPack", () => {
await runPack([entry, "--outdir", outdir]);
expect(stderr()).toContain('warning: dag "empty_dag" has no tasks\n');
- expect(JSON.parse(readEmbeddedMetadata(path.join(outdir,
"bundle.mjs")))).toHaveProperty(
+ expect(JSON.parse(readEmbeddedMetadata(path.join(outdir,
"bundle.min.mjs")))).toHaveProperty(
"dags.empty_dag.tasks",
[],
);
@@ -418,7 +475,7 @@ describe("runPack", () => {
await runPack([entry, "--outdir", outdir]);
- const metadata = JSON.parse(readEmbeddedMetadata(path.join(outdir,
"bundle.mjs")));
+ const metadata = JSON.parse(readEmbeddedMetadata(path.join(outdir,
"bundle.min.mjs")));
expect(metadata).toHaveProperty("dags.sales_dag");
expect(metadata).not.toHaveProperty("dags.billing_dag");
});