Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/worker-configuration.d.ts linguist-generated=true
23 changes: 23 additions & 0 deletions cli/src/validateLiveDemoLinks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { getPublishedTemplates } from "./util";
import MarkdownError from "./MarkdownError";
import { execFileSync } from "node:child_process";

export type ValidateLiveDemoLinksConfig = {
templateDirectory: string;
};

function isNewPullRequestTemplate(name: string): boolean {
if (!process.env.GITHUB_BASE_REF) {
return false;
}

try {
execFileSync("git", ["cat-file", "-e", `HEAD^1:${name}/package.json`], {
stdio: "ignore",
});
return false;
} catch {
return true;
}
}

export async function validateLiveDemoLinks({
templateDirectory,
}: ValidateLiveDemoLinksConfig) {
Expand All @@ -18,6 +34,13 @@ export async function validateLiveDemoLinks({
const url = `https://${name}.templates.workers.dev`;
const response = await fetch(url);
if (!response.ok) {
// A newly published template cannot have a live demo until the
// trusted post-merge workflow deploys it with Cloudflare credentials.
if (response.status === 404 && isNewPullRequestTemplate(name)) {
successes.push(`- ⏭️ ${url} (new template; deploys after merge)`);
return;
}

if (!retried) {
/**
* For brand new workers, it may take some time for dns to propagate.
Expand Down
10 changes: 10 additions & 0 deletions grpc-container-template/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.wrangler
node_modules
.dev.vars*
.env*
test
scripts
README.md
preview.png
worker-configuration.d.ts
7 changes: 7 additions & 0 deletions grpc-container-template/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
.wrangler/
.dev.vars
.dev.vars.*
.env
.env.*
*.log
15 changes: 15 additions & 0 deletions grpc-container-template/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM node:22-slim

WORKDIR /app

COPY container/package*.json ./
RUN npm ci --omit=dev

COPY proto ./proto
COPY container ./container

ENV GRPC_PORT=50051
EXPOSE 50051

CMD ["node", "container/server.js"]

135 changes: 135 additions & 0 deletions grpc-container-template/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# gRPC Container

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/grpc-container-template)

![gRPC logo](./preview.png)

<!-- dash-content-start -->

Run a bidirectional streaming gRPC service inside a Cloudflare Container and
proxy its raw bytes through a Worker and Durable Object.

This template combines:

- A Worker `connect()` handler for inbound TCP streams.
- A Durable Object `connect()` handler that owns the Container instance.
- The low-level Container TCP port API for reaching the gRPC server.

The Worker does not parse or terminate gRPC. It streams bytes in both directions,
leaving HTTP/2 and gRPC handling to the service inside the Container.

<!-- dash-content-end -->

Outside of this repository, create a new project from the template with
[C3](https://developers.cloudflare.com/pages/get-started/c3/) (`create-cloudflare`):

```sh
npm create cloudflare@latest -- --template=cloudflare/templates/grpc-container-template
```

## Architecture

```text
gRPC client
│
│ raw TCP
▼
Worker.connect()
│
│ GRPC_CONTAINER.getByName(...).connect()
▼
GrpcContainer.connect()
│
│ ctx.container.getTcpPort(50051).connect()
▼
gRPC server in the Container
```

The gRPC listener uses port `8788` during local development.

## Prerequisites

- Node.js 20.16 or newer
- Docker running locally
- Access to Cloudflare Containers for deployment
- A Cloudflare zone on a Pro or Business plan for inbound TCP access

## Getting Started

Install dependencies:

```sh
npm install
```

Start the Worker, Durable Object, and Container:

```sh
npm run dev
```

Wrangler listens for local gRPC traffic at `127.0.0.1:8788`.

In another terminal, run the included streaming client:

```sh
npm run grpc:client
```

The client receives a greeting, sends a sequence of byte payloads, receives an
echo for each payload, half-closes its request stream, and receives a final
goodbye.

## Testing

Run the Worker unit tests:

```sh
npm test
```

Run the complete local tunnel smoke test:

```sh
npm run test:e2e
```

The end-to-end test starts Wrangler, connects the included gRPC client to port
`8788`, and verifies traffic traverses the Worker, Durable Object, and Container.
Docker must be running.

## Deploying

Deploy the Worker and Container:

```sh
npm run deploy
```

Inbound TCP is available for Workers attached to a Cloudflare zone on a Pro or
Business plan. The public hostname and port for the raw TCP listener depend on
the inbound TCP configuration for your zone. The Worker's normal HTTP URL
returns only a plain-text status response for live-demo and health checks; it
does not serve a browser UI.

## Caveats

The included client uses an unencrypted connection for local testing. Use a
TLS-enabled endpoint and configure the client with TLS credentials before
sending traffic over an untrusted network.

## Project Structure

- `src/index.ts` implements the raw TCP Worker and Durable Object handlers plus
the plain-text HTTP status response.
- `container/server.js` implements the bidirectional gRPC service.
- `container/client.js` is a small test client.
- `proto/bytes.proto` defines the streaming service.
- `scripts/smoke-test.mjs` verifies the complete local data path.

## Learn More

- [Cloudflare Containers](https://developers.cloudflare.com/containers/)
- [Durable Object Container API](https://developers.cloudflare.com/durable-objects/api/container/)
- [Workers TCP sockets](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/)
- [Workers protocol support](https://developers.cloudflare.com/workers/reference/protocols/)
79 changes: 79 additions & 0 deletions grpc-container-template/container/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const path = require("node:path");
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");

const target = process.argv[2] || "127.0.0.1:8788";
const messageCount = Number.parseInt(process.argv[3] || "4", 10);
const messageIntervalMs = Number.parseInt(process.argv[4] || "200", 10);
const protoPath =
process.env.PROTO_PATH || path.resolve(__dirname, "../proto/bytes.proto");

if (!Number.isInteger(messageCount) || messageCount < 1) {
throw new Error("message count must be a positive integer");
}

if (!Number.isInteger(messageIntervalMs) || messageIntervalMs < 0) {
throw new Error("message interval must be a non-negative integer");
}

const definition = protoLoader.loadSync(protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(definition).cloudflare.grpcdemo;
const client = new proto.ByteStream(target, grpc.credentials.createInsecure());
const call = client.Chat();
const startedAt = Date.now();

function elapsed() {
return `${String(Date.now() - startedAt).padStart(4, " ")}ms`;
}

call.on("data", (chunk) => {
const payload = Buffer.isBuffer(chunk.payload)
? chunk.payload
: Buffer.from(chunk.payload || "");
console.log(
`[${elapsed()}] server -> client: ${payload.toString().trimEnd()}`,
);
});

call.on("end", () => {
console.log(`[${elapsed()}] server ended the response stream`);
client.close();
});

call.on("error", (error) => {
if (error.code !== grpc.status.CANCELLED) {
console.error("stream error", error);
process.exitCode = 1;
}
client.close();
});

let sent = 0;
let timer;

function sendNext() {
sent += 1;
const message = `streaming message ${sent}/${messageCount}\n`;

console.log(`[${elapsed()}] client -> server: ${message.trimEnd()}`);
call.write({ payload: Buffer.from(message) });

if (sent === messageCount) {
if (timer) {
clearInterval(timer);
}
console.log(`[${elapsed()}] client half-closes its request stream`);
call.end();
}
}

sendNext();
if (sent < messageCount) {
timer = setInterval(sendNext, messageIntervalMs);
}
Loading
Loading