@tineira/apc-conformance 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +35 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +129 -0
- package/dist/http.d.ts +85 -0
- package/dist/http.js +455 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +419 -0
- package/dist/shared.d.ts +43 -0
- package/dist/shared.js +124 -0
- package/dist/workers.d.ts +27 -0
- package/dist/workers.js +95 -0
- package/package.json +52 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The APC conformance suite (APC 0.2, TIN-577; `apc_link` since 0.3, TIN-578): checks that an app
|
|
3
|
+
* implements the core of the standard (`core.ts` in @tineira/apc-protocol), on either profile, through its MCP
|
|
4
|
+
* endpoint, the way an agent or a dock's hub reaches it, and, for an app that offers both profiles,
|
|
5
|
+
* `apc_link` (give `link` in the adapter). What happens in the person's page (the view they report, the pin they drop,
|
|
6
|
+
* the presence the app shows them, Revoke in the agent bar) comes through a small adapter the app
|
|
7
|
+
* provides, since only the app knows its page. Since 0.5 it also reads the app's `apc.json`
|
|
8
|
+
* (`GET {origin}/.well-known/apc.json`, through the adapter's `fetch`): its shape, its origin, the
|
|
9
|
+
* standard, and that its profiles and `mcp.path` match what the app serves. Since 0.6 an adapter may
|
|
10
|
+
* also give `page.mobile` (the page on a phone): the suite then checks that every agent is shown in the
|
|
11
|
+
* page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag.
|
|
12
|
+
*
|
|
13
|
+
* Framework-free: `apcConformance(app)` returns named checks that throw `ConformanceError`, for any
|
|
14
|
+
* test runner (`for (const c of apcConformance(app)) test(c.name, c.run)` under `node --test`), and
|
|
15
|
+
* `runConformance(app)` runs them all and reports.
|
|
16
|
+
*
|
|
17
|
+
* Against a live app with no adapter, `httpConformance` / `runHttpConformance` (`http.ts`) run the part
|
|
18
|
+
* of the suite that HTTP reaches (apc.json, and with a grant's MCP URL the core tools' shapes and timing,
|
|
19
|
+
* and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
|
|
20
|
+
*/
|
|
21
|
+
import { APC_AGENT_CLIENT_META, APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_MOBILE_VIEWPORT, APC_STANDARD_VERSION, APC_LINK_KEY, APC_LINK_TOOL, APC_LINK_TTL_S, CORE_TOOLS, LINK_REFUSED_ERROR, REVOKED_ERROR, REVOKED_HTTP_STATUS, canonicalTarget, compareStandard, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
|
|
22
|
+
import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody, text } from "./shared.js";
|
|
23
|
+
export { ConformanceError } from "./shared.js";
|
|
24
|
+
export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, } from "./http.js";
|
|
25
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
/** One JSON-RPC request over Streamable HTTP, stateless, as a dock's hub sends it: the HTTP status and the reply. */
|
|
27
|
+
async function send(app, url, method, params) {
|
|
28
|
+
const request = new Request(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
|
|
31
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
32
|
+
});
|
|
33
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
34
|
+
const body = rpcBody(response.headers.get("content-type") ?? "", await response.text());
|
|
35
|
+
return { status: response.status, body };
|
|
36
|
+
}
|
|
37
|
+
async function result(app, url, method, params = {}) {
|
|
38
|
+
const { status, body } = await send(app, url, method, params);
|
|
39
|
+
expect(status === 200, `${method}: HTTP ${status}, expected 200 (${JSON.stringify(body).slice(0, 200)})`);
|
|
40
|
+
expect(isObject(body) && !("error" in body && body.error), `${method}: JSON-RPC error ${JSON.stringify(body).slice(0, 200)}`);
|
|
41
|
+
return body.result;
|
|
42
|
+
}
|
|
43
|
+
async function call(app, url, tool, args = {}) {
|
|
44
|
+
const r = await result(app, url, "tools/call", { name: tool, arguments: args });
|
|
45
|
+
expect(isObject(r), `${tool}: no result`);
|
|
46
|
+
return r;
|
|
47
|
+
}
|
|
48
|
+
/** A focus in the core's format, naming `target`. */
|
|
49
|
+
function checkFocus(focus, target, where) {
|
|
50
|
+
const parsed = checkFocusShape(focus, where);
|
|
51
|
+
expect(canonicalTarget(parsed) === canonicalTarget(target), `${where}: the focus names ${canonicalTarget(parsed)}, the pin was ${canonicalTarget(target)}`);
|
|
52
|
+
return focus;
|
|
53
|
+
}
|
|
54
|
+
async function timed(work) {
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
const value = await work;
|
|
57
|
+
return { value, ms: Date.now() - started };
|
|
58
|
+
}
|
|
59
|
+
/** The checks of the core, in order. Each makes its own agents, so any one can run alone. */
|
|
60
|
+
export function apcConformance(app) {
|
|
61
|
+
const [first, second] = app.targets;
|
|
62
|
+
const check = (name, run) => ({ name: `${app.name} (${app.profile}): ${name}`, run });
|
|
63
|
+
return [
|
|
64
|
+
check("serves apc.json from its own origin, naming the profiles it serves and where its MCP URLs live", async () => {
|
|
65
|
+
const { mcpUrl } = await app.connect();
|
|
66
|
+
const origin = new URL(mcpUrl).origin;
|
|
67
|
+
const request = new Request(`${origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
68
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
69
|
+
expect(response.status === 200, `GET ${APC_JSON_PATH}: HTTP ${response.status}, expected 200`);
|
|
70
|
+
expect((response.headers.get("content-type") ?? "").includes("application/json"), `${APC_JSON_PATH} is not served as application/json`);
|
|
71
|
+
const text = await response.text();
|
|
72
|
+
expect(new TextEncoder().encode(text).length <= APC_JSON_MAX_BYTES, `${APC_JSON_PATH} is over ${APC_JSON_MAX_BYTES} bytes`);
|
|
73
|
+
let body;
|
|
74
|
+
try {
|
|
75
|
+
body = JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new ConformanceError(`${APC_JSON_PATH} is not JSON`);
|
|
79
|
+
}
|
|
80
|
+
const parsed = parseApcManifest(body, origin);
|
|
81
|
+
expect(parsed.ok, `${APC_JSON_PATH}: ${parsed.ok ? "" : `${parsed.error}, ${parsed.detail}`}`);
|
|
82
|
+
const m = parsed.manifest;
|
|
83
|
+
expect(compareStandard(m.standard, "0.5.0") >= 0 && compareStandard(m.standard, APC_STANDARD_VERSION) <= 0, `standard is ${m.standard}: apc.json exists since 0.5.0, and this suite checks up to ${APC_STANDARD_VERSION}`);
|
|
84
|
+
expect(!m.profiles.includes("hub"), "an app's profiles are direct, dock or both, never hub");
|
|
85
|
+
expect(m.profiles.includes(app.profile), `profiles ${JSON.stringify(m.profiles)} leave out ${app.profile}, which the app serves`);
|
|
86
|
+
if (app.link)
|
|
87
|
+
expect(m.profiles.includes("direct") && m.profiles.includes("dock"), `profiles ${JSON.stringify(m.profiles)}: an app that links both paths offers both`);
|
|
88
|
+
expect(declaresMcpUrl(m, mcpUrl), `the grant's MCP URL is not under ${origin}${m.mcp.path} (mcp.path)`);
|
|
89
|
+
if (m.profiles.includes("direct"))
|
|
90
|
+
expect(m.direct && new URL(m.direct.connect).origin === origin, "direct.connect is the app's own page");
|
|
91
|
+
}),
|
|
92
|
+
check("lists the core tools, with their arguments", async () => {
|
|
93
|
+
const { mcpUrl } = await app.connect();
|
|
94
|
+
checkCoreToolList(await result(app, mcpUrl, "tools/list"), CORE_TOOLS);
|
|
95
|
+
}),
|
|
96
|
+
check("get_current_view returns the view as identifiers, and no focus before a pin", async () => {
|
|
97
|
+
const { mcpUrl } = await app.connect();
|
|
98
|
+
await app.page.setOpen(true);
|
|
99
|
+
await app.page.showView();
|
|
100
|
+
const r = await call(app, mcpUrl, "get_current_view");
|
|
101
|
+
expect(!r.isError, "get_current_view failed");
|
|
102
|
+
const d = data(r);
|
|
103
|
+
expect("view" in d && "focus" in d, "the result has view and focus");
|
|
104
|
+
expect(d.focus === null, "no pin yet, so no focus");
|
|
105
|
+
checkView(d.view, "get_current_view");
|
|
106
|
+
expect(attentionOf(r) === undefined, "no pin, so no attention");
|
|
107
|
+
}),
|
|
108
|
+
check("a pin rides as attention on any result until it is read, and next reads it", async () => {
|
|
109
|
+
const { mcpUrl, grantId } = await app.connect();
|
|
110
|
+
expect(await app.page.pin(grantId, first), "the app refused a pin on the first target");
|
|
111
|
+
const probed = await call(app, mcpUrl, app.probe.tool, app.probe.args);
|
|
112
|
+
expect(!probed.isError, `${app.probe.tool} failed`);
|
|
113
|
+
const attention = attentionOf(probed);
|
|
114
|
+
expect(isObject(attention) && typeof attention.note === "string", "an unread pin puts attention { focus, note } on the result");
|
|
115
|
+
checkFocus(attention.focus, first, "attention");
|
|
116
|
+
const view = await call(app, mcpUrl, "get_current_view");
|
|
117
|
+
const focus = checkFocus(data(view).focus, first, "get_current_view");
|
|
118
|
+
const after = await call(app, mcpUrl, app.probe.tool, app.probe.args);
|
|
119
|
+
expect(attentionOf(after) === undefined, "reading the focus marks it seen: no attention after");
|
|
120
|
+
const read = await call(app, mcpUrl, focus.next, focus.next_args);
|
|
121
|
+
expect(!read.isError, `next (${String(focus.next)}) with next_args failed: ${JSON.stringify(read.content).slice(0, 200)}`);
|
|
122
|
+
// One pin per grant: a new drop replaces it, unread again.
|
|
123
|
+
expect(await app.page.pin(grantId, second), "the app refused a pin on the second target");
|
|
124
|
+
const again = attentionOf(await call(app, mcpUrl, app.probe.tool, app.probe.args));
|
|
125
|
+
expect(isObject(again), "a new pin is unread again");
|
|
126
|
+
checkFocus(again.focus, second, "attention after a second pin");
|
|
127
|
+
}),
|
|
128
|
+
check("wait_for_focus returns a pin dropped during the wait", async () => {
|
|
129
|
+
const { mcpUrl, grantId } = await app.connect();
|
|
130
|
+
await app.page.setOpen(true);
|
|
131
|
+
const waiting = timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 10 }));
|
|
132
|
+
await sleep(300);
|
|
133
|
+
expect(await app.page.pin(grantId, second), "the app refused the pin");
|
|
134
|
+
const { value: r, ms } = await waiting;
|
|
135
|
+
expect(!r.isError, "wait_for_focus failed");
|
|
136
|
+
const d = data(r);
|
|
137
|
+
expect(d.timed_out === false, "a wait that got a pin did not time out");
|
|
138
|
+
checkFocus(d.focus, second, "wait_for_focus");
|
|
139
|
+
expect(ms < 9000, `it returned at ${ms} ms, not soon after the pin`);
|
|
140
|
+
const after = await call(app, mcpUrl, app.probe.tool, app.probe.args);
|
|
141
|
+
expect(attentionOf(after) === undefined, "the wait marks the pin seen");
|
|
142
|
+
}),
|
|
143
|
+
check("wait_for_focus times out cleanly", async () => {
|
|
144
|
+
const { mcpUrl } = await app.connect();
|
|
145
|
+
await app.page.setOpen(true);
|
|
146
|
+
const { value: r, ms } = await timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 1 }));
|
|
147
|
+
expect(!r.isError, "wait_for_focus failed");
|
|
148
|
+
const d = data(r);
|
|
149
|
+
expect(d.focus === null && d.timed_out === true, `a wait with no pin times out: ${JSON.stringify(d)}`);
|
|
150
|
+
expect(typeof d.note === "string" && d.note.length > 0, "a wait with no pin carries a note for the agent");
|
|
151
|
+
expect(d.page_open === undefined || d.page_open === true, "the page is open, so page_open is true or left out");
|
|
152
|
+
expect(ms >= 900 && ms < 5000, `it took ${ms} ms for timeout_s 1`);
|
|
153
|
+
expect(text(r).then === undefined && d.then === undefined, "a wait that came back empty ends the loop: no then");
|
|
154
|
+
}),
|
|
155
|
+
check("wait_for_focus ends when the person has no page open", async () => {
|
|
156
|
+
const { mcpUrl } = await app.connect();
|
|
157
|
+
await app.page.setOpen(false);
|
|
158
|
+
const { value: r, ms } = await timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 3 }));
|
|
159
|
+
const d = data(r);
|
|
160
|
+
expect(d.focus === null, "no pin, no focus");
|
|
161
|
+
if (d.page_open === undefined)
|
|
162
|
+
expect(d.timed_out === true, "an app that cannot tell runs to the timeout");
|
|
163
|
+
else {
|
|
164
|
+
expect(d.page_open === false && d.timed_out === false, `a closed page: page_open false, timed_out false (${JSON.stringify(d)})`);
|
|
165
|
+
expect(ms < 2900, `a closed page ends the wait before its timeout (${ms} ms)`);
|
|
166
|
+
}
|
|
167
|
+
await app.page.setOpen(true);
|
|
168
|
+
}),
|
|
169
|
+
check("then rides on results only while the person's page is open", async () => {
|
|
170
|
+
const { mcpUrl } = await app.connect();
|
|
171
|
+
await app.page.setOpen(true);
|
|
172
|
+
const open = await call(app, mcpUrl, app.probe.tool, app.probe.args);
|
|
173
|
+
// resultKey: the same value in the structured result and the text (a text-only model follows the text).
|
|
174
|
+
const then = resultKey(open, "then");
|
|
175
|
+
expect(typeof then.value === "string" && typeof text(open).then === "string", "with the page open, a result carries then, structured and in the text");
|
|
176
|
+
await app.page.setOpen(false);
|
|
177
|
+
const closed = await call(app, mcpUrl, app.probe.tool, app.probe.args);
|
|
178
|
+
expect(resultKey(closed, "then").value === undefined && text(closed).then === undefined, "with no page open, no then");
|
|
179
|
+
await app.page.setOpen(true);
|
|
180
|
+
}),
|
|
181
|
+
check("presence names what a read touched, with ids only", async () => {
|
|
182
|
+
const { mcpUrl, grantId } = await app.connect();
|
|
183
|
+
await app.page.setOpen(true);
|
|
184
|
+
expect(await app.page.pin(grantId, first), "the app refused the pin");
|
|
185
|
+
const focus = checkFocus(data(await call(app, mcpUrl, "get_current_view")).focus, first, "get_current_view");
|
|
186
|
+
app.page.takePresence();
|
|
187
|
+
const read = await call(app, mcpUrl, focus.next, focus.next_args);
|
|
188
|
+
expect(!read.isError, "next failed");
|
|
189
|
+
const events = app.page.takePresence().filter((e) => e.grant_id === grantId && e.tool === focus.next);
|
|
190
|
+
expect(events.length === 1, `one presence event for the read, got ${events.length}`);
|
|
191
|
+
const e = events[0];
|
|
192
|
+
expect(Object.keys(e).sort().join() === "at,count,grant_id,kind,targets,tool,v", `presence carries ids only: ${Object.keys(e).join()}`);
|
|
193
|
+
expect(e.v === 1 && (e.kind === "read" || e.kind === "write") && ISO.test(e.at), "presence is v 1, read or write, with an ISO time");
|
|
194
|
+
expect(Array.isArray(e.targets) && e.targets.length <= 20 && e.count >= e.targets.length, "at most 20 targets, and count covers them");
|
|
195
|
+
expect(e.targets.every((t) => Object.keys(t).sort().join() === "id,kind"), "a presence target is { kind, id }");
|
|
196
|
+
if (first.id)
|
|
197
|
+
expect(e.targets.some((t) => t.kind === first.kind && t.id === first.id), `the read of ${first.kind}:${first.id} names it`);
|
|
198
|
+
}),
|
|
199
|
+
check("revoke from the app cuts that agent at once, and only that agent", async () => {
|
|
200
|
+
const a = await app.connect();
|
|
201
|
+
const b = await app.connect();
|
|
202
|
+
await app.page.setOpen(true);
|
|
203
|
+
expect(await app.page.pin(a.grantId, first), "the app refused the pin");
|
|
204
|
+
expect(await app.page.revoke(a.grantId), "the app refused to revoke");
|
|
205
|
+
for (const [method, params] of [["initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-conformance", version: "0" } }], ["tools/list", {}], ["tools/call", { name: "get_current_view", arguments: {} }], ["tools/call", { name: app.probe.tool, arguments: app.probe.args }]]) {
|
|
206
|
+
const { status, body } = await send(app, a.mcpUrl, method, params);
|
|
207
|
+
expect(status === REVOKED_HTTP_STATUS, `${method} on a revoked grant: HTTP ${status}, expected ${REVOKED_HTTP_STATUS}`);
|
|
208
|
+
expect(isRevokedAnswer(body) && body.revoked_by.length > 0 && ISO.test(body.revoked_at), `${method} on a revoked grant answers { error: "revoked", revoked_by, revoked_at }: ${JSON.stringify(body).slice(0, 200)}`);
|
|
209
|
+
}
|
|
210
|
+
// Whatever the HTTP method: a GET (the SSE stream) or a DELETE (ending a session) is answered "revoked" too, not 405.
|
|
211
|
+
for (const method of ["GET", "DELETE"]) {
|
|
212
|
+
const request = new Request(a.mcpUrl, { method, headers: { accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" } });
|
|
213
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
let body = null;
|
|
216
|
+
try {
|
|
217
|
+
body = JSON.parse(text);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// not JSON
|
|
221
|
+
}
|
|
222
|
+
expect(response.status === REVOKED_HTTP_STATUS, `${method} on a revoked grant: HTTP ${response.status}, expected ${REVOKED_HTTP_STATUS}`);
|
|
223
|
+
expect(isRevokedAnswer(body), `${method} on a revoked grant answers { error: "revoked", revoked_by, revoked_at }: ${text.slice(0, 200)}`);
|
|
224
|
+
}
|
|
225
|
+
expect(!(await app.page.pin(a.grantId, second)), "a revoked agent cannot be pinned");
|
|
226
|
+
const other = await call(app, b.mcpUrl, "get_current_view");
|
|
227
|
+
expect(!other.isError && "view" in data(other), "another agent is not touched");
|
|
228
|
+
}),
|
|
229
|
+
check("a call already running when the grant is revoked ends revoked, with no data", async () => {
|
|
230
|
+
const { mcpUrl, grantId } = await app.connect();
|
|
231
|
+
await app.page.setOpen(true);
|
|
232
|
+
const waiting = timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 10 }));
|
|
233
|
+
await sleep(300);
|
|
234
|
+
expect(await app.page.revoke(grantId), "the app refused to revoke");
|
|
235
|
+
const { value: r, ms } = await waiting;
|
|
236
|
+
expect(r.isError === true, `the wait ends as an error: ${JSON.stringify(r).slice(0, 200)}`);
|
|
237
|
+
const d = text(r);
|
|
238
|
+
expect(d.error === REVOKED_ERROR, `its error is "revoked": ${JSON.stringify(d).slice(0, 200)}`);
|
|
239
|
+
expect(!("focus" in d) && !("view" in d) && r.structuredContent === undefined, "it carries no focus, view or other data");
|
|
240
|
+
expect(ms < 9000, `it ended at ${ms} ms, not soon after the revoke`);
|
|
241
|
+
}),
|
|
242
|
+
...mobileChecks(app, app.page.mobile),
|
|
243
|
+
...(app.link ? linkChecks(app, app.link) : []),
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
/** The mobile rule (APC 0.6): on a phone the agents are in the page, never collapsed, and a finger can pin and unpin. */
|
|
247
|
+
function mobileChecks(app, mobile) {
|
|
248
|
+
const [first, second] = app.targets;
|
|
249
|
+
const { width, height } = APC_MOBILE_VIEWPORT;
|
|
250
|
+
const check = (name, run) => ({ name: `${app.name} (${app.profile}): mobile ${width}x${height}: ${name}`, run });
|
|
251
|
+
return [
|
|
252
|
+
check("the page adapter for a phone is given (required of an app that claims 0.6.0 or later)", async () => {
|
|
253
|
+
if (mobile)
|
|
254
|
+
return;
|
|
255
|
+
const { mcpUrl } = await app.connect();
|
|
256
|
+
const request = new Request(`${new URL(mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
257
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
258
|
+
const claimed = (await response.json().catch(() => ({}))).standard;
|
|
259
|
+
expect(typeof claimed === "string" && compareStandard(claimed, "0.6.0") < 0, `apc.json claims standard ${String(claimed)}, which requires the mobile rule to be checked: give page.mobile in the adapter (ApcMobilePage: agentBar, pinByTouch, unpinByTouch)`);
|
|
260
|
+
}),
|
|
261
|
+
check("every agent is shown in the page, and the agent bar cannot be collapsed or hidden", async () => {
|
|
262
|
+
if (!mobile)
|
|
263
|
+
return;
|
|
264
|
+
const a = await app.connect();
|
|
265
|
+
const b = await app.connect();
|
|
266
|
+
await app.page.setOpen(true);
|
|
267
|
+
const bar = await mobile.agentBar();
|
|
268
|
+
for (const { grantId } of [a, b])
|
|
269
|
+
expect(bar.visible.includes(grantId), `the agent ${grantId} is not shown in the page on a phone: the page shows ${JSON.stringify(bar.visible)}`);
|
|
270
|
+
expect(bar.collapsible === false, "the agent bar can be collapsed or hidden on a phone: there the agent in the page is the only way to point it");
|
|
271
|
+
}),
|
|
272
|
+
check("a person pins with a finger, no mouse drag, and the agent reads the pin; unpin works by touch too", async () => {
|
|
273
|
+
if (!mobile)
|
|
274
|
+
return;
|
|
275
|
+
const { mcpUrl, grantId } = await app.connect();
|
|
276
|
+
await app.page.setOpen(true);
|
|
277
|
+
await app.page.showView();
|
|
278
|
+
expect(await mobile.pinByTouch(grantId, first), "the page refused a pin made by tapping the agent and then the target");
|
|
279
|
+
const r = await call(app, mcpUrl, "get_current_view");
|
|
280
|
+
expect(!r.isError, "get_current_view failed");
|
|
281
|
+
checkFocus(data(r).focus, first, "get_current_view after a pin by touch");
|
|
282
|
+
// The same again on another target: one pin per grant, replaced.
|
|
283
|
+
expect(await mobile.pinByTouch(grantId, second), "the page refused a second pin by touch");
|
|
284
|
+
checkFocus(data(await call(app, mcpUrl, "get_current_view")).focus, second, "get_current_view after a second pin by touch");
|
|
285
|
+
expect(await mobile.unpinByTouch(grantId), "the page offers no way to remove the pin with a finger");
|
|
286
|
+
const after = await call(app, mcpUrl, "get_current_view");
|
|
287
|
+
expect(data(after).focus === null, "the pin is still there after the person removed it by touch");
|
|
288
|
+
}),
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
/** A connection arrives: the agent's own initialize on Direct; on Dock, its hub's, naming the agent's client. */
|
|
292
|
+
async function arrive(app, conn, client) {
|
|
293
|
+
const params = conn.path === "direct"
|
|
294
|
+
? { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: client, version: "1.0.0" } }
|
|
295
|
+
: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-hub", title: `${client} via dock`, version: "0.1.0" }, _meta: { [APC_AGENT_CLIENT_META]: client } };
|
|
296
|
+
await result(app, conn.mcpUrl, "initialize", params);
|
|
297
|
+
}
|
|
298
|
+
/** The offer on a result, where the model sees it: in the structured result and the same in the text. */
|
|
299
|
+
function offerOf(r) {
|
|
300
|
+
const inData = data(r)[APC_LINK_KEY];
|
|
301
|
+
const inText = text(r)[APC_LINK_KEY];
|
|
302
|
+
expect(JSON.stringify(inData) === JSON.stringify(inText), "apc_link is the same in the structured result and the text");
|
|
303
|
+
if (inData === undefined)
|
|
304
|
+
return undefined;
|
|
305
|
+
expect(isObject(inData) && typeof inData.code === "string" && inData.code.length >= 16, `apc_link is { code, expires_in, note }: ${JSON.stringify(inData)}`);
|
|
306
|
+
expect(typeof inData.expires_in === "number" && inData.expires_in > 0 && inData.expires_in <= APC_LINK_TTL_S, `expires_in is 1 to ${APC_LINK_TTL_S} s`);
|
|
307
|
+
expect(typeof inData.note === "string" && inData.note.includes(APC_LINK_TOOL), "its note names the apc_link tool");
|
|
308
|
+
return inData;
|
|
309
|
+
}
|
|
310
|
+
/** `apc_link` (APC 0.4): the checks of an app that offers both profiles. */
|
|
311
|
+
function linkChecks(app, link) {
|
|
312
|
+
const check = (name, run) => ({ name: `${app.name} (apc_link): ${name}`, run });
|
|
313
|
+
let clients = 0;
|
|
314
|
+
const client = () => `conformance-agent-${++clients}`;
|
|
315
|
+
const connect = async (path) => ({ ...(await link.connect(path)), path });
|
|
316
|
+
/** The same agent, connected directly and then through a dock: the dock's connection is the new one, and gets the offer. */
|
|
317
|
+
const pair = async () => {
|
|
318
|
+
const name = client();
|
|
319
|
+
const direct = await connect("direct");
|
|
320
|
+
await arrive(app, direct, name);
|
|
321
|
+
const dock = await connect("dock");
|
|
322
|
+
await arrive(app, dock, name);
|
|
323
|
+
const offered = await call(app, dock.mcpUrl, app.probe.tool, app.probe.args);
|
|
324
|
+
expect(!offered.isError, `${app.probe.tool} failed`);
|
|
325
|
+
const offer = offerOf(offered);
|
|
326
|
+
expect(offer, `the second connection of ${name}, on the other path, gets apc_link on a result of one of the app's own tools`);
|
|
327
|
+
return { name, direct, dock, offer };
|
|
328
|
+
};
|
|
329
|
+
const redeem = (conn, code) => call(app, conn.mcpUrl, APC_LINK_TOOL, { code });
|
|
330
|
+
const refused = (r, reason, where) => {
|
|
331
|
+
expect(r.isError === true, `${where}: refused, as a tool error (${JSON.stringify(r).slice(0, 200)})`);
|
|
332
|
+
const d = text(r);
|
|
333
|
+
expect(d.error === LINK_REFUSED_ERROR && d.reason === reason, `${where}: { error: "link_refused", reason: "${reason}" }, got ${JSON.stringify(d).slice(0, 200)}`);
|
|
334
|
+
};
|
|
335
|
+
const linkedEntry = async (a, b) => (await link.agents()).find((e) => e.grants.includes(a) && e.grants.includes(b));
|
|
336
|
+
const works = async (conn, where) => {
|
|
337
|
+
const r = await call(app, conn.mcpUrl, "get_current_view");
|
|
338
|
+
expect(!r.isError && "view" in data(r), `${where} still reads the app`);
|
|
339
|
+
};
|
|
340
|
+
return [
|
|
341
|
+
check("the same client on the other path is offered a code once per session, and the code links them", async () => {
|
|
342
|
+
const { direct, dock, offer } = await pair();
|
|
343
|
+
const again = await call(app, dock.mcpUrl, app.probe.tool, app.probe.args);
|
|
344
|
+
expect(offerOf(again) === undefined, "asked at most once per MCP session");
|
|
345
|
+
const done = await redeem(direct, offer.code);
|
|
346
|
+
expect(!done.isError && data(done).linked === true, `the other connection redeems it: ${JSON.stringify(done).slice(0, 200)}`);
|
|
347
|
+
expect(await linkedEntry(direct.grantId, dock.grantId), "the agent bar shows one agent with both grants");
|
|
348
|
+
// Linking grants nothing: each connection keeps its own grant.
|
|
349
|
+
await works(direct, "the Direct connection");
|
|
350
|
+
await works(dock, "the Dock connection");
|
|
351
|
+
}),
|
|
352
|
+
check("a code is refused on the connection that got it, on the same path, on another client, and once used", async () => {
|
|
353
|
+
const { name, direct, dock, offer } = await pair();
|
|
354
|
+
const sameSide = await connect("dock");
|
|
355
|
+
await arrive(app, sameSide, name);
|
|
356
|
+
const stranger = await connect("direct");
|
|
357
|
+
await arrive(app, stranger, client());
|
|
358
|
+
refused(await redeem(dock, offer.code), "same_connection", "on the connection that got it");
|
|
359
|
+
refused(await redeem(sameSide, offer.code), "same_path", "on another connection of the same path");
|
|
360
|
+
refused(await redeem(stranger, offer.code), "not_offered", "on the other path, by another client");
|
|
361
|
+
refused(await redeem(direct, "lk_000000000000000000000000"), "unknown_code", "a code the app never gave");
|
|
362
|
+
const done = await redeem(direct, offer.code);
|
|
363
|
+
expect(!done.isError, "the right connection still redeems it");
|
|
364
|
+
refused(await redeem(direct, offer.code), "used", "a second time");
|
|
365
|
+
expect(!(await linkedEntry(direct.grantId, sameSide.grantId)), "nothing else was linked");
|
|
366
|
+
}),
|
|
367
|
+
check("an expired code is refused, and the app does not ask again in that session", async () => {
|
|
368
|
+
const { direct, dock, offer } = await pair();
|
|
369
|
+
if (link.expireCodes)
|
|
370
|
+
await link.expireCodes();
|
|
371
|
+
else
|
|
372
|
+
await sleep((APC_LINK_TTL_S + 1) * 1000);
|
|
373
|
+
refused(await redeem(direct, offer.code), "expired", "after expires_in");
|
|
374
|
+
expect(!(await linkedEntry(direct.grantId, dock.grantId)), "it stays two agents");
|
|
375
|
+
expect(offerOf(await call(app, dock.mcpUrl, app.probe.tool, app.probe.args)) === undefined, "not asked again in that session");
|
|
376
|
+
}),
|
|
377
|
+
check("a connection of another client is offered nothing", async () => {
|
|
378
|
+
const direct = await connect("direct");
|
|
379
|
+
await arrive(app, direct, client());
|
|
380
|
+
const dock = await connect("dock");
|
|
381
|
+
await arrive(app, dock, client());
|
|
382
|
+
expect(offerOf(await call(app, dock.mcpUrl, app.probe.tool, app.probe.args)) === undefined, "no offer between two clients");
|
|
383
|
+
}),
|
|
384
|
+
check("revoke on a linked agent cuts both grants", async () => {
|
|
385
|
+
const { direct, dock, offer } = await pair();
|
|
386
|
+
expect(!(await redeem(direct, offer.code)).isError, "linked");
|
|
387
|
+
expect(await app.page.revoke(dock.grantId), "the app refused to revoke");
|
|
388
|
+
for (const [conn, where] of [[direct, "Direct"], [dock, "Dock"]]) {
|
|
389
|
+
const { status, body } = await send(app, conn.mcpUrl, "tools/call", { name: "get_current_view", arguments: {} });
|
|
390
|
+
expect(status === REVOKED_HTTP_STATUS && isRevokedAnswer(body), `the ${where} grant answers 403 revoked: HTTP ${status}`);
|
|
391
|
+
}
|
|
392
|
+
}),
|
|
393
|
+
check("unlink shows two agents again, each with its own grant", async () => {
|
|
394
|
+
const { direct, dock, offer } = await pair();
|
|
395
|
+
expect(!(await redeem(direct, offer.code)).isError, "linked");
|
|
396
|
+
expect(await link.unlink(direct.grantId), "the app refused to unlink");
|
|
397
|
+
expect(!(await linkedEntry(direct.grantId, dock.grantId)), "two agents again");
|
|
398
|
+
await works(direct, "the Direct connection");
|
|
399
|
+
await works(dock, "the Dock connection");
|
|
400
|
+
expect(await app.page.revoke(direct.grantId), "the app refused to revoke");
|
|
401
|
+
await works(dock, "once unlinked, revoking one leaves the other: the Dock connection");
|
|
402
|
+
}),
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
/** Every check, one after another: what passed and what failed, with why. */
|
|
406
|
+
export async function runConformance(app) {
|
|
407
|
+
const results = [];
|
|
408
|
+
for (const c of apcConformance(app)) {
|
|
409
|
+
try {
|
|
410
|
+
await c.run();
|
|
411
|
+
results.push({ name: c.name, ok: true });
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
results.push({ name: c.name, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
418
|
+
return { app: app.name, profile: app.profile, passed: results.length - failed, failed, results };
|
|
419
|
+
}
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the in-process suite (`index.ts`) and the HTTP checks (`http.ts`) share: the error a check
|
|
3
|
+
* throws, how a JSON-RPC reply is read off Streamable HTTP, and the shapes of the core's view and focus.
|
|
4
|
+
*/
|
|
5
|
+
import { type FocusTarget } from "@tineira/apc-protocol";
|
|
6
|
+
export declare class ConformanceError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export declare function expect(condition: unknown, message: string): asserts condition;
|
|
9
|
+
export declare const ISO: RegExp;
|
|
10
|
+
export declare const isObject: (v: unknown) => v is Record<string, unknown>;
|
|
11
|
+
export interface ToolCallResult {
|
|
12
|
+
content?: Array<{
|
|
13
|
+
type: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
}>;
|
|
16
|
+
structuredContent?: Record<string, unknown>;
|
|
17
|
+
isError?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The body of an MCP reply: the JSON-RPC message of an SSE stream (the one with a result or an error), or the JSON. */
|
|
20
|
+
export declare function rpcBody(contentType: string, text: string): unknown;
|
|
21
|
+
/** The structured result, or the JSON of the first text block (what a model that sees only text reads). */
|
|
22
|
+
export declare function data(r: ToolCallResult): Record<string, unknown>;
|
|
23
|
+
export declare function text(r: ToolCallResult): Record<string, unknown>;
|
|
24
|
+
/** A focus in the core's format, whatever it names: the target it names. */
|
|
25
|
+
export declare function checkFocusShape(focus: unknown, where: string): FocusTarget;
|
|
26
|
+
/** A view in the core's format: identifiers only. */
|
|
27
|
+
export declare function checkView(view: unknown, where: string): void;
|
|
28
|
+
/** The same JSON value, whatever the order of object keys. */
|
|
29
|
+
export declare function sameJson(a: unknown, b: unknown): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* A top-level key of a tool result where the model sees it: when the result has `structuredContent`, the key is
|
|
32
|
+
* in both or in neither, with the same value in the structured result and the text (a text that says something
|
|
33
|
+
* else is what a text-only model would follow). With no `structuredContent` there is only the text to read; the
|
|
34
|
+
* caller can tell by `structured`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resultKey(r: ToolCallResult, key: string): {
|
|
37
|
+
value: unknown;
|
|
38
|
+
structured: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** The attention key, where the model sees it: in the structured result and in the text, the same. */
|
|
41
|
+
export declare function attentionOf(r: ToolCallResult): unknown;
|
|
42
|
+
/** What `tools/list` must say about the core tools (shared by the suite and the CLI). */
|
|
43
|
+
export declare function checkCoreToolList(listed: unknown, coreTools: readonly string[]): void;
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the in-process suite (`index.ts`) and the HTTP checks (`http.ts`) share: the error a check
|
|
3
|
+
* throws, how a JSON-RPC reply is read off Streamable HTTP, and the shapes of the core's view and focus.
|
|
4
|
+
*/
|
|
5
|
+
import { parseParams, parseTarget } from "@tineira/apc-protocol";
|
|
6
|
+
export class ConformanceError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export function expect(condition, message) {
|
|
9
|
+
if (!condition)
|
|
10
|
+
throw new ConformanceError(message);
|
|
11
|
+
}
|
|
12
|
+
export const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
13
|
+
export const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
/** The body of an MCP reply: the JSON-RPC message of an SSE stream (the one with a result or an error), or the JSON. */
|
|
15
|
+
export function rpcBody(contentType, text) {
|
|
16
|
+
if (contentType.includes("text/event-stream")) {
|
|
17
|
+
let body = null;
|
|
18
|
+
for (const line of text.split("\n")) {
|
|
19
|
+
if (!line.startsWith("data:"))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(line.slice(5));
|
|
23
|
+
if (isObject(parsed) && ("result" in parsed || "error" in parsed))
|
|
24
|
+
body = parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// not a message
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The structured result, or the JSON of the first text block (what a model that sees only text reads). */
|
|
40
|
+
export function data(r) {
|
|
41
|
+
if (r.structuredContent)
|
|
42
|
+
return r.structuredContent;
|
|
43
|
+
return text(r);
|
|
44
|
+
}
|
|
45
|
+
export function text(r) {
|
|
46
|
+
const first = r.content?.[0];
|
|
47
|
+
try {
|
|
48
|
+
const parsed = first?.type === "text" && first.text ? JSON.parse(first.text) : null;
|
|
49
|
+
return isObject(parsed) ? parsed : {};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A focus in the core's format, whatever it names: the target it names. */
|
|
56
|
+
export function checkFocusShape(focus, where) {
|
|
57
|
+
expect(isObject(focus), `${where}: focus is ${JSON.stringify(focus)}, expected an object`);
|
|
58
|
+
const parsed = parseTarget({ kind: focus.kind, id: focus.id, part: focus.part, params: focus.params });
|
|
59
|
+
expect(parsed, `${where}: the focus is not a valid target: ${JSON.stringify({ kind: focus.kind, id: focus.id, part: focus.part, params: focus.params })}`);
|
|
60
|
+
expect(typeof focus.app_url === "string" && URL.canParse(focus.app_url), `${where}: app_url is not a URL`);
|
|
61
|
+
expect(typeof focus.pinned_at === "string" && ISO.test(focus.pinned_at), `${where}: pinned_at is not an ISO time`);
|
|
62
|
+
expect(typeof focus.next === "string" && focus.next.length > 0, `${where}: next is not a tool name`);
|
|
63
|
+
expect(isObject(focus.next_args), `${where}: next_args is not an object`);
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
/** A view in the core's format: identifiers only. */
|
|
67
|
+
export function checkView(view, where) {
|
|
68
|
+
expect(isObject(view), `${where}: view is ${JSON.stringify(view)}, expected the screen the page reported`);
|
|
69
|
+
expect(typeof view.screen === "string" && view.screen.length > 0, `${where}: screen is not a name`);
|
|
70
|
+
expect(isObject(view.params) && parseParams(view.params) !== null, `${where}: params are not keys only: ${JSON.stringify(view.params)}`);
|
|
71
|
+
const s = view.selection;
|
|
72
|
+
expect(s === null || (isObject(s) && typeof s.kind === "string" && typeof s.id === "string"), `${where}: selection is neither null nor { kind, id }`);
|
|
73
|
+
expect(typeof view.app_url === "string" && URL.canParse(view.app_url), `${where}: app_url is not a URL`);
|
|
74
|
+
expect(typeof view.updated_at === "string" && ISO.test(view.updated_at), `${where}: updated_at is not an ISO time`);
|
|
75
|
+
expect(typeof view.age_seconds === "number" && view.age_seconds >= 0, `${where}: age_seconds is not a number of seconds`);
|
|
76
|
+
expect(typeof view.stale === "boolean", `${where}: stale is not a boolean`);
|
|
77
|
+
expect(view.next === null || typeof view.next === "string", `${where}: next is neither null nor a tool name`);
|
|
78
|
+
}
|
|
79
|
+
/** The same JSON value, whatever the order of object keys. */
|
|
80
|
+
export function sameJson(a, b) {
|
|
81
|
+
if (a === b)
|
|
82
|
+
return true;
|
|
83
|
+
if (Array.isArray(a) || Array.isArray(b))
|
|
84
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => sameJson(x, b[i]));
|
|
85
|
+
if (!isObject(a) || !isObject(b))
|
|
86
|
+
return false;
|
|
87
|
+
const ka = Object.keys(a).sort();
|
|
88
|
+
const kb = Object.keys(b).sort();
|
|
89
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i] && sameJson(a[k], b[k]));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A top-level key of a tool result where the model sees it: when the result has `structuredContent`, the key is
|
|
93
|
+
* in both or in neither, with the same value in the structured result and the text (a text that says something
|
|
94
|
+
* else is what a text-only model would follow). With no `structuredContent` there is only the text to read; the
|
|
95
|
+
* caller can tell by `structured`.
|
|
96
|
+
*/
|
|
97
|
+
export function resultKey(r, key) {
|
|
98
|
+
const inText = text(r)[key];
|
|
99
|
+
if (!r.structuredContent)
|
|
100
|
+
return { value: inText, structured: false };
|
|
101
|
+
const inData = r.structuredContent[key];
|
|
102
|
+
expect((inText === undefined) === (inData === undefined), `${key} is in the structured result or the text, not both`);
|
|
103
|
+
if (inData !== undefined && !sameJson(inData, inText)) {
|
|
104
|
+
throw new ConformanceError(`${key} differs between the structured result and the text: ${String(JSON.stringify(inData)).slice(0, 120)} vs ${String(JSON.stringify(inText)).slice(0, 120)}`);
|
|
105
|
+
}
|
|
106
|
+
return { value: inData, structured: true };
|
|
107
|
+
}
|
|
108
|
+
/** The attention key, where the model sees it: in the structured result and in the text, the same. */
|
|
109
|
+
export function attentionOf(r) {
|
|
110
|
+
return resultKey(r, "attention").value;
|
|
111
|
+
}
|
|
112
|
+
/** What `tools/list` must say about the core tools (shared by the suite and the CLI). */
|
|
113
|
+
export function checkCoreToolList(listed, coreTools) {
|
|
114
|
+
const tools = listed?.tools;
|
|
115
|
+
expect(Array.isArray(tools), "tools/list has no tools");
|
|
116
|
+
for (const name of coreTools)
|
|
117
|
+
expect(tools.some((t) => t.name === name), `${name} is not listed`);
|
|
118
|
+
const view = tools.find((t) => t.name === "get_current_view");
|
|
119
|
+
expect(!view.inputSchema?.required?.length, "get_current_view takes no required arguments");
|
|
120
|
+
const wait = tools.find((t) => t.name === "wait_for_focus");
|
|
121
|
+
const timeout = wait.inputSchema?.properties?.timeout_s;
|
|
122
|
+
expect(timeout && (timeout.type === "integer" || timeout.type === "number") && timeout.minimum === 1, "wait_for_focus takes timeout_s, a whole number from 1");
|
|
123
|
+
expect(!(wait.inputSchema?.required ?? []).includes("timeout_s"), "timeout_s is optional");
|
|
124
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
/** Lets "cloudflare:workers" resolve on Node, to a DurableObject base class that keeps ctx and env. */
|
|
3
|
+
export declare function installWorkersStandIn(): void;
|
|
4
|
+
/** One page of the person's, connected to the object: what it was sent, and whether it is still open. */
|
|
5
|
+
export interface FakePage {
|
|
6
|
+
readyState: number;
|
|
7
|
+
sent: string[];
|
|
8
|
+
send(text: string): void;
|
|
9
|
+
close(): void;
|
|
10
|
+
serializeAttachment(value: unknown): void;
|
|
11
|
+
deserializeAttachment(): unknown;
|
|
12
|
+
}
|
|
13
|
+
/** What a Durable Object gets from Cloudflare, on a fresh in-memory database, plus the pages around it. */
|
|
14
|
+
export interface DurableObjectRoom {
|
|
15
|
+
ctx: unknown;
|
|
16
|
+
db: DatabaseSync;
|
|
17
|
+
/** The alarm the object set last, in ms. */
|
|
18
|
+
alarm: number | null;
|
|
19
|
+
pages: FakePage[];
|
|
20
|
+
/** The person opens a page: a socket that answered its heartbeat just now. */
|
|
21
|
+
openPage(): FakePage;
|
|
22
|
+
/** The person closes every page. */
|
|
23
|
+
closePages(): void;
|
|
24
|
+
/** Every message sent to any page since the last call, parsed, oldest first. */
|
|
25
|
+
takeMessages(): Array<Record<string, unknown>>;
|
|
26
|
+
}
|
|
27
|
+
export declare function durableObjectRoom(): DurableObjectRoom;
|