Skip to content
Closed
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
27 changes: 27 additions & 0 deletions examples/multitenant-deploys/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM node:18-alpine

WORKDIR /app

# Install rivet CLI
RUN apk add --no-cache curl unzip
RUN curl -fsSL https://get.rivet.gg/install.sh | sh

# Copy package files and install dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Copy application code
COPY . .

# Build the application
RUN yarn build

# Expose the port the app will run on
EXPOSE 3000

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Start the application
CMD ["node", "dist/index.js"]
121 changes: 121 additions & 0 deletions examples/multitenant-deploys/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Multitenant Deploys for Rivet

A simple Node.js service for handling multi-tenant deployments with Rivet.

## Features

- Accepts source code uploads via a multipart POST request
- Validates the presence of a Dockerfile
- Deploys the code to Rivet using `rivet publish`
- Sets up a custom domain route for the application

## Getting Started

### Prerequisites

- Node.js
- [Rivet CLI](https://rivet.gg/docs/install)
- Rivet cloud token ([instructions on how to generate](https://rivet.gg/docs/tokens#cloud-token))
- Rivet project ID
- For example if your project is at `https://hub.rivet.gg/projects/foobar`, the ID is `foobar`
- Rivet environment ID
- For example if your environment is at `https://hub.rivet.gg/projects/foobar/environments/prod`, the ID is `prod`

### Environment Variables

You'll need to set the following environment variables:

```bash
RIVET_CLOUD_TOKEN=your_rivet_cloud_token
RIVET_PROJECT=your_project_id
RIVET_ENVIRONMENT=your_environment_name
PORT=3000 # Optional, defaults to 3000
```

You can do this by using [`export`](https://askubuntu.com/a/58828) or [dotenv](https://www.npmjs.com/package/dotenv).

### Developing

```bash
yarn install
yarn dev
```

You can now use `POST http://locahlost:3000/deploy/my-app-id`. Read more about example usage below.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax: Typo in example URL: 'locahlost' should be 'localhost'

Suggested change
You can now use `POST http://locahlost:3000/deploy/my-app-id`. Read more about example usage below.
You can now use `POST http://localhost:3000/deploy/my-app-id`. Read more about example usage below.


### Testing

```bash
yarn test
```

## API Usage

`POST /deploy/:appId`

**Request:**
- URL Path Parameter:
- `appId`: Unique identifier for the application (3-30 characters, lowercase alphanumeric with hyphens)
- Multipart form data containing:
- `Dockerfile`: A valid Dockerfile for the application (required)
- Additional files for the application

**Response:**
```json
{
"success": true,
"appId": "your-app-id",
"endpoint": "https://your-app-id.example.com"
}
```

## Example Usage

```javascript
const appId = "my-app-id";

// Form data that includes project files
const formData = new FormData();

const serverContent = `
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from " + process.env.MY_ENV_VAR);
});
server.listen(8080);
`;
const serverBlob = new Blob([serverContent], {
type: "application/octet-stream"
});
formData.append("server.js", serverBlob, "server.js");

const dockerfileContent = `
FROM node:22-alpine
WORKDIR /app
COPY . .

# Set env var from build arg
ARG MY_ENV_VAR
ENV MY_ENV_VAR=$MY_ENV_VAR

# Create a non-root user
RUN addgroup -S rivetgroup && adduser -S rivet -G rivetgroup
USER rivet

CMD ["node", "server.js"]
`;
const dockerfileBlob = new Blob([dockerfileContent], {
type: "application/octet-stream"
});
formData.append("Dockerfile", dockerfileBlob, "Dockerfile");

// Run the deploy
const response = fetch(`http://localhost:3000/deploy/${appId}`, {
method: "POST",
body: formData
});
if (response.ok) {
const { endpoint } = await response.json();
Comment on lines +114 to +119
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Example code is missing 'await' for fetch call and error handling for non-OK responses

Suggested change
const response = fetch(`http://localhost:3000/deploy/${appId}`, {
method: "POST",
body: formData
});
if (response.ok) {
const { endpoint } = await response.json();
const response = await fetch(`http://localhost:3000/deploy/${appId}`, {
method: "POST",
body: formData
});
if (response.ok) {
const { endpoint } = await response.json();

}
```
23 changes: 23 additions & 0 deletions examples/multitenant-deploys/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "multitenant-deploys",
"packageManager": "yarn@4.6.0",
"scripts": {
"dev": "tsx src/index.ts",
"test": "vitest run",
"build": "tsc"
},
"dependencies": {
"@hono/node-server": "^1.7.0",
"@rivet-gg/api-full": "workspace:*",
"axios": "^1.6.7",
"hono": "^4.0.5",
"temp": "^0.9.4"
},
"devDependencies": {
"@types/node": "^20.11.19",
"@types/temp": "^0.9.4",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"vitest": "^1.2.2"
}
}
155 changes: 155 additions & 0 deletions examples/multitenant-deploys/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Hono } from "hono";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import temp from "temp";
import { RivetClient } from "@rivet-gg/api-full";

const execAsync = promisify(exec);

// Auto-track and cleanup temp directories/files
temp.track();

// Config
const RIVET_CLOUD_TOKEN = process.env.RIVET_CLOUD_TOKEN;
const RIVET_PROJECT = process.env.RIVET_PROJECT;
const RIVET_ENVIRONMENT = process.env.RIVET_ENVIRONMENT;

if (!RIVET_CLOUD_TOKEN || !RIVET_PROJECT || !RIVET_ENVIRONMENT) {
throw new Error(
"Missing required environment variables: RIVET_CLOUD_TOKEN, RIVET_PROJECT, RIVET_ENVIRONMENT",
);
}

export const rivet = new RivetClient({ token: RIVET_CLOUD_TOKEN });

export const app = new Hono();

app.onError((err, c) => {
console.error("Error during operation:", err);
return c.json(
{
error: "Operation failed",
message: err instanceof Error ? err.message : String(err),
},
500,
);
});

app.get("/", (c) => {
return c.text("Multitenant Deploy Example");
});

app.post("/deploy/:appId", async (c) => {
const appId = c.req.param("appId");

// Get the form data
const formData = await c.req.formData();

if (!appId || typeof appId !== "string") {
return c.json({ error: "Missing or invalid appId" }, 400);
}

// Validate app ID (alphanumeric and hyphens only, 3-30 chars)
if (!/^[a-z0-9-]{3,30}$/.test(appId)) {
return c.json(
{
error: "Invalid appId format. Must be 3-30 characters, lowercase alphanumeric with hyphens.",
},
400,
);
}

// Create a temp directory for the files
const tempDir = await temp.mkdir("rivet-deploy-");
const tempDirProject = path.join(tempDir, "project");

// Process and save each file
let hasDockerfile = false;
for (const [fieldName, value] of formData.entries()) {
// Skip non-file fields
if (!(value instanceof File)) continue;

const filePath = path.join(tempDirProject, fieldName);

await fs.mkdir(path.dirname(filePath), { recursive: true });

await fs.writeFile(filePath, Buffer.from(await value.arrayBuffer()));

if (fieldName === "Dockerfile") {
hasDockerfile = true;
}
}

if (!hasDockerfile) {
return c.json({ error: "Dockerfile is required" }, 400);
}

// Tags unique to this app's functions
const appTags = {
// Specifies that this app is deployed by a user
type: "user-app",
// Specifies which app this function belongs to
//
// Used for attributing billing & more
app: appId,
};

// Write Rivet config
const functionName = `fn-${appId}`;
const rivetConfig = {
functions: {
[functionName]: {
build_path: "./project/",
dockerfile: "./project/Dockerfile",
build_args: {
// See MY_ENV_VAR build args in Dockerfile
MY_ENV_VAR: "custom env var",
APP_ID: appId,
},
tags: appTags,
route_subpaths: true,
strip_prefix: true,
resources: { cpu: 125, memory: 128 },
// If you want to host at a subpath:
// path: "/foobar"
},
},
};
await fs.writeFile(
path.join(tempDir, "rivet.json"),
JSON.stringify(rivetConfig),
);

// Run rivet publish command
console.log(`Deploying app ${appId} from ${tempDir}...`);

// Run the deploy command
const deployResult = await execAsync(
`rivet deploy --environment ${RIVET_ENVIRONMENT} --non-interactive`,
{
cwd: tempDir,
},
);

console.log("Publish output:", deployResult.stdout);

// Get the function endpoint
const endpointResult = await execAsync(
`rivet function endpoint --environment prod ${functionName}`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Environment mismatch. Using 'prod' here but RIVET_ENVIRONMENT from env vars on line 130

Suggested change
`rivet function endpoint --environment prod ${functionName}`,
`rivet function endpoint --environment ${RIVET_ENVIRONMENT} ${functionName}`,

{
cwd: tempDir,
},
);

// Strip any extra text and just get the URL
const endpointUrl = endpointResult.stdout.trim();
console.log("Function endpoint:", endpointUrl);

return c.json({
success: true,
appId,
endpoint: endpointUrl,
});
});
10 changes: 10 additions & 0 deletions examples/multitenant-deploys/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { serve } from "@hono/node-server";
import { app } from "./app";

const PORT = process.env.PORT || 3000;
console.log(`Server starting on port ${PORT}...`);
serve({
fetch: app.fetch,
port: Number(PORT),
});

Loading
Loading