Skip to content

Effect RPC on Cloud Run

Effect RPC gives you procedures instead of URLs, and a typed client with no codegen. Every request is checked against a schema, so use it where a web app or another service calls into yours. The concept lives at Effect RPC. This page wires it onto Cloud Run.

src/Job.ts
import * as Schema from "effect/Schema";
export const JobId = Schema.String;
export class Job extends Schema.Class<Job>("Job")({
id: JobId,
content: Schema.String,
}) {}
export class JobNotFound extends Schema.TaggedClass<JobNotFound>()(
"JobNotFound",
{ jobId: JobId },
) {}
export class PutJobFailed extends Schema.TaggedClass<PutJobFailed>()(
"PutJobFailed",
{ reason: Schema.String },
) {}

The client receives these errors as typed values it can catchTag.

src/JobRpcs.ts
import * as Schema from "effect/Schema";
import { Rpc, RpcGroup } from "effect/rpc";
import { Job, JobId, JobNotFound, PutJobFailed } from "./Job.ts";
const getJob = Rpc.make("getJob", {
success: Job,
error: JobNotFound,
payload: { jobId: JobId },
});
const createJob = Rpc.make("createJob", {
success: JobId,
error: PutJobFailed,
payload: { content: Schema.String },
});
export class JobRpcs extends RpcGroup.make(getJob, createJob) {}

RpcServer.toHttpEffect turns the group into a fetch handler:

src/JobService.ts
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { RpcSerialization, RpcServer } from "effect/rpc";
import { Job, JobNotFound, PutJobFailed } from "./Job.ts";
import { JobRpcs } from "./JobRpcs.ts";
export default class JobService extends GCP.Function<JobService>()(
"JobService",
{ main: import.meta.url, invokerIamDisabled: true },
Effect.gen(function* () {
const jobs = yield* GCP.Firestore.Database("Jobs", {
type: "FIRESTORE_NATIVE",
});
const db = yield* GCP.Firestore.ReadWriteDatabase(jobs);
const handlersLayer = JobRpcs.toLayer({
getJob: ({ jobId }) =>
db.get(`jobs/${jobId}`).pipe(
Effect.orDie,
Effect.flatMap((doc) =>
doc
? Effect.succeed(
new Job({ id: jobId, content: String(doc.fields.content ?? "") }),
)
: Effect.fail(new JobNotFound({ jobId })),
),
),
createJob: ({ content }) =>
Effect.gen(function* () {
const jobId = crypto.randomUUID();
yield* db.set(`jobs/${jobId}`, { content });
return jobId;
}).pipe(
Effect.mapError((error) => new PutJobFailed({ reason: error._tag })),
),
});
return {
fetch: yield* RpcServer.toHttpEffect(JobRpcs).pipe(
Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerJson)),
),
};
}).pipe(Effect.provide(GCP.Firestore.ReadWriteDatabaseHttp)),
) {}

getJob turns Firestore failures into 500s and keeps JobNotFound for the client. createJob maps them to the declared PutJobFailed.

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
import JobService from "./src/JobService.ts";
export default Alchemy.Stack(
"JobRpc",
{ providers: GCP.providers(), state: GCP.state() },
Effect.gen(function* () {
const service = yield* JobService;
return { url: service.uri };
}),
);
Terminal window
bun alchemy deploy
scripts/client.ts
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import { RpcClient, RpcSerialization } from "effect/rpc";
import { JobRpcs } from "../src/JobRpcs.ts";
const program = Effect.gen(function* () {
const client = yield* RpcClient.make(JobRpcs);
const jobId = yield* client.createJob({ content: "Ship the docs" });
const job = yield* client.getJob({ jobId });
yield* Effect.log(job.content);
});
Effect.runPromise(
program.pipe(
Effect.scoped,
Effect.provide(
RpcClient.layerProtocolHttp({ url: process.env.JOB_RPC_URL! }).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(RpcSerialization.layerJson),
),
),
),
);
Terminal window
JOB_RPC_URL=https://<service>.run.app bun scripts/client.ts

Use Effect RPC when every caller is Effect. Use Effect HTTP when callers want plain REST. The RPC overview has the full decision.

  • Effect RPC — procedures, streaming, and clients in depth.
  • Private services — keep the RPC service private and call it from another service.