@webhands/core 0.1.0 → 1.0.0

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 (49) hide show
  1. package/LICENSE +661 -0
  2. package/dist/cookies-export.d.ts +5 -5
  3. package/dist/cookies-export.d.ts.map +1 -1
  4. package/dist/cookies-export.js +4 -4
  5. package/dist/hand-host.d.ts +217 -0
  6. package/dist/hand-host.d.ts.map +1 -0
  7. package/dist/hand-host.js +351 -0
  8. package/dist/hand-host.js.map +1 -0
  9. package/dist/hand-loading.d.ts +128 -0
  10. package/dist/hand-loading.d.ts.map +1 -0
  11. package/dist/hand-loading.js +143 -0
  12. package/dist/hand-loading.js.map +1 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/playwright-attach-transport.d.ts +9 -0
  18. package/dist/playwright-attach-transport.d.ts.map +1 -1
  19. package/dist/playwright-attach-transport.js +53 -91
  20. package/dist/playwright-attach-transport.js.map +1 -1
  21. package/dist/playwright-launch-transport.d.ts +7 -62
  22. package/dist/playwright-launch-transport.d.ts.map +1 -1
  23. package/dist/playwright-launch-transport.js +51 -204
  24. package/dist/playwright-launch-transport.js.map +1 -1
  25. package/dist/remote-session.d.ts +12 -2
  26. package/dist/remote-session.d.ts.map +1 -1
  27. package/dist/remote-session.js +37 -6
  28. package/dist/remote-session.js.map +1 -1
  29. package/dist/seam.d.ts +13 -5
  30. package/dist/seam.d.ts.map +1 -1
  31. package/dist/session-rpc.d.ts +76 -12
  32. package/dist/session-rpc.d.ts.map +1 -1
  33. package/dist/session-rpc.js +76 -8
  34. package/dist/session-rpc.js.map +1 -1
  35. package/dist/stub-transport.d.ts +2 -2
  36. package/dist/stub-transport.d.ts.map +1 -1
  37. package/dist/stub-transport.js +11 -0
  38. package/dist/stub-transport.js.map +1 -1
  39. package/package.json +18 -1
  40. package/src/cookies-export.ts +5 -5
  41. package/src/hand-host.ts +511 -0
  42. package/src/hand-loading.ts +254 -0
  43. package/src/index.ts +18 -1
  44. package/src/playwright-attach-transport.ts +65 -119
  45. package/src/playwright-launch-transport.ts +63 -244
  46. package/src/remote-session.ts +43 -5
  47. package/src/seam.ts +13 -5
  48. package/src/session-rpc.ts +121 -11
  49. package/src/stub-transport.ts +15 -3
@@ -4,12 +4,12 @@ import {
4
4
  type Snapshot,
5
5
  type WaitCondition,
6
6
  } from './seam.js';
7
- import type {Page} from './seam.js';
7
+ import type {WebHandsPage} from './seam.js';
8
8
 
