@effect-agent/platform-cloudflare 0.1.0-beta.120 → 0.1.0-beta.122

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.
@@ -1,10 +1,15 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import { type Browser, type Page, type CDPSession } from "@cloudflare/puppeteer";
2
+
3
3
  import { Cause, Context, Crypto, Effect, Layer, Redacted, Schema, type Scope } from "effect";
4
4
  import { type InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
5
5
  import { ProtectedBrowserError } from "effect-agent/protected-browser";
6
+ import {
7
+ type Browser,
8
+ type Page,
9
+ type CDPSession,
10
+ } from "puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js";
6
11
 
7
- import { BrowserRunBinding } from "../internal/browser-binding.ts";
12
+ import { BrowserRunBinding, type BrowserRunAttachment } from "../internal/browser-binding.ts";
8
13
  import {
9
14
  browserFailure,
10
15
  BrowserRunFailure,
@@ -45,6 +50,9 @@ export class BrowserRunProtectedBinding extends Context.Service<
45
50
  policy: InteractiveBrowserPolicy,
46
51
  identity?: ProtectedProviderIdentity,
47
52
  ) => Effect.Effect<ProtectedProviderSession, ProtectedBrowserError, Scope.Scope>;
53
+ readonly keepAlive: (
54
+ sessionId: Redacted.Redacted<string>,
55
+ ) => Effect.Effect<void, ProtectedBrowserError>;
48
56
  }
49
57
  >()("@effect-agent/platform-cloudflare/BrowserRunProtectedBinding") {}
50
58
 
@@ -78,6 +86,7 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
78
86
  let control: CDPSession | undefined;
79
87
  let providerIdentity: ProtectedProviderIdentity | undefined;
80
88
  let browser: Browser | undefined;
89
+ let attachment: BrowserRunAttachment | undefined;
81
90
  let driver: ProtectedBrowserTransport | undefined;
82
91
  let invalid = false;
83
92
  // SDK acquisition may finish after interruption. Its late-reply callback must await cleanup,
@@ -89,6 +98,32 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
89
98
  driver?.invalidate();
90
99
  if (sessionId === undefined) return "unconfirmed" as const;
91
100
 
101
+ // A failed resume has not acquired the host's retained provider. Release only
102
+ // this connection; successful attachments and new allocations own termination.
103
+ if (identity !== undefined && driver === undefined) {
104
+ if (attachment !== undefined)
105
+ yield* attachment.retire.pipe(
106
+ Effect.interruptible,
107
+ Effect.timeoutOrElse({
108
+ duration: "1 second",
109
+ orElse: () =>
110
+ Effect.fail(
111
+ new BrowserRunFailure({
112
+ operation: "protected.disconnect",
113
+ reason: "timeout",
114
+ }),
115
+ ),
116
+ }),
117
+ Effect.catch((error) =>
118
+ reportBrowserCause("protected.disconnect", Cause.fail(error)).pipe(
119
+ Effect.andThen(() => Effect.die(reportedBrowserError(error))),
120
+ ),
121
+ ),
122
+ );
123
+
124
+ return "unconfirmed" as const;
125
+ }
126
+
92
127
  const cleanup = yield* lifecycle.close(sessionId).pipe(
93
128
  Effect.as("confirmed" as const),
94
129
  Effect.catchCause((cause) =>
@@ -155,7 +190,8 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
155
190
  }
156
191
  // Resume only a host-persisted exact page. Never open a replacement page or context.
157
192
  stage = "protected.connect";
158
- browser = await binding.connect(Redacted.value(sessionId), stage);
193
+ attachment = binding.connect(Redacted.value(sessionId), stage, signal);
194
+ browser = await attachment.browser;
159
195
  if (signal.aborted || invalid) {
160
196
  await runCleanup(terminate);
161
197
  throw new ProtectedTransportError({ reason: "stale-reference" });
@@ -203,32 +239,38 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
203
239
  targetId: Redacted.make(info.targetInfo.targetId),
204
240
  });
205
241
 
