effect-browser 0.3.0-beta.7 → 0.3.0-beta.9

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.
Files changed (64) hide show
  1. package/README.md +114 -19
  2. package/dist/{Browser-CNwK0r0q.mjs → Browser-Bc_LIqTC.mjs} +184 -16
  3. package/dist/Browser-Bc_LIqTC.mjs.map +1 -0
  4. package/dist/Browser.d.mts +16 -4
  5. package/dist/Browser.mjs +2 -2
  6. package/dist/{BrowserError-DXXIA6Rk.d.mts → BrowserError-Coy7EW1N.d.mts} +10 -5
  7. package/dist/BrowserError.d.mts +2 -2
  8. package/dist/BrowserError.mjs +10 -1
  9. package/dist/BrowserError.mjs.map +1 -1
  10. package/dist/Cdp.d.mts +1 -1
  11. package/dist/Cdp.mjs +3 -3
  12. package/dist/Chromium.d.mts +1 -1
  13. package/dist/Chromium.mjs +2 -2
  14. package/dist/Expect.d.mts +2 -2
  15. package/dist/Expect.mjs +2 -2
  16. package/dist/Moment.d.mts +1 -1
  17. package/dist/Moment.mjs +1 -1
  18. package/dist/{Page-D4281dIi.mjs → Page-B5cjaUp5.mjs} +2 -2
  19. package/dist/{Page-D4281dIi.mjs.map → Page-B5cjaUp5.mjs.map} +1 -1
  20. package/dist/{Page-C30aG2RQ.d.mts → Page-BlPjfibv.d.mts} +2 -2
  21. package/dist/Page.d.mts +1 -1
  22. package/dist/Page.mjs +1 -1
  23. package/dist/{Plan-BvCS9qAf.d.mts → Plan-BI8XGvDu.d.mts} +3 -3
  24. package/dist/{Plan-CyQNv49C.mjs → Plan-D1wrJVs8.mjs} +3 -3
  25. package/dist/{Plan-CyQNv49C.mjs.map → Plan-D1wrJVs8.mjs.map} +1 -1
  26. package/dist/Plan.d.mts +1 -1
  27. package/dist/Plan.mjs +1 -1
  28. package/dist/Policy.d.mts +1 -1
  29. package/dist/Policy.mjs +1 -1
  30. package/dist/Presentation.d.mts +1 -1
  31. package/dist/Presentation.mjs +1 -1
  32. package/dist/Stage.d.mts +2 -2
  33. package/dist/Stage.mjs +2 -2
  34. package/dist/{Supervisor-CLJaT7rG.d.mts → Supervisor-CYWFK3vM.d.mts} +105 -55
  35. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  36. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  37. package/dist/Supervisor.d.mts +2 -2
  38. package/dist/Supervisor.mjs +2 -2
  39. package/dist/{context-DAYjATgC.mjs → context-B6xr8nVi.mjs} +134 -10
  40. package/dist/context-B6xr8nVi.mjs.map +1 -0
  41. package/dist/index.d.mts +4 -4
  42. package/dist/index.mjs +4 -4
  43. package/dist/{page-DHeYWEs-.mjs → page-D-OedyBw.mjs} +59 -126
  44. package/dist/page-D-OedyBw.mjs.map +1 -0
  45. package/dist/{url-CcL0K7S0.mjs → url-Cd04Okid.mjs} +6 -4
  46. package/dist/url-Cd04Okid.mjs.map +1 -0
  47. package/package.json +1 -1
  48. package/src/Browser.ts +57 -16
  49. package/src/BrowserError.ts +9 -0
  50. package/src/Supervisor.ts +384 -138
  51. package/src/internal/input/gate.ts +224 -0
  52. package/src/internal/input/guard.ts +3 -2
  53. package/src/internal/input/perform.ts +84 -32
  54. package/src/internal/input/replies.ts +52 -16
  55. package/src/internal/page/context.ts +21 -11
  56. package/src/internal/page/lane.ts +20 -10
  57. package/src/internal/supervisor/lifecycle.ts +291 -140
  58. package/src/internal/timeline/events.ts +1 -0
  59. package/dist/Browser-CNwK0r0q.mjs.map +0 -1
  60. package/dist/Supervisor-BtEG93gR.mjs +0 -420
  61. package/dist/Supervisor-BtEG93gR.mjs.map +0 -1
  62. package/dist/context-DAYjATgC.mjs.map +0 -1
  63. package/dist/page-DHeYWEs-.mjs.map +0 -1
  64. package/dist/url-CcL0K7S0.mjs.map +0 -1
