@effect-agent/platform-cloudflare 0.1.0-beta.37 → 0.1.0-beta.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-agent/platform-cloudflare",
3
- "version": "0.1.0-beta.37",
3
+ "version": "0.1.0-beta.38",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/index.d.mts",
@@ -25,11 +25,11 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@cloudflare/puppeteer": "1.1.0",
28
- "@effect-agent/core": "0.1.0-beta.37",
29
- "@effect-agent/engine": "0.1.0-beta.37",
30
- "@effect-agent/sandbox": "0.1.0-beta.37",
31
- "@effect-agent/session": "0.1.0-beta.37",
32
- "@effect-agent/storage-cloudflare": "0.1.0-beta.37",
28
+ "@effect-agent/core": "0.1.0-beta.38",
29
+ "@effect-agent/engine": "0.1.0-beta.38",
30
+ "@effect-agent/sandbox": "0.1.0-beta.38",
31
+ "@effect-agent/session": "0.1.0-beta.38",
32
+ "@effect-agent/storage-cloudflare": "0.1.0-beta.38",
33
33
  "@effect/platform-browser": "4.0.0-rc.111",
34
34
  "@effect/sql-sqlite-do": "4.0.0-rc.111",
35
35
  "effect": "4.0.0-rc.111"
@@ -0,0 +1,142 @@
1
+ import { Context, Effect, Layer, Option, Redacted, Schema, Stream } from "effect";
2
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3
+
4
+ export class BrowserRunCleanupError extends Schema.TaggedError<BrowserRunCleanupError>()(
5
+ "BrowserRunCleanupError",
6
+ {
7
+ reason: Schema.Literals([
8
+ "configuration",
9
+ "authorization",
10
+ "rate-limited",
11
+ "provider",
12
+ "malformed",
13
+ "timeout",
14
+ "pending",
15
+ ]),
16
+ status: Schema.optionalKey(Schema.Int),
17
+ },
18
+ ) {}
19
+
20
+ export interface BrowserRunLifecycleOptions {
21
+ readonly accountId: string;
22
+ readonly apiToken: Redacted.Redacted<string>;
23
+ }
24
+
25
+ const Identity = Schema.String.check(Schema.isUUID());
26
+ const Account = Schema.String.check(Schema.isPattern(/^[a-f0-9]{32}$/));
27
+ const Closed = Schema.Struct({ status: Schema.Literals(["closing", "closed"]) });
28
+ const Metadata = Schema.Struct({ sessionId: Identity, endTime: Schema.optionalKey(Schema.Finite) });
29
+ // This exact provider response is accepted only on a fixed-origin, authenticated,
30
+ // exact-session request. An arbitrary 404 or a listing omission is never absence.
31
+ const Absent = Schema.Struct({ error: Schema.Literal("Session not found") });
32
+
33
+ export class BrowserRunSessionLifecycle extends Context.Service<
34
+ BrowserRunSessionLifecycle,
35
+ {
36
+ readonly close: (
37
+ sessionId: Redacted.Redacted<string>,
38
+ ) => Effect.Effect<void, BrowserRunCleanupError>;
39
+ }
40
+ >()("@effect-agent/platform-cloudflare/BrowserRunSessionLifecycle") {
41
+ static layer(options: BrowserRunLifecycleOptions) {
42
+ return Layer.effect(
43
+ this,
44
+ Effect.gen(function* () {
45
+ if (
46
+ !Schema.is(Account)(options.accountId) ||
47
+ Redacted.value(options.apiToken).length === 0
48
+ ) {
49
+ return yield* new BrowserRunCleanupError({ reason: "configuration" });
50
+ }
51
+ const client = yield* HttpClient.HttpClient;
52
+ const request = Effect.fn("BrowserRunSessionLifecycle.request")(function* (
53
+ method: "GET" | "DELETE",
54
+ sessionId: string,
55
+ ) {
56
+ const path = method === "DELETE" ? "browser" : "session";
57
+ const response = yield* client
58
+ .execute(
59
+ HttpClientRequest.make(method)(
60
+ `https://api.cloudflare.com/client/v4/accounts/${options.accountId}/browser-rendering/devtools/${path}/${sessionId}`,
61
+ ).pipe(HttpClientRequest.bearerToken(options.apiToken)),
62
+ )
63
+ .pipe(
64
+ // workerd supports manual/follow, not error. Reject every redirect below.
65
+ Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
66
+ Effect.mapError(() => new BrowserRunCleanupError({ reason: "provider" })),
67
+ );
68
+ if (response.status === 401 || response.status === 403)
69
+ return yield* new BrowserRunCleanupError({
70
+ reason: "authorization",
71
+ status: response.status,
72
+ });
73
+ if (response.status === 429)
74
+ return yield* new BrowserRunCleanupError({
75
+ reason: "rate-limited",
76
+ status: response.status,
77
+ });
78
+ if (response.status !== 200 && response.status !== 404)
79
+ return yield* new BrowserRunCleanupError({
80
+ reason: "provider",
81
+ status: response.status,
82
+ });
83
+ if (response.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json")
84
+ return yield* new BrowserRunCleanupError({ reason: "malformed" });
85
+ const bytes = yield* Stream.runFoldEffect(
86
+ response.stream,
87
+ () => new Uint8Array(),
88
+ (body, chunk) => {
89
+ if (body.byteLength + chunk.byteLength > 16_384)
90
+ return Effect.fail(new BrowserRunCleanupError({ reason: "malformed" }));
91
+ const combined = new Uint8Array(body.byteLength + chunk.byteLength);
92
+ combined.set(body);
93
+ combined.set(chunk, body.byteLength);
94
+ return Effect.succeed(combined);
95
+ },
96
+ ).pipe(Effect.mapError(() => new BrowserRunCleanupError({ reason: "malformed" })));
97
+ const body = yield* Effect.try({
98
+ try: () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes),
99
+ catch: () => new BrowserRunCleanupError({ reason: "malformed" }),
100
+ });
101
+ if (response.status === 404) {
102
+ const absent = Schema.decodeUnknownOption(Schema.fromJsonString(Absent))(body, {
103
+ onExcessProperty: "error",
104
+ });
105
+ if (Option.isSome(absent)) return true;
106
+ return yield* new BrowserRunCleanupError({ reason: "malformed" });
107
+ }
108
+ if (method === "DELETE") {
109
+ const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Closed))(
110
+ body,
111
+ ).pipe(Effect.mapError(() => new BrowserRunCleanupError({ reason: "malformed" })));
112
+ return result.status === "closed";
113
+ }
114
+ const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Metadata))(
115
+ body,
116
+ ).pipe(Effect.mapError(() => new BrowserRunCleanupError({ reason: "malformed" })));
117
+ if (result.sessionId !== sessionId)
118
+ return yield* new BrowserRunCleanupError({ reason: "malformed" });
119
+ return result.endTime !== undefined && result.endTime > 0;
120
+ });
121
+ const close = Effect.fn("BrowserRunSessionLifecycle.close")(
122
+ function* (sessionId: Redacted.Redacted<string>) {
123
+ const id = yield* Schema.decodeUnknownEffect(Identity)(Redacted.value(sessionId)).pipe(
124
+ Effect.mapError(() => new BrowserRunCleanupError({ reason: "configuration" })),
125
+ );
126
+ if (yield* request("DELETE", id)) return;
127
+ for (let read = 0; read < 2; read++) {
128
+ if (yield* request("GET", id)) return;
129
+ }
130
+ return yield* new BrowserRunCleanupError({ reason: "pending" });
131
+ },
132
+ Effect.timeoutOrElse({
133
+ duration: "10 seconds",
134
+ orElse: () => Effect.fail(new BrowserRunCleanupError({ reason: "timeout" })),
135
+ }),
136
+ Effect.withTracerEnabled(false),
137
+ );
138
+ return { close };
139
+ }),
140
+ );
141
+ }
142
+ }
@@ -32,6 +32,7 @@ import {
32
32
  } from "@effect-agent/sandbox";