206
- stage = "protected.interception";
207
- await page.setBypassServiceWorker(true);
208
- await page.setRequestInterception(true);
209
- page.on("request", (request) => {
210
- let allowed = policy.network._tag === "Unrestricted";
211
-
212
- try {
213
- const url = new URL(request.url());
214
-
215
- allowed ||=
216
- policy.network._tag === "ExactHosts" &&
217
- url.protocol === "https:" &&
218
- !url.username &&
219
- !url.password &&
220
- policy.network.allowedHosts.includes(url.host);
221
- } catch {
222
- /* Refuse malformed destinations. */
223
- }
224
- void (
225
- allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
226
- ).catch(async (cause) => {
227
- invalid = true;
228
- driver?.invalidate();
229
- await runCleanup(reportBrowserCause("protected.interception", Cause.fail(cause)));
242
+ // Unrestricted passes need no attachment-local network enforcement. Leave their
243
+ // service workers and requests alone, including across host-owned detach/resume.
244
+ if (policy.network._tag !== "Unrestricted") {
245
+ stage = "protected.interception";
246
+ await page.setBypassServiceWorker(true);
247
+ await page.setRequestInterception(true);
248
+ page.on("request", (request) => {
249
+ let allowed = false;
250
+
251
+ try {
252
+ const url = new URL(request.url());
253
+
254
+ allowed =
255
+ policy.network._tag === "ExactHosts" &&
256
+ url.protocol === "https:" &&
257
+ !url.username &&
258
+ !url.password &&
259
+ policy.network.allowedHosts.includes(url.host);
260
+ } catch {
261
+ /* Refuse malformed destinations. */
262
+ }
263
+ void (
264
+ allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
265
+ ).catch(async (cause) => {
266
+ invalid = true;
267
+ driver?.invalidate();
268
+ await runCleanup(
269
+ reportBrowserCause("protected.interception", Cause.fail(cause)),
270
+ );
271
+ });
230
272
  });
231
- });
273
+ }
232
274
  if (signal.aborted || invalid) {
233
275
  await runCleanup(terminate);
234
276
  throw new ProtectedTransportError({ reason: "stale-reference" });
@@ -253,8 +295,26 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
253
295
  orElse: () =>
254
296
  Effect.fail(new BrowserRunFailure({ operation: stage, reason: "timeout" })),
255
297
  }),
256
- Effect.catch((error) =>
257
- reportBrowserCause(stage, Cause.fail(error)).pipe(
298
+ Effect.catch((error) => {
299
+ if (identity !== undefined && driver === undefined)
300
+ return reportBrowserCause(stage, Cause.fail(error)).pipe(
301
+ Effect.andThen(
302
+ Effect.fail(
303
+ reportedBrowserError(
304
+ new ProtectedBrowserError({
305
+ ...failure(),
306
+ reason: error.reason === "timeout" ? "timeout" : "provider",
307
+ cleanup: "not-requested",
308
+ }),
309
+ ),
310
+ ),
311
+ ),
312
+ // A disposal defect stays alongside the original failure. Only a pure typed
313
+ // failure proves that this pre-handle attachment is safe to relinquish.
314
+ Effect.ensuring(close),
315
+ );
316
+
317
+ return reportBrowserCause(stage, Cause.fail(error)).pipe(
258
318
  Effect.andThen(close),
259
319
  Effect.flatMap((cleanup) =>
260
320
  Effect.fail(
@@ -267,8 +327,8 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
267
327
  ),
268
328
  ),
269
329
  ),
270
- ),
271
- ),
330
+ );
331
+ }),
272
332
  );
273
333
 
274
334
  driver = yield* makeProtectedNativeTransport(policy).pipe(
@@ -334,7 +394,38 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
334
394
  };
335
395
  }, Effect.withTracerEnabled(false));
336
396
 
337
- return { open };
397
+ const keepAlive = Effect.fn("BrowserRunProtectedBinding.keepAlive")(function* (
398
+ sessionId: Redacted.Redacted<string>,
399
+ ) {
400
+ const failure = (reason: ProtectedBrowserError["reason"]) =>
401
+ new ProtectedBrowserError({
402
+ reason,
403
+ dispatch: "not-dispatched",
404
+ milestone: "none",
405
+ observation: "protected",
406
+ cleanup: "not-requested",
407
+ });
408
+
409
+ const id = yield* Schema.decodeEffect(ProtectedProviderIdentity.fields.sessionId)(
410
+ sessionId,
411
+ ).pipe(Effect.mapError(() => failure("denied")));
412
+
413
+ yield* binding.keepAlive(Redacted.value(id)).pipe(
414
+ Effect.timeoutOrElse({
415
+ duration: "10 seconds",
416
+ orElse: () =>
417
+ Effect.fail(
418
+ new BrowserRunFailure({ operation: "protected.keepAlive", reason: "timeout" }),
419
+ ),
420
+ }),
421
+ Effect.tapError((error) => reportBrowserCause("protected.keepAlive", Cause.fail(error))),
422
+ Effect.mapError((error) =>
423
+ reportedBrowserError(failure(error.reason === "timeout" ? "timeout" : "provider")),
424
+ ),
425
+ );
426
+ }, Effect.withTracerEnabled(false));
427
+
428
+ return { open, keepAlive };
338
429
  }),