9
9
  /**
10
10
  * The wire protocol for driving the long-lived session over HTTP (ADR-0005).
11
11
  *
12
- * The served process holds ONE live {@link Page} in memory; a thin client verb
12
+ * The served process holds ONE live {@link WebHandsPage} in memory; a thin client verb
13
13
  * cannot hold a JS reference to it across the process boundary, so each verb
14
14
  * call is sent as a small JSON request to the server, which runs it against its
15
15
  * live page and returns the result. This module is the SINGLE source of truth
@@ -17,11 +17,35 @@ import type {Page} from './seam.js';
17
17
  * client proxy so they cannot drift (mirrors how `serializeCookies` is shared
18
18
  * by the cookies verb and its test).
19
19
  *
20
- * It is a thin transport detail, NOT a second verb surface: every request maps
21
- * 1:1 to a {@link Page} method, and the seam's verb semantics (ADR-0003/0004)
22
- * are unchanged. The {@link LocatorString} brand and the structured
23
- * {@link WaitCondition} cross as plain JSON and are re-branded on the server
24
- * with {@link locator}; no Playwright/CDP type is ever named here.
20
+ * It is a thin transport detail, NOT a second verb surface: every built-in
21
+ * request maps 1:1 to a {@link WebHandsPage} method, and the seam's verb semantics
22
+ * (ADR-0003/0004) are unchanged. The {@link LocatorString} brand and the
23
+ * structured {@link WaitCondition} cross as plain JSON and are re-branded on the
24
+ * server with {@link locator}; no Playwright/CDP type is ever named here.
25
+ *
26
+ * THIRD-PARTY HAND VERBS (Phase 2, Model B of the "hands" prd; ADR-0007). The
27
+ * eight built-in verbs stay a CLOSED union (the 1:1 source of truth above). A
28
+ * dynamically-loaded hand contributes a verb whose name `core` does NOT know at
29
+ * compile time, so it cannot be a named member of that closed union without
30
+ * re-meaning "closed". Instead it crosses as a SINGLE generic
31
+ * {@link SessionRpcHandRequest} variant (`{verb: 'hand', name, args}`) that
32
+ * names the contributed verb at runtime and carries its arguments. This is the
33
+ * exact wire parallel of how a hand verb composes into the page object: by name,
34
+ * dynamically, alongside the typed built-ins. The agent thereby gains a new tool
35
+ * over the wire WITHOUT ever holding a live page handle.
36
+ *
37
+ * SERIALIZATION BOUNDARY (the load-bearing rule; prd's resolved Q3). A hand
38
+ * verb's result crosses this RPC, so it MUST be serializable under the same
39
+ * structured-clone contract `eval` documents (see {@link WebHandsPage.eval}): richer
40
+ * than JSON, but a value with no transferable form does not round-trip. This is
41
+ * enforced by CONVENTION + TYPES (a hand author returns serializable values),
42
+ * NOT a blanket runtime clone here — a blanket clone would corrupt legitimate
43
+ * in-process (Model A) returns, where a hand may pass/return live Playwright
44
+ * handles within a single in-process call chain. A host-side runtime clone of
45
+ * agent-verb results is available HARDENING for untrusted hands, not built here.
46
+ * A page/in-hand throw REJECTS faithfully on the client exactly as the `eval`
47
+ * path already does (the server maps it to an `ok: false` reply carrying the
48
+ * message; the client re-throws a faithful `Error`).
25
49
  */
26
50
 
27
51
  /** The path the session RPC is served under, below the server's base URL. */
@@ -29,6 +53,15 @@ export const SESSION_RPC_PATH = '/session/call';
29
53
 
30
54
  /** A single verb call to run against the served live page. */
31
55
  export type SessionRpcRequest =
56
+ | SessionRpcBuiltInRequest
57
+ | SessionRpcHandRequest;
58
+
59
+ /**
60
+ * The CLOSED union of webhands' eight built-in verbs. Each variant maps 1:1 to a
61
+ * {@link WebHandsPage} method in {@link applySessionRpc}; this is the single source of
62
+ * truth for the built-in verb surface, shared verbatim by server and client.
63
+ */
64
+ export type SessionRpcBuiltInRequest =
32
65
  | {readonly verb: 'navigate'; readonly url: string}
33
66
  | {readonly verb: 'snapshot'; readonly full?: boolean}
34
67
  | {readonly verb: 'click'; readonly locator: string}
@@ -38,6 +71,25 @@ export type SessionRpcRequest =
38
71
  | {readonly verb: 'cookies'}
39
72
  | {readonly verb: 'setCookies'; readonly cookies: readonly Cookie[]};
40
73
 
