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().
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);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
| Express | ZenZip |
|---|---|
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.
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 });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 optsnew Queue(name)+new Worker(name, fn)→app.queue(name)+.process(fn).queue.add(jobName, data, opts)→queue.push(data, opts)(delay,priority,retries).attempts→retries; 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.
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 } });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.sleepmap 1:1;step.waitForEvent,step.invoke,step.allare all there.inngest.send(event)→app.emit(event, payload); triggered runs get{ event, payload, emittedAt }asinput.
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.
// 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 + UIimport { 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| Temporal | ZenZip |
|---|---|
Activity / proxyActivities | step.run(id, fn) |
workflow.sleep | step.sleep(id, dur) |
signals / await condition | step.waitForEvent(...) |
child workflows | step.invoke(id, wf, input) |
Worker + cluster + UI | app.start() + embedded dashboard |
determinism + versioning rules | none — 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.