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: 0 additions & 1 deletion benchmarks/dynamic-apps/src/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ export function createBenchmarkApplication(): Hono {
headers.set("x-agentos-app-registry-dispatch", "1");
return appsRouter.fetch(new Request(request, { headers }));
};
app.all("/api/rivet", (c) => privateRegistry(c.req.raw));
app.all("/api/rivet/*", (c) => privateRegistry(c.req.raw));

app.all("/bench/noop", () => {
Expand Down
13 changes: 7 additions & 6 deletions benchmarks/dynamic-apps/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ export async function deployActorBenchmarkFixture(
type: "module",
main: "index.js",
dependencies: {
hono: "4.13.3",
rivetkit: "2.3.11",
},
}),
"index.js": `
import { Hono } from "hono";
import { actor, event, setup } from "rivetkit";
import { db } from "rivetkit/db";

Expand Down Expand Up @@ -106,12 +108,11 @@ const counter = actor({
},
});

export const registry = setup({ use: { counter } });
registry.start();

export default function fetch() {
return Response.json({ ok: true, workload: "actor-and-direct-http" });
}
const registry = setup({ use: { counter } });
const app = new Hono();
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
app.all("*", () => Response.json({ ok: true, workload: "actor-and-direct-http" }));
export default app;
`,
},
scaling: {
Expand Down
18 changes: 6 additions & 12 deletions benchmarks/dynamic-apps/src/runtime-stress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,9 @@ async function main(): Promise<void> {
);
const actorHandlerStallArtifact = await createActorArtifact(
"actor-handler-stall",
`export const registry = {
handler() {
`export function handler() {
while (true) {}
},
};`,
}`,
);
const actorMemoryArtifact = await createActorArtifact(
"actor-memory",
Expand Down Expand Up @@ -1274,26 +1272,22 @@ function createActorArtifact(
function actorTrafficSource(): string {
return `
let counter = 0;
export const registry = {
async handler(request) {
export async function handler(request) {
counter += 1;
const requestBytes = (await request.arrayBuffer()).byteLength;
return Response.json({ counter, requestBytes });
},
};
}
`;
}

function actorMemorySource(allocationBytes: number): string {
return `
let retained;
export const registry = {
handler() {
export function handler() {
retained = new Uint8Array(${allocationBytes});
retained.fill(1);
return new Response(String(retained.byteLength));
},
};
}
`;
}

Expand Down
13 changes: 6 additions & 7 deletions packages/dynamic-apps-builder/test/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,17 @@ describe("apps-builder", () => {
entrypoint: "app.ts",
release: "rivetkit-direct-test",
maxResponseBytes: 1024 * 1024,
usesRivetKit: true,
}),
);
await writeFile(
join(workspace, "app.ts"),
[
'import { actor, setup } from "rivetkit";',
"const counter = actor({ state: { count: 0 } });",
"export const registry = setup({ use: { counter } });",
"registry.start();",
"const registry = setup({ use: { counter } });",
"export default {",
" fetch() {",
" fetch(request) {",
' if (new URL(request.url).pathname.startsWith("/api/rivet")) return registry.handler(request);',
' return new Response(typeof registry.handler + ":" + typeof counter);',
" },",
"};",
Expand Down Expand Up @@ -283,7 +282,7 @@ describe("apps-builder", () => {
});
});

test("emits a small platform-linked actor registry bundle", async () => {
test("emits a small platform-linked actor fetch bundle", async () => {
const root = await mkdtemp(join(tmpdir(), "agentos-apps-actor-builder-"));
const workspace = join(root, "workspace");
const release = join(root, "release");
Expand All @@ -297,8 +296,8 @@ describe("apps-builder", () => {
[
'import { actor, setup } from "rivetkit";',
"const counter = actor({ state: { count: 0 } });",
"export const registry = setup({ use: { counter } });",
"registry.start();",
"const registry = setup({ use: { counter } });",
"export default { fetch: (request) => registry.handler(request) };",
].join("\n"),
);
const configPath = join(root, "config.json");
Expand Down
10 changes: 5 additions & 5 deletions packages/dynamic-apps/API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,10 @@ part of the retained API.

## App-defined RivetKit actor contract

An application that declares `rivetkit` may export
`const registry = setup(...)` and may retain its `registry.start()` call. The
platform suppresses that call while importing the managed actor bundle. The
same app must still provide a valid direct default fetch handler.
An application that declares `rivetkit` mounts `registry.handler()` at
`/api/rivet` and `/api/rivet/*` in its default exported fetch router. It must
not call `registry.start()` or `serve()` because Dynamic Apps owns the HTTP
listener. The same mounted router serves ordinary direct requests.

On activation, deployment configures the returned `namespace` and `pool` with
an authenticated serverless callback to the private `agentOSAppsApp` actor.
Expand All @@ -355,7 +355,7 @@ actor-enabled release.

The callback lazily verifies and extracts `actor/main.mjs`, then caches one
worker thread per active release. The worker uses the platform's pinned
RivetKit WebAssembly runtime and the app namespace/pool connection. Actor
RivetKit native runtime and the app namespace/pool connection. Actor
state, actions, events, connections, request/response streaming, backpressure,
and cancellation use the ordinary RivetKit protocol. Worker entries are
bounded by count, V8 heap, idle TTL, callback body size, and container memory
Expand Down
37 changes: 27 additions & 10 deletions packages/dynamic-apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ bodies are not supported.

## App-defined actors

An app that declares `rivetkit` may also export a registry. The platform owns
the runner lifecycle, so the existing `registry.start()` call remains valid:
An app that declares `rivetkit` mounts the registry handler in its normal fetch
router. Dynamic Apps owns the listener, so the application must not call
`registry.start()` or `serve()`:

```ts
import { actor, setup } from "rivetkit";
import { Hono } from "hono";

const counter = actor({
state: { value: 0 },
Expand All @@ -49,10 +51,11 @@ const counter = actor({
},
});

export const registry = setup({ use: { counter } });
registry.start();

export default () => new Response("ok");
const registry = setup({ use: { counter } });
const app = new Hono();
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
app.all("*", () => new Response("ok"));
export default app;
```

Use the unchanged `deployApp` result to create the app client:
Expand All @@ -74,13 +77,27 @@ Every app is deployed to its own stable Rivet namespace. On Rivet Cloud, set
uses it to provision the namespace and namespace-scoped access, secret, and
publishable credentials. The management credential is never returned or passed
to app code; `deployApp()` returns only the app's publishable token.
Rivet Compute derives the app actor callback from its `.rivet.run` hostname.
Set `DYNAMIC_APPS_CALLBACK_URL` only when the host is exposed at a different
public origin; Dynamic Apps appends `/api/rivet`.

The deploy CLI's cached management credential is not injected into the app.
Pass the token as a server-side Compute environment variable:

```sh
npx @rivetkit/cli deploy \
--namespace <namespace> \
--env PORT=3000 \
--env RIVET_CLOUD_TOKEN="$RIVET_CLOUD_TOKEN"
```

By default, the app actor callback enters through the host app actor's Rivet
gateway and authenticates with the publishable credential from
`RIVET_PUBLIC_ENDPOINT`. This keeps the child app namespace separate from the
host registry namespace. `DYNAMIC_APPS_CALLBACK_URL` is only for a custom
receiver that accepts child-namespace lifecycle callbacks; Dynamic Apps appends
`/api/rivet` to that origin.

Actor requests follow the normal Rivet Engine path. The app's serverless
callback loads its verified actor bundle into a bounded process-local worker
thread and uses the host's pinned RivetKit WebAssembly runtime. State, actions,
thread and uses the host's pinned RivetKit native runtime. State, actions,
events, connections, and streaming actor responses are handled by RivetKit;
ordinary HTTP for the same app still uses the agentOS evaluation path.

Expand Down
52 changes: 21 additions & 31 deletions packages/dynamic-apps/src/actor-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ export class DynamicActorRuntime {
status: 413,
});
}
console.info(
JSON.stringify({
msg: "Dynamic App actor callback received",
method: input.request.method,
path: new URL(input.request.url).pathname,
bodyBytes: body.byteLength,
contentLength: input.request.headers.get("content-length"),
}),
);
const entry = await this.#entry(input);
try {
await entry.ready;
Expand Down Expand Up @@ -396,10 +405,10 @@ export class DynamicActorRuntime {
await mkdir(dirname(target), { recursive: true });
await writeFile(target, bytes, { mode: 0o600 });
}
const platformPackages = await resolvePlatformActorPackages();
const rivetkitPackage = await resolvePlatformRivetKitPackage();
await mkdir(join(directory, "node_modules"), { recursive: true });
await symlink(
platformPackages.rivetkit,
rivetkitPackage,
join(directory, "node_modules", "rivetkit"),
"dir",
);
Expand All @@ -415,10 +424,7 @@ export class DynamicActorRuntime {
`data:text/javascript,${encodeURIComponent(ACTOR_WORKER_SOURCE)}`,
),
{
workerData: {
entrypoint,
wasmPath: platformPackages.wasmPath,
},
workerData: { entrypoint },
env: actorWorkerEnvironment(input),
resourceLimits: {
maxOldGenerationSizeMb: this.config.heapLimitMb,
Expand Down Expand Up @@ -711,24 +717,15 @@ async function readBoundedBody(
}
}

let platformActorPackages:
| Promise<{ rivetkit: string; wasmPath: string }>
| undefined;
let platformRivetKitPackage: Promise<string> | undefined;

function resolvePlatformActorPackages(): Promise<{
rivetkit: string;
wasmPath: string;
}> {
platformActorPackages ??= (async () => {
function resolvePlatformRivetKitPackage(): Promise<string> {
platformRivetKitPackage ??= (async () => {
const hostRequire = createRequire(import.meta.url);
const rivetkitEntry = hostRequire.resolve("rivetkit");
const rivetkit = await findPackageRoot(rivetkitEntry);
const wasmPath = createRequire(rivetkitEntry).resolve(
"@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm",
);
return { rivetkit, wasmPath };
return findPackageRoot(rivetkitEntry);
})();
return platformActorPackages;
return platformRivetKitPackage;
}

async function findPackageRoot(entrypoint: string): Promise<string> {
Expand Down Expand Up @@ -777,7 +774,7 @@ export function actorWorkerEnvironment(
endpoint.password = "";
return {
NODE_ENV: "production",
RIVETKIT_RUNTIME: "wasm",
RIVETKIT_RUNTIME: "native",
RIVETKIT_RUNTIME_MODE: "serverless",
RIVET_ENDPOINT: endpoint.toString().replace(/\/$/u, ""),
RIVET_NAMESPACE: input.namespace,
Expand Down Expand Up @@ -958,17 +955,10 @@ class ActorAdmission {
}

const ACTOR_WORKER_SOURCE = `
import { readFile } from "node:fs/promises";
import { parentPort, workerData } from "node:worker_threads";
if (!parentPort) throw new Error("Dynamic App actor worker has no parent port");
const { registry } = await import(workerData.entrypoint);
if (typeof registry?.handler !== "function") throw new TypeError("Dynamic App actor registry is invalid");
if (process.env.RIVETKIT_RUNTIME === "wasm" && registry.config) {
registry.config.wasm = {
...registry.config.wasm,
initInput: await readFile(workerData.wasmPath),
};
}
const { handler } = await import(workerData.entrypoint);
if (typeof handler !== "function") throw new TypeError("Dynamic App actor fetch handler is invalid");
const requests = new Map();
const acknowledgements = new Map();
const waitForAck = (id) => new Promise((resolve) => acknowledgements.set(id, resolve));
Expand All @@ -995,7 +985,7 @@ parentPort.on("message", (message) => {
const controller = new AbortController();
requests.set(message.id, controller);
try {
const response = await registry.handler(new Request(message.url, {
const response = await handler(new Request(message.url, {
method: message.method,
headers: message.headers,
body: message.method === "GET" || message.method === "HEAD" ? undefined : message.body,
Expand Down
1 change: 0 additions & 1 deletion packages/dynamic-apps/src/actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,6 @@ async function buildRelease(
entrypoint: plan.entrypoint,
release,
maxResponseBytes: config.maxResponseBytes,
usesRivetKit: plan.usesRivetKit,
}),
),
});
Expand Down
44 changes: 34 additions & 10 deletions packages/dynamic-apps/src/control-plane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,19 +299,41 @@ export async function provisionAppNamespace(
};
}

function serverlessAppUrl(
function serverlessAppCallback(
appActorId: string,
connection: ResolvedRivetConnection,
): string {
): { url: string; token?: string } {
const configured = process.env.DYNAMIC_APPS_CALLBACK_URL;
if (configured) return new URL("/api/rivet", configured).toString();
if (process.env._RIVET_COMPUTE) {
return `https://${connection.namespace}.rivet.run/api/rivet`;
if (configured) {
return { url: new URL("/api/rivet", configured).toString() };
}
return new URL(
`/gateway/${encodeURIComponent(appActorId)}/request/.agentos/apps/rivet`,
connection.endpoint,
).toString();

const publicEndpoint = process.env.RIVET_PUBLIC_ENDPOINT;
const callbackConnection = publicEndpoint
? resolveRivetConnection(publicEndpoint)
: connection;
return {
url: new URL(
`/gateway/${encodeURIComponent(appActorId)}/request/.agentos/apps/rivet`,
callbackConnection.endpoint,
).toString(),
...(callbackConnection.token ? { token: callbackConnection.token } : {}),
};
}

function resolveRivetConnection(rawEndpoint: string): ResolvedRivetConnection {
const url = new URL(rawEndpoint);
const namespace = url.username
? decodeURIComponent(url.username)
: (process.env.RIVET_NAMESPACE ?? "default");
const token = url.password ? decodeURIComponent(url.password) : undefined;
url.username = "";
url.password = "";
return {
endpoint: url.toString().replace(/\/$/, ""),
namespace,
...(token ? { token } : {}),
};
}

/** Configure the nested actor pool only after its release is ready. */
Expand All @@ -327,12 +349,14 @@ export async function configureAppNamespaceRunner(
callbackConnection = resolveDefaultRivetConnection(),
): Promise<void> {
try {
const callback = serverlessAppCallback(appActorId, callbackConnection);
await ensureServerlessRunnerConfig({
endpoint: runtime.endpoint,
namespace: runtime.namespace,
url: serverlessAppUrl(appActorId, callbackConnection),
url: callback.url,
pool: runtime.pool,
token: runtime.controlToken,
callbackToken: callback.token,
callbackSecret,
});
} catch (error) {
Expand Down
Loading
Loading