74
+ /**
75
+ * The OPEN escape for a dynamically-loaded third-party hand verb (Phase 2,
76
+ * Model B; ADR-0007). Unlike the closed built-in union, `core` does not know the
77
+ * verb's name at compile time, so it is carried at runtime: `name` is the
78
+ * contributed verb's plain name (exactly as it composed into the page object,
79
+ * not namespaced — a hand may even deliberately override a built-in, the
80
+ * operator's choice per ADR-0007) and `args` are its JSON arguments.
81
+ *
82
+ * The returned value and any thrown error obey the same serialization +
83
+ * rejection contract as `eval` (see this module's overview and {@link WebHandsPage.eval}).
84
+ */
85
+ export interface SessionRpcHandRequest {
86
+ readonly verb: 'hand';
87
+ /** The hand-contributed verb's name (as it composed into the page object). */
88
+ readonly name: string;
89
+ /** The verb's arguments, carried as plain JSON (must be serializable). */
90
+ readonly args: readonly unknown[];
91
+ }
92
+
41
93
  /**
42
94
  * The server's reply to a {@link SessionRpcRequest}. `ok: true` carries the
43
95
  * verb's return value (for verbs that return data); `ok: false` carries the
@@ -49,7 +101,7 @@ export type SessionRpcResponse =
49
101
  | {readonly ok: false; readonly error: string};
50
102
 
51
103
  /**
52
- * Run one {@link SessionRpcRequest} against a live {@link Page}, returning the
104
+ * Run one {@link SessionRpcRequest} against a live {@link WebHandsPage}, returning the
53
105
  * value the wire should carry back. The server's HTTP handler is just this plus
54
106
  * JSON framing; keeping the dispatch here (not inline in the handler) means the
55
107
  * verb-to-page mapping is in one place and unit-testable without HTTP.
@@ -59,7 +111,7 @@ export type SessionRpcResponse =
59
111
  * branded-string contract holds.
60
112
  */