33
33
  import {
34
34
  Context,
35
+ Clock,
35
36
  Duration,
36
37
  Effect,
37
38
  Layer,
@@ -43,6 +44,9 @@ import {
43
44
  type Scope,
44
45
  } from "effect";
45
46
 
47
+ import { BrowserRunSessionLifecycle } from "./browser-session-lifecycle.ts";
48
+ export { BrowserRunCleanupError, BrowserRunSessionLifecycle } from "./browser-session-lifecycle.ts";
49
+
46
50
  export const browserRunInteractiveImplementation = SandboxImplementation.make({
47
51
  isolation: "isolated",
48
52
  identity: "cloudflare-browser-run-interactive",
@@ -57,7 +61,6 @@ const MAX_LIVE_VIEW_EXPIRY_MILLIS = 60 * 60_000;
57
61
  const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 60_000;
58
62
  const MAX_HOST_TEXT_LENGTH = 8 * 1024;
59
63
  const CLEANUP_STEP_TIMEOUT_MILLIS = 10_000;
60
- const CLOSE_SESSION_TIMEOUT_MILLIS = 10_000;
61
64
  const ACTION_NETWORK_QUIET_MILLIS = 200;
62
65
  const ACTION_NETWORK_SETTLE_MILLIS = 2_000;
63
66
  const ACTION_POST_STATE_MILLIS = 250;
@@ -330,15 +333,23 @@ export class BrowserRunInteractiveBinding extends Context.Service<
330
333
  BrowserRunInteractiveBinding,
331
334
  {
332
335
  readonly launch: (keepAliveMillis: number) => Promise<BrowserRunInteractiveBrowser>;
333
- readonly connect: (sessionId: string) => Promise<BrowserRunInteractiveBrowser>;
336
+ /** Success proves whole-browser termination or exact-session absence. */
337
+ readonly closeSession: (
338
+ sessionId: Redacted.Redacted<string>,
339
+ ) => Effect.Effect<void, InteractiveBrowserError>;
334
340
  }
335
341
  >()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
336
342
  static layer(options: {
337
343
  readonly browser: BrowserRun;
338
344
  readonly viewport?: BrowserRunViewport;
339
- }): Layer.Layer<BrowserRunInteractiveBinding, InteractiveBrowserPolicyDeniedError> {
345
+ }): Layer.Layer<
346
+ BrowserRunInteractiveBinding,
347
+ InteractiveBrowserPolicyDeniedError,
348
+ BrowserRunSessionLifecycle
349
+ > {
340
350
  return Layer.effect(BrowserRunInteractiveBinding)(
341
351
  Effect.gen(function* () {
352
+ const lifecycle = yield* BrowserRunSessionLifecycle;
342
353
  const viewport =
343
354
  options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
344
355
  return {
@@ -349,8 +360,10 @@ export class BrowserRunInteractiveBinding extends Context.Service<
349
360
  ...(viewport === undefined ? {} : { defaultViewport: { ...viewport } }),
350
361
  }),
351
362
  ),
352
- connect: async (sessionId: string) =>
353
- makeProductionBrowser(await puppeteer.connect(options.browser, sessionId)),
363
+ closeSession: (sessionId: Redacted.Redacted<string>) =>
364
+ lifecycle
365
+ .close(sessionId)
366
+ .pipe(Effect.mapError((cause) => actionError("close", cause))),
354
367
  };
355
368
  }),
356
369
  );
