Pipeline extension API

The pipeline extension API lets you hook into rk build and rk dev from your roku-config.ts. You can use it for the following scenarios:

  • Running generators before a build
  • Uploading source maps after a build
  • Adding custom esbuild plugins at named points in the tooling plugin chain

To use the API, define a pipeline object with hooks and plugins and add it to your roku-config.ts.

Importing and declaring the pipeline

The following code demonstrates how to import and declare the pipeline extension (alternatively, you can define pipeline inline directly in roku-config.ts).

import type { RokuConfig } from "@roku-sdk/rsg-ts/roku-config";
import { pipeline } from "./roku-pipeline";

export default {
    app: {
        entryPoint: "src/index.ts"
    },
    pipeline
} satisfies RokuConfig;

Hook context types

Hooks receive a context object. The following import brings in the context types:

import type {
    BuildHookContext,
    PostBuildHookContext,
    DeployHookContext,
    DeployHookDevice,
    ManifestEntries,
    RokuPipeline
} from "@roku-sdk/rsg-ts/roku-config";

The following interfaces define the context types:

interface BuildHookContext {
    config: CommonBuildConfig; // merged config: defaults -> roku-config.ts -> command-line flags
    isRebuild: boolean;        // false on first build, true on later watch cycles
}

interface PostBuildHookContext extends BuildHookContext {
    result: esbuild.BuildResult;
    bundleDir: string; // absolute path
    pkgDir: string;    // absolute path
}

interface DeployHookContext extends PostBuildHookContext {
    device: DeployHookDevice;
}

// Narrow on `kind === "rce"` before using `ip` as an address: a Roku Cloud Emulator
// device has none, and its `ip` is the empty string.
type DeployHookDevice =
    | { kind?: "lan"; id?: string; ip: string; name?: string } // id: from `rk device ls`, or `host:<ip>` for `--host`
    | { kind: "rce"; id: string; ip: string; name?: string };  // name: `rce:<name>`; ip: always ""
// rk always sets `kind` and `id`; id is empty when no registered device was resolved.
// They are optional on the LAN variant only so that a context built as `{ ip, name }`
// (in a hook's own unit tests, say) still type-checks.

Pipeline types

The following types define the pipeline, its plugin slots, and its hooks:

type BuildPluginSlot = "workspaceResolve" | "images" | "assets" | "fonts" | "solid" | "hermes";
type StartDevPluginSlot = BuildPluginSlot | "sideload";

type PositionFor<TSlot extends string> =
    | "first"
    | "last"
    | { before: TSlot }
    | { after: TSlot };

interface PipelinePlugin<TSlot extends string> {
    plugin: esbuild.Plugin;
    position: PositionFor<TSlot>;
}

interface BuildPipeline {
    preBuild?:          (ctx: BuildHookContext) => Promise<void> | void;
    transformManifest?: (entries: ManifestEntries) => ManifestEntries | Promise<ManifestEntries>;
    postBuild?:         (ctx: PostBuildHookContext) => Promise<void> | void;
    plugins?:           PipelinePlugin<BuildPluginSlot>[];
}

interface StartDevPipeline {
    preBuild?:          (ctx: BuildHookContext) => Promise<void> | void;
    transformManifest?: (entries: ManifestEntries) => ManifestEntries | Promise<ManifestEntries>;
    postBuild?:         (ctx: PostBuildHookContext) => Promise<void> | void;
    postDeploy?:        (ctx: DeployHookContext) => Promise<void> | void;
    plugins?:           PipelinePlugin<StartDevPluginSlot>[];
}

interface RokuPipeline {
    build?: BuildPipeline;
    startDev?: StartDevPipeline;
}

Built-in plugin chain

The build chain order is as follows: workspaceResolve -> images -> assets -> fonts -> solid -> hermes.

The rk dev chain order is as follows: workspaceResolve -> images -> assets -> fonts -> solid -> hermes -> sideload. The hermes slot uses debug-upload for JavaScript by default, or hermes-compile when --compile-ts (or compileTs: true in config) enables local bytecode compilation. The --no-sideload flag omits the sideload step.

The following rules apply to slots:

  • The sideload slot exists only in rk dev.
  • Build pipeline typing does not allow sideload.
  • If you use an unknown slot, config loading fails with an error.
  • Multiple plugins targeting the same position keep declaration order.