339
430
  ).pipe(Layer.provide(BrowserRunBinding.layer(options.browser)));
340
431
 
@@ -83,6 +83,14 @@ export interface BrowserRunProtectedSession {
83
83
  export class BrowserRunProtectedHost extends Context.Service<
84
84
  BrowserRunProtectedHost,
85
85
  {
86
+ /**
87
+ * Refresh provider inactivity on an exact retained session with one browser-level command.
88
+ * Internally scoped and bounded to ten seconds; releases only its own connection on every exit.
89
+ * Does not acquire a page, transfer control, extend host expiry, or close the provider on failure.
90
+ */
91
+ readonly keepAlive: (
92
+ sessionId: Redacted.Redacted<string>,
93
+ ) => Effect.Effect<void, ProtectedBrowserError>;
86
94
  readonly open: (
87
95
  policy: InteractiveBrowserPolicy,
88
96
  ) => Effect.Effect<
@@ -90,6 +98,13 @@ export class BrowserRunProtectedHost extends Context.Service<
90
98
  ProtectedBrowserError,
91
99
  Scope.Scope | BrowserCredentialAccess
92
100
  >;
101
+ /**
102
+ * A pure pre-handle provider failure with not-dispatched/none/not-requested evidence
103
+ * retires this attempt's SDK callbacks and confirms local socket closure before returning
104
+ * (or never starts SDK initialization). Raw retirement failure remains a mixed defect.
105
+ * This does not confirm provider health or undo already-sent CDP; the host still owns
106
+ * atomic claim restoration and must exclude defects, interruption, and post-handle errors.
107
+ */
93
108
  readonly resume: (
94
109
  checkpoint: BrowserRunProtectedCheckpoint,
95
110
  ) => Effect.Effect<
@@ -362,6 +377,7 @@ export const browserRunProtectedHostLayer = () =>
362
377
  }, Effect.withTracerEnabled(false));
363
378
 
364
379
  return {
380
+ keepAlive: binding.keepAlive,
365
381
  open: (policy: InteractiveBrowserPolicy) => open(policy),
366
382
  resume: (checkpoint: BrowserRunProtectedCheckpoint) =>
367
383
  Schema.decodeEffect(BrowserRunProtectedCheckpoint)(checkpoint).pipe(
@@ -1,5 +1,5 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import type { Browser, Frame, JSHandle, Page } from "@cloudflare/puppeteer";
2
+
3
3
  import { Cause, Clock, Context, Crypto, Effect, Redacted, Schema, type Scope } from "effect";
4
4
  import { type InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
5
5
  import {
@@ -7,6 +7,12 @@ import {
7
7
  CredentialTarget,
8
8
  ProtectedBrowserControl,
9
9
  } from "effect-agent/protected-browser";
10
+ import type {
11
+ Browser,
12
+ Frame,
13
+ JSHandle,
14
+ Page,
15
+ } from "puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js";
10
16
 
11
17
  import {
12
18
  browserFailure,
@@ -22,14 +28,6 @@ import {
22
28
  type ProtectedBrowserTransport,
23
29
  } from "./policy.ts";
24
30
 
25
- // The pinned SDK implements this method but strips its internal declaration from lib/types.d.ts.
26
- // Keep the declaration narrow and refuse runtimes without it. No main-world fallback is safe.
27
- declare module "@cloudflare/puppeteer" {
28
- interface Frame {
29
- isolatedRealm(): { evaluateHandle(source: string): Promise<JSHandle<unknown>> };
30
- }
31
- }
32
-
33
31
  const Description = Schema.Struct({
34
32
  role: ProtectedBrowserControl.fields.role,
35
33
  formIndex: Schema.Natural,