@@ -383,6 +396,7 @@ export interface BrowserRunInteractiveSession {
383
396
  export class BrowserRunInteractiveHost extends Context.Service<
384
397
  BrowserRunInteractiveHost,
385
398
  {
399
+ readonly cleanupSemantics?: "confirmed-terminal";
386
400
  readonly open: (
387
401
  policy: InteractiveBrowserPolicy,
388
402
  ) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
@@ -1719,6 +1733,55 @@ const cdpCommand = <A>(
1719
1733
  const makeHostService = (
1720
1734
  binding: BrowserRunInteractiveBinding["Service"],
1721
1735
  ): BrowserRunInteractiveHost["Service"] => {
1736
+ const closeSession = binding.closeSession;
1737
+ const terminate = Effect.fn("BrowserRunInteractiveHost.terminate")(function* (
1738
+ sessionId: Redacted.Redacted<string>,
1739
+ entries: ReadonlyArray<CloseEntry>,
1740
+ ) {
1741
+ const deadline = (yield* Clock.currentTimeMillis) + 10_000;
1742
+ yield* closeSession(sessionId).pipe(
1743
+ Effect.interruptible,
1744
+ Effect.timeoutOrElse({
1745
+ duration: "10 seconds",
1746
+ orElse: () => Effect.fail(actionError("close")),
1747
+ }),
1748
+ );
1749
+ const remaining = deadline - (yield* Clock.currentTimeMillis);
1750
+ if (remaining <= 0)
1751
+ return yield* Effect.logWarning(
1752
+ "Local browser teardown skipped after confirmed termination deadline",
1753
+ );
1754
+ // Remote termination is authoritative. Local cleanup must not veto it or extend the deadline.
1755
+ yield* runTeardown(entries).pipe(
1756
+ Effect.flatMap((failures) =>
1757
+ Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning), {
1758
+ discard: true,
1759
+ }),
1760
+ ),
1761
+ Effect.interruptible,
1762
+ Effect.timeout(`${remaining} millis`),
1763
+ Effect.catchCause(() =>
1764
+ Effect.logWarning("Local browser teardown incomplete after confirmed termination"),
1765
+ ),
1766
+ );
1767
+ });
1768
+ const closeAcquired = Effect.fn("BrowserRunInteractiveHost.closeAcquired")(function* (
1769
+ browser: BrowserRunInteractiveBrowser,
1770
+ ) {
1771
+ const sessionId = yield* Effect.try({
1772
+ try: browser.sessionId,
1773
+ catch: () => actionError("close"),
1774
+ }).pipe(
1775
+ Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
1776
+ Effect.mapError(() => actionError("close")),
1777
+ Effect.onError(() =>
1778
+ closeWithWarning(browser.close, "Closing an unidentified browser failed"),
1779
+ ),
1780
+ );
1781
+ yield* terminate(Redacted.make(sessionId), [
1782
+ closeEntry(browser.close, "Closing the local browser connection failed"),
1783
+ ]);
1784
+ });
1722
1785
  const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (
1723
1786
  policy: InteractiveBrowserPolicy,
1724
1787
  ): Effect.fn.Return<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope> {
@@ -1733,7 +1796,6 @@ const makeHostService = (
1733
1796
  };
1734
1797
  const lifecycle = {
1735
1798
  managedTeardownInstalled: false,
1736
- explicitCloseInvoked: false,
1737
1799
  };
1738
1800
  const closers: Array<CloseEntry> = [];
1739
1801
  const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
@@ -1750,7 +1812,7 @@ const makeHostService = (
1750
1812
  closeLateAcquisition(
1751
1813
  signal,
1752
1814
  () => binding.launch(keepAliveMillis(fixedPolicy)),
1753
- (acquired) => acquired.close(),
1815
+ (acquired) => Effect.runPromise(closeAcquired(acquired)),
1754
1816
  ),
1755
1817
  catch: (cause) =>
1756
1818
  isCapacityRefusal(cause)
@@ -1765,9 +1827,11 @@ const makeHostService = (
1765
1827
  ),
1766
1828
  (acquired) => {
1767
1829
  state.disconnected.value = true;
1768
- return releaseBeforeManaged(
1769
- closeEntry(acquired.close, "Closing the interactive browser failed"),
1770
- );
1830
+ return lifecycle.managedTeardownInstalled
1831
+ ? Effect.void
1832
+ : closeAcquired(acquired).pipe(
1833
+ Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
1834
+ );
1771
1835
  },
1772
1836
  { interruptible: true },
1773
1837
  );
@@ -1901,7 +1965,7 @@ const makeHostService = (
1901
1965
 
1902
1966
  const teardown = yield* Effect.uninterruptible(
1903
1967
  Effect.gen(function* () {
1904
- const cached = yield* Effect.cached(runTeardown(closers));
1968
+ const cached = yield* Effect.cached(terminate(Redacted.make(sessionIdValue), closers));
1905
1969
  lifecycle.managedTeardownInstalled = true;
1906
1970
  yield* Effect.addFinalizer(() =>
1907
1971
  Effect.uninterruptible(
@@ -1910,13 +1974,7 @@ const makeHostService = (
1910
1974
  state.disconnected.value = true;
1911
1975
  }).pipe(
1912
1976
  Effect.andThen(cached),
1913
- Effect.flatMap((failures) =>
1914
- lifecycle.explicitCloseInvoked
1915
- ? Effect.void
1916
- : Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning)).pipe(
1917
- Effect.asVoid,
1918
- ),
1919
- ),
1977
+ Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
1920
1978
  ),
1921
1979
  ),
1922
1980
  );
@@ -1926,15 +1984,9 @@ const makeHostService = (
1926
1984
 
1927
1985
  const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
1928
1986
  Effect.sync(() => {
1929
- lifecycle.explicitCloseInvoked = true;
1930
1987
  state.closed.value = true;
1931
1988
  state.disconnected.value = true;
1932
- }).pipe(
1933
- Effect.andThen(teardown),
1934
- Effect.flatMap((failures) =>
1935
- failures[0] === undefined ? Effect.void : Effect.fail(failures[0].error),
1936
- ),
1937
- ),
1989
+ }).pipe(Effect.andThen(teardown)),
1938
1990
  );
1939
1991
 
1940
1992
  const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
@@ -2057,52 +2109,11 @@ const makeHostService = (
2057
2109
  };
2058
2110
  });