61
113
  export async function applySessionRpc(
62
- page: Page,
114
+ page: WebHandsPage,
63
115
  request: SessionRpcRequest,
64
116
  ): Promise<unknown> {
65
117
  switch (request.verb) {
@@ -84,11 +136,46 @@ export async function applySessionRpc(
84
136
  case 'setCookies':
85
137
  await page.setCookies(request.cookies);
86
138
  return undefined;
139
+ case 'hand':
140
+ return applyHandVerb(page, request);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Invoke a dynamically-loaded hand verb by name against the live composed page.
146
+ *
147
+ * The composed {@link WebHandsPage} carries the hand's verbs at runtime (the host merged
148
+ * them in by name alongside the built-ins, see `composePage`), even though the
149
+ * seam `WebHandsPage` TYPE only names the eight built-ins. We therefore look the verb up
150
+ * on the page object as a runtime method and invoke it with the request's args.
151
+ * An unknown name is a faithful error (the hand was not loaded / named that
152
+ * verb), surfaced the same way a page-side throw is so the client rejects.
153
+ *
154
+ * The result is returned as-is: the serializable-only boundary is enforced by
155
+ * convention + types on the hand author, NOT a runtime clone here (a blanket
156
+ * clone would corrupt legitimate in-process Model A returns; see this module's
157
+ * overview). What the hand returns is what the wire carries back; the JSON
158
+ * framing in the server handler is the only encoding applied.
159
+ */
160
+ async function applyHandVerb(
161
+ page: WebHandsPage,
162
+ request: SessionRpcHandRequest,
163
+ ): Promise<unknown> {
164
+ const verb = (page as unknown as Record<string, unknown>)[request.name];
165
+ if (typeof verb !== 'function') {
166
+ throw new Error(
167
+ `no such hand verb '${request.name}' on the live page ` +
168
+ `(is the hand loaded and named in config?)`,
169
+ );
87
170
  }
171
+ return (verb as (...args: readonly unknown[]) => unknown).call(
172
+ page,
173
+ ...request.args,
174
+ );
88
175
  }
89
176
 
90
177
  /**
91
- * A {@link Page} whose verbs forward to a server via the supplied transport.
178
+ * A {@link WebHandsPage} whose verbs forward to a server via the supplied transport.
92
179
  *
93
180
  * Used by the client-side proxy (see `remote-session.ts`): each verb builds a
94
181
  * {@link SessionRpcRequest}, hands it to `send`, and shapes the reply back into
@@ -98,7 +185,7 @@ export async function applySessionRpc(
98
185
  */
99
186
  export function makeRpcPage(
100
187
  send: (request: SessionRpcRequest) => Promise<unknown>,
101
- ): Page {
188
+ ): WebHandsPage {
102
189
  return {
103
190
  async navigate(url) {
104
191
  await send({verb: 'navigate', url});
@@ -130,6 +217,29 @@ export function makeRpcPage(
130
217
  };
131
218
  }
132
219
 
220
+ /**
221
+ * Invoke a dynamically-loaded hand verb over the session RPC by name (Phase 2,
222
+ * Model B; ADR-0007). The client-side mirror of {@link applyHandVerb}: it builds
223
+ * the single generic {@link SessionRpcHandRequest} and hands it to the SAME
224
+ * `send` the built-in verbs use, so request/response shapes cannot drift.
225
+ *
226
+ * This is how the agent gains a new tool over the wire WITHOUT holding a live
227
+ * page handle: it names the contributed verb and passes serializable args; the
228
+ * server runs the hand against its own live page and returns a serializable
229
+ * result. A page/in-hand throw rejects faithfully (the `send` re-throws the
230
+ * server's error message), exactly as the `eval` path does.
231
+ *
232
+ * The result type is `unknown` because the hand decides the shape; callers
233
+ * narrow it (mirrors {@link WebHandsPage.eval}).
234
+ */
235
+ export async function callHandVerb(
236
+ send: (request: SessionRpcRequest) => Promise<unknown>,
237
+ name: string,
238
+ ...args: readonly unknown[]
239
+ ): Promise<unknown> {
240
+ return send({verb: 'hand', name, args});
241
+ }
242
+
133
243
  /**
134
244
  * Re-brand a {@link WaitCondition} that arrived as plain JSON: only the
135
245
  * `locator` form carries a branded string, which JSON flattens to a plain
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  Cookie,
3
3
  OpenTarget,
4
- Page,
4
+ WebHandsPage,
5
5
  Session,
6
6
  Snapshot,
7
7
  SnapshotOptions,
@@ -13,7 +13,7 @@ import type {
13
13
  * A record of one verb call against the stub, for assertions in seam tests.
14
14
  */
15
15
  export interface StubCall {
16
- readonly verb: keyof Page;
16
+ readonly verb: keyof WebHandsPage;
17
17
  readonly args: readonly unknown[];
18
18
  }
19
19
 
@@ -37,6 +37,11 @@ export class StubTransport implements Transport {
37
37
  const calls = this.calls;
38
38
  let closed = false;
39
39
 
40
+ let resolveClosed!: () => void;
41
+ const closedSignal = new Promise<void>((resolve) => {
42
+ resolveClosed = resolve;
43
+ });
44
+
40
45
  const ensureOpen = () => {
41
46
  if (closed) {
42
47
  throw new Error('session is closed');
@@ -48,7 +53,7 @@ export class StubTransport implements Transport {
48
53
  ? `stub://launch/${target.profile}`
49
54
  : `stub://attach/${target.endpoint}`;
50
55
 
51
- const page: Page = {
56
+ const page: WebHandsPage = {
52
57
  async navigate(to: string): Promise<void> {
53
58
  ensureOpen();
54
59
  calls.push({verb: 'navigate', args: [to]});
@@ -93,7 +98,14 @@ export class StubTransport implements Transport {
93
98
  return {
94
99
  page,
95
100
  async close(): Promise<void> {
101
+ if (closed) {
102
+ return;
103
+ }
96
104
  closed = true;
105
+ resolveClosed();
106
+ },
107
+ waitForClose(): Promise<void> {
108
+ return closedSignal;
97
109
  },
98
110
  };
99
111
  }