@bowmark/web 1.12.2 → 1.14.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.
- package/README.md +36 -3
- package/{src → dist}/generated/library.d.ts +3638 -84
- package/dist/generated/validators.d.ts +2 -0
- package/dist/generated/validators.js +26012 -0
- package/dist/guard.d.ts +64 -0
- package/dist/guard.js +174 -0
- package/dist/index.d.ts +29 -0
- package/{src/index.ts → dist/index.js} +7 -33
- package/dist/session.d.ts +46 -0
- package/dist/session.js +153 -0
- package/dist/transport.d.ts +150 -0
- package/dist/transport.js +183 -0
- package/dist/validate.d.ts +132 -0
- package/dist/validate.js +278 -0
- package/package.json +10 -6
- package/src/generated/validators.ts +0 -23104
- package/src/guard.ts +0 -198
- package/src/session.ts +0 -194
- package/src/transport.ts +0 -342
- package/src/validate.ts +0 -371
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** What is wrong with a value, and WHERE. The path is Playwright's format —
|
|
2
|
+
* `a.inner[1].property` — because naming the offending path is the difference
|
|
3
|
+
* between a caller fixing the bug in a minute and bisecting their own arguments. */
|
|
4
|
+
export interface WireProblem {
|
|
5
|
+
/** Dotted/bracketed path from the root. Empty string at the root. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** What was found and why the wire refuses it, in one clause. */
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
/** Walk `value` and return the FIRST thing the wire refuses, or `null` when it is
|
|
11
|
+
* clean.
|
|
12
|
+
*
|
|
13
|
+
* `undefined` is allowed, at the root and at a key — it is what an absent optional
|
|
14
|
+
* property is, and refusing it would refuse every legal partial object. It is
|
|
15
|
+
* DROPPED on the way across, which `Wire<T>` states in the type. */
|
|
16
|
+
export declare function wireProblem(value: unknown): WireProblem | null;
|
|
17
|
+
/** Refuse the whole argument list before a byte leaves the process.
|
|
18
|
+
*
|
|
19
|
+
* Throws `BowmarkError` with code `wire_refused`, naming the exact position:
|
|
20
|
+
* `bowmark.hotels.search(args[0].checkIn)`. Failure is loud here for the reason EF
|
|
21
|
+
* Core made silent client-side fallback an error: a boundary that quietly accepts
|
|
22
|
+
* something it will mangle produces behaviour that only breaks in production, and
|
|
23
|
+
* getting better at serialization later must not silently change a caller's
|
|
24
|
+
* results. */
|
|
25
|
+
export declare function assertWireSafeArgs(label: string, args: readonly unknown[]): void;
|
|
26
|
+
/** Refuse an argument list the declared signature does not accept, and refuse a path
|
|
27
|
+
* this package has never heard of.
|
|
28
|
+
*
|
|
29
|
+
* Runs AFTER `assertWireSafeArgs`, deliberately. A `Date` and a `Map` are refused by
|
|
30
|
+
* the wire guard with a message about JSON, which is the right explanation; reaching
|
|
31
|
+
* the shape check first would report the same value as "expected a string" and send
|
|
32
|
+
* the caller looking for the wrong bug.
|
|
33
|
+
*
|
|
34
|
+
* ── FAILING CLOSED, and the two things it must NOT close on ─────────────────
|
|
35
|
+
*
|
|
36
|
+
* The rule is "refuse what the table knows is wrong; pass anything it cannot know",
|
|
37
|
+
* and both halves cost something real, so both are stated.
|
|
38
|
+
*
|
|
39
|
+
* **A known unit with an unknown FUNCTION is refused.** The table is authoritative
|
|
40
|
+
* about a unit it carries, so `bowmark.music.searchHarder(…)` is a typo or an
|
|
41
|
+
* install older than the function. The compile-time surface already refuses that
|
|
42
|
+
* call — it is a `Property does not exist` error against the same generated data —
|
|
43
|
+
* so anybody reaching this line came through `as any`, plain JavaScript or a stale
|
|
44
|
+
* package, and a refusal naming the manifest version is a better answer for all
|
|
45
|
+
* three than a request that succeeds against declarations they do not have. The
|
|
46
|
+
* cost, named rather than hidden: this package is published on its own cadence, so
|
|
47
|
+
* a caller on version N cannot reach a function the library gained in N+1 even
|
|
48
|
+
* though the api would serve it. `run(script)` reaches anything and is untyped by
|
|
49
|
+
* construction, so nothing is unreachable.
|
|
50
|
+
*
|
|
51
|
+
* **An unknown UNIT passes straight through, and that is not a hedge.** A Shopify
|
|
52
|
+
* family MEMBER — `bowmark.providers.gymshark.search(…)` — is deliberately absent
|
|
53
|
+
* from every manifest: `listProviders()` excludes members by design and always
|
|
54
|
+
* will, because there are half a million of them. So an unknown unit is the NORMAL
|
|
55
|
+
* case for most of the library, not a stale one, and refusing it would have this
|
|
56
|
+
* package refuse the largest part of what it is a client for. Found by running the
|
|
57
|
+
* Phase 5 session suite, whose worked example is gymshark.
|
|
58
|
+
*
|
|
59
|
+
* **An `unchecked` function passes through too** — one whose declared argument is a
|
|
60
|
+
* bare destructuring pattern. It is an EXPLICIT null in the table rather than an
|
|
61
|
+
* absence, and that distinction is what makes the first rule safe at all: 20 real
|
|
62
|
+
* functions have no readable argument shape, and refusing them would delete them
|
|
63
|
+
* from the runtime as well as from the types. */
|
|
64
|
+
export declare function assertArgShape(label: string, path: readonly string[], args: readonly unknown[]): void;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The argument guard — refuse a value the wire cannot carry BEFORE the request.
|
|
2
|
+
//
|
|
3
|
+
// ── This is a deliberate COPY of `wireProblem` in `packages/schema/src/wire.ts` ──
|
|
4
|
+
//
|
|
5
|
+
// Not an import, and not an oversight. `@bowmark/schema` is a workspace package;
|
|
6
|
+
// importing it would put `"@bowmark/schema": "workspace:*"` in the published
|
|
7
|
+
// `package.json` and the tarball would not install for anyone outside this repo.
|
|
8
|
+
// The package's zero-dependency promise is the reason this file exists at all.
|
|
9
|
+
//
|
|
10
|
+
// A copy drifts, so the copy is CHECKED: `tests/unit/bowmark-web-guard.test.ts`
|
|
11
|
+
// runs both implementations over one fixture table and asserts identical
|
|
12
|
+
// `{ path, reason }` for every entry. A new refusal in `wire.ts` that is not
|
|
13
|
+
// mirrored here fails that test by name.
|
|
14
|
+
//
|
|
15
|
+
// Why a walk and not a `try { JSON.stringify(args) }`: stringify throws on exactly
|
|
16
|
+
// two things, a circular structure and a `BigInt`. Everything else it "handles"
|
|
17
|
+
// lossily — `Date` → string, `Map`/`Set` → `{}`, class instance → plain object,
|
|
18
|
+
// function-valued key → dropped — so a try/catch catches almost nothing. Temporal
|
|
19
|
+
// shipped that exact bug, diagnosed it as a typing problem, and closed it won't-fix.
|
|
20
|
+
import { VALIDATORS } from "./generated/validators.js";
|
|
21
|
+
import { BowmarkError } from "./transport.js";
|
|
22
|
+
import { argsProblem, lookupParams } from "./validate.js";
|
|
23
|
+
const PLAIN_PROTOTYPES = [Object.prototype, null];
|
|
24
|
+
/** Walk `value` and return the FIRST thing the wire refuses, or `null` when it is
|
|
25
|
+
* clean.
|
|
26
|
+
*
|
|
27
|
+
* `undefined` is allowed, at the root and at a key — it is what an absent optional
|
|
28
|
+
* property is, and refusing it would refuse every legal partial object. It is
|
|
29
|
+
* DROPPED on the way across, which `Wire<T>` states in the type. */
|
|
30
|
+
export function wireProblem(value) {
|
|
31
|
+
return walk(value, "", new Set());
|
|
32
|
+
}
|
|
33
|
+
function walk(value, path, seen) {
|
|
34
|
+
const bad = (reason) => ({ path, reason });
|
|
35
|
+
if (value === null || value === undefined)
|
|
36
|
+
return null;
|
|
37
|
+
switch (typeof value) {
|
|
38
|
+
case "string":
|
|
39
|
+
case "boolean":
|
|
40
|
+
return null;
|
|
41
|
+
case "number":
|
|
42
|
+
// JSON.stringify turns these into `null` without complaint, which is the
|
|
43
|
+
// silent-lossy class this guard exists to catch.
|
|
44
|
+
return Number.isFinite(value) ? null : bad(`the number ${String(value)} has no JSON form`);
|
|
45
|
+
case "bigint":
|
|
46
|
+
return bad("a bigint has no JSON form (JSON.stringify throws on one)");
|
|
47
|
+
case "function":
|
|
48
|
+
return bad("a function cannot cross the wire — code never crosses, only data");
|
|
49
|
+
case "symbol":
|
|
50
|
+
return bad("a symbol has no JSON form");
|
|
51
|
+
}
|
|
52
|
+
const object = value;
|
|
53
|
+
if (seen.has(object))
|
|
54
|
+
return bad("a circular reference");
|
|
55
|
+
seen.add(object);
|
|
56
|
+
try {
|
|
57
|
+
if (Array.isArray(object)) {
|
|
58
|
+
for (let i = 0; i < object.length; i++) {
|
|
59
|
+
const problem = walk(object[i], `${path}[${i}]`, seen);
|
|
60
|
+
if (problem)
|
|
61
|
+
return problem;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// A NOMINAL refusal, and the only one available at runtime. `Wire<T>` cannot
|
|
66
|
+
// express it — TypeScript has no nominal typing — so a Date, a Map, a class
|
|
67
|
+
// instance and anything else carrying its own prototype is refused here rather
|
|
68
|
+
// than being quietly flattened into a plain object or a string.
|
|
69
|
+
const prototype = Object.getPrototypeOf(object);
|
|
70
|
+
if (!PLAIN_PROTOTYPES.includes(prototype)) {
|
|
71
|
+
return bad(`a ${constructorName(object)} instance — only plain objects and arrays cross`);
|
|
72
|
+
}
|
|
73
|
+
for (const key of Object.keys(object)) {
|
|
74
|
+
const child = object[key];
|
|
75
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
76
|
+
// A function- or symbol-valued key is DROPPED rather than refused: that is
|
|
77
|
+
// what JSON.stringify does, and `Wire<T>` removes the key from its key set to
|
|
78
|
+
// say so. Refusing here would make a legal object with a method unusable.
|
|
79
|
+
if (typeof child === "function" || typeof child === "symbol")
|
|
80
|
+
continue;
|
|
81
|
+
const problem = walk(child, childPath, seen);
|
|
82
|
+
if (problem)
|
|
83
|
+
return problem;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
seen.delete(object);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function constructorName(object) {
|
|
92
|
+
const name = Object.getPrototypeOf(object)
|
|
93
|
+
?.constructor?.name;
|
|
94
|
+
return name && name.length > 0 ? name : "non-plain-object";
|
|
95
|
+
}
|
|
96
|
+
/** Refuse the whole argument list before a byte leaves the process.
|
|
97
|
+
*
|
|
98
|
+
* Throws `BowmarkError` with code `wire_refused`, naming the exact position:
|
|
99
|
+
* `bowmark.hotels.search(args[0].checkIn)`. Failure is loud here for the reason EF
|
|
100
|
+
* Core made silent client-side fallback an error: a boundary that quietly accepts
|
|
101
|
+
* something it will mangle produces behaviour that only breaks in production, and
|
|
102
|
+
* getting better at serialization later must not silently change a caller's
|
|
103
|
+
* results. */
|
|
104
|
+
export function assertWireSafeArgs(label, args) {
|
|
105
|
+
for (let i = 0; i < args.length; i++) {
|
|
106
|
+
const problem = wireProblem(args[i]);
|
|
107
|
+
if (!problem)
|
|
108
|
+
continue;
|
|
109
|
+
const where = problem.path
|
|
110
|
+
? problem.path.startsWith("[")
|
|
111
|
+
? `args[${i}]${problem.path}`
|
|
112
|
+
: `args[${i}].${problem.path}`
|
|
113
|
+
: `args[${i}]`;
|
|
114
|
+
throw new BowmarkError(`${label} was not called: ${where} is ${problem.reason}. ` +
|
|
115
|
+
"Only JSON — plain objects, arrays, strings, finite numbers, booleans and null — crosses to a capability.", { code: "wire_refused", path: label });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Refuse an argument list the declared signature does not accept, and refuse a path
|
|
119
|
+
* this package has never heard of.
|
|
120
|
+
*
|
|
121
|
+
* Runs AFTER `assertWireSafeArgs`, deliberately. A `Date` and a `Map` are refused by
|
|
122
|
+
* the wire guard with a message about JSON, which is the right explanation; reaching
|
|
123
|
+
* the shape check first would report the same value as "expected a string" and send
|
|
124
|
+
* the caller looking for the wrong bug.
|
|
125
|
+
*
|
|
126
|
+
* ── FAILING CLOSED, and the two things it must NOT close on ─────────────────
|
|
127
|
+
*
|
|
128
|
+
* The rule is "refuse what the table knows is wrong; pass anything it cannot know",
|
|
129
|
+
* and both halves cost something real, so both are stated.
|
|
130
|
+
*
|
|
131
|
+
* **A known unit with an unknown FUNCTION is refused.** The table is authoritative
|
|
132
|
+
* about a unit it carries, so `bowmark.music.searchHarder(…)` is a typo or an
|
|
133
|
+
* install older than the function. The compile-time surface already refuses that
|
|
134
|
+
* call — it is a `Property does not exist` error against the same generated data —
|
|
135
|
+
* so anybody reaching this line came through `as any`, plain JavaScript or a stale
|
|
136
|
+
* package, and a refusal naming the manifest version is a better answer for all
|
|
137
|
+
* three than a request that succeeds against declarations they do not have. The
|
|
138
|
+
* cost, named rather than hidden: this package is published on its own cadence, so
|
|
139
|
+
* a caller on version N cannot reach a function the library gained in N+1 even
|
|
140
|
+
* though the api would serve it. `run(script)` reaches anything and is untyped by
|
|
141
|
+
* construction, so nothing is unreachable.
|
|
142
|
+
*
|
|
143
|
+
* **An unknown UNIT passes straight through, and that is not a hedge.** A Shopify
|
|
144
|
+
* family MEMBER — `bowmark.providers.gymshark.search(…)` — is deliberately absent
|
|
145
|
+
* from every manifest: `listProviders()` excludes members by design and always
|
|
146
|
+
* will, because there are half a million of them. So an unknown unit is the NORMAL
|
|
147
|
+
* case for most of the library, not a stale one, and refusing it would have this
|
|
148
|
+
* package refuse the largest part of what it is a client for. Found by running the
|
|
149
|
+
* Phase 5 session suite, whose worked example is gymshark.
|
|
150
|
+
*
|
|
151
|
+
* **An `unchecked` function passes through too** — one whose declared argument is a
|
|
152
|
+
* bare destructuring pattern. It is an EXPLICIT null in the table rather than an
|
|
153
|
+
* absence, and that distinction is what makes the first rule safe at all: 20 real
|
|
154
|
+
* functions have no readable argument shape, and refusing them would delete them
|
|
155
|
+
* from the runtime as well as from the types. */
|
|
156
|
+
export function assertArgShape(label, path, args) {
|
|
157
|
+
const found = lookupParams(VALIDATORS, path);
|
|
158
|
+
if (found.kind === "unknown-function") {
|
|
159
|
+
throw new BowmarkError(`${label} was not called: this package's declarations were generated from library ` +
|
|
160
|
+
`manifest ${VALIDATORS.version.slice(0, 12)}, which has no such function on that unit. ` +
|
|
161
|
+
`If it is newer than this package, upgrade @bowmark/web; if you meant a different name, ` +
|
|
162
|
+
`the typed surface will offer it. \`run(script)\` reaches anything, typed or not.`, { code: "unknown_function", path: label });
|
|
163
|
+
}
|
|
164
|
+
if (found.kind === "unknown-unit" || found.kind === "unchecked")
|
|
165
|
+
return;
|
|
166
|
+
const unit = VALIDATORS.units[path.slice(0, -1).join(".")];
|
|
167
|
+
const problem = argsProblem(found.params, args, unit?.defs ?? {});
|
|
168
|
+
if (!problem)
|
|
169
|
+
return;
|
|
170
|
+
throw new BowmarkError(`${label} was not called: ${problem.path} is ${problem.reason}.`, {
|
|
171
|
+
code: "bad_argument",
|
|
172
|
+
path: label,
|
|
173
|
+
});
|
|
174
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/// <reference path="./generated/library.d.ts" />
|
|
2
|
+
import { type ClientOptions, type RunEnvelope } from "./transport.js";
|
|
3
|
+
export { type WireProblem, wireProblem } from "./guard.js";
|
|
4
|
+
export { client, openManagedSession, type SessionHandle, session } from "./session.js";
|
|
5
|
+
export { type AuthNeed, BowmarkError, BowmarkNeedsUserError, type CallEnvelope, type ClientOptions, type ClosedSession, type FetchLike, type Handoff, type OpenedSession, type RunEnvelope, } from "./transport.js";
|
|
6
|
+
/** The whole callable library, typed. `await bowmark.music.search("aphex twin")`.
|
|
7
|
+
*
|
|
8
|
+
* Each call opens its own one-shot session, which is right for ONE call and wrong
|
|
9
|
+
* for several: two calls get two instances and two cookie jars, so a cart filled by
|
|
10
|
+
* the first does not exist for the second. Reach for `session()` the moment a flow
|
|
11
|
+
* has a second step.
|
|
12
|
+
*
|
|
13
|
+
* Configuration comes from `BOWMARK_API_KEY` and `BOWMARK_API_URL`, read at CALL
|
|
14
|
+
* time. `client({ apiKey })` is the explicit form. */
|
|
15
|
+
export declare const bowmark: BowmarkLibrary;
|
|
16
|
+
/** Run a SCRIPT, as a string, in our sandbox — the agent path, and the
|
|
17
|
+
* zero-dependency `curl` path given a client.
|
|
18
|
+
*
|
|
19
|
+
* Not deprecated and not hidden: an LLM emits text natively, so a tool call carrying
|
|
20
|
+
* a script is the right shape for one. It is also **untyped by construction** — a
|
|
21
|
+
* template literal gets no typechecking, so the generated types cover `session()`
|
|
22
|
+
* and `bowmark`, never this. A person writing TypeScript wants `session()`.
|
|
23
|
+
*
|
|
24
|
+
* Returns the envelope rather than throwing: a script is composite, so `status`,
|
|
25
|
+
* `logs` and `result` are read together. `needs_user` carries a handoff URL in
|
|
26
|
+
* `meta.handoff`. */
|
|
27
|
+
export declare function run<T = unknown>(script: string, opts?: ClientOptions): Promise<RunEnvelope<T>>;
|
|
28
|
+
export type Library = BowmarkLibrary;
|
|
29
|
+
export type Providers = BowmarkProviders;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
/// <reference path="./generated/library.d.ts" />
|
|
2
|
-
|
|
3
2
|
// @bowmark/web — real TypeScript for the whole `bowmark.*` surface, plus the client
|
|
4
3
|
// that executes it on our servers.
|
|
5
4
|
//
|
|
@@ -15,25 +14,11 @@
|
|
|
15
14
|
// process. `bowmark` inside a `run()` script string is the sandbox's own global,
|
|
16
15
|
// running in an isolate on ours. The types are generated once and describe both, so
|
|
17
16
|
// the two must stay in sync — see `packages/runtime/src/namespace.ts`.
|
|
18
|
-
|
|
19
17
|
import { client } from "./session.js";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
export {
|
|
23
|
-
export {
|
|
24
|
-
export {
|
|
25
|
-
type AuthNeed,
|
|
26
|
-
BowmarkError,
|
|
27
|
-
BowmarkNeedsUserError,
|
|
28
|
-
type CallEnvelope,
|
|
29
|
-
type ClientOptions,
|
|
30
|
-
type ClosedSession,
|
|
31
|
-
type FetchLike,
|
|
32
|
-
type Handoff,
|
|
33
|
-
type OpenedSession,
|
|
34
|
-
type RunEnvelope,
|
|
35
|
-
} from "./transport.js";
|
|
36
|
-
|
|
18
|
+
import { postRun, resolveClient } from "./transport.js";
|
|
19
|
+
export { wireProblem } from "./guard.js";
|
|
20
|
+
export { client, openManagedSession, session } from "./session.js";
|
|
21
|
+
export { BowmarkError, BowmarkNeedsUserError, } from "./transport.js";
|
|
37
22
|
/** The whole callable library, typed. `await bowmark.music.search("aphex twin")`.
|
|
38
23
|
*
|
|
39
24
|
* Each call opens its own one-shot session, which is right for ONE call and wrong
|
|
@@ -43,8 +28,7 @@ export {
|
|
|
43
28
|
*
|
|
44
29
|
* Configuration comes from `BOWMARK_API_KEY` and `BOWMARK_API_URL`, read at CALL
|
|
45
30
|
* time. `client({ apiKey })` is the explicit form. */
|
|
46
|
-
export const bowmark
|
|
47
|
-
|
|
31
|
+
export const bowmark = client();
|
|
48
32
|
/** Run a SCRIPT, as a string, in our sandbox — the agent path, and the
|
|
49
33
|
* zero-dependency `curl` path given a client.
|
|
50
34
|
*
|
|
@@ -56,16 +40,6 @@ export const bowmark: BowmarkLibrary = client();
|
|
|
56
40
|
* Returns the envelope rather than throwing: a script is composite, so `status`,
|
|
57
41
|
* `logs` and `result` are read together. `needs_user` carries a handoff URL in
|
|
58
42
|
* `meta.handoff`. */
|
|
59
|
-
export function run
|
|
60
|
-
|
|
61
|
-
opts: ClientOptions = {},
|
|
62
|
-
): Promise<RunEnvelope<T>> {
|
|
63
|
-
return postRun<T>(resolveClient(opts), script);
|
|
43
|
+
export function run(script, opts = {}) {
|
|
44
|
+
return postRun(resolveClient(opts), script);
|
|
64
45
|
}
|
|
65
|
-
|
|
66
|
-
// ALIASES, not re-exports. A global from an ambient file is not a local declaration,
|
|
67
|
-
// so `export type { BowmarkLibrary }` is `TS2661`. Aliasing is what lets a consumer
|
|
68
|
-
// name the type without the ambient file being on their `types` path — which is the
|
|
69
|
-
// whole point of the client half being importable and the catalog half being ambient.
|
|
70
|
-
export type Library = BowmarkLibrary;
|
|
71
|
-
export type Providers = BowmarkProviders;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ClientOptions, type ClosedSession } from "./transport.js";
|
|
2
|
+
/** Everything a session block hands its callback. */
|
|
3
|
+
export interface SessionHandle {
|
|
4
|
+
/** The server-side id. Present so a caller can quote it in a bug report or find
|
|
5
|
+
* the run in a trace. */
|
|
6
|
+
readonly sessionId: string;
|
|
7
|
+
/** ISO instant after which the session is gone and its calls 410. */
|
|
8
|
+
readonly expiresAt: string;
|
|
9
|
+
}
|
|
10
|
+
/** Run a block of calls against ONE live instance.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const total = await session(async (bm) => {
|
|
14
|
+
* const found = await bm.providers.gymshark.search({ query: "hoodie" });
|
|
15
|
+
* await bm.providers.gymshark.addToCart({ variantId: found.products[0].variantId });
|
|
16
|
+
* return (await bm.providers.gymshark.getCart()).itemCount;
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* **Each call is a round trip.** Stated rather than hidden, because a surface that
|
|
21
|
+
* looks like a local function call and is actually stateful is the leaky abstraction
|
|
22
|
+
* Cap'n Web is most criticised for, and it is how a caller writes an N+1 without
|
|
23
|
+
* noticing.
|
|
24
|
+
*
|
|
25
|
+
* The session closes in a `finally`, so a throw inside the callback still releases
|
|
26
|
+
* it. Metering is per CALL on both surfaces — opening and closing cost nothing. */
|
|
27
|
+
export declare function session<T>(callback: (bowmark: BowmarkLibrary, handle: SessionHandle) => Promise<T>, opts?: ClientOptions): Promise<T>;
|
|
28
|
+
/** Open a session by hand, for a caller whose lifetime is not a block — a REPL, a
|
|
29
|
+
* long-lived server object, a test fixture.
|
|
30
|
+
*
|
|
31
|
+
* `session()` is the form to reach for; this one moves the `finally` to the caller,
|
|
32
|
+
* and a caller who forgets it holds a session until its TTL. */
|
|
33
|
+
export declare function openManagedSession(opts?: ClientOptions): Promise<{
|
|
34
|
+
bowmark: BowmarkLibrary;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
close(): Promise<ClosedSession | null>;
|
|
38
|
+
}>;
|
|
39
|
+
/** The bare per-call form: every call opens its own one-shot session and closes it.
|
|
40
|
+
*
|
|
41
|
+
* Correct for a single call and WRONG for several — two calls get two instances and
|
|
42
|
+
* two cookie jars, so a cart filled by the first does not exist for the second, and
|
|
43
|
+
* the failure is silent (Shopify answers `POST /cart/add.js` with 200 and the line
|
|
44
|
+
* echoed back, then reports `item_count: 0`). Use `session()` for anything
|
|
45
|
+
* multi-step. */
|
|
46
|
+
export declare function client(opts?: ClientOptions): BowmarkLibrary;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/// <reference path="./generated/library.d.ts" />
|
|
2
|
+
// The session client — the surface a person actually uses.
|
|
3
|
+
//
|
|
4
|
+
// The caller's callback runs on the CALLER'S MACHINE, in ordinary JavaScript, and
|
|
5
|
+
// each capability call is one typed round trip into one live instance on ours. Real
|
|
6
|
+
// `if`, real `for`, real closures, real autocomplete, and no script string.
|
|
7
|
+
//
|
|
8
|
+
// The callback is never stringified and never shipped. `toString()` on a user
|
|
9
|
+
// function returns whatever the CALLER'S build tooling emitted — istanbul's
|
|
10
|
+
// `cov_npcpcae6x.f[2]++`, esbuild's `__name`, TypeScript downleveling's `tslib_1` —
|
|
11
|
+
// so accepting a function would be shipping a footgun whose trigger is in somebody
|
|
12
|
+
// else's toolchain. Playwright was asked to fix that class of bug and formally
|
|
13
|
+
// declined. See `docs/decisions/2026-08-03-the-session-surface-and-why-run-string-stays.md`.
|
|
14
|
+
import { assertArgShape, assertWireSafeArgs } from "./guard.js";
|
|
15
|
+
import { BowmarkError, callInSession, closeSession, openSession, resolveClient, } from "./transport.js";
|
|
16
|
+
/** Property names a proxy must NOT answer with another node.
|
|
17
|
+
*
|
|
18
|
+
* `then` is the load-bearing one: `await bowmark.music` would otherwise find a
|
|
19
|
+
* callable `then`, invoke it as a thenable, and hang forever on a path that was
|
|
20
|
+
* never a call. The other two round out the Promise protocol, and `toJSON` stops a
|
|
21
|
+
* node being silently serialized into somebody's log. */
|
|
22
|
+
const NOT_A_PATH_SEGMENT = new Set(["then", "catch", "finally", "toJSON"]);
|
|
23
|
+
/** Build the `bowmark`-shaped Proxy over a dispatcher.
|
|
24
|
+
*
|
|
25
|
+
* **The Proxy accepts every name at runtime** — `bowmark.anything.at.all()` builds a
|
|
26
|
+
* path and sends it. That is not a bug, it is why the generated types are
|
|
27
|
+
* load-bearing rather than decorative: the Proxy makes a correct call work without
|
|
28
|
+
* enumerating half a million names, and TypeScript makes a wrong one a compile
|
|
29
|
+
* error. Neither half is sufficient alone, and it is the same split
|
|
30
|
+
* `packages/runtime/src/namespace.ts` already ships inside the sandbox. */
|
|
31
|
+
function libraryProxy(dispatch) {
|
|
32
|
+
const node = (path) => {
|
|
33
|
+
// The target is a FUNCTION so the proxy is callable at any depth. A plain
|
|
34
|
+
// object target makes `apply` an illegal trap and every call a TypeError.
|
|
35
|
+
const target = () => undefined;
|
|
36
|
+
return new Proxy(target, {
|
|
37
|
+
get(_target, property) {
|
|
38
|
+
if (typeof property !== "string")
|
|
39
|
+
return undefined;
|
|
40
|
+
if (NOT_A_PATH_SEGMENT.has(property))
|
|
41
|
+
return undefined;
|
|
42
|
+
return node([...path, property]);
|
|
43
|
+
},
|
|
44
|
+
apply(_target, _thisArg, args) {
|
|
45
|
+
return dispatch(path, args);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
return node([]);
|
|
50
|
+
}
|
|
51
|
+
/** One dispatch: validate the path, refuse a non-wire argument, then send it. */
|
|
52
|
+
function dispatchThrough(send) {
|
|
53
|
+
// `async`, so EVERY refusal is a rejected promise rather than a synchronous
|
|
54
|
+
// throw. A generated signature says the call returns a `Promise`, and a function
|
|
55
|
+
// that sometimes throws before returning one breaks `.catch()` — the caller's
|
|
56
|
+
// handler is never attached. Playwright's argument guard has the same shape for
|
|
57
|
+
// the same reason.
|
|
58
|
+
return async (path, args) => {
|
|
59
|
+
const label = ["bowmark", ...path].join(".");
|
|
60
|
+
// The api refuses a path shorter than two segments, and it is right to: every
|
|
61
|
+
// real call is `<unit>.<fn>` or `providers.<id>.<fn>`. Saying so here costs a
|
|
62
|
+
// round trip nothing and names the shape.
|
|
63
|
+
if (path.length < 2) {
|
|
64
|
+
throw new BowmarkError(`${label} is not a callable path. Call a function on a unit — bowmark.music.search(…) or bowmark.providers.gymshark.search(…).`, { code: "bad_path", path: label });
|
|
65
|
+
}
|
|
66
|
+
// BEFORE the request, deliberately. A guard that ran server-side would report
|
|
67
|
+
// a mangled value as a capability failure, after it had been metered.
|
|
68
|
+
//
|
|
69
|
+
// Two guards, in this order and not the other. `assertWireSafeArgs` answers
|
|
70
|
+
// "can this value cross at all" — a `Date`, a `Map`, a function — and its
|
|
71
|
+
// message is about JSON. `assertArgShape` answers "does it match what this
|
|
72
|
+
// function declares". Shape-first would report a `Date` as "expected a string"
|
|
73
|
+
// and point the caller at the wrong bug.
|
|
74
|
+
assertWireSafeArgs(label, args);
|
|
75
|
+
assertArgShape(label, path, args);
|
|
76
|
+
return send(path, args);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Run a block of calls against ONE live instance.
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* const total = await session(async (bm) => {
|
|
83
|
+
* const found = await bm.providers.gymshark.search({ query: "hoodie" });
|
|
84
|
+
* await bm.providers.gymshark.addToCart({ variantId: found.products[0].variantId });
|
|
85
|
+
* return (await bm.providers.gymshark.getCart()).itemCount;
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* **Each call is a round trip.** Stated rather than hidden, because a surface that
|
|
90
|
+
* looks like a local function call and is actually stateful is the leaky abstraction
|
|
91
|
+
* Cap'n Web is most criticised for, and it is how a caller writes an N+1 without
|
|
92
|
+
* noticing.
|
|
93
|
+
*
|
|
94
|
+
* The session closes in a `finally`, so a throw inside the callback still releases
|
|
95
|
+
* it. Metering is per CALL on both surfaces — opening and closing cost nothing. */
|
|
96
|
+
export async function session(callback, opts = {}) {
|
|
97
|
+
const client = resolveClient(opts);
|
|
98
|
+
const opened = await openSession(client);
|
|
99
|
+
const proxy = libraryProxy(dispatchThrough((path, args) => callInSession(client, opened.sessionId, path, args)));
|
|
100
|
+
try {
|
|
101
|
+
return await callback(proxy, { sessionId: opened.sessionId, expiresAt: opened.expiresAt });
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
// Swallowed, and reported through `onLog` rather than thrown. A close that
|
|
105
|
+
// failed on top of a callback that threw would replace the caller's real error
|
|
106
|
+
// with a cleanup detail; the session's TTL reaps what this could not reach.
|
|
107
|
+
await closeSession(client, opened.sessionId).catch((err) => {
|
|
108
|
+
client.onLog?.(`[bowmark] could not close session ${opened.sessionId}: ${String(err)}`);
|
|
109
|
+
return null;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Open a session by hand, for a caller whose lifetime is not a block — a REPL, a
|
|
114
|
+
* long-lived server object, a test fixture.
|
|
115
|
+
*
|
|
116
|
+
* `session()` is the form to reach for; this one moves the `finally` to the caller,
|
|
117
|
+
* and a caller who forgets it holds a session until its TTL. */
|
|
118
|
+
export async function openManagedSession(opts = {}) {
|
|
119
|
+
const client = resolveClient(opts);
|
|
120
|
+
const opened = await openSession(client);
|
|
121
|
+
return {
|
|
122
|
+
bowmark: libraryProxy(dispatchThrough((path, args) => callInSession(client, opened.sessionId, path, args))),
|
|
123
|
+
sessionId: opened.sessionId,
|
|
124
|
+
expiresAt: opened.expiresAt,
|
|
125
|
+
close: () => closeSession(client, opened.sessionId),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** The bare per-call form: every call opens its own one-shot session and closes it.
|
|
129
|
+
*
|
|
130
|
+
* Correct for a single call and WRONG for several — two calls get two instances and
|
|
131
|
+
* two cookie jars, so a cart filled by the first does not exist for the second, and
|
|
132
|
+
* the failure is silent (Shopify answers `POST /cart/add.js` with 200 and the line
|
|
133
|
+
* echoed back, then reports `item_count: 0`). Use `session()` for anything
|
|
134
|
+
* multi-step. */
|
|
135
|
+
export function client(opts = {}) {
|
|
136
|
+
return libraryProxy(dispatchThrough(async (path, args) => {
|
|
137
|
+
// Resolved per CALL, not once at construction. The exported `bowmark` is
|
|
138
|
+
// built at module load, and a consumer whose `fetch` or `BOWMARK_API_KEY`
|
|
139
|
+
// arrives after the import would otherwise be frozen against the environment
|
|
140
|
+
// as it stood at the top of their file.
|
|
141
|
+
const resolved = resolveClient(opts);
|
|
142
|
+
const opened = await openSession(resolved);
|
|
143
|
+
try {
|
|
144
|
+
return await callInSession(resolved, opened.sessionId, path, args);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await closeSession(resolved, opened.sessionId).catch((err) => {
|
|
148
|
+
resolved.onLog?.(`[bowmark] could not close session ${opened.sessionId}: ${String(err)}`);
|
|
149
|
+
return null;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
}
|