2059
2111
 
2060
- const closeSession = Effect.fn("BrowserRunInteractiveHost.closeSession")(function* (
2061
- sessionId: Redacted.Redacted<string>,
2062
- ) {
2063
- const decoded = yield* Schema.decodeUnknownEffect(Schema.Redacted(BrowserRunSessionId))(
2064
- sessionId,
2065
- ).pipe(
2066
- Effect.mapError(() => policyError("The Browser Run cleanup session identity is malformed")),
2067
- );
2068
- return yield* Effect.scoped(
2069
- Effect.gen(function* () {
2070
- const closeAttempted = { value: false };
2071
- const browser = yield* Effect.acquireRelease(
2072
- Effect.tryPromise({
2073
- try: (signal) =>
2074
- closeLateAcquisition(
2075
- signal,
2076
- () => binding.connect(Redacted.value(decoded)),
2077
- (acquired) => acquired.close(),
2078
- ),
2079
- catch: (cause) => actionError("close", cause),
2080
- }),
2081
- (acquired) =>
2082
- closeAttempted.value
2083
- ? Effect.void
2084
- : closeWithWarning(acquired.close, "Closing the leaked Browser Run session failed"),
2085
- { interruptible: true },
2086
- );
2087
- return yield* Effect.tryPromise({
2088
- try: () => {
2089
- // Mark and start are synchronous so interruption cannot suppress the
2090
- // Scope fallback before the one remote close attempt begins.
2091
- closeAttempted.value = true;
2092
- return browser.close();
2093
- },
2094
- catch: (cause) => actionError("close", cause),
2095
- });
2096
- }),
2097
- ).pipe(
2098
- Effect.timeoutOrElse({
2099
- duration: Duration.millis(CLOSE_SESSION_TIMEOUT_MILLIS),
2100
- orElse: () => Effect.fail(actionError("close")),
2101
- }),
2102
- );
2112
+ return BrowserRunInteractiveHost.of({
2113
+ open,
2114
+ closeSession,
2115
+ cleanupSemantics: "confirmed-terminal",
2103
2116
  });
2104
-
2105
- return BrowserRunInteractiveHost.of({ open, closeSession });
2106
2117
  };
2107
2118
 
2108
2119
  /** Cloudflare host controls and private session identity for one scoped Browser Run pass. */