ZenZip logozenzip

Migrating to ZenZip

Side-by-side guides from the tools ZenZip replaces — Express for HTTP, BullMQ/Inngest/Temporal for durable work. The shapes are deliberately familiar.

Coming from Express#

The HTTP layer is Express-shaped on purpose (see Express & Middleware). Most apps move over by renaming the import and adding await app.start() before listen().

express.ts
import express from "express";
const app = express();

app.use(express.json());
app.use(cors());

const api = express.Router();
api.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
app.use("/api", api);

app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
app.listen(3000);
zenzip.ts
import { zenzip } from "zenzipjs";
const app = zenzip();

app.use(zenzip.json());
app.use(zenzip.cors());

const api = zenzip.Router();
api.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
app.use("/api", api);

app.use((err, req, res, next) => res.status(500).json({ error: err.message }));

await app.start();              // ← the one new line: boot the runtime
await app.listen({ port: 3000 });

What maps directly

ExpressZenZip
express()zenzip()
app.use / get / post / …identical
express.Router()zenzip.Router()
express.json() / urlencoded()zenzip.json() / urlencoded()
express.static()zenzip.static()
cors() / morgan()zenzip.cors() / zenzip.logger()
(req, res, next)identical (+ optional ctx handlers)

The upgrade path

Once you're on app, durability is right there: app.queue(), app.workflow(), app.agent() — call trigger/push from any route handler. No new service, no broker.

From BullMQ#

Same queue/worker mental model — minus Redis. The embedded store is the broker; multi-node uses Postgres, never a separate Redis.

bullmq.ts
import { Queue, Worker } from "bullmq";
const connection = { host: "localhost", port: 6379 };   // Redis required

const emails = new Queue("emails", { connection });
new Worker("emails", async (job) => {
  await sendEmail(job.data);
}, { connection, concurrency: 5 });

await emails.add("welcome", { to: "a@b.com" }, { attempts: 3 });
zenzip.ts
import { zenzip } from "zenzipjs";
const app = zenzip();                       // no Redis — embedded store

const emails = app.queue("emails", { concurrency: 5, retries: 2 });
emails.process(async (job) => {
  await sendEmail(job.data);                // job.data, job.attempt, …
});

await app.start();
await emails.push({ to: "a@b.com" });       // delay/priority/retries opts
  • new Queue(name) + new Worker(name, fn) app.queue(name) + .process(fn).
  • queue.add(jobName, data, opts) queue.push(data, opts) (delay, priority, retries).
  • attemptsretries; concurrency, backoff, and rate limit are queue options.
  • No connection config — nothing to provision in dev.

From Inngest#

The step model is nearly identical — ZenZip uses the same memoization approach (steps are journaled and replayed-by-result, not re-executed). The big difference: it runs in your process, on your store, with no SaaS.

inngest.ts
import { Inngest } from "inngest";
const inngest = new Inngest({ id: "app" });

export const onSignup = inngest.createFunction(
  { id: "welcome" },
  { event: "user.created" },
  async ({ event, step }) => {
    await step.run("email", () => sendWelcome(event.data));
    await step.sleep("wait", "1d");
    await step.run("nudge", () => sendNudge(event.data));
  },
);

await inngest.send({ name: "user.created", data: { id: 1 } });
zenzip.ts
import { zenzip } from "zenzipjs";
const app = zenzip();

app.workflow("welcome", { on: "user.created" }, async ({ step, input }) => {
  // triggered runs receive { event, payload, emittedAt }
  await step.run("email", () => sendWelcome(input.payload));
  await step.sleep("wait", "1d");           // durable, holds no resources
  await step.run("nudge", () => sendNudge(input.payload));
});

await app.start();
app.emit("user.created", { id: 1 });
  • createFunction({ event }, fn) app.workflow(name, { on: "evt" }, fn).
  • step.run / step.sleep map 1:1; step.waitForEvent, step.invoke, step.all are all there.
  • inngest.send(event)app.emit(event, payload); triggered runs get { event, payload, emittedAt } as input.

From Temporal#

ZenZip targets the same guarantee — work that survives crashes — without the heaviest costs: no cluster, no separate worker fleet, and no determinism tax. Steps are memoized by result, so between-step code is ordinary TypeScript; there are no replay constraints, no patched() versioning gymnastics.

temporal.ts
// activities.ts + workflow.ts + worker.ts + client.ts — 4 files,
// determinism rules, versioning/patching, a Temporal cluster to run.
export async function order(input) {
  const a = proxyActivities({ startToCloseTimeout: "1m" });
  await a.charge(input);
  await a.ship(input);
}
// Worker.create({ taskQueue, workflows, activities }) + cluster + UI
zenzip.ts
import { zenzip } from "zenzipjs";
const app = zenzip();                       // no cluster, no separate worker

app.workflow("order", async ({ step, input }) => {
  await step.run("charge", () => charge(input));   // activities → step.run
  await step.run("ship", () => ship(input));
});

await app.start();                          // worker + dashboard in-process
// between-step code is plain TypeScript — no determinism constraints
TemporalZenZip
Activity / proxyActivitiesstep.run(id, fn)
workflow.sleepstep.sleep(id, dur)
signals / await conditionstep.waitForEvent(...)
child workflowsstep.invoke(id, wf, input)
Worker + cluster + UIapp.start() + embedded dashboard
determinism + versioning rulesnone — plain TS between steps

What you give up

Temporal's strict event-sourced replay enables features ZenZip intentionally does not (e.g. arbitrarily long history with full deterministic re-execution). For the vast majority of durable backends and agents, memoization is the better trade — see Durability & Semantics.