Hook execution order

The hooks run in a different order for rk build, a single rk dev run, and rk dev --watch.

rk build

The rk build command runs the following steps:

  1. Load and merge the config.
  2. Clean the output directory.
  3. Run pipeline.build.preBuild.
  4. Run the esbuild build (plus custom injected plugins).
  5. Assemble the manifest (pipeline.build.transformManifest runs before the manifest values are converted to strings and written).
  6. Run the tooling build-end plugins (Hermes or debug).
  7. Run pipeline.build.postBuild.
  8. Package the app (when you use --package).

rk dev (single run)

A single rk dev run follows these steps:

  1. Load and merge the config.
  2. Clean the output directory.
  3. Run pipeline.startDev.preBuild.
  4. Run the esbuild build (plus custom injected plugins).
  5. Assemble the manifest (pipeline.startDev.transformManifest runs before the manifest values are converted to strings and written).
  6. Run the tooling build-end plugins (Hermes or debug, plus sideload).
  7. Run pipeline.startDev.postBuild.
  8. Run pipeline.startDev.postDeploy (only after a successful sideload).

rk dev --watch

Each watch cycle runs the following steps:

  1. Run preBuild.
  2. Build.
  3. Run transformManifest (before the manifest is converted to strings and written).
  4. Run postBuild.
  5. Run postDeploy (after a successful sideload).

The isRebuild value is false for the first cycle and true for all later cycles.

Error behavior

The pipeline handles hook exceptions according to the execution context:

  • Non-watch mode (rk build, rk dev): Hook exceptions abort the command.
  • Watch mode (rk dev --watch): The pipeline logs hook failures for that cycle, and watch continues.
  • Shell strings: Hooks are function-based only. Shell-string hook syntax is not supported.

transformManifest

The transformManifest hook runs during manifest assembly, after the pipeline merges base entries from pkg/manifest and roku-config.ts but before it converts the values to strings and writes them. The following code shows the hook type:

type ManifestEntries = RokuManifestEntries & Record<string, string | number | boolean>;

transformManifest?: (entries: ManifestEntries) => ManifestEntries | Promise<ManifestEntries>;

The following notes apply to the hook:

  • The transformManifest hook runs on every build cycle, including rk dev --watch rebuilds.
  • Throwing from the hook aborts the current build cycle.
  • The hook can return build_version as a number; tooling zero-pads it during manifest serialization.

Example

See baseline-app-pipeline-test and its roku-pipeline.ts for more information about a complete app that configures every hook, including startDev hooks and plugins at each supported position. The following example shows a pipeline with build and startDev hooks:

import { execSync } from "node:child_process";
import type { RokuPipeline } from "@roku-sdk/rsg-ts/roku-config";

export const pipeline: RokuPipeline = {
    build: {
        transformManifest: entries => ({
            ...entries,
            build_version: Number(process.env.CI_BUILD_NUMBER ?? entries.build_version ?? 1)
        }),
        preBuild: ({ isRebuild }) => {
            console.log("build preBuild", { isRebuild });
        },
        postBuild: ({ bundleDir }) => {
            execSync(`echo uploading maps from ${bundleDir}`, { stdio: "inherit" });
        },
        plugins: [
            {
                plugin: {
                    name: "my-build-plugin",
                    setup(build) {
                        build.onStart(() => console.log("my-build-plugin onStart"));
                    }
                },
                position: { before: "solid" }
            }
        ]
    },
    startDev: {
        postDeploy: ({ device }) => {
            console.log("deployed to", device.name ?? device.id);
        }
    }
};

Understanding recent changes

The following change affects hooks that use the device. The DeployHookContext.device field is now DeployHookDevice. It used to be { ip: string; name?: string }. It now also carries kind ("lan" or "rce") and id (the device's id in rk device ls). A Roku Cloud Emulator device (kind: "rce") does not have an address of its own; therefore, its ip is always "", and a postDeploy hook that talks to the device by IP must check kind first. On the LAN variant, kind and id are optional; therefore, existing code that builds a { ip, name } context keeps compiling, and narrowing on device.kind === "rce" works either way.

Related documentation


Did this page help you?