browser-broker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,184 @@
1
+ import { toolStdioAdapter } from "./adapter.js";
2
+ import { encodeMessage, METHODS, decodeMessage } from "./protocol.js";
3
+ import { serveSession } from "./session.js";
4
+ import { TOOL_DEFINITIONS } from "./tools.js";
5
+ /**
6
+ * How the conformance suite drives the tool surface.
7
+ *
8
+ * ── It goes through the real session loop, and that is the whole point ───
9
+ *
10
+ * The command line's driver builds an argv and calls the real entry point.
11
+ * This one builds a **line on the wire** and runs the real
12
+ * {@link serveSession}, then reads the outcome back out of the line that came
13
+ * off it. So every part of this route that could hold a rule of its own is
14
+ * exercised: the framing, the decode, the method dispatch, the tool lookup,
15
+ * the argument shaping, and the never-returned rule.
16
+ *
17
+ * **The hollow version is worth naming so nobody reinvents it.** A driver
18
+ * that called `toolStdioAdapter.invoke` directly — or worse, called the
19
+ * service — would produce a green matrix while testing none of that, and it
20
+ * would pass. That is the "tested a local copy of the logic so the shipped
21
+ * code was never exercised" failure this repository has caught before.
22
+ *
23
+ * ── One session per case, because that is the deployment ────────────────
24
+ *
25
+ * `MILESTONES.md` #27: the service is spawned by its caller, serves that
26
+ * session and exits with it. A driver that kept one long-lived session across
27
+ * the whole matrix would be measuring an arrangement this design does not
28
+ * have, and it would hide any state accidentally held between calls — the one
29
+ * bug this lifecycle is chosen to make impossible. So each case gets its own
30
+ * session, opened and ended around the single call.
31
+ *
32
+ * The process boundary is deliberately **not** crossed here: the matrix runs
33
+ * in process, and a smaller spawned smoke subset proves the wiring
34
+ * separately.
35
+ */
36
+ /** Turn a neutral case input into this route's own vocabulary: a tool call. */
37
+ export function toolCallFor(testCase) {
38
+ const tool = TOOL_DEFINITIONS.find((entry) => entry.operation === testCase.operation);
39
+ if (tool === undefined) {
40
+ throw new Error(`the tool surface has no tool for "${testCase.operation}"`);
41
+ }
42
+ const args = {};
43
+ for (const [key, value] of Object.entries(testCase.input)) {
44
+ if (value === undefined || value === null) {
45
+ continue;
46
+ }
47
+ // This transport carries JSON, so unlike the command line it can express
48
+ // a structure. What it cannot express is a value with no JSON
49
+ // representation, and coercing one would put something meaningless on the
50
+ // wire and then assert against whatever came back — a comparison that
51
+ // runs, means nothing, and passes.
52
+ if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
53
+ throw new TypeError(`case "${testCase.name}" gives ${key} a value the tool surface cannot carry`);
54
+ }
55
+ // The case table is authored in the service's own spelling, and the
56
+ // arguments the tool takes are the same names (§3), so this is a copy
57
+ // rather than a translation. Where a route's spelling differs — the
58
+ // command line's hyphens — the translation lives in that route's driver.
59
+ args[key] = value;
60
+ }
61
+ return { name: tool.name, arguments: args };
62
+ }
63
+ /**
64
+ * Read the outcome back out of the line the session wrote.
65
+ *
66
+ * A refusal arrives as a **successful response carrying a refusal**, never as
67
+ * a protocol error (`session.ts`), so a protocol error here is a real failure
68
+ * of this route rather than something to reinterpret as a refusal. Raising it
69
+ * is what keeps the two apart in the matrix: a route that reported refusals
70
+ * as protocol errors would be a route with its own rules, which is the exact
71
+ * thing #30 asserts against.
72
+ */
73
+ export function outcomeFrom(line) {
74
+ if (line === undefined) {
75
+ throw new Error('the session wrote no response');
76
+ }
77
+ const decoded = JSON.parse(line);
78
+ if (decoded === null || typeof decoded !== 'object') {
79
+ throw new Error('the session wrote something that is not a message');
80
+ }
81
+ const message = decoded;
82
+ if (message['error'] !== undefined) {
83
+ const error = message['error'];
84
+ throw new Error(`the tool surface answered with a protocol error (${String(error.code)}): ${String(error.message)}`);
85
+ }
86
+ const result = message['result'];
87
+ if (result === null || typeof result !== 'object') {
88
+ throw new Error('the response carries no result');
89
+ }
90
+ // The outcome is read out of `structuredContent`, which is where a
91
+ // `tools/call` result carries its machine-readable half. **This is not a
92
+ // second spelling of the domain object** — the result root holds `content`,
93
+ // `structuredContent` and `isError`, which is the specification's shape and
94
+ // the only shape a client can read.
95
+ //
96
+ // Reading it here does not make the wire shape *asserted*: this function
97
+ // normalises a route's answer back into a neutral outcome so the matrix can
98
+ // compare two routes, and it would go on doing that faithfully if `content`
99
+ // vanished tomorrow. That is exactly how the missing content array shipped
100
+ // — the suite compared outcomes at the service layer and both routes agreed.
101
+ // The shape itself is asserted by its own test, against the bytes.
102
+ const structured = result['structuredContent'];
103
+ if (structured === null || typeof structured !== 'object') {
104
+ throw new Error('the result carries no structured content');
105
+ }
106
+ const record = structured;
107
+ if (record['outcome'] === 'accepted') {
108
+ return { outcome: 'accepted', value: (record['value'] ?? {}) };
109
+ }
110
+ if (record['outcome'] === 'refused') {
111
+ return {
112
+ outcome: 'refused',
113
+ code: String(record['code']),
114
+ rule: String(record['rule']),
115
+ // The sentence is worded for this transport and is never compared
116
+ // across routes (§3.14), so it is carried but not read back for the
117
+ // comparison.
118
+ message: typeof record['message'] === 'string' ? record['message'] : '',
119
+ ...(record['details'] === undefined
120
+ ? {}
121
+ : { details: record['details'] }),
122
+ };
123
+ }
124
+ throw new Error(`the result names no outcome: ${line}`);
125
+ }
126
+ /**
127
+ * One line in, as an async iterable, because that is what the loop reads.
128
+ *
129
+ * The loop's input is asynchronous because a real standard input is; a
130
+ * driver supplying one line has nothing to wait for, so this adapts a value
131
+ * to the shape rather than pretending to be asynchronous.
132
+ */
133
+ function oneLine(line) {
134
+ return {
135
+ [Symbol.asyncIterator]: () => {
136
+ let sent = false;
137
+ return {
138
+ next: () => {
139
+ if (sent) {
140
+ return Promise.resolve({ done: true, value: undefined });
141
+ }
142
+ sent = true;
143
+ return Promise.resolve({ done: false, value: line });
144
+ },
145
+ };
146
+ },
147
+ };
148
+ }
149
+ /** The tool surface's conformance driver. */
150
+ export const toolStdioConformanceDriver = {
151
+ adapter: toolStdioAdapter,
152
+ run: async (service, testCase, observe) => {
153
+ const written = [];
154
+ // Built through the surface's own encoder rather than by hand, because
155
+ // the framing is the thing under test as much as the dispatch is.
156
+ const request = encodeMessage({
157
+ id: 1,
158
+ method: METHODS.callTool,
159
+ params: toolCallFor(testCase),
160
+ });
161
+ // Confirm the line this driver built is one the surface's own decoder
162
+ // accepts. Without this a malformed request would be answered with a
163
+ // protocol error, `outcomeFrom` would raise, and the case would fail with
164
+ // a message about the surface rather than about the driver.
165
+ const decoded = decodeMessage(request);
166
+ if (decoded.kind !== 'request') {
167
+ // Every line this driver builds carries an identifier and a method, so
168
+ // neither of the other two readings is reachable from here. The reason
169
+ // is named anyway rather than reported as a bare kind, because the
170
+ // whole point of the check is that a driver defect should say so.
171
+ const why = decoded.kind === 'malformed' ? decoded.why : 'it decoded as a notification';
172
+ throw new Error(`the driver built a line the surface rejects: ${why}`);
173
+ }
174
+ await serveSession(oneLine(request), {
175
+ service,
176
+ streams: { write: (line) => written.push(line) },
177
+ });
178
+ return {
179
+ outcome: outcomeFrom(written[0]),
180
+ driverCalls: observe.driverCalls(),
181
+ liveClaimCount: observe.liveClaimCount(),
182
+ };
183
+ },
184
+ };
@@ -0,0 +1,310 @@
1
+ /**
2
+ * The wire format the tool surface speaks, implemented directly.
3
+ *
4
+ * ── Why this is written out rather than taken from a package ────────────
5
+ *
6
+ * `MILESTONES.md`'s binding for this row: no runtime dependency is added for
7
+ * it, because the protocol is a documented wire format and a framework would
8
+ * be a design regression rather than a convenience. The whole of what a
9
+ * caller needs is here: newline-delimited JSON objects on standard input and
10
+ * standard output, an integer or string identifier correlating a response
11
+ * with its request, and a small fixed set of method names.
12
+ *
13
+ * The cost of that choice is stated rather than implied: this implements the
14
+ * subset this service uses — the handshake, listing the tools and calling one
15
+ * — and nothing else. A caller sending a method outside that set gets an
16
+ * explicit `method_not_found` rather than silence, which is the part that
17
+ * makes the subset honest instead of merely small.
18
+ *
19
+ * ── The envelope is JSON-RPC 2.0, because a client will not speak anything ─
20
+ *
21
+ * The subset above was, for a time, *only* those last two methods, framed as
22
+ * bare JSON objects. That was reachable by a program written against this
23
+ * file and by nothing else: a Model Context Protocol client opens with
24
+ * `initialize`, waits for the server's `protocolVersion`, `capabilities` and
25
+ * `serverInfo`, sends `notifications/initialized`, and only then asks what
26
+ * tools exist. Against a surface with no `initialize` the very first message
27
+ * is answered `method_not_found` and the client hangs up — so ten working,
28
+ * conformance-proven tools sat behind a doorway that did not open.
29
+ *
30
+ * So the envelope here is the real one: `jsonrpc: "2.0"` on every message,
31
+ * the identifier echoed, `result` exclusive-or `error`, and errors carrying
32
+ * JSON-RPC's *numeric* codes. Implemented against Model Context Protocol
33
+ * revision {@link PROTOCOL_VERSION}
34
+ * (https://modelcontextprotocol.io/specification/2025-06-18).
35
+ *
36
+ * ── Two code spaces, deliberately, and both of them travel ──────────────
37
+ *
38
+ * JSON-RPC requires a small fixed set of integers. This surface already had a
39
+ * vocabulary of its own — `method_not_found`, `tool_not_found`,
40
+ * `malformed_call`, `unexpected_failure` — and those distinctions are
41
+ * load-bearing rather than decorative: `SCHEMA.md` and two build checks read
42
+ * them by name, and the paragraph on {@link ProtocolError} explains why a
43
+ * caller must be able to tell a typo from a capacity refusal.
44
+ *
45
+ * Collapsing the vocabulary into five integers would have thrown that away to
46
+ * satisfy a transport. So **both travel**: {@link ProtocolError.code} keeps
47
+ * the name, and {@link toJsonRpcCode} maps it onto the integer the transport
48
+ * requires. A generic client reads the integer and behaves correctly; a
49
+ * caller who knows this service reads the name and keeps every distinction it
50
+ * had before. The mapping is the only thing that is new, and it is one
51
+ * function rather than a rewrite.
52
+ *
53
+ * ── Framing: one JSON object per line ───────────────────────────────────
54
+ *
55
+ * A line is a message. That is the whole framing rule, and it is chosen over
56
+ * a length-prefixed header for a reason worth keeping: a line-delimited
57
+ * stream is readable by a person watching it, debuggable with ordinary text
58
+ * tools, and has exactly one failure mode — a message containing a raw
59
+ * newline. {@link encodeMessage} is where that is prevented, by serialising
60
+ * without pretty-printing, and {@link decodeMessage} refuses a line it cannot
61
+ * parse rather than skipping it silently.
62
+ */
63
+ /** The JSON-RPC version string every message on this surface carries. */
64
+ export const JSONRPC_VERSION = '2.0';
65
+ /**
66
+ * The Model Context Protocol revision this surface implements.
67
+ *
68
+ * Named as a constant because it is answered to a client during negotiation
69
+ * and asserted by a test; a version that lived only in a string literal
70
+ * inside a handler would drift from the one the documentation claims.
71
+ */
72
+ export const PROTOCOL_VERSION = '2025-06-18';
73
+ /**
74
+ * The revisions this surface will agree to speak.
75
+ *
76
+ * Ordered newest first, which is what makes {@link negotiateProtocolVersion}
77
+ * able to answer "the newest thing we both know" without a second list. A
78
+ * revision is added here only when this surface has actually been made to
79
+ * speak it — the point of the list is that it is a claim, not a wish.
80
+ */
81
+ export const SUPPORTED_PROTOCOL_VERSIONS = [PROTOCOL_VERSION, '2025-03-26'];
82
+ /**
83
+ * JSON-RPC's own error codes, which are integers and are not negotiable.
84
+ *
85
+ * These are the transport's, not this service's. They exist so a client that
86
+ * has never heard of this service still behaves correctly — retry, report,
87
+ * give up — and the names above are what a caller that *has* heard of it
88
+ * reads instead. See {@link toJsonRpcCode} for why both travel.
89
+ */
90
+ export const JSONRPC_ERROR_CODES = {
91
+ parseError: -32700,
92
+ invalidRequest: -32600,
93
+ methodNotFound: -32601,
94
+ invalidParams: -32602,
95
+ internalError: -32603,
96
+ };
97
+ /**
98
+ * This surface's refusal vocabulary, mapped onto the transport's integers.
99
+ *
100
+ * **The mapping is many-to-one and that is the point.** `tool_not_found` and
101
+ * `method_not_found` are genuinely different facts — one means the caller
102
+ * mistyped a tool, the other that it spoke a method this surface does not
103
+ * implement — and JSON-RPC has one integer for both. Rather than pick a
104
+ * winner, the name survives on {@link ProtocolError.code} and the integer is
105
+ * derived here. Nothing reading the name loses a distinction; nothing reading
106
+ * the integer sees a code it does not recognise.
107
+ *
108
+ * An unrecognised name maps to `internalError`, which is the honest answer:
109
+ * a code this function has not been taught about is, from the transport's
110
+ * point of view, this surface failing to describe itself.
111
+ */
112
+ export function toJsonRpcCode(code) {
113
+ switch (code) {
114
+ case 'method_not_found':
115
+ case 'tool_not_found':
116
+ return JSONRPC_ERROR_CODES.methodNotFound;
117
+ case 'malformed_message':
118
+ return JSONRPC_ERROR_CODES.invalidRequest;
119
+ case 'malformed_call':
120
+ case 'unsupported_protocol_version':
121
+ return JSONRPC_ERROR_CODES.invalidParams;
122
+ default:
123
+ return JSONRPC_ERROR_CODES.internalError;
124
+ }
125
+ }
126
+ /**
127
+ * Agree a revision with the caller.
128
+ *
129
+ * The rule the specification gives is short: answer with the caller's own
130
+ * revision when this surface speaks it, and otherwise answer with the newest
131
+ * one it does speak. **The second branch is not a failure** — a client asking
132
+ * for something newer than this surface knows is told what is on offer and
133
+ * decides for itself whether to continue, which is the difference between a
134
+ * negotiation and a rejection. So this returns a version in every case and
135
+ * never throws: a handshake that crashed on an unfamiliar version string
136
+ * would break on the next revision of the specification rather than on
137
+ * anything wrong with the caller.
138
+ */
139
+ export function negotiateProtocolVersion(requested) {
140
+ if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
141
+ return requested;
142
+ }
143
+ return PROTOCOL_VERSION;
144
+ }
145
+ /** The methods this surface answers. Anything else is `method_not_found`. */
146
+ export const METHODS = {
147
+ /** Open the session: negotiate a revision and describe the server. */
148
+ initialize: 'initialize',
149
+ /** Enumerate the tools, with their descriptions and argument schemas. */
150
+ listTools: 'tools/list',
151
+ /** Call one tool by name. */
152
+ callTool: 'tools/call',
153
+ };
154
+ /**
155
+ * The notifications this surface accepts — and answers with silence.
156
+ *
157
+ * `notifications/initialized` is the client saying the handshake is complete.
158
+ * There is nothing to do with it and nothing to send back; accepting it
159
+ * without replying is the whole of the requirement.
160
+ *
161
+ * `notifications/cancelled` is listed because a client may send it at any
162
+ * time and a surface that treated it as an unknown *method* would try to
163
+ * answer a notification — the one thing a notification must never draw. This
164
+ * surface answers a call when it finishes, so there is no work to interrupt;
165
+ * ignoring it is both correct and complete.
166
+ */
167
+ export const NOTIFICATIONS = {
168
+ initialized: 'notifications/initialized',
169
+ cancelled: 'notifications/cancelled',
170
+ };
171
+ /**
172
+ * Serialise a message to one line.
173
+ *
174
+ * `JSON.stringify` without an indent argument emits no newline of its own,
175
+ * and every newline inside a string value is escaped as `\n` by the
176
+ * serialiser, so the result is guaranteed to be a single line. That guarantee
177
+ * is the framing, so it is asserted by a test rather than assumed here.
178
+ */
179
+ export function encodeMessage(message) {
180
+ return JSON.stringify(withEnvelope(message));
181
+ }
182
+ /**
183
+ * Put the JSON-RPC envelope on a message on its way out.
184
+ *
185
+ * Two properties are enforced here rather than trusted to every call site,
186
+ * because both are the kind of thing that is right nine times and wrong once:
187
+ *
188
+ * **`jsonrpc: "2.0"` leads.** Key order is not semantically meaningful, but
189
+ * this stream is read by people as well as programs — that is the stated
190
+ * reason the framing is lines rather than length prefixes — and a message
191
+ * whose first field names the protocol is one a reader can identify at a
192
+ * glance.
193
+ *
194
+ * **`result` and `error` are exclusive.** JSON-RPC requires exactly one, and
195
+ * a response carrying both is the ambiguity a client cannot resolve. So an
196
+ * error wins and `result` is dropped, rather than both being written and the
197
+ * contradiction shipped; and an error is rewritten to carry the transport's
198
+ * integer in `code` with this surface's own name preserved beside it, which
199
+ * is the whole of the two-code-spaces bargain in {@link toJsonRpcCode}.
200
+ */
201
+ function withEnvelope(message) {
202
+ if ('method' in message) {
203
+ return { jsonrpc: JSONRPC_VERSION, ...message };
204
+ }
205
+ if (message.error !== undefined) {
206
+ return {
207
+ jsonrpc: JSONRPC_VERSION,
208
+ id: message.id,
209
+ error: {
210
+ code: toJsonRpcCode(message.error.code),
211
+ message: message.error.message,
212
+ // The name is kept, in the place JSON-RPC reserves for exactly this.
213
+ // A caller reading `error.code` gets the integer it expects; one that
214
+ // knows this service reads the name and keeps the distinction the
215
+ // integer cannot carry.
216
+ data: { code: message.error.code },
217
+ },
218
+ };
219
+ }
220
+ return { jsonrpc: JSONRPC_VERSION, id: message.id, result: message.result };
221
+ }
222
+ /**
223
+ * Read one line as a request.
224
+ *
225
+ * **A malformed line is reported, never skipped.** A surface that ignored
226
+ * what it could not parse would leave a caller waiting forever for a response
227
+ * to a message the surface decided not to mention — and from the caller's
228
+ * side that is indistinguishable from a hang. So this returns a description
229
+ * of the problem, and the loop above answers with it where there is an
230
+ * identifier to answer to.
231
+ *
232
+ * ── Why the id KEY's presence is checked separately from its value ───────
233
+ *
234
+ * `record['id']` on an object with no `id` property and `record['id']` on
235
+ * one with `id: null` are both `undefined` in JavaScript, so a check that
236
+ * reads only the value cannot tell "there is no id to answer to" from "there
237
+ * is an id, and it is unusable". Those are different situations: the first
238
+ * is a notification-shaped line with genuinely nobody to answer; the second
239
+ * is a request-shaped line whose id this surface cannot echo back, but can
240
+ * still answer with `id: null` — JSON-RPC's own way of saying "I received
241
+ * this, and could not identify it". Losing that distinction is what let a
242
+ * caller sending `id: null` or `id: true` — a malformed request, not a
243
+ * notification — be read as a notification and dropped, indistinguishable
244
+ * from a hang.
245
+ */
246
+ export function decodeMessage(line) {
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(line);
250
+ }
251
+ catch {
252
+ return { kind: 'malformed', id: undefined, why: 'the line is not JSON' };
253
+ }
254
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
255
+ return { kind: 'malformed', id: undefined, why: 'a message is a JSON object' };
256
+ }
257
+ const record = parsed;
258
+ const hasIdKey = 'id' in record;
259
+ const rawId = record['id'];
260
+ const id = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : undefined;
261
+ const method = record['method'];
262
+ const params = record['params'];
263
+ const paramsAreWrong = params !== undefined && (params === null || typeof params !== 'object');
264
+ // **A message with no id KEY at all but a method is a NOTIFICATION, not a
265
+ // malformed message**, and reading it as the latter is how a surface ends
266
+ // up either answering one or refusing the handshake that follows it. The
267
+ // key's absence — not merely an unusable value — is the signal, so it is
268
+ // tested before anything is concluded from it.
269
+ if (!hasIdKey && typeof method === 'string') {
270
+ if (paramsAreWrong) {
271
+ // Nobody to answer — a notification has no identifier by construction —
272
+ // so the loop above logs this rather than replying to it.
273
+ return { kind: 'malformed', id: undefined, why: 'params, when present, is an object' };
274
+ }
275
+ return {
276
+ kind: 'notification',
277
+ notification: {
278
+ method,
279
+ ...(params === undefined ? {} : { params: params }),
280
+ },
281
+ };
282
+ }
283
+ if (id === undefined) {
284
+ // The id key is present (checked above) but its value is neither a
285
+ // number nor a string — `null`, `true`, an object, an array. There is an
286
+ // id to answer to; this surface simply cannot echo the caller's own
287
+ // value back, so it answers with `id: null` rather than staying silent.
288
+ // Absent the id key entirely, this line would have taken the
289
+ // notification branch above and never reached here.
290
+ return {
291
+ kind: 'malformed',
292
+ id: hasIdKey ? null : undefined,
293
+ why: 'a message carries an id, which is a number or a string',
294
+ };
295
+ }
296
+ if (typeof method !== 'string') {
297
+ return { kind: 'malformed', id, why: 'a message carries a method, which is a string' };
298
+ }
299
+ if (paramsAreWrong) {
300
+ return { kind: 'malformed', id, why: 'params, when present, is an object' };
301
+ }
302
+ return {
303
+ kind: 'request',
304
+ request: {
305
+ id,
306
+ method,
307
+ ...(params === undefined ? {} : { params: params }),
308
+ },
309
+ };
310
+ }