Skip to content
Open
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
44 changes: 44 additions & 0 deletions .changeset/tunnel-reconciler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
'@cloudflare/sandbox': patch
---

Add a `@cloudflare/sandbox/tunnels` entrypoint with helpers for sweeping abandoned named tunnels and orphaned DNS records from your Cloudflare account. Useful for long-lived deployments where Durable Object cleanup may not run before tunnel resources are abandoned (e.g. DO eviction, crashes, missed `destroy()` calls). Designed to run from a cron-triggered Worker.

```ts
import { createScheduledTunnelCleanupHandler } from '@cloudflare/sandbox/tunnels';

export default {
scheduled: createScheduledTunnelCleanupHandler({
staleAfterMs: 24 * 60 * 60_000
})
};
```

```jsonc
{
"triggers": {
"crons": ["0 3 * * *"]
}
}
```

The handler reads `CLOUDFLARE_API_TOKEN` from env at run time and is a no-op when the token is missing. It uses `CLOUDFLARE_TUNNEL_ACCOUNT_ID` or `CLOUDFLARE_ACCOUNT_ID` when present, otherwise it infers the account from single-account tokens. `CLOUDFLARE_ZONE_ID` is optional and is inferred when unambiguous; without a resolved zone ID, cleanup still deletes stale tunnels but skips DNS-record cleanup. Workers with unrelated cron jobs can call the returned handler from their own cron dispatch:

```ts
const tunnelCleanup = createScheduledTunnelCleanupHandler({
staleAfterMs: 24 * 60 * 60_000
});

export default {
async scheduled(controller, env, ctx) {
switch (controller.cron) {
case '0 3 * * *':
return tunnelCleanup(controller, env, ctx);
case '*/5 * * * *':
ctx.waitUntil(runUnrelatedJob(env));
}
}
};
```

Cleanup only deletes tunnels tagged by this SDK and refuses to delete any tunnel that is missing its `metadata.sandboxId` tag, so a misconfigured token can't wipe resources created by other tools. The lower-level `sweepStale`, `listSandboxTunnels`, and `listSandboxDNSRecords` helpers are also exported for one-off audits and custom cleanup workflows.
5 changes: 5 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
"types": "./dist/bridge/index.d.ts",
"import": "./dist/bridge/index.js",
"require": "./dist/bridge/index.js"
},
"./tunnels": {
"types": "./dist/tunnels/index.d.ts",
"import": "./dist/tunnels/index.js",
"require": "./dist/tunnels/index.js"
}
},
"keywords": [],
Expand Down
101 changes: 1 addition & 100 deletions packages/sandbox/src/tunnels/cloudflare-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,7 @@
* stops two sandboxes from racing on the same hostname.
*/

const API_BASE = 'https://api.cloudflare.com/client/v4';

/** Cloudflare's standard envelope around every response. */
interface CloudflareResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code?: number; message?: string }>;
}

type Fetcher = typeof fetch;
import { API_BASE, cfRequest, type Fetcher } from './request';

interface BaseArgs {
token: string;
Expand All @@ -44,96 +35,6 @@ export interface TunnelMetadata {
port: number;
}

interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: unknown;
/** Treat these HTTP statuses as success and skip envelope parsing. */
acceptStatuses?: number[];
/**
* Per-request timeout in milliseconds. Defaults to `DEFAULT_TIMEOUT_MS`.
* Without a timeout a hung Cloudflare call wedges the per-port lock in
* `rpc-target.ts` indefinitely, which then blocks every subsequent
* `get(port)` / `destroy(port)` on that port. The shared
* `#zoneNamePromise` makes the impact span every port for named
* tunnels.
*/
timeoutMs?: number;
}

/**
* Default request timeout. Cloudflare API P99 latency is well under
* this; values much smaller risk false positives on cold control-plane
* paths (e.g. first `cfd_tunnel` POST in a new account).
*/
const DEFAULT_TIMEOUT_MS = 10_000;

/**
* Internal request helper. Centralises auth header, JSON encoding,
* timeout enforcement, and envelope unwrapping so each wrapper above
* stays declarative.
*/
async function cfRequest<T>(
url: string,
token: string,
fetcher: Fetcher,
options: RequestOptions = {}
): Promise<T | undefined> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const init: RequestInit = {
method: options.method ?? 'GET',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json'
},
signal: AbortSignal.timeout(timeoutMs)
};
if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
}

let response: Response;
try {
response = await fetcher(url, init);
} catch (err) {
// `AbortSignal.timeout` rejects with a DOMException whose name is
// 'TimeoutError'. Surface it as a clearly-labelled error so callers
// can distinguish a transport hang from a Cloudflare-side failure;
// a SandboxSecurityError-shaped class would be better but we keep
// the error shape consistent with the rest of this module.
if (err instanceof Error && err.name === 'TimeoutError') {
throw new Error(
`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`
);
}
throw err;
}
if (options.acceptStatuses?.includes(response.status)) {
return undefined;
}

let envelope: CloudflareResponse<T>;
try {
envelope = (await response.json()) as CloudflareResponse<T>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Cloudflare API returned non-JSON response (status ${response.status}): ${message}`
);
}

if (!response.ok || envelope.success === false) {
const errs = envelope.errors ?? [];
const summary = errs.length
? errs
.map((e) => `${e.code ?? '???'}: ${e.message ?? 'unknown'}`)
.join(', ')
: `HTTP ${response.status}`;
throw new Error(`Cloudflare API error: ${summary}`);
}

return envelope.result;
}

/**
* Heuristic for the "tags are an Enterprise-only feature" error class.
* Empirically grounded against a non-Enterprise account:
Expand Down
35 changes: 35 additions & 0 deletions packages/sandbox/src/tunnels/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Public entry point for `@cloudflare/sandbox/tunnels`.
*
* Account-wide tunnel-resource helpers. Kept off the main entry point
* (and off the `Sandbox` DO surface) so per-sandbox callers don't
* accidentally reach for sweep primitives that operate on everything
* the token can see.
*
* Typical usage from a cron-triggered Worker:
*
* ```ts
* import { sweepStale } from '@cloudflare/sandbox/tunnels';
*
* await sweepStale(
* { token, accountId, zoneId },
* { staleAfterMs: 24 * 60 * 60_000 }
* );
* ```
*
* The `createScheduledTunnelCleanupHandler` helper is the zero-boilerplate
* way to wire the sweep into a Cron Trigger.
*/

export {
type CloudflareCredentials,
type DNSSummary,
listSandboxDNSRecords,
listSandboxTunnels,
type TunnelSummary
} from './inventory';
export {
createScheduledTunnelCleanupHandler,
type ScheduledTunnelCleanupOptions
} from './scheduled-cleanup';
export { type SweepOptions, type SweepResult, sweepStale } from './sweep';
Loading
Loading