@@ -108,6 +108,7 @@ const make = (options) => {
108
108
  kind
109
109
  };
110
110
  const askedAt = now();
111
+ if (kind === "write" && options.inputGate !== void 0) yield* options.inputGate.check(operation);
111
112
  if ((yield* FailFast) && !admitsNow(state, kind)) return yield* busy(operation, askedAt);
112
113
  const admitted = Deferred.makeUnsafe();
113
114
  admissions.set(ticket.id, admitted);
@@ -115,10 +116,11 @@ const make = (options) => {
115
116
  _tag: "Ask",
116
117
  ticket
117
118
  });
118
- const given = yield* Effect.exit(restore(Deferred.await(admitted).pipe(Effect.timeoutOrElse({
119
+ const waiting = Deferred.await(admitted).pipe(Effect.timeoutOrElse({
119
120
  duration: wait,
120
121
  orElse: () => Deferred.isDoneUnsafe(admitted) ? Effect.void : Effect.fail(busy(operation, askedAt, ticket.id))
121
- }))));
122
+ }));
123
+ const given = yield* Effect.exit(restore(kind === "write" && options.inputGate !== void 0 ? Effect.raceFirst(waiting, options.inputGate.refused(operation)) : waiting));
122
124
  if (Exit.isFailure(given)) {
123
125
  apply({
124
126
  _tag: "Leave",
@@ -190,7 +192,7 @@ const make = (options) => {
190
192
  /** A turn alone, for an action: `wait` bounds the wait for it. */
191
193
  write: (operation, wait) => turn("write", operation, wait),
192
194
  /** A turn beside other reads, waited for within the action timeout. */
193
- read: (operation) => turn("read", operation, actionTimeout),
195
+ read: (operation, wait = actionTimeout) => turn("read", operation, wait),
194
196
  shared
195
197
  };
196
198
  };
@@ -259,4 +261,4 @@ const redactWithin = (text) => text.replace(quoted, (url) => redact(url));
259
261
  //#endregion
260
262
  export { redactWithin as a, FailFast as c, redact as i, make as l, parse as n, withheld as o, quoted as r, Correlation as s, addresses as t };
261
263
 
262
- //# sourceMappingURL=url-CcL0K7S0.mjs.map
264
+ //# sourceMappingURL=url-Cd04Okid.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-Cd04Okid.mjs","names":[],"sources":["../src/internal/page/lane.ts","../src/internal/page/url.ts"],"sourcesContent":["/**\n * A page's admission: the one lane every operation on the page takes its turn in, so a page waits\n * only for itself. A write, an action that sends input or navigates, holds the lane alone, in the\n * order writes asked; reads share it, after the write in flight and every write asked before them,\n * so a read describes the page an action left, never one an action is still changing. Each write\n * starts a new epoch as it asks. A turn not given in time fails `Busy`, never `Timeout`, and a\n * caller under `FailFast` gets it at once.\n *\n * Reads keep their work. An identical read asked in the same epoch and document joins the one in\n * flight, which runs in the page's scope under the action timeout whoever gives up; one that ends\n * after all its callers gave up serves the next to ask the same, within an action timeout. A write\n * stops the reads nobody awaits, since no caller after it could use them.\n *\n * Effect's `Semaphore` is not fair to a writer that needs every permit, so the rule is this\n * module's own: one state, changed by `step` alone, which the model test drives.\n */\nimport { Context, Deferred, Duration, Effect, Exit, type Fiber, type Scope } from \"effect\";\n\nimport { BrowserError, Busy } from \"../../BrowserError.ts\";\nimport type { Gate } from \"../input/gate.ts\";\n\nexport type Kind = \"read\" | \"write\";\n\nexport interface Ticket {\n readonly id: number;\n readonly kind: Kind;\n}\n\n/** Who holds the lane: no one, reads together, or one write. */\nexport type Turn =\n | { readonly _tag: \"Free\" }\n | { readonly _tag: \"Reading\"; readonly count: number }\n | { readonly _tag: \"Writing\" };\n\nexport interface State {\n readonly turn: Turn;\n /** The tickets waiting, in the order they asked. */\n readonly queue: ReadonlyArray<Ticket>;\n /** The writes asked so far: two reads in one epoch follow the same writes. */\n readonly epoch: number;\n}\n\nexport type Input =\n | { readonly _tag: \"Ask\"; readonly ticket: Ticket }\n /** The ticket gave up waiting, or ended its turn. */\n | { readonly _tag: \"Leave\"; readonly ticket: Ticket };\n\nexport const initial: State = { turn: { _tag: \"Free\" }, queue: [], epoch: 0 };\n\nconst holders = (turn: Turn) =>\n turn._tag === \"Reading\" ? turn.count : turn._tag === \"Writing\" ? 1 : 0;\n\n/** Whether a ticket of `kind` asking now would hold the lane at once. */\nexport const admitsNow = ({ turn, queue }: State, kind: Kind) =>\n queue.length === 0 && (kind === \"read\" ? turn._tag !== \"Writing\" : turn._tag === \"Free\");\n\n/**\n * The operations ahead of a waiting ticket: those holding the lane and those asked before it, or\n * all that are queued for a ticket not in the queue.\n */\nexport const ahead = ({ turn, queue }: State, id?: number) => {\n const at = queue.findIndex((ticket) => ticket.id === id);\n\n return holders(turn) + (at === -1 ? queue.length : at);\n};\n\n/**\n * The lane after `input`, and the tickets it lets in. Tickets go in from the head of the queue\n * only: a write once the lane is free, reads while no write holds it, so reads asked before a\n * write go before it, and reads asked after it wait for it.\n */\nexport const step = (\n state: State,\n input: Input,\n): { readonly state: State; readonly admitted: ReadonlyArray<Ticket> } => {\n let { turn, queue, epoch } = state;\n\n if (input._tag === \"Ask\") {\n queue = [...queue, input.ticket];\n if (input.ticket.kind === \"write\") epoch += 1;\n } else {\n const at = queue.findIndex((ticket) => ticket.id === input.ticket.id);\n\n if (at !== -1) queue = queue.toSpliced(at, 1);\n else\n turn =\n turn._tag === \"Reading\" && turn.count > 1\n ? { _tag: \"Reading\", count: turn.count - 1 }\n : { _tag: \"Free\" };\n }\n\n const admitted: Array<Ticket> = [];\n\n for (const ticket of queue) {\n if (ticket.kind === \"write\" ? turn._tag !== \"Free\" : turn._tag === \"Writing\") break;\n admitted.push(ticket);\n turn =\n ticket.kind === \"write\" ? { _tag: \"Writing\" } : { _tag: \"Reading\", count: holders(turn) + 1 };\n }\n\n return { state: { turn, queue: queue.slice(admitted.length), epoch }, admitted };\n};\n\n/** Whether operations on a busy page fail `Busy` at once rather than wait their turn. */\nexport const FailFast = Context.Reference<boolean>(\"effect-browser/internal/page/FailFast\", {\n defaultValue: () => false,\n});\n\n/** The id a caller gives the actions it asks for, which each records as its `correlation`. */\nexport const Correlation = Context.Reference<string | undefined>(\n \"effect-browser/internal/page/Correlation\",\n { defaultValue: () => undefined },\n);\n\n/** A shared read in flight: its epoch, its result once it ends, and how many callers await it. */\ninterface Flight<A> {\n readonly epoch: number;\n readonly result: Deferred.Deferred<A, BrowserError>;\n waiting: number;\n fiber: Fiber.Fiber<A, BrowserError> | undefined;\n}\n\nexport const make = (options: {\n readonly now: () => number;\n readonly actionTimeout: Duration.Duration;\n /** When the page's current document began: a read is shared only within one. */\n readonly documentAt: () => number;\n /** The failure of a read still awaited as the page closes. */\n readonly gone: (operation: string) => BrowserError;\n /** The page's scope, which shared reads run in. */\n readonly scope: Scope.Scope;\n readonly inputGate?: Gate | undefined;\n}) => {\n const { now, actionTimeout } = options;\n let state = initial;\n let tickets = 0;\n const admissions = new Map<number, Deferred.Deferred<void>>();\n // Each shared read's sweep, which a write runs as it asks.\n const sweeps: Array<() => void> = [];\n\n const apply = (input: Input) => {\n const next = step(state, input);\n\n state = next.state;\n if (input._tag === \"Leave\") admissions.delete(input.ticket.id);\n for (const ticket of next.admitted) {\n const admitted = admissions.get(ticket.id);\n\n admissions.delete(ticket.id);\n if (admitted !== undefined) Deferred.doneUnsafe(admitted, Exit.void);\n }\n if (input._tag === \"Ask\" && input.ticket.kind === \"write\") for (const sweep of sweeps) sweep();\n };\n\n const busy = (operation: string, askedAt: number, id?: number) =>\n new BrowserError({\n operation,\n reason: new Busy({ waitedMillis: Math.round(now() - askedAt), ahead: ahead(state, id) }),\n dispatched: false,\n });\n\n /**\n * Run `effect` in a turn of `kind`, waiting at most `wait` for it, or failing `Busy`. The turn\n * ends with `effect`, however it ends.\n */\n const turn =\n (kind: Kind, operation: string, wait: Duration.Duration) =>\n <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | BrowserError, R> =>\n Effect.uninterruptibleMask((restore) =>\n Effect.gen(function* () {\n const ticket: Ticket = { id: ++tickets, kind };\n const askedAt = now();\n\n if (kind === \"write\" && options.inputGate !== undefined)\n yield* options.inputGate.check(operation);\n\n if ((yield* FailFast) && !admitsNow(state, kind)) return yield* busy(operation, askedAt);\n const admitted = Deferred.makeUnsafe<void>();\n\n admissions.set(ticket.id, admitted);\n apply({ _tag: \"Ask\", ticket });\n\n const waiting = Deferred.await(admitted).pipe(\n Effect.timeoutOrElse({\n duration: wait,\n orElse: () =>\n Deferred.isDoneUnsafe(admitted)\n ? Effect.void\n : Effect.fail(busy(operation, askedAt, ticket.id)),\n }),\n );\n\n const given = yield* Effect.exit(\n restore(\n kind === \"write\" && options.inputGate !== undefined\n ? Effect.raceFirst(waiting, options.inputGate.refused(operation))\n : waiting,\n ),\n );\n\n if (Exit.isFailure(given)) {\n apply({ _tag: \"Leave\", ticket });\n\n return yield* Effect.failCause(given.cause);\n }\n\n return yield* restore(effect).pipe(\n Effect.ensuring(Effect.sync(() => apply({ _tag: \"Leave\", ticket }))),\n );\n }),\n );\n\n /**\n * A read whose identical calls share their work: `work` runs once per epoch and document while\n * calls await it, and with `keep` its result serves the next call after all of them gave up,\n * within an action timeout of its end. Pictures, whose callers say how old they may be, only\n * join.\n */\n const shared = <A>(operation: string, keep: boolean) => {\n const flights = new Map<string, Flight<A>>();\n const kept = new Map<string, { readonly value: A; readonly until: number }>();\n\n sweeps.push(() => {\n kept.clear();\n for (const flight of flights.values())\n if (flight.waiting === 0) flight.fiber?.interruptUnsafe();\n });\n\n // A read in flight, its first caller waiting. However its fiber ends, even before it began,\n // as when the page closes, its callers hear.\n const start = (id: string, work: Effect.Effect<A, BrowserError>) =>\n Effect.gen(function* () {\n const flight: Flight<A> = {\n epoch: state.epoch,\n result: Deferred.makeUnsafe(),\n waiting: 1,\n fiber: undefined,\n };\n\n flights.set(id, flight);\n flight.fiber = yield* turn(\n \"read\",\n operation,\n actionTimeout,\n )(work).pipe(\n // The caller chose whether to wait; the work it started serves others too.\n Effect.provideService(FailFast, false),\n Effect.forkIn(options.scope),\n );\n flight.fiber.addObserver((exit) => {\n flights.delete(id);\n if (keep && Exit.isSuccess(exit) && flight.waiting === 0 && flight.epoch === state.epoch)\n kept.set(id, { value: exit.value, until: now() + Duration.toMillis(actionTimeout) });\n Deferred.doneUnsafe(\n flight.result,\n Exit.hasInterrupts(exit) ? Effect.fail(options.gone(operation)) : exit,\n );\n });\n\n return flight;\n });\n\n const join = (id: string, work: Effect.Effect<A, BrowserError>) =>\n Effect.acquireUseRelease(\n Effect.suspend(() => {\n const flying = flights.get(id);\n\n if (flying === undefined) return start(id, work);\n flying.waiting += 1;\n\n return Effect.as(Effect.annotateCurrentSpan(\"shared\", \"joined\"), flying);\n }),\n (flight) => Deferred.await(flight.result),\n (flight) =>\n Effect.sync(() => {\n flight.waiting -= 1;\n if (flight.waiting === 0 && flight.epoch < state.epoch) flight.fiber?.interruptUnsafe();\n }),\n );\n\n return (key: string, work: Effect.Effect<A, BrowserError>): Effect.Effect<A, BrowserError> =>\n Effect.gen(function* () {\n const id = `${state.epoch} ${options.documentAt()} ${key}`;\n const at = now();\n\n for (const [old, { until }] of kept) if (until < at) kept.delete(old);\n const hit = kept.get(id);\n\n if (hit !== undefined) {\n kept.delete(id);\n yield* Effect.annotateCurrentSpan(\"shared\", \"kept\");\n\n return hit.value;\n }\n if ((yield* FailFast) && !admitsNow(state, \"read\")) return yield* busy(operation, at);\n\n return yield* join(id, work);\n });\n };\n\n return {\n /** A turn alone, for an action: `wait` bounds the wait for it. */\n write: (operation: string, wait: Duration.Duration) => turn(\"write\", operation, wait),\n /** A turn beside other reads, waited for within the action timeout. */\n read: (operation: string, wait: Duration.Duration = actionTimeout) =>\n turn(\"read\", operation, wait),\n shared,\n };\n};\n\nexport type Lane = ReturnType<typeof make>;\n","/**\n * Addresses: reading one the way a person types it, for `Page.goto` and the browser tools alike,\n * and reporting one without the values that are credentials, by one rule that the page script runs\n * too, for the outline's links.\n */\n\nconst scheme = /^[a-z][a-z0-9+.-]*:/i;\n\n// `localhost:3000` and `example.com:8080/a` look like a scheme to the URL parser; a port decides.\nconst hostAndPort = /^[^\\s/?#@:]+:\\d+(?:[/?#]|$)/;\n\nconst loopback = /^(?:localhost|127(?:\\.\\d+){3}|\\[::1\\])(?:[:/?#]|$)/i;\n\n/** The URL `input` names. Without a scheme it is a web address: HTTPS, or HTTP on loopback. */\nexport const parse = (input: string): URL | null => {\n const text = input.trim();\n\n if (scheme.test(text) && !hostAndPort.test(text)) return URL.parse(text);\n\n return URL.parse(`${loopback.test(text) ? \"http\" : \"https\"}://${text}`);\n};\n\n/**\n * The rule that reports an address without its credentials. The page script runs it too, so it\n * stays self-contained, as `names.inpage.ts` says a page-side part must.\n */\nexport const addresses = () => {\n // What a credential's value reads as, as a secret field's text does (`Page.redacted`).\n const withheld = \"••••••••\" as const;\n\n // A parameter's name, compared in lowercase without separators, says it is a credential: a token,\n // secret, password, signature, assertion, session id or one-time code, or a code or key of a kind\n // that signs someone in or signs a request, such as `verification_code` or `api_key`.\n const credential =\n /token|secret|passw|passcode|passphrase|signature|credential|assertion|verifier|bearer|jwt|hmac|apikey|accesskey|privatekey|sess(?:ion)?id|saml(?:response|art)|(?:sig|otp)$|(?:auth|api|access|private|secret|signing|client|encryption|licen[cs]e|session|master|app|device|verification|verify|confirmation|confirm|activation|activate|reset|recovery|login|security|magic|onetime|mfa|2fa|sms|email|otp|oob)(?:code|key)$|^(?:auth|authorization|pwd|pass)$/;\n\n // These names as often say what an address is about, such as a stock code, a support ticket or a\n // conference session. Under them a value is a credential when it looks generated, at least 16\n // characters with letters and digits, as tokens, session ids and authorization codes do.\n const ambiguous = /^(?:code|key|session|sid|ticket)$/;\n\n const secret = (name: string, value: string) => {\n const bare = name.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n\n return (\n value !== \"\" &&\n (credential.test(bare) ||\n (ambiguous.test(bare) && value.length >= 16 && /\\d/.test(value) && /[a-z]/i.test(value)))\n );\n };\n\n // Parameters as written, each credential's value withheld: a query's, a fragment's written as a\n // query, or a path's segments', as a Java session id is.\n const masked = (parameters: string, separator: string) =>\n parameters\n .split(separator)\n .map((parameter) => {\n const [name = \"\", value = \"\"] = [...new URLSearchParams(parameter)][0] ?? [];\n\n return secret(name, value)\n ? `${parameter.slice(0, parameter.indexOf(\"=\") + 1)}${withheld}`\n : parameter;\n })\n .join(separator);\n\n /**\n * `url` as the library reports it: without userinfo, and with each credential's value withheld,\n * so the rest keeps its identity, such as a chart's `?ticker=ETH`. An address without a host,\n * such as `data:` or `about:blank`, is content or a fixed name, and stays as it is.\n */\n const redact = (url: string): string => {\n const parsed = URL.parse(url);\n\n if (parsed === null || parsed.host === \"\") return url;\n parsed.username = \"\";\n parsed.password = \"\";\n parsed.pathname = masked(parsed.pathname, \";\");\n parsed.search = masked(parsed.search.slice(1), \"&\");\n if (parsed.hash.includes(\"=\")) {\n const fragment = parsed.hash.slice(1);\n const question = fragment.indexOf(\"?\");\n // A route before the parameters, as in `#/inbox?token=…`, stays.\n const start = question >= 0 && question < fragment.indexOf(\"=\") ? question + 1 : 0;\n\n parsed.hash = fragment.slice(0, start) + masked(fragment.slice(start), \"&\");\n }\n\n return parsed.href.replaceAll(encodeURIComponent(withheld), withheld);\n };\n\n return { withheld, redact };\n};\n\nexport const { withheld, redact } = addresses();\n\n/** An address inside text, as a failure's message quotes one. */\nexport const quoted = /[a-z][a-z\\d+.-]*:\\/\\/[^\\s\"'<>]+/gi;\n\n/** `text` with each address in it redacted. */\nexport const redactWithin = (text: string) => text.replace(quoted, (url) => redact(url));\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-browser",
3
- "version": "0.3.0-beta.7",
3
+ "version": "0.3.0-beta.9",
4
4
  "description": "Effect browser sessions, page control, screencast and moments over Playwright and CDP.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/Browser.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  Exit,
19
19
  Option,
20
20
  Queue,
21
+ Schema,
21
22
  Scope,
22
23
  Semaphore,
23
24
  type Stream,
@@ -36,6 +37,7 @@ import {
36
37
  type RecordedEvent,
37
38
  SessionEnding,
38
39
  } from "./BrowserEvent.ts";
40
+ import * as Gate from "./internal/input/gate.ts";
39
41
  import { call, failWith, type Settings, undispatched } from "./internal/page/context.ts";
40
42
  import { FailFast } from "./internal/page/lane.ts";
41
43
  import * as Openings from "./internal/page/openings.ts";
@@ -91,6 +93,17 @@ export interface EventOptions {
91
93
  readonly after?: number | undefined;
92
94
  }
93
95
 
96
+ export interface SuspendOptions {
97
+ /** Finite, nonnegative drain bound; defaults to the browser's navigationTimeout. */
98
+ readonly drain?: Duration.Input | undefined;
99
+ }
100
+
101
+ /** When admitted input finished; events after `since` describe the handoff interval. */
102
+ export class Suspension extends Schema.Class<Suspension>("effect-browser/Suspension")({
103
+ since: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
104
+ at: Schema.Finite,
105
+ }) {}
106
+
94
107
  /**
95
108
  * A connection to the browser apart from the one that drives its pages, for their screencasts,
96
109
  * as a provider that reaches the browser over a network supplies it: a frame and its
@@ -134,6 +147,10 @@ export interface Service {
134
147
  readonly expiresAt: DateTime.Utc | undefined;
135
148
  /** Host monotonic milliseconds on the clock shared by this browser’s events and frames. */
136
149
  readonly now: Effect.Effect<number>;
150
+ /** Drain admitted input and withhold new input for this scope; reads and capture continue. */
151
+ readonly suspend: (
152
+ options?: SuspendOptions,
153
+ ) => Effect.Effect<Suspension, BrowserError, Scope.Scope>;
137
154
  /** The Playwright context, for anything this API does not cover. Never give it to a model. */
138
155
  readonly context: BrowserContext;
139
156
  /** Open pages in the order this browser began tracking them. */
@@ -426,6 +443,33 @@ const sentNow = <A>(sent: Promise<A>) => {
426
443
  return sent;
427
444
  };
428
445
 
446
+ /** Close tab resources after publishing their close; a browser loss speaks for all its tabs. */
447
+ const closures = (
448
+ lostBy: () => DisconnectCause | undefined,
449
+ publish: (event: BrowserEvent) => number,
450
+ now: () => number,
451
+ ) =>
452
+ Effect.gen(function* () {
453
+ const closing = yield* Queue.unbounded<{
454
+ readonly scope: Scope.Closeable;
455
+ readonly page: string;
456
+ readonly cause: PageClosed["cause"];
457
+ }>();
458
+
459
+ yield* Queue.take(closing).pipe(
460
+ Effect.tap(({ page, cause }) =>
461
+ Effect.sync(() => {
462
+ if (lostBy() === undefined) publish(new PageClosed({ at: now(), page, cause }));
463
+ }),
464
+ ),
465
+ Effect.flatMap(({ scope }) => Scope.close(scope, Exit.void)),
466
+ Effect.forever,
467
+ Effect.forkScoped,
468
+ );
469
+
470
+ return closing;
471
+ });
472
+
429
473
  /**
430
474
  * Build the service over a context the provider owns. The provider closes the context; this only
431
475
  * tracks its pages and events, for as long as the surrounding scope is open. Each page's protocol
@@ -464,6 +508,15 @@ export const make = Effect.fn("Browser.make")(function* (
464
508
  (cause) => publish(new Disconnected({ at: now(), cause })),
465
509
  );
466
510
 
511
+ const inputGate = Gate.make({
512
+ now,
513
+ sequence: timeline.sequence,
514
+ closedBy: lostBy,
515
+ lost,
516
+ clock,
517
+ drain: settings.navigationTimeout,
518
+ });
519
+
467
520
  const registry = new Map<PlaywrightPage, Page.Page>();
468
521
  const registrations: Openings.Registrations = new WeakMap();
469
522
  const registering = yield* Semaphore.make(1);
@@ -477,22 +530,7 @@ export const make = Effect.fn("Browser.make")(function* (
477
530
  // Each tab's scope closes with the tab or the browser. A dropped connection closes every page,
478
531
  // then the context, and `Disconnected` stands for all of them: the queue hands a close to its
479
532
  // fiber in a later turn, once Playwright has reported the context's.
480
- const closing = yield* Queue.unbounded<{
481
- readonly scope: Scope.Closeable;
482
- readonly page: string;
483
- readonly cause: PageClosed["cause"];
484
- }>();
485
-
486
- yield* Queue.take(closing).pipe(
487
- Effect.tap(({ page, cause }) =>
488
- Effect.sync(() => {
489
- if (lostBy() === undefined) publish(new PageClosed({ at: now(), page, cause }));
490
- }),
491
- ),
492
- Effect.flatMap(({ scope }) => Scope.close(scope, Exit.void)),
493
- Effect.forever,
494
- Effect.forkScoped,
495
- );
533
+ const closing = yield* closures(lostBy, publish, now);
496
534
 
497
535
  const register = (playwright: PlaywrightPage) =>
498
536
  registering.withPermits(1)(
@@ -527,6 +565,7 @@ export const make = Effect.fn("Browser.make")(function* (
527
565
  cdp,
528
566
  untilLost,
529
567
  settings,
568
+ inputGate,
530
569
  clock,
531
570
  mapping,
532
571
  publish,
@@ -605,6 +644,8 @@ export const make = Effect.fn("Browser.make")(function* (
605
644
  provider: info.provider,
606
645
  expiresAt: info.expiresAt,
607
646
  now: Effect.sync(now),
647
+ suspend: (options = {}) =>
648
+ Effect.map(inputGate.suspend(options.drain), (held) => new Suspension(held)),
608
649
  context,
609
650
  pages,
610
651
  page: (id) =>
@@ -37,6 +37,13 @@ export class Busy extends Schema.TaggedError<Busy>()("Busy", {
37
37
  }
38
38
  }
39
39
 
40
+ /** Input is withheld by a scoped suspension; nothing was dispatched. */
41
+ export class Suspended extends Schema.TaggedError<Suspended>()("Suspended", {}) {
42
+ override get message() {
43
+ return "browser input is suspended";
44
+ }
45
+ }
46
+
40
47
  /** The browser has as many pages open as `maxPages` allows, and none closed in time. */
41
48
  export class Limit extends Schema.TaggedError<Limit>()("Limit", {
42
49
  maxPages: Schema.Int,
@@ -158,6 +165,7 @@ export class Failed extends Schema.TaggedError<Failed>()("Failed", {
158
165
  export const Reason = Schema.Union([
159
166
  Timeout,
160
167
  Busy,
168
+ Suspended,
161
169
  Limit,
162
170
  StaleRef,
163
171
  NotFound,
@@ -207,6 +215,7 @@ export const consequence = ({ reason, dispatched }: BrowserError): Consequence =
207
215
  repeat,
208
216
  };
209
217
  case "Busy":
218
+ case "Suspended":
210
219
  case "Timeout":
211
220
  case "Failed":
212
221
  case "NavigationFailed":