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.

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) {}

GCP.Run.ServeRpc turns the group into a fetch handler. It serves NDJSON, so streaming procedures work, and builds the server once per instance:

src/JobService.ts
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
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: GCP.Run.ServeRpc(JobRpcs, handlersLayer),
};
}).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
pnpm 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.layerNdjson),
),
),
),
);
Terminal window
JOB_RPC_URL=https://<service>.run.app bun scripts/client.ts

Keep the RPC service private (drop invokerIamDisabled) and call it from another Cloud Run service. GCP.Run.InvokeService with schema grants roles/run.invoker, signs each call with an ID token, and returns the typed client:

src/Gateway.ts
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
import JobService from "./JobService.ts";
import { JobRpcs } from "./JobRpcs.ts";
export default class Gateway extends GCP.Run.Service<Gateway>()(
"Gateway",
{ main: import.meta.url, invokerIamDisabled: true },
Effect.gen(function* () {
const jobs = yield* GCP.Run.InvokeService(JobService, { schema: JobRpcs });
return {
fetch: Effect.gen(function* () {
const jobId = yield* jobs.createJob({ content: "Ship the docs" });
return yield* HttpServerResponse.json(yield* jobs.getJob({ jobId }));
}).pipe(Effect.orDie),
};
}).pipe(Effect.provide(GCP.Run.InvokeServiceHttp)),
) {}

The client speaks NDJSON, the serialization GCP.Run.ServeRpc serves.

The same group can run next to the caller in one instance. Declare it as a GCP.Run.Container and bind it with GCP.Run.bindContainer:

src/JobSidecar.ts
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
import { JobRpcs } from "./JobRpcs.ts";
import { handlersLayer } from "./handlers.ts";
export default class JobSidecar extends GCP.Run.Container<JobSidecar>()(
"JobSidecar",
{ main: import.meta.url, port: 3000, startupProbe: { tcpSocket: {} } },
Effect.gen(function* () {
return { fetch: GCP.Run.ServeRpc(JobRpcs, handlersLayer) };
}),
) {}
// inside the calling service's Effect
const jobs = yield* GCP.Run.bindContainer(JobSidecar, { schema: JobRpcs });
const job = yield* jobs.getJob({ jobId });

Calls go over localhost and never leave the instance. Streaming procedures return a Stream on both clients.

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.