Effect
Mount pushduck in an Effect Platform HTTP app, and drive uploads from an Effect program
Using pushduck with Effect
Effect Platform's HTTP layer is built on Web-standard Request/Response, and
pushduck's router.handler is exactly a (Request) => Promise<Response>. So
the server integration is a single function call — HttpApp.fromWebHandler —
with no adapter.
Both the server mount and the client recipe on this page are covered by tests
in the pushduck repo (src/__tests__/effect-platform.test.ts), against
effect 3.x and @effect/platform 0.97.
Server Setup
Define the upload router
Nothing Effect-specific here — the standard pushduck router.
import { createUploadConfig } from "pushduck/server";
const { s3 } = createUploadConfig()
.provider("aws", {
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
})
.build();
export const uploadRouter = s3.createRouter({
imageUpload: s3.image().maxFileSize("5MB"),
documentUpload: s3.file().maxFileSize("10MB"),
});
export type AppUploadRouter = typeof uploadRouter;Mount it in your HTTP app
import { HttpApp, HttpRouter, HttpServer } from "@effect/platform";
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Layer } from "effect";
import { createServer } from "node:http";
import { uploadRouter } from "./lib/upload";
// The entire integration.
const uploadApp = HttpApp.fromWebHandler(uploadRouter.handler);
const app = HttpRouter.empty.pipe(
HttpRouter.mountApp("/api/upload", uploadApp)
);
const ServerLive = NodeHttpServer.layer(() => createServer(), { port: 3000 });
NodeRuntime.runMain(
Layer.launch(HttpServer.serve(app).pipe(Layer.provide(ServerLive)))
);Mount with mountApp, not mount. mountApp forwards the request to the
sub-app untouched; pushduck reads route and action from the query string,
which must survive the prefix rewrite.
Client Usage
If your frontend is React, Vue, Svelte, or Solid, use the binding for it — the Effect server makes no difference to the client.
To drive uploads from inside an Effect program, pushduck/core exposes
subscribe/getSnapshot, which lifts into SubscriptionRef directly:
import { createUploadEngine } from "pushduck/core";
import { Effect, SubscriptionRef, Stream } from "effect";
export const uploadFiles = (files: File[]) =>
Effect.gen(function* () {
const engine = createUploadEngine({
endpoint: "/api/upload",
route: "imageUpload",
});
const state = yield* SubscriptionRef.make(engine.getSnapshot());
const unsubscribe = engine.subscribe(() => {
Effect.runSync(SubscriptionRef.set(state, engine.getSnapshot()));
});
// Observe progress as a Stream while the upload runs.
yield* Effect.forkScoped(
Stream.runForEach(state.changes, (snapshot) =>
Effect.log(`progress: ${snapshot.progress}%`)
)
);
yield* Effect.promise(() => engine.upload(files));
unsubscribe();
return yield* SubscriptionRef.get(state);
});Cancellation
Wire Effect's interruption to the engine so an interrupted fiber aborts the transfer rather than leaking it:
Effect.promise(() => engine.upload(files)).pipe(
Effect.onInterrupt(() => Effect.sync(() => engine.cancelAll()))
);What Is Not Effect-Native
Being direct about the seams, so you can decide whether they matter to you.
Errors are not in the error channel
pushduck reports failures through state and callbacks, not by rejecting. So
engine.upload(...) lifts as Effect<void, never, never> — you get no typed
error channel, which is most of why you would reach for Effect in the first
place.
You can recover the failures yourself:
const uploaded = yield* Effect.promise(() => engine.upload(files)).pipe(
Effect.flatMap(() => SubscriptionRef.get(state)),
Effect.flatMap((snapshot) =>
snapshot.errors.length > 0
? Effect.fail(new UploadFailed({ errors: snapshot.errors }))
: Effect.succeed(snapshot.files)
)
);Server-side the same gap applies: a .middleware() that throws currently
produces a 500 rather than a typed, status-mapped failure.
Middleware is promise-based
.middleware(async ({ req }) => …) takes an async function, so you cannot yield
an Effect inside it or reach your services through the R channel. Run your
Effect program manually at the boundary:
.middleware(async ({ req }) => {
const user = await Effect.runPromise(
authenticate(req).pipe(Effect.provide(AuthLive))
);
return { userId: user.id };
})Configuration is not a Layer
pushduck is configured with a builder returning a module value, rather than a
Layer you provide. Wrap it in a Layer yourself if you want it in the
dependency graph:
import { Context, Layer } from "effect";
export class UploadRouter extends Context.Tag("UploadRouter")<
UploadRouter,
typeof uploadRouter
>() {}
export const UploadRouterLive = Layer.succeed(UploadRouter, uploadRouter);Schema is pushduck's, not effect/Schema
File constraints use pushduck's chain (s3.image().maxFileSize("5MB")) rather
than effect/Schema. The two coexist fine — validate upload metadata with
effect/Schema in your middleware if you want that guarantee.
Summary: the transport layer integrates cleanly and is verified. The functional-programming ergonomics — typed errors, Layers, Effect-valued middleware — are not there yet. If you want pushduck to feel native inside Effect, the error taxonomy is the change that would matter most; it is on the roadmap.