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