diff --git a/src/content/docs/sandbox/api/file-watching.mdx b/src/content/docs/sandbox/api/file-watching.mdx index 929d18d71f15b15..be9cb32898db09a 100644 --- a/src/content/docs/sandbox/api/file-watching.mdx +++ b/src/content/docs/sandbox/api/file-watching.mdx @@ -7,7 +7,9 @@ sidebar: import { TypeScriptExample } from "~/components"; -Monitor filesystem changes in real-time using Linux's native inotify system. The `watch()` method returns a Server-Sent Events (SSE) stream of file change events that you consume with `parseSSEStream()`. +Monitor filesystem changes in real-time using Linux's native inotify system. Use `watch()` for a live Server-Sent Events (SSE) stream while a consumer is actively connected. + +Use the persistent watch APIs when background consumers need to keep watch state alive across reconnects while the container is still running. ## Methods @@ -81,6 +83,104 @@ const stream = await session.watch("/workspace/src", { ::: +### `ensureWatch()` + +Create or reuse a persistent watch. Unlike `watch()`, this keeps change state in the container even when no SSE client is connected. + +```ts +const result = await sandbox.ensureWatch( + path: string, + options?: PersistentWatchOptions, +): Promise +``` + +**Parameters**: + +- `path` - Absolute path or relative to `/workspace` (for example, `/app/src` or `src`) +- `options` (optional): + - `recursive` - Watch subdirectories recursively (default: `true`) + - `include` - Glob patterns to include. Cannot be used together with `exclude`. + - `exclude` - Glob patterns to exclude (default: `['.git', 'node_modules', '.DS_Store']`). Cannot be used together with `include`. + - `sessionId` - Session to run the watch in (if omitted, the default session is used) + - `resumeToken` - Stable token that lets repeated `ensureWatch()` calls reconnect to the same persistent watch + +**Returns**: `Promise` - the current `watch` state plus a `leaseToken` used by `checkpointWatch()` and `stopWatch()` + + + +```ts +const { watch, leaseToken } = await sandbox.ensureWatch("/workspace/src", { + resumeToken: "builder:user-123", + recursive: true, + exclude: [".git", "node_modules"], +}); + +const state = await sandbox.getWatchState(watch.watchId); + +if (state.watch.changed || state.watch.overflowed) { + await rebuildFromFilesystem(); + await sandbox.checkpointWatch(watch.watchId, { + cursor: state.watch.cursor, + leaseToken, + }); +} +``` + + + +### `getWatchState()` + +Get the retained state for a persistent watch created with `ensureWatch()`. + +```ts +const result = await sandbox.getWatchState(watchId: string): Promise +``` + +**Parameters**: + +- `watchId` - Watch identifier returned by `ensureWatch()` + +**Returns**: `Promise` - the latest persistent state for the watch + +### `checkpointWatch()` + +Mark changes up to a cursor as processed. + +```ts +const result = await sandbox.checkpointWatch( + watchId: string, + request: WatchCheckpointRequest, +): Promise +``` + +**Parameters**: + +- `watchId` - Watch identifier returned by `ensureWatch()` +- `request`: + - `cursor` - Cursor value from the current `WatchState` + - `leaseToken` - Lease token returned by `ensureWatch()` + +**Returns**: `Promise` - whether the checkpoint was accepted and the updated watch state + +### `stopWatch()` + +Stop a watch by ID. When stopping a persistent watch created with `ensureWatch()`, pass the `leaseToken`. + +```ts +const result = await sandbox.stopWatch( + watchId: string, + options?: WatchStopOptions, +): Promise +``` + +**Parameters**: + +- `watchId` - Watch identifier returned by `watch()` or `ensureWatch()` +- `options` (optional): + - `leaseToken` - Required when stopping a persistent watch obtained from `ensureWatch()` + +**Returns**: `Promise` - confirmation that the watch stopped + ## Types ### `FileWatchSSEEvent` @@ -92,6 +192,7 @@ type FileWatchSSEEvent = | { type: "watching"; path: string; watchId: string } | { type: "event"; + eventId: string; eventType: FileWatchEventType; path: string; isDirectory: boolean; @@ -102,7 +203,7 @@ type FileWatchSSEEvent = ``` - **`watching`** — Emitted once when the watch is established. Contains the `watchId` and the `path` being watched. -- **`event`** — Emitted for each filesystem change. Contains the `eventType`, the `path` that changed, and whether it `isDirectory`. +- **`event`** — Emitted for each filesystem change. Contains an `eventId`, the `eventType`, the `path` that changed, and whether it `isDirectory`. - **`error`** — Emitted when the watch encounters an error. - **`stopped`** — Emitted when the watch is stopped, with a `reason`. @@ -144,10 +245,119 @@ interface WatchOptions { } ``` +### `PersistentWatchOptions` + +Options for creating a persistent watch. + +```ts +interface PersistentWatchOptions extends WatchOptions { + /** Stable token used to reconnect to the same persistent watch. */ + resumeToken?: string; +} +``` + :::caution[Mutual exclusivity] `include` and `exclude` cannot be used together. Use `include` to allowlist patterns, or `exclude` to blocklist patterns. Requests that specify both are rejected with a validation error. ::: +### `WatchState` + +State retained for a persistent watch while the container stays alive. + +```ts +interface WatchState { + watchId: string; + path: string; + recursive: boolean; + include?: string[]; + exclude?: string[]; + cursor: number; + changed: boolean; + overflowed: boolean; + lastEventAt: string | null; + expiresAt: string | null; + subscriberCount: number; + startedAt: string; +} +``` + +- `changed` - Files changed since the last successful checkpoint. +- `cursor` - Monotonic change counter used for checkpointing. +- `overflowed` - Incremental delivery may have dropped events, so reconcile from current filesystem state before checkpointing. +- `expiresAt` - When an idle persistent watch will expire automatically. +- `subscriberCount` - Number of live SSE subscribers currently attached to the watch. + +### `WatchEnsureResult` + +Returned by `ensureWatch()`. + +```ts +interface WatchEnsureResult { + success: boolean; + watch: WatchState; + leaseToken: string; + timestamp: string; +} +``` + +### `WatchStateResult` + +Returned by `getWatchState()`. + +```ts +interface WatchStateResult { + success: boolean; + watch: WatchState; + timestamp: string; +} +``` + +### `WatchCheckpointRequest` + +Request body for `checkpointWatch()`. + +```ts +interface WatchCheckpointRequest { + cursor: number; + leaseToken: string; +} +``` + +### `WatchCheckpointResult` + +Returned by `checkpointWatch()`. + +```ts +interface WatchCheckpointResult { + success: boolean; + checkpointed: boolean; + watch: WatchState; + timestamp: string; +} +``` + +### `WatchStopOptions` + +Options for `stopWatch()`. + +```ts +interface WatchStopOptions { + leaseToken?: string; +} +``` + +### `WatchStopResult` + +Returned by `stopWatch()`. + +```ts +interface WatchStopResult { + success: boolean; + watchId: string; + timestamp: string; +} +``` + ### `parseSSEStream()` Converts a `ReadableStream` into a typed `AsyncGenerator` of events. Accepts an optional `AbortSignal` to cancel the stream. @@ -195,11 +405,19 @@ Character classes (`[abc]`), brace expansion (`{a,b}`), and backslash escapes ar ## Notes :::note[Deterministic readiness] -`watch()` blocks until the filesystem watcher is established on the server. When the promise resolves, the watcher is active and you can immediately perform filesystem actions that depend on the watch being in place. +`watch()` blocks until the filesystem watcher is established on the server. `ensureWatch()` also resolves only after the persistent watch exists. When either promise resolves, the watch is active and you can immediately perform filesystem actions that depend on it. +::: + +:::note[Persistent watch state] +Persistent watch state is an invalidation signal, not an event log. `changed` means files changed since the last successful checkpoint. `overflowed` means incremental delivery may have dropped events, so reconcile from the current filesystem state before calling `checkpointWatch()`. +::: + +:::note[Idle expiry] +Persistent watches expire automatically if they are not refreshed. Call `ensureWatch()` again with the same `resumeToken` when a consumer reconnects if you need to keep the watch alive. ::: :::note[Container lifecycle] -File watchers are automatically stopped when the sandbox container sleeps or is destroyed. You do not need to manually cancel the stream on container shutdown. +Live and persistent watches are automatically stopped when the sandbox container sleeps or is destroyed. Persistent watch state is container-lifetime only and is not durable across container restarts. ::: :::caution[Path requirements] diff --git a/src/content/docs/sandbox/guides/file-watching.mdx b/src/content/docs/sandbox/guides/file-watching.mdx index 8a2abd9c331e17a..412183c0a9ddd4b 100644 --- a/src/content/docs/sandbox/guides/file-watching.mdx +++ b/src/content/docs/sandbox/guides/file-watching.mdx @@ -12,6 +12,17 @@ This guide shows you how to monitor filesystem changes in real-time using the Sa The `watch()` method returns an SSE (Server-Sent Events) stream that you consume with `parseSSEStream()`. Each event in the stream describes a filesystem change. +Use `watch()` when you have an active consumer reading the SSE stream. Use persistent watch state when a background consumer, hibernating Durable Object, or reconnecting client needs to keep track of whether files changed while nothing is connected. + +## Choose a watch model + +- `watch()` - Best for active consumers such as live development tools, terminal sessions, and streaming UIs that stay connected. +- `ensureWatch()` - Best for background consumers that need reconnect-friendly invalidation state without holding an SSE connection open. + +:::note[Container-lifetime state] +Persistent watch state lives only as long as the sandbox container stays alive. If the sandbox sleeps or is destroyed, create a new persistent watch after it wakes up. +::: + ## Basic file watching Start by watching a directory for any changes: @@ -211,6 +222,48 @@ for await (const event of parseSSEStream(stream)) { +## Track changes across reconnects + +Persistent watch state lets a background consumer keep a watch alive without holding an SSE connection open. This pattern works well for hibernating Durable Objects, background indexers, and retrying workers. + +Use the following flow: + +1. Call `ensureWatch()` to create or reconnect to a persistent watch. +2. Call `getWatchState()` when the consumer wakes up. +3. Reconcile from the current filesystem state if `changed` or `overflowed` is `true`. +4. Call `checkpointWatch()` with the latest `cursor` after reconciliation finishes. + + + +```ts +const { watch: persistentWatch, leaseToken } = await sandbox.ensureWatch( + "/workspace/docs", + { + resumeToken: "indexer:user-123", + recursive: true, + exclude: [".git", "node_modules"], + }, +); + +const state = await sandbox.getWatchState(persistentWatch.watchId); + +if (state.watch.changed || state.watch.overflowed) { + await rebuildIndexFromFilesystem(); + await sandbox.checkpointWatch(persistentWatch.watchId, { + cursor: state.watch.cursor, + leaseToken, + }); +} +``` + + + +- `resumeToken` lets repeated `ensureWatch()` calls reconnect to the same persistent watch after retries or wake-ups. +- `leaseToken` is returned by `ensureWatch()` and is required for `checkpointWatch()` and `stopWatch()`. +- `changed` means at least one file changed since the last successful checkpoint. +- `overflowed` means incremental delivery may have dropped events, so rebuild from the current filesystem state instead of assuming you saw every change. +- `expiresAt` shows when an idle persistent watch will expire automatically. + ## Advanced patterns ### Process events with a helper function @@ -330,11 +383,11 @@ for await (const event of parseSSEStream(stream)) { ## Stop a watch -The stream ends naturally when the container sleeps or shuts down. There are two ways to stop a watch early: +The stream ends naturally when the container sleeps or shuts down. Use the stop pattern that matches your watch model: ### Use an AbortController -Pass an `AbortSignal` to `parseSSEStream`. Aborting the signal cancels the stream reader, which propagates cleanup to the server. This is the recommended approach when you need to cancel the watch from outside the consuming loop: +Pass an `AbortSignal` to `parseSSEStream`. Aborting the signal cancels the stream reader, which propagates cleanup to the server. This is the recommended way to stop a live `watch()` stream from outside the consuming loop: @@ -364,7 +417,7 @@ console.log("Watch stopped"); ### Break out of the loop -Breaking out of the `for await` loop also cancels the stream: +Breaking out of the `for await` loop also cancels a live `watch()` stream: @@ -392,6 +445,27 @@ console.log("Watch stopped"); +### Use `stopWatch()` for persistent watches + +Use `stopWatch()` when you no longer need a persistent watch created with `ensureWatch()`: + + + +```ts +const { watch: persistentWatch, leaseToken } = await sandbox.ensureWatch( + "/workspace/src", + { + resumeToken: "builder:user-123", + }, +); + +await sandbox.stopWatch(persistentWatch.watchId, { + leaseToken, +}); +``` + + + ## Best practices ### Use server-side filtering @@ -495,7 +569,7 @@ If watching large directories causes performance issues: All paths must exist and resolve to within `/workspace`. Relative paths are resolved from `/workspace`. :::note[Container lifecycle] -File watchers are automatically stopped when the sandbox sleeps or shuts down. If the sandbox wakes up, you must re-establish watches in your application logic. +Live watchers and persistent watch state are automatically stopped when the sandbox sleeps or shuts down. If the sandbox wakes up, you must create a new watch in your application logic. ::: ## Related resources diff --git a/src/content/docs/sandbox/index.mdx b/src/content/docs/sandbox/index.mdx index 0b405e3ac735699..409855949ed6b77 100644 --- a/src/content/docs/sandbox/index.mdx +++ b/src/content/docs/sandbox/index.mdx @@ -119,38 +119,40 @@ With Sandbox, you can execute Python scripts, run Node.js applications, analyze ``` - - ```typescript - import { getSandbox } from '@cloudflare/sandbox'; - - export { Sandbox } from '@cloudflare/sandbox'; - - export default { - async fetch(request: Request, env: Env): Promise { - const sandbox = getSandbox(env.Sandbox, 'user-123'); - - // Watch for file changes in real-time - const watcher = await sandbox.watch('/workspace/src', { - include: ['*.js', '*.ts'], - onEvent: (event) => { - console.log(`${event.type}: ${event.path}`); - if (event.type === 'modify') { - // Trigger rebuild or hot reload - console.log('Code changed, recompiling...'); - } - }, - onError: (error) => { - console.error('Watch error:', error); - } - }); - - // Stop watching when done - setTimeout(() => watcher.stop(), 60000); - - return Response.json({ message: 'File watcher started' }); - } - }; - ``` + + ```typescript + import { getSandbox } from '@cloudflare/sandbox'; + + export { Sandbox } from '@cloudflare/sandbox'; + + export default { + async fetch(request: Request, env: Env): Promise { + const sandbox = getSandbox(env.Sandbox, 'user-123'); + + const { watch, leaseToken } = await sandbox.ensureWatch('/workspace/src', { + include: ['*.js', '*.ts'], + resumeToken: 'builder:user-123' + }); + + const state = await sandbox.getWatchState(watch.watchId); + + if (state.watch.changed || state.watch.overflowed) { + // Reconcile from the current filesystem state before checkpointing + await sandbox.checkpointWatch(watch.watchId, { + cursor: state.watch.cursor, + leaseToken + }); + } + + return Response.json({ + watchId: watch.watchId, + changed: state.watch.changed + }); + } + }; + ``` + + Use `watch()` for live SSE streams, or `ensureWatch()` when a background consumer needs watch state across reconnects. @@ -250,7 +252,7 @@ Mount S3-compatible object storage (R2, S3, GCS, and more) as local filesystems. -Monitor files and directories for changes using native filesystem events. Perfect for building hot reloading development servers, build automation systems, and configuration monitoring tools. +Monitor files and directories with live SSE streams or persistent watch state. Build hot reloading development servers, background indexers, and automation that reconnects while the sandbox stays alive.