Skip to content

File I/O ​

Jobs frequently need to produce artifacts — JSON reports, CSV dumps, generated assets — and sometimes also to read inputs: fixtures, reference datasets, files produced by another job. The runner splits these into two configured destinations per environment:

  • Input — where the job reads from. Exposed as a path via getInputFilesPath().
  • Output — where the job writes to. Exposed as a path via getOutputFilesPath(), and wrapped by getFileWriter() for convenient writes.

Each destination resolves to a local directory for local runs and a gs://bucket[/prefix] URI for cloud runs. Both are independently optional — a job that only writes outputs needs no input config, and vice versa. They may point at the same location when a single directory serves both roles.

Writing Output Files ​

typescript
import { defineJob, getFileWriter } from "gcp-job-runner";

export default defineJob({
  description: "Export pending comments as JSON and CSV",
  handler: async () => {
    const files = getFileWriter();

    await files.writeJson("pending-comments.json", rows);
    await files.writeText("pending-comments.csv", csvString);
  },
});

The writer reads its destination from your runner config. For most setups you'll pair a top-level localOutputFilesPath — used for every local run regardless of environment — with a per-environment outputFilesPath used for cloud execution:

typescript
// job-runner.config.ts
import { defineRunnerConfig, defineRunnerEnv } from "gcp-job-runner";

export default defineRunnerConfig({
  localOutputFilesPath: "./output",
  environments: {
    stag: defineRunnerEnv({
      project: "my-project-stag",
      outputFilesPath: "gs://my-project-stag-output",
    }),
    prod: defineRunnerEnv({
      project: "my-project-prod",
      outputFilesPath: "gs://my-project-prod-output",
    }),
  },
});

Reading Input Files ​

Most jobs read JSON or plain text. Use readInputJson() / readInputText() / readInputBuffer() — they transparently handle local directories and gs:// buckets, reject absolute paths and upward traversal, and throw the same "no input destination configured" error as the writer when nothing is set:

typescript
import { defineJob, readInputJson } from "gcp-job-runner";

interface Airline {
  iata: string;
  name: string;
}

export default defineJob({
  handler: async () => {
    const airlines = await readInputJson<Airline[]>("airlines.json");
    // ...
  },
});

Configure an inputFilesPath per environment and an optional top-level localInputFilesPath for local overrides:

typescript
export default defineRunnerConfig({
  localInputFilesPath: "./input",
  environments: {
    stag: defineRunnerEnv({
      project: "my-project-stag",
      inputFilesPath: "gs://my-project-stag-input",
    }),
  },
});

Reader API ​

typescript
function readInputText(relativePath: string): Promise<string>;
function readInputJson<T = unknown>(relativePath: string): Promise<T>;
function readInputBuffer(relativePath: string): Promise<Buffer>;
  • readInputText returns the file contents decoded as UTF-8. Use for CSV, JSON, plain text, SVG — anything character-based.
  • readInputJson is a thin wrapper that parses the decoded text; parse errors on malformed input propagate as standard SyntaxErrors from JSON.parse.
  • readInputBuffer returns the raw bytes — use for binary inputs (PNG, protobuf, etc.).

For formats the library doesn't ship a helper for (e.g. CSV), read the text and feed it to your preferred parser:

typescript
import { readInputText } from "gcp-job-runner";
import Papa from "papaparse";

const rows = Papa.parse(await readInputText("users.csv"), {
  header: true,
}).data;

Streaming or Custom Access ​

When the file is too large to buffer or you need a streaming API — @google-cloud/storage read streams, createReadStream, readdir of a subdirectory — fall back to getInputFilesPath() and pair it with node:fs or @google-cloud/storage directly:

typescript
import { readFile } from "node:fs/promises";
import path from "node:path";
import { getInputFilesPath } from "gcp-job-runner";

const base = getInputFilesPath();
const raw = await readFile(path.join(base, "airlines.json"), "utf-8");

The readers above cover the common case; getInputFilesPath() is the escape hatch.

Picking Files Interactively ​

Mark a schema field with fileInput() to turn it into a picker in --interactive mode. Interactive runs list the files under the configured inputFilesPath (local readdir or gs:// bucket list) and present them as a select prompt instead of asking for a free-text path:

typescript
import { z } from "zod";
import { defineJob, fileInput, readInputText } from "gcp-job-runner";

export default defineJob({
  description: "Process a CSV from the input directory",
  schema: z.object({
    file: fileInput().describe("CSV to process"),
  }),
  handler: async ({ file }) => {
    const csv = await readInputText(file);
    // ...
  },
});

Run it with job local run <env> <job> --interactive and the --file prompt becomes a scrollable list of files in the configured input directory. fileInput() chains with .optional(), .default(), and .describe() as usual.

When the destination is a gs:// URI, the picker uses a lazy @google-cloud/storage list — it authenticates with Application Default Credentials on the local machine running the prompt. An empty directory or missing config falls back to a free-text prompt with a warning so the flow isn't blocked.

listInputFiles() is also exported if you need the same listing outside the interactive flow.

Reading Your Own Output ​

getOutputFilesPath() returns the resolved output destination. Useful when a handler writes a file and then needs its path for a follow-up step, or when chaining steps:

typescript
import { getFileWriter, getOutputFilesPath } from "gcp-job-runner";

const writer = getFileWriter();
await writer.writeJson("intermediate.json", data);

const base = getOutputFilesPath();
// Feed `path.join(base, "intermediate.json")` to the next step, etc.

Writer API ​

typescript
interface FileWriter {
  writeJson(relativePath: string, data: unknown): Promise<string>;
  writeText(relativePath: string, content: string): Promise<string>;
  writeBuffer(
    relativePath: string,
    content: Buffer | Uint8Array,
  ): Promise<string>;
}

function getFileWriter(): FileWriter;

Each method returns the resolved destination — an absolute path for local writes or the full gs:// URI for cloud writes — and logs the same value so you can grab it from the job output.

writeJson(relativePath, data) ​

Serializes data as pretty-printed JSON (2-space indent, trailing newline). Adds .json to relativePath if it isn't already present.

typescript
await files.writeJson("daily-stats", { users: 1234, orders: 56 });
// → ./output/daily-stats.json

writeText(relativePath, content) ​

Writes a UTF-8 string as-is. Use for CSV, SVG, Markdown, plain text — anything character-based. The content type sent to Cloud Storage is derived from the file extension.

typescript
await files.writeText(`unmatched-${today}.csv`, csv);

writeBuffer(relativePath, content) ​

Writes raw bytes. Use for binary formats like PNG, PDF, or protobuf. Uploads with application/octet-stream.

typescript
await files.writeBuffer("chart.png", pngBuffer);

Nested Paths ​

All writer methods accept paths with forward slashes. Parent directories are created automatically for local writes; for GCS the segments become part of the object key.

typescript
await files.writeJson(`db/airlines/${code}.json`, airline);
// Local: ./output/db/airlines/UA.json
// Cloud: gs://my-bucket/db/airlines/UA.json

Absolute paths and upward traversal (..) are rejected so a job can't accidentally escape its configured destination.

Local vs Cloud ​

AspectLocal (./input, ./output)Cloud (gs://bucket/prefix)
ResolutionRelative to the service directory (where job-runner.config.ts lives)Parsed as a GCS URI, bucket + optional prefix
Storagenode:fs — directories created as needed@google-cloud/storage, uploaded with .save()
AuthenticationNone requiredApplication Default Credentials (ADC)
Returned valueAbsolute filesystem pathFull gs:// URI

The @google-cloud/storage module is lazy-loaded — jobs that only ever write to local paths don't pay the startup cost.

Missing Configuration ​

Each accessor validates its own destination. Asking for input with no input configured throws — even if output is configured, and vice versa:

Error: No input files destination configured.
Set `localInputFilesPath` or the current environment's `inputFilesPath`
in your job-runner.config.ts, or set JOB_INPUT_FILES_PATH directly in
the environment.
Error: No output files destination configured.
Set `localOutputFilesPath` or the current environment's `outputFilesPath`
in your job-runner.config.ts, or set JOB_OUTPUT_FILES_PATH directly in
the environment.

Choose the opt-in explicitly rather than relying on implicit defaults.

Cloud Deployment Safety ​

Any inputFilesPath or outputFilesPath value that is not a gs:// URI is rejected at cloud deploy/run time — local paths have no meaning inside a container, and silently reading from or writing to the container's filesystem would fail or lose data when the task exits. Configure a gs:// URI for every non-local environment.

Released under the MIT License.