-
Notifications
You must be signed in to change notification settings - Fork 33
Description
Feature Request: Sandbox Lifecycle Hooks in sandbox-sdk
Summary
sandbox-sdk should expose explicit lifecycle hooks so durable objects can reliably react to key container milestones such as “sandbox named” and “sandbox ready”. Today those events are implicit and occur after the durable object's constructor runs, which makes it difficult to coordinate work that depends on the sandbox name or other environment state.
Current Limitation
The Sandbox durable object must wait until Cloudflare assigns a sandbox name before it can expose a preview port. The name is only set once getSandbox returns, which happens after the constructor finishes. Because there is no hook, my code currently:
- Creates a
WorkspaceLifecycleCoordinatorto track asynchronous stages (previewServerStarted,nameSet,portExposed). - Calls
getSandboxexternally, then manually invokesworkspace.markNameAsSet()to let the instance know its name is available. - Delays
exposePreviewPort()until both the preview server is running and the sandbox has been marked as named.
This workaround is fragile, adds coordination boilerplate, and obscures the intended lifecycle.
Proposed Enhancement
Provide first-class lifecycle hooks from sandbox-sdk, for example:
onSandboxNamed((name: string) => Promise<void> | void)– invoked when the sandbox container receives its name/identity.onSandboxReady((context) => Promise<void> | void)– invoked once core runtime resources are available (e.g., after naming, environment hydration, initial filesystem setup).
These hooks should:
- Fire before any external API calls that require the sandbox name (e.g.,
exposePort) are expected to succeed. - Guarantee ordering (constructor → hook registration → hook execution) so that durable object instances can safely register callbacks during construction.
Additional Use Cases Where these lifecycles may be helpful
- State restoration: After naming, fetch git patches or workspace snapshots from S3 keyed by
<sandboxName>/<hash>and rehydrate files before serving traffic.- This is a planned feature in my projects backlog
Benefits
- Eliminates custom lifecycle coordinators and ad-hoc signalling patterns.
- Makes durable objects deterministic and easier to reason about.
- Enables richer provisioning flows (state restoration) without racing the runtime.
Requested Action
Expose lifecycle hook APIs in sandbox-sdk that allow durable objects to subscribe to sandbox naming/ready events, and document their ordering guarantees so application code can safely perform name-dependent work (port exposure, state recovery, etc.) immediately after the hooks fire.
Existing code with workaround to manage named lifecycle event
import { getSandbox, Sandbox } from '@cloudflare/sandbox';
import { DurableObject } from 'cloudflare:workers';
import { readReader } from 'common-utils/streams/readReader';
import { Deferred, makeDeferred } from 'common-utils/promises/makeDeferred';
import { withTimeout } from 'common-utils/promises/withTimeout.ts';
export type StageName =
| 'previewServerStarted'
| 'previewServerReadyForRequests'
| 'nameSet'
| 'portExposed';
class WorkspaceLifecycleCoordinator {
private stages: Map<StageName, Deferred<void>>;
constructor() {
this.stages = new Map([
['previewServerStarted', makeDeferred<void>()],
['previewServerReadyForRequests', makeDeferred<void>()],
['nameSet', makeDeferred<void>()],
['portExposed', makeDeferred<void>()],
]);
}
markStageComplete(stage: StageName): void {
const deferred = this.stages.get(stage);
if (!deferred) {
throw new Error(`Unknown stage: ${stage}`);
}
deferred.resolve();
}
markStageFailed(stage: StageName, error: Error): void {
const deferred = this.stages.get(stage);
if (!deferred) {
throw new Error(`Unknown stage: ${stage}`);
}
deferred.reject(error);
}
async whenStageReady(stage: StageName, timeoutMs?: number): Promise<void> {
const deferred = this.stages.get(stage);
if (!deferred) {
throw new Error(`Unknown stage: ${stage}`);
}
const promise = deferred.promise;
if (timeoutMs !== undefined) {
return withTimeout(promise, timeoutMs);
}
return promise;
}
}
type Preview = {
url: string;
port: number;
name: string | undefined;
};
const PREVIEW_SERVER_STARTUP_TIMEOUT_MS = 30 * 1000; // 30s
export class Workspace extends Sandbox<Env> {
sleepAfter = '10m';
static async provision(
namespace: DurableObjectNamespace<Workspace>,
id: number,
) {
const sandboxNamespace = namespace as unknown as DurableObjectNamespace<
Sandbox<unknown>
>;
const sandbox = getSandbox(
sandboxNamespace,
id.toString(),
) as unknown as DurableObjectStub<Workspace>;
// This is a bit of a hack but the workspace name is only set after calling getSandbox so we need to mark it as set here.
sandbox.markNameAsSet();
return sandbox;
}
private preview: Preview | undefined;
private previewServerProcessId: string | undefined;
private lifecycleCoordinator = new WorkspaceLifecycleCoordinator();
constructor(ctx: DurableObject['ctx'], env: Env) {
super(ctx, env);
console.log('Creating new Workspace instance');
this.startPreviewServer();
this.exposePreviewPort();
}
private isPreviewServerLogReady(chunk: string): boolean {
return Boolean(chunk.match(/VITE\s+(v[\d.]+)\s+ready in/));
}
private async startPreviewServer(): Promise<void> {
const serverSession = await this.createSession({
id: 'serverSession',
cwd: '/workspace',
});
const process = await serverSession.startProcess(
'pnpm --filter react-sandbox-renderer start',
);
this.previewServerProcessId = process.id;
if (!this.previewServerProcessId) {
this.lifecycleCoordinator.markStageFailed(
'previewServerReadyForRequests',
new Error('Failed to start preview server process'),
);
console.error('Failed to start preview server process');
return;
}
this.lifecycleCoordinator.markStageComplete('previewServerStarted');
console.log(
`Vite dev server process started with id: ${this.previewServerProcessId}`,
);
const logStream = await serverSession.streamProcessLogs(
this.previewServerProcessId,
);
const reader = logStream.getReader();
readReader({
reader,
onChunk: chunk => {
if (this.isPreviewServerLogReady(chunk)) {
console.log('✓ Vite dev server is ready');
this.lifecycleCoordinator.markStageComplete(
'previewServerReadyForRequests',
);
return false;
}
return true;
},
onError: error => {
this.lifecycleCoordinator.markStageFailed(
'previewServerReadyForRequests',
error as Error,
);
},
onEnd: () => {
this.lifecycleCoordinator.markStageFailed(
'previewServerReadyForRequests',
new Error('Preview server process exited'),
);
},
});
}
public markNameAsSet(): void {
this.lifecycleCoordinator.markStageComplete('nameSet');
}
// needs to be called after getSandbox is called since the sandbox needs a name to expose its port
async exposePreviewPort(): Promise<void> {
await Promise.all([
this.lifecycleCoordinator.whenStageReady('previewServerStarted'),
this.lifecycleCoordinator.whenStageReady('nameSet'),
]);
this.preview = await this.exposePort(8000, {
hostname: this.env.WORKER_HOSTNAME,
});
this.lifecycleCoordinator.markStageComplete('portExposed');
}
getPreviewUrl(): string {
if (!this.preview) {
throw new Error('Preview port not exposed');
}
return `${this.preview.url}`;
}
async getPreviewServerProcessId(): Promise<string> {
if (!this.previewServerProcessId) {
throw new Error('Preview server process not started');
}
return this.previewServerProcessId;
}
async isPreviewReady(): Promise<void> {
await Promise.all([
this.lifecycleCoordinator.whenStageReady(
'previewServerReadyForRequests',
PREVIEW_SERVER_STARTUP_TIMEOUT_MS,
),
this.lifecycleCoordinator.whenStageReady(
'portExposed',
PREVIEW_SERVER_STARTUP_TIMEOUT_MS,
),
]);
}
}