Status: the constrained forge-application/v1alpha1 Alpha runtime and numeric
limits are implemented for Alpha-enabled fixtures. Broader compatibility
remains proposed.
The normal Forge author declares one application in forgeBuild.ts:
app: {
entrypoint: 'src/server.ts',
assets: { directory: 'dist', fallback: 'index.html' },
}Forge compiles that application into static assets, an HTTP runtime, managed
durable state, and managed realtime components as needed. Worker, Durable Object, namespace, class, service binding, and provider script are compiled
plan vocabulary, not application choices.
Every application entrypoint receives the managed state and realtime APIs.
They are lazy: no durable storage is retained until a successful mutation, and
no realtime connection exists until a client subscribes. An assets-only
application has no code runtime and creates neither. A site administrator may
disable realtime as a spend guard without changing the module shape;
authorization then returns realtime_refused.
(scope, topic), and
non-authoritative.Unsupported semantics fail planning or execution with a stable diagnostic. Forge never silently weakens serializability, isolation, or access policy.
Forge's trusted build step produces:
entrypoint;Rules:
fetch method;eval, and new Function are rejected;v1alpha1;Forge invokes a pinned bundler with no repository plugins or configuration. The repository build may prepare assets, generated source, and installed dependencies, but it does not supply the provider-ready wrapper or deployment artifact.
Example:
export default {
async fetch(request, env) {
const url = new URL(request.url);
const eventId = url.searchParams.get("event");
const event = env.forge.state.scope(`event/${eventId}`);
return Response.json(await event.list({ prefix: "booth/" }));
},
};declare const forgeRealtimeGrantBrand: unique symbol;
interface ForgeRealtimeGrant {
readonly [forgeRealtimeGrantBrand]: true;
}
type ForgeHandlerResult = Response | ForgeRealtimeGrant;
interface ForgeApplicationModule {
fetch(
request: Request,
env: Readonly<ForgeEnvironment>,
context: ForgeExecutionContext,
): ForgeHandlerResult | Promise<ForgeHandlerResult>;
}There is one HTTP handler per application in v1alpha1. The handler receives
the original normalized URL path selected by Forge routing. Exact /.forge
and /.forge/* requests remain platform-owned and never reach repository code.
Unsupported repository-authored handlers include scheduled events, queues,
email, tail or trace, alarms, WebSocket upgrade, raw TCP, and background
daemons. ForgeRealtimeGrant is an unforgeable, opaque platform value that an
authorization endpoint may return directly. Managed realtime is exposed
through the reserved platform endpoint, not an application 101 response.
The portable baseline includes:
Request, Response, Headers, URL, and URLSearchParams;fetch under the effective Forge egress policy;crypto and crypto.subtle;TextEncoder, TextDecoder, atob, and btoa;ReadableStream, WritableStream, and TransformStream;AbortController and AbortSignal;structuredClone;console;It excludes process, Buffer, require, filesystem and operating-system
access, raw provider bindings, provider identifiers, Forge service bindings,
storage credentials, and management APIs.
interface ForgeExecutionContext {
waitUntil(promise: Promise<unknown>): void;
}waitUntil shares the request's project, deployment, CPU, egress, log, and
cost attribution; it is cancelled at the post-response deadline.
passThroughOnException is unsupported.
Every application entrypoint receives the same portable environment:
interface ForgeEnvironment {
readonly forge: {
readonly state: ForgeStateNamespace;
readonly realtime: ForgeRealtime;
};
}The adapter does not upload the repository bundle as the top-level provider handler. It generates a versioned trusted wrapper that imports the immutable user module and alone receives provider bindings.
The wrapper:
The release records the user-bundle digest. Each environment deployment also records a provider-artifact digest derived from the wrapper version, user-bundle digest, application components, policy, and compatibility settings.
Repository code selects a logical scope, not a Durable Object:
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
type ForgeGetResult =
| {
found: false;
scopeVersion: string;
}
| {
found: true;
value: JsonValue;
version: string;
scopeVersion: string;
};
interface ForgeStateEntry {
key: string;
value: JsonValue;
version: string;
}
interface ForgeRealtimeCursor {
topic: string;
sequence: string;
scopeVersion: string;
}
interface ForgeStateNamespace {
scope(name: string): ForgeDurableScope;
}
interface ForgeDurableScope {
get(key: string): Promise<ForgeGetResult>;
list(options?: {
prefix?: string;
cursor?: string;
limit?: number;
}): Promise<{
entries: ForgeStateEntry[];
cursor?: string;
complete: boolean;
scopeVersion: string;
}>;
snapshot(options: {
topic: string;
prefix?: string;
limit?: number;
}): Promise<{
entries: ForgeStateEntry[];
complete: boolean;
realtimeCursor: ForgeRealtimeCursor;
}>;
mutate(operations: ForgeMutation[]): Promise<{
scopeVersion: string;
results: ForgeMutationResult[];
}>;
}Initial mutation operations are:
type ForgeMutation =
| { assertAbsent: { key: string } }
| { assertVersion: { key: string; version: string } }
| { put: { key: string; value: JsonValue } }
| { delete: { key: string } }
| { increment: { key: string; delta: number } }
| {
emit: {
topic: string;
event: JsonValue;
};
};
type ForgeMutationResult =
| { type: "assertAbsent" }
| { type: "assertVersion" }
| { type: "put"; version: string }
| { type: "delete"; existed: boolean }
| { type: "increment"; value: number; version: string }
| {
type: "emit";
sequence: string;
delivery: "queued" | "suppressed";
};All operations in one mutate call commit atomically and serializably inside
one scope. Results are positional. A failed assertion commits nothing and
rejects with state_conflict. Every successful mutation advances an opaque
monotonic scopeVersion. Stored numbers must be finite. increment accepts
only safe-integer deltas, treats an absent key as zero, and rejects a
non-number or overflow.
Each (scope, topic) also has a contiguous unsigned 64-bit event sequence,
encoded as a decimal string. An emit advances that sequence and records the
event intent in the same SQLite transaction as its state mutation. It becomes
visible only after the state commit succeeds. State is authoritative;
realtime events are change notifications that prompt clients to apply a
versioned update or refetch a snapshot.
delivery: "queued" means admitted for best-effort live fan-out, not
guaranteed delivery. Realtime disablement, event-rate exhaustion, or a full
short-lived outbox returns "suppressed" without rolling back the state
mutation. The sequence still advances, and Forge closes or marks affected live
subscriptions for resync. Realtime cost controls can therefore degrade live
updates without taking down authoritative state.
snapshot reads the selected entries, scopeVersion, and topic sequence in
one transaction. Its initial limit ceiling is 100 and complete: false is
not valid for a browser snapshot endpoint; applications with larger snapshots
must expose a different bounded projection or wait for a future contract.
The managed implementation:
Custom Durable Object code, alarms, arbitrary methods, schema migrations,
outbound connections, and provider bindings require a future
cloudflare-native capability.
The managed realtime interface is always present but creates no connection until the Forge browser client subscribes:
interface ForgeSnapshotEnvelope<T extends JsonValue = JsonValue> {
type: "forge-snapshot";
data: T;
cursor: ForgeRealtimeCursor;
}
interface ForgeRealtime {
authorize(
request: Request,
subscription: {
scope: string;
topic: string;
},
): Promise<ForgeRealtimeGrant>;
snapshotResponse<T extends JsonValue>(
request: Request,
snapshot: ForgeSnapshotEnvelope<T>,
): Response;
}Every application event in v1alpha1 is an emit inside state.mutate.
Standalone publishing, transient events unrelated to state, and browser
publishing are deferred.
Browser use is through the versioned Forge client:
const subscription = forge.realtime.subscribe({
authorize: `/api/events/${eventId}/subscribe`,
snapshot: `/api/events/${eventId}/availability`,
onSnapshot(value) {
render(value);
},
onEvent(event) {
applyVersionedEvent(event);
},
});The application's subscribe endpoint authenticates and authorizes the ordinary HTTP request, then calls:
return env.forge.realtime.authorize(request, {
scope: `event/${eventId}`,
topic: "availability",
});The return value has no enumerable or readable data. The trusted wrapper keeps its brand and ticket bytes in private lexical state, recognizes it only as the handler's final result, and serializes the wire response after repository code has returned. Repository code cannot inspect, clone, stringify, or log the ticket. The SDK calls this endpoint for the first connection and every reconnect. For intentionally public data, the application endpoint may authorize an anonymous request. Forge still applies project, origin, deployment, generation, and quota policy.
The snapshot endpoint reads state and the realtime sequence atomically, builds
the application projection, and uses snapshotResponse:
const snapshot = await event.snapshot({
topic: "availability",
prefix: "booth/",
});
if (!snapshot.complete) {
return Response.json({ error: "snapshot_too_large" }, { status: 413 });
}
return env.forge.realtime.snapshotResponse(request, {
type: "forge-snapshot",
data: Object.fromEntries(
snapshot.entries.map(({ key, value }) => [key, value]),
),
cursor: snapshot.realtimeCursor,
});snapshotResponse emits the normative JSON envelope and an ETag derived
from both scope version and topic sequence. It returns 304 for a matching
If-None-Match. Arbitrary JSON snapshot responses are not valid SDK inputs.
Every mutation that affects a topic's snapshot must include an emit for that
topic; Forge does not infer application-level dependencies.
The client:
/.forge/realtime endpoint;ready, buffers events without invoking application callbacks;The SDK buffers at most 256 events or 1 MiB during bootstrap or resync. Overflow discards the buffer and starts a fresh snapshot. There is no partial replay.
Wire events use:
interface ForgeRealtimeEvent<T extends JsonValue = JsonValue> {
type: "event";
sequence: string;
scopeVersion: string;
event: T;
}authorize internally creates a single-use, 60-second handshake ticket scoped
to tenant, project, environment or preview deployment, normalized public
origin, scope, topic, deployment generation, access epoch, and issuing
release. The expected origin is new URL(request.url).origin after trusted
host normalization; an incoming Origin, when present, must match it exactly.
The SDK offers subprotocols
forge.realtime.v1, forge-ticket.<base64url-ticket>. The server echoes only
forge.realtime.v1, atomically consumes the ticket immediately before a
successful upgrade, and redacts both protocol fields from platform and
provider logs. Tickets never appear in a URL. Reconnect always requires a new
opaque grant from the application endpoint.
The grant wire response carries an SDK-only fallbackAllowed decision. Policy,
access, suspension, and exhausted-budget denials issue no grant. A later
1008/4403 access close or 4429 budget close forbids polling; a transient
4503 or abnormal network failure permits it only when the grant did.
Polling uses the snapshot endpoint, its ETag, the same access and generation
checks, and the same realtime budget. 401, 403, 410, or 429 stops
polling.
Realtime guarantees:
(scope, topic);Activation, rollback, disablement, access changes, and suspension close
affected sockets with a platform code. Activation and rollback stop new
old-generation grants immediately, drain for at most 30 seconds, and close
remaining sockets with 1012 (Service Restart). The SDK applies 0–30
seconds of jitter. A one-use reconnect lease tied to the prior connection
exempts one attempt within two minutes from the connection-attempt rate limit,
but not from authorization, concurrency, request, bandwidth, or spend limits.
Suspension revokes grants, increments the access epoch, makes every socket
inert, and closes sockets with 1008 (Policy Violation). The client does not
reconnect automatically. The fixed shard pool gives the control plane a
bounded set of hibernating objects to wake, enumerate, close, and require
acknowledgment from. Inertness is the safety guarantee; physical closure has a
ten-second objective while the provider control path is available. A missed
notification is retried and cannot permit state mutation or event delivery.
The booth application uses one scope per event:
const event = env.forge.state.scope(`event/${eventId}`);
await event.mutate([
{ assertAbsent: { key: `booth/${boothId}` } },
{ put: { key: `booth/${boothId}`, value: reservation } },
{
emit: {
topic: "availability",
event: { boothId, status: "reserved" },
},
},
]);Concurrent claim attempts serialize inside the event scope. Exactly one
assertion succeeds; losers receive state_conflict, which the HTTP handler
maps to 409. Availability updates commit atomically with the reservation.
Clients see live changes when admitted to realtime and bounded conditional
polling otherwise. No D1 database, companion Worker, raw Durable Object class,
Cloudflare token, or additional CI target is application-visible.
If one event later becomes a hotspot, an explicitly reviewed application change may partition state by booth while preserving event-level notifications through a managed durable outbox. Forge does not silently change partitioning or consistency.
User-authored outbound traffic originates only from the application HTTP handler.
fetch():
https: by default;Managed durable state and realtime make no user-directed outbound requests. Cloudflare Outbound Workers do not intercept Durable Object fetches, which is why custom Durable Object code and outbound sockets are not part of the automatic runtime.
Response within limits or an
opaque ForgeRealtimeGrant as their final value.101 is rejected; only /.forge/realtime may upgrade.500 responses.The trusted edge:
CF-*, and X-Forge-*;noindex policy;Cache-Control: no-store;Domain attribute and reserves
__Host-forge-*;v1alpha1; andStatic assets retain the immutable-asset response policy.
Application, state, and realtime logs are attributed to tenant, project, environment, release, deployment, component, scope digest, request or connection, and provider operation. Raw state values, preview credentials, share tokens, cookies, realtime tickets, and provider secrets are not logged.
Every managed runtime method rejects with the same typed error:
type ForgeErrorCode =
| "binding_unavailable"
| "invalid_scope"
| "scope_limit_exceeded"
| "invalid_key"
| "invalid_number"
| "value_too_large"
| "state_conflict"
| "quota_exceeded"
| "rate_limited"
| "realtime_refused"
| "resync_required"
| "egress_denied"
| "request_cancelled"
| "runtime_limit_exceeded"
| "unsupported_runtime_feature";
class ForgeRuntimeError extends Error {
readonly code: ForgeErrorCode;
readonly retryable: boolean;
readonly retryAfterMs?: number;
}Application code may catch and map it to a domain response. If it does not, the trusted wrapper returns a bounded JSON error with no provider detail:
{
"error": {
"code": "state_conflict",
"retryable": false
}
}Invalid input uses 400 or 413, state conflicts use 409, policy denials
use 403, quota and rate limits use 429 with Retry-After, unavailable
dependencies use 503, and unsupported runtime features use 501. Other
uncaught application exceptions remain sanitized 500 responses.
These are Forge ceilings, not provider promises. A repository may request lower limits. Higher limits require a project entitlement and budget review.
| Limit | Default |
|---|---|
| Application bundle, uncompressed | 5 MiB |
| Request body | 10 MiB |
| Response body | 25 MiB |
| CPU per request | 50 ms |
| Wall time per request | 30 s |
| Outbound subrequests | 20 |
| Outbound response bytes | 10 MiB |
waitUntil promises |
5 |
Post-response waitUntil |
10 s |
| Log bytes per request | 64 KiB |
| Limit | Default |
|---|---|
| Fixed production shards per project | 16 |
| Fixed shards per preview deployment | 4 |
| Production scopes per project | 1,000 |
| Preview scopes per deployment | 100 |
| New production scopes | 100/hour |
| New preview scopes | 20/hour |
| Distinct production scope names addressed | 200/hour |
| Distinct preview scope names addressed | 50/hour |
| Scope-name bytes | 256 |
| Keys per list page | 100 |
| Operations per HTTP request | 50 |
| Operations per atomic mutation | 32 |
| Key bytes | 512 |
| Value bytes | 256 KiB |
| Stored bytes per scope | 10 MiB |
| Production stored bytes per project | 100 MiB |
| Stored bytes per preview deployment | 10 MiB |
| Stored bytes across live previews | 50 MiB |
Distinct-scope limits count the first address of each normalized scope name in a rolling hour, including misses and rejected mutations, and are enforced before a provider-object call. All state rates are keyed by tenant, project, and production environment or exact preview deployment. Fixed sharding bounds provider-object cardinality even when code rotates unretained scope names.
| Limit | Default |
|---|---|
| Fixed production shards per project | 16 |
| Fixed shards per preview deployment | 4 |
| Active topics per project | 100 |
| Active topics per preview | 20 |
| New topics per project | 50/hour |
| New topics per preview | 10/hour |
| Concurrent connections per project, all environments | 200 |
| Concurrent connections per preview | 20 |
| Concurrent connections per scope | 50 |
| Concurrent connections per browser session | 4 |
| Soft concurrent connections per source IP | 20 |
| New non-platform connection attempts | 60/minute/project |
| Scheduled reconnect | randomized between 45 and 60 minutes |
| Hard session lifetime | 75 minutes |
| Visible idle lifetime | no timeout; hibernation required |
| Hidden-page close | after 10 minutes |
| Event bytes | 8 KiB |
| Events per scope | 30/minute |
| Events per project | 300/minute |
| Recipients per event | 50 |
| Short-lived outbox retention | 60 seconds |
| Short-lived outbox per shard | 1 MiB |
| Managed realtime bandwidth | 50 MiB/day/project |
| Bootstrap/resync buffer | 256 events or 1 MiB |
| Polling fallback | 15 seconds to 5 minutes with backoff |
Topic creation and connection-attempt windows are rolling and keyed by tenant, project, and environment. The one-use platform reconnect lease described above is the only attempt-rate exemption.
Socket connections, fallback polls, messages, deliveries, fan-out, and bytes consume one shared realtime budget. The platform additionally enforces project, tenant, provider-account, and global request, active-duration, storage, bandwidth, and spend circuit breakers. Approaching a realtime budget first refuses new subscriptions and lengthens fallback backoff. Reaching the budget disables both sockets and polling for that project; it never converts a socket denial into extra polling. It does not disable authoritative HTTP or durable state unless their own safety limits are exceeded.
The adapter fixture must verify:
waitUntil, cancellation, streaming, logging, CPU, wall, and subrequest
limits;/.forge/* routing;Until this fixture passes, the manifest parser may recognize application,
but planning must return runtime_not_enabled.