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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ jobs:
- run: pnpm build
- run: pnpm check-types
- run: pnpm test
- run: pnpm test:core-quickstart
- run: pnpm check-boundaries
- run: pnpm lint
- run: npm pack --dry-run
working-directory: packages/dynamic-apps-builder
- run: npm pack --dry-run
working-directory: packages/dynamic-apps-core
- run: npm pack --dry-run
working-directory: packages/dynamic-apps
- run: pnpm test:packed
22 changes: 21 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
run: |
set -euo pipefail
npm ping
for package in @rivet-dev/dynamic-apps-builder @rivet-dev/dynamic-apps; do
for package in @rivet-dev/dynamic-apps-builder @rivet-dev/dynamic-apps-core @rivet-dev/dynamic-apps; do
if npm view "$package@${{ steps.release.outputs.version }}" version >/dev/null 2>&1; then
echo "$package@${{ steps.release.outputs.version }} already exists" >&2
exit 1
Expand Down Expand Up @@ -81,6 +81,26 @@ jobs:
done
echo "builder version did not become visible" >&2
exit 1
- name: Publish core
env:
NODE_AUTH_TOKEN: ""
run: >-
npm publish
.pack/rivet-dev-dynamic-apps-core-${{ steps.release.outputs.version }}.tgz
--access public
--provenance
--tag ${{ steps.release.outputs.npm_tag }}
- name: Wait for core registry visibility
run: |
set -euo pipefail
for attempt in $(seq 1 30); do
if npm view "@rivet-dev/dynamic-apps-core@${{ steps.release.outputs.version }}" version >/dev/null 2>&1; then
exit 0
fi
sleep 10
done
echo "core version did not become visible" >&2
exit 1
- name: Publish main package
env:
# setup-node provides a dummy token value; clear it so npm uses
Expand Down
1 change: 1 addition & 0 deletions benchmarks/dynamic-apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@rivet-dev/agentos-core": "0.2.15",
"@rivet-dev/agentos-toolchain": "0.2.15",
"@rivet-dev/dynamic-apps": "workspace:*",
"@rivet-dev/dynamic-apps-core": "workspace:*",
"hono": "^4.12.9",
"rivetkit": "2.3.11"
},
Expand Down
11 changes: 6 additions & 5 deletions benchmarks/dynamic-apps/src/edge.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { randomUUID } from "node:crypto";
import { availableParallelism } from "node:os";
import { appsRouter } from "@rivet-dev/dynamic-apps";
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import {
DynamicAppsExecutor,
readExecutorConfig,
} from "../../../packages/dynamic-apps/src/executor.js";
} from "@rivet-dev/dynamic-apps-core/internal";
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import { createRivetReleaseStore } from "../../../packages/dynamic-apps/src/release-store.js";
import {
ACTOR_BENCHMARK_APP_ID,
BENCHMARK_APP_ID,
Expand Down Expand Up @@ -66,12 +67,12 @@ export function createBenchmarkApplication(): Hono {
...process.env,
DYNAMIC_APPS_TIMING_HEADERS: "1",
});
const pooled = new DynamicAppsExecutor({
const pooled = new DynamicAppsExecutor(createRivetReleaseStore(), {
...baseConfig,
executionMode: "pooled",
contextPoolSize: integerEnv("BENCH_CONTEXT_POOL_SIZE", 8, 1, 128),
});
const ephemeral = new DynamicAppsExecutor({
const ephemeral = new DynamicAppsExecutor(createRivetReleaseStore(), {
...baseConfig,
executionMode: "ephemeral",
contextPoolSize: 0,
Expand Down
81 changes: 57 additions & 24 deletions benchmarks/dynamic-apps/src/runtime-stress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain";
import { DynamicActorRuntime } from "../../../packages/dynamic-apps/src/actor-runtime.js";
import {
type ActiveRelease,
DIRECT_ENTRYPOINT,
DIRECT_RUNTIME_FORMAT,
DynamicAppsExecutor,
readExecutorConfig,
} from "../../../packages/dynamic-apps/src/executor.js";
} from "@rivet-dev/dynamic-apps-core/internal";
import { DynamicActorRuntime } from "../../../packages/dynamic-apps/src/actor-runtime.js";
import { setDynamicAppsLogHandler } from "../../../packages/dynamic-apps/src/logging.js";
import {
DIRECT_ENTRYPOINT,
DIRECT_RUNTIME_FORMAT,
} from "../../../packages/dynamic-apps/src/runtime.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -70,6 +69,43 @@ class FakeStatePlane {
readonly calls = { resolve: 0, manifest: 0, chunk: 0, connect: 0 };
beforeChunk?: () => Promise<void>;

async watchActiveRelease(
appId: string,
invalidate: () => void,
): Promise<() => void> {
this.calls.connect += 1;
const listeners = this.listeners.get(appId) ?? new Set();
listeners.add(invalidate);
this.listeners.set(appId, listeners);
return () => {
listeners.delete(invalidate);
};
}

async loadActiveRelease(appId: string): Promise<ActiveRelease> {
this.calls.resolve += 1;
this.calls.manifest += 1;
const state = this.#state(appId);
await this.beforeChunk?.();
this.calls.chunk += 1;
return {
appId,
release: state.artifact.release,
artifact: {
format: DIRECT_RUNTIME_FORMAT,
entrypoint: DIRECT_ENTRYPOINT,
hash: state.artifact.hash,
bytes: new Uint8Array(state.artifact.bytes),
byteLength: state.artifact.bytes.byteLength,
usesRivetKit: false,
},
regions: ["local"],
scaling: { minReplicas: 0, maxReplicas: 1, targetConcurrency: 32 },
maxRequestBytes: 1024 * 1024,
maxResponseBytes: 4 * 1024 * 1024,
};
}

readonly client = {
agentOSAppsApp: {
getOrCreate: (key: string[]) => this.#handle(key[0] ?? ""),
Expand Down Expand Up @@ -381,13 +417,13 @@ async function multiAppStress(
plane.set(`stress-app-${index}`, artifact);
}
const executor = new DynamicAppsExecutor(
plane,
executorConfig({
appEntries: artifacts.length,
concurrency,
poolMaxTotal,
poolSize,
}),
plane.client as never,
);
const rss = rssSampler();
const startedAt = performance.now();
Expand Down Expand Up @@ -440,8 +476,8 @@ async function payloadBurstStress(
const plane = new FakeStatePlane();
plane.set("payload", artifact);
const executor = new DynamicAppsExecutor(
plane,
executorConfig({ appEntries: 2, concurrency, poolMaxTotal, poolSize }),
plane.client as never,
);
const latencies: number[] = [];
const rss = rssSampler();
Expand Down Expand Up @@ -492,8 +528,8 @@ async function invalidationStress(
const plane = new FakeStatePlane();
plane.set("invalidate", before);
const executor = new DynamicAppsExecutor(
plane,
executorConfig({ appEntries: 2, concurrency, poolMaxTotal, poolSize }),
plane.client as never,
);
let activated = false;
let oldResponsesAfterActivation = 0;
Expand Down Expand Up @@ -540,8 +576,8 @@ async function coldFanoutStress(
{ length: count },
() =>
new DynamicAppsExecutor(
plane,
executorConfig({ appEntries: 1, concurrency: 1, poolMaxTotal: 0 }),
plane.client as never,
),
);
const startedAt = performance.now();
Expand Down Expand Up @@ -574,17 +610,14 @@ async function admissionStress(
plane.set("admission", artifact);
const active = Math.min(4, concurrency);
const queued = Math.min(8, Math.max(0, concurrency - active));
const executor = new DynamicAppsExecutor(
{
...executorConfig({
appEntries: 1,
concurrency: active,
poolMaxTotal: 2,
}),
executionQueueSize: queued,
},
plane.client as never,
);
const executor = new DynamicAppsExecutor(plane, {
...executorConfig({
appEntries: 1,
concurrency: active,
poolMaxTotal: 2,
}),
executionQueueSize: queued,
});
let release = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
Expand Down Expand Up @@ -849,7 +882,7 @@ async function directStallStress(
}),
executionTimeoutMs: 50,
};
const executor = new DynamicAppsExecutor(config, plane.client as never);
const executor = new DynamicAppsExecutor(plane, config);
const startedAt = performance.now();
try {
await runConcurrent(requests, requests, async (index) => {
Expand All @@ -875,13 +908,13 @@ async function logFloodStress(artifact: Artifact): Promise<unknown> {
const plane = new FakeStatePlane();
plane.set("log-flood", artifact);
const executor = new DynamicAppsExecutor(
plane,
executorConfig({
appEntries: 1,
concurrency: 1,
poolMaxTotal: 1,
poolSize: 1,
}),
plane.client as never,
);
let delivered = 0;
try {
Expand Down Expand Up @@ -1103,13 +1136,13 @@ async function directShutdownStress(
await gate;
};
const executor = new DynamicAppsExecutor(
plane,
executorConfig({
appEntries: 1,
concurrency: requests,
poolMaxTotal: 8,
poolSize: 8,
}),
plane.client as never,
);
const outcomes = Array.from({ length: requests }, (_, index) =>
executor
Expand Down
46 changes: 46 additions & 0 deletions docs/content/docs/custom-storage.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: "Custom Storage"
description: "Implement durable release publication, loading, and invalidation for Dynamic Apps Core."
skill: true
---

Use `@rivet-dev/dynamic-apps-core` when your host owns release persistence and
notifications:

```ts
import { createDynamicApps } from "@rivet-dev/dynamic-apps-core";

const dynamicApps = createDynamicApps({
async publishRelease(input) {
// Persist input.artifact.bytes, then atomically make this release active.
await store.putArtifact(input.buildId, input.artifact.bytes);
await store.activate(input.appId, input.buildId, input);
return { appId: input.appId, release: input.buildId };
},
async loadActiveRelease(appId) {
// Return one coherent metadata + complete-artifact snapshot.
return store.loadActive(appId);
},
async watchActiveRelease(appId, invalidate) {
// Resolve only after the subscription is live.
return store.subscribe(appId, invalidate);
},
});
```

## Hook guarantees

- **Publish a release:** make the verified artifact durable before atomically
activating it. Do not resolve until a load can observe the new release. A
failed publish must leave the previous release active.
- **Load the active release:** return coherent metadata and complete bytes in
one logical operation. Core copies and independently verifies the bytes.
- **Watch for updates:** subscribe before resolving, invalidate after every
activation, and invalidate after a disconnect that may have missed events.
Duplicate invalidations are safe.

The watcher is required. A no-op watcher is safe only when an app ID cannot
change for the entire lifetime of every serving process.

Call `await dynamicApps.dispose()` during shutdown to release subscriptions,
build resources, cached runtimes, and agentOS contexts.
5 changes: 5 additions & 0 deletions docs/content/docs/deploy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ A failed build or incomplete artifact write never replaces the active release.
A successful call returns only after the immutable artifact is persisted and
activated; it does not mean a request-serving replica was warmed.

With core, this is the `publishRelease` guarantee: make the complete artifact
durable first, atomically replace the active release second, and resolve only
when `loadActiveRelease` can read it. The default adapter provides those
semantics through its per-app Rivet actor.

`appId` must contain 1–63 lowercase letters, numbers, or hyphens. Pass exactly
one of `source` or `files`.

Expand Down
15 changes: 10 additions & 5 deletions docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ skill: true
---

Dynamic Apps builds user-generated HTTP applications in a sandboxed deployment
VM, stores immutable releases in a per-app Rivet actor, and serves ordinary HTTP
through agentOS in your own server process.
VM and serves ordinary HTTP through agentOS in your own server process. The
default `@rivet-dev/dynamic-apps` package stores releases in a per-app Rivet
actor; `@rivet-dev/dynamic-apps-core` lets you supply another store.

<Note>
Dynamic Apps is in preview and its API is subject to change.
Expand All @@ -20,12 +21,16 @@ Hono server, its authentication, and the URL on which applications are mounted.
Deployment and request serving are deliberately separate:

```text
deployApp -> per-app state actor -> AgentOS build VM -> immutable AOSP release
deployApp -> Dynamic Apps Core -> agentOS build VM -> publish release
-> Rivet actor (default adapter)

first HTTP request -> state actor -> verified artifact -> cached agentOS VM
cache-hit HTTP request -> headless JavaScript evaluation -> response (zero actor calls)
first HTTP request -> watch + load active release -> verified artifact -> cached agentOS VM
warm HTTP request -> headless JavaScript evaluation -> response (zero storage/actor calls)
```

The Rivet actor is the default storage and control plane, not a serving hop for
warm requests.

`appsRouter` executes each request in a clean JavaScript context. The default
mode keeps a small bounded pool of retained agentOS contexts, resetting and
reinitializing each one after use. Ephemeral mode asks agentOS for a fresh
Expand Down
Loading
Loading