- Posts
- A File Upload Can Start a Workflow
A File Upload Can Start a Workflow
Your import screen says “Upload complete.” The file is safely stored. Nobody has read the orders inside it yet.
You could have the browser make a second request to start processing. But the user might close the tab after the upload succeeds, or that second request might fail. Now you have a file sitting in an inbox and no job to read it.
Ironflow v0.41.0 gives files their own events. In this example, a signed URL
accepts an order batch, ironflow.file.created starts a worker, and the worker
reads the exact file version named by the event.
Start a local project
Section titled “Start a local project”This walkthrough uses Ironflow v0.41.0, Node.js 24 or newer, and three terminals. Start the engine in the first terminal:
ironflow serve --dev--dev runs locally without API-key authentication. In a second terminal,
create a temporary TypeScript project:
mkdir upload-workflowcd upload-workflownpm init -ynpm pkg set type=modulenpm install @ironflow/node@0.41.0 @ironflow/browser@0.41.0npm install --save-dev tsxGive the file an inbox
Section titled “Give the file an inbox”A bucket holds the rules for its files. Save this as setup.ts and run it once:
import { createClient } from "@ironflow/node";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
await client.files().createBucket("order-imports", { maxObjectBytes: 5 * 1024 * 1024, allowedContentTypes: ["application/json"], emitEvents: true, allowSignedUrls: true,});npx tsx setup.tsThe two switches matter. allowSignedUrls lets your backend give a browser a
short-lived upload URL. emitEvents makes a successful write produce an
ironflow.file.created event. Both are off by default in the API.
Start the worker before uploading
Section titled “Start the worker before uploading”Save this as worker.ts:
import { createClient, createFunction, createWorker, NonRetryableError,} from "@ironflow/node";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
const inspectImport = createFunction( { id: "inspect-order-import", triggers: [{ event: "ironflow.file.created", expression: "data.bucket == 'order-imports'", }], }, async ({ event, step }) => { const { bucket, path, etag } = event.data as { bucket: string; path: string; etag: string; };
return step.run("inspect-json", async () => { const file = await client.files().bucket(bucket).get(path, { ifMatch: etag }); let orders: unknown; try { orders = JSON.parse(await file.text()); } catch { throw new NonRetryableError("Import is not valid JSON"); } if (!Array.isArray(orders)) { throw new NonRetryableError("Import must contain an array of orders"); } return { path, rows: orders.length, etag }; }); },);
await createWorker({ functions: [inspectImport] }).start();Run it in the second terminal. Leave it running. Wait for Registered function: inspect-order-import before sending a file. A function registered after the
upload does not receive that earlier event.
npx tsx worker.tsThe event carries an ETag for the object that caused it. ifMatch pins the read
to that version. If someone overwrites the path before the worker reads it,
Ironflow returns 412 instead of handing the worker different bytes.
Send the file
Section titled “Send the file”Open a third terminal in upload-workflow and save this as upload.ts:
import { createClient } from "@ironflow/node";import { uploadToSignedUrl } from "@ironflow/browser";import { writeFile } from "node:fs/promises";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });const path = `incoming/${crypto.randomUUID()}.json`;const { url } = await client.files().bucket("order-imports").signUpload(path, { ttlSeconds: 600, maxBytes: 5 * 1024 * 1024, contentType: "application/json", createOnly: true,});
const fileFromInput = new File( [JSON.stringify([{ id: "ord-1" }, { id: "ord-2" }])], "orders.json", { type: "application/json" },);const info = await uploadToSignedUrl(url, fileFromInput);await writeFile("last-upload.json", JSON.stringify({ path, etag: info.etag }));console.log({ path, etag: info.etag });npx tsx upload.tsTo wait for the run and see its output, save this as inspect.ts:
import { createClient } from "@ironflow/node";import { readFile } from "node:fs/promises";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });const { path } = JSON.parse(await readFile("last-upload.json", "utf8")) as { path: string;};const deadline = Date.now() + 30_000;
while (Date.now() < deadline) { const { runs } = await client.listRuns({ functionId: "inspect-order-import" }); const run = runs.find((candidate) => (candidate.input as { path?: string })?.path === path, ); if (run?.status === "completed") { console.log(run.output); process.exit(0); } if (run?.status === "failed") throw new Error(JSON.stringify(run.error)); await new Promise((resolve) => setTimeout(resolve, 200));}throw new Error("Import run did not complete within 30 seconds");npx tsx inspect.tsThe output includes rows: 2. last-upload.json holds only the path and ETag,
so the inspection script checks this upload’s run. The upload script creates a
File in Node so you can try the flow without building a web form. In an app,
the backend signs the URL and returns it to the browser. The browser passes the
file selected by the user to uploadToSignedUrl. The Ironflow API key stays on
the backend; the signed URL is the browser’s temporary permission to upload to
one path.
The upload writes a file record and its event in one database transaction. If
the write fails, there is no created event. Once it commits, Ironflow dispatches
the event to the registered function. createOnly makes a second upload to the
same path fail with 412 instead of replacing the file. The signed token stays
valid until it expires, so keep the URL out of logs and messages you share.
Treat repeat processing as normal
Section titled “Treat repeat processing as normal”If dispatch is interrupted, a recovery sweep can try the event again. The engine deduplicates dispatch for the same event and function, but a worker can still retry a run or a step. If a step writes orders to another database and loses the response before Ironflow records its completion, that write may be attempted again. Give the destination a unique key based on the bucket, ETag, and each order’s ID. A durable step does not make an external write exactly once.
The files guide covers signed downloads, file limits, and event recovery. The file storage explanation describes how file metadata, bytes, and events are stored.