@webex/http-core 3.12.0-next.8 → 3.12.0-next.9

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.
@@ -0,0 +1,481 @@
1
+ ---
2
+ type: Module Spec
3
+ title: 'request pipeline specification'
4
+ description: Responsibilities, boundaries, design, invariants, and verification for the http-core request pipeline and its Node and browser transports.
5
+ tags: [module, specification]
6
+ ---
7
+ <!-- sdd-generated-metadata
8
+ doc_kind: module-spec
9
+ generated_from: module-spec@0.3.0
10
+ generated_by: claude-code
11
+ approved_by: pending
12
+ updated_at: 2026-09-23T06:29:48Z
13
+ validation_status: pass-with-warnings
14
+ -->
15
+
16
+ # request pipeline
17
+
18
+ This source-local document at `src/request/docs/README.md` owns the stable specification for the
19
+ **request pipeline**: applying the interceptor chain around a platform-specific transport, and the
20
+ two transports themselves.
21
+
22
+ The export surface, error taxonomy, and interceptor base class belong to the parent module and are
23
+ specified in [`src/docs/README.md`](../../docs/README.md).
24
+
25
+ Related context: [repository architecture](../../../docs/architecture.md) ·
26
+ [documentation index](../../../docs/index.md) · [agent instructions](../../../AGENTS.md) ·
27
+ [specification registry](../../../docs/specs/README.md)
28
+
29
+ ## Metadata
30
+
31
+ | Field | Value |
32
+ | ----------------- | ------------------------------------------- |
33
+ | Owner | Cisco Webex for Developers |
34
+ | Source path | `src/request` |
35
+ | Resource kind | Capability module |
36
+ | Status | Active |
37
+ | Last verified | 2026-09-23 |
38
+ | Module id | `src/request` |
39
+ | Parent spec | [`src/docs/README.md`](../../docs/README.md) |
40
+ | Doc kind | Module spec |
41
+ | Coverage score | 93.3% assessed 2026-09-23; 14 of 15 mandatory fields present — a characterization baseline is the one outstanding gap |
42
+ | Validation status | pass-with-warnings; validator `codex` assessed 2026-09-23; prior B1 `options.sync` and B2 transport-loading findings resolved; current result has 0 Blocking, 1 Important, 1 Medium at repository scope; characterization baseline still required |
43
+
44
+ ## Applicability
45
+
46
+ | Condition ID | Status | Evidence or reason | Owned section |
47
+ | ------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------- | ----------------------------- |
48
+ | `module.has_tiers` | N/A | The repository assigns no operational or review tiers | Tier |
49
+ | `module.has_ui` | N/A | No components or rendering | UI use-case flow |
50
+ | `module.crosses_service_boundaries` | Applicable | This module performs the network I/O, in `src/request/request.js` and `src/request/request.shim.js` | Cross-boundary use-case flow |
51
+ | `module.holds_client_state` | N/A | Emitters and options are per-request and discarded with the request | Client state model |
52
+ | `module.enforces_domain_rules` | N/A | Payloads are opaque; no domain entities or invariants | Business rules and invariants |
53
+ | `module.is_concurrent_async` | Applicable | Promise-based dispatch with an interceptor fold in `src/request/utils.ts` and streaming progress events | Concurrency and reactive flow |
54
+ | `module.owns_persistence` | N/A | No store, schema, or migration | Data, schema, and migration |
55
+ | `module.stateful_transitions` | N/A | A request is a single dispatch, not a lifecycle with states | State machine |
56
+ | `module.exposes_wire_protocol` | N/A | Speaks standard HTTP; defines no protocol or binary format of its own | Protocol and wire format |
57
+ | `module.ui_multi_screen` | N/A | Gated by module.has_ui, which is N/A | UI flow |
58
+ | `module.large_data_model` | N/A | Gated by module.owns_persistence, which is N/A | Data model |
59
+ | `module.returns_caller_errors` | Applicable | Both transports normalize failures into a statusCode: 0 response the parent converts into an error | Caller-visible failure modes |
60
+ | `module.module_specific_conventions` | Applicable | The browser field in `package.json` swaps transports at build time, so changes must be mirrored | Module-specific rules |
61
+ | `module.published_package` | N/A | Not published independently; it ships inside the parent package | Export stability |
62
+ | `module.embedded_in_host` | N/A | Not mounted into a host application | Host integration and theming |
63
+ | `module.has_design_tradeoff` | Applicable | Failure-as-response normalization and the build-time transport swap are both non-obvious | Key design trade-off |
64
+ | `module.has_submodules` | N/A | No child modules; computed from the manifest module tree | Sub-modules |
65
+
66
+ ## Evidence register
67
+
68
+ | Evidence | What it establishes |
69
+ | ------------------------------------------ | -------------------------------------------------------------------------------------------------- |
70
+ | `src/request/index.js` | Option normalization, emitter creation, the two interceptor passes, and custom-transport dispatch |
71
+ | `src/request/utils.ts` | The interceptor fold, its error routing, and fetch-option preparation |
72
+ | `src/request/request.js` | The Node transport: option preparation, failure normalization, and download progress |
73
+ | `src/request/request.shim.js` | The browser transport: option translation onto XHR, auth, payloads, and logging |
74
+ | `package.json` | The build-time transport swap and every dependency the transports use |
75
+ | `test/unit/spec/request/index.js` | Option normalization, emitter creation, both interceptor passes, and custom-transport dispatch |
76
+ | `test/unit/spec/request/utils.js` | Interceptor folding and three `prepareFetchOptions` header and body cases |
77
+ | `test/unit/spec/request/request.shim.js` | Basic auth header construction |
78
+ | `test/integration/spec/request.js` | End-to-end behavior across methods, payload forms, auth, cookies, progress, and network failure |
79
+
80
+ The `Configuration surface` subsection under `Public surface` is a repo-specific extension. It is
81
+ retained because this module's entire configuration surface is per-request option keys rather than
82
+ environment variables or a config file, and those keys behave differently on each platform — a fact
83
+ scattered across `src/request/request.js`, `src/request/request.shim.js`, and `src/lib/xhr.js` with
84
+ no canonical home in the source template. It adds no claim not already evidenced by those three
85
+ files and duplicates no other section's content.
86
+
87
+ ## Purpose and boundary
88
+
89
+ - **Responsibility:** turn a prepared options object into a response. That means folding the
90
+ interceptor chain over it in both directions, choosing and invoking a transport, and normalizing
91
+ whatever comes back into a uniform response shape.
92
+ - **In scope:** interceptor application order and error routing; per-request emitter creation;
93
+ `options.url` to `options.uri` normalization; the Node transport over the `request` library; the
94
+ browser transport over the vendored XHR fork; the option-vocabulary translation the browser
95
+ transport performs; failure normalization to `statusCode: 0`; progress emission; fetch-option
96
+ preparation for the metrics path.
97
+ - **Out of scope:** the public export surface, default option values, the `HttpError` taxonomy, the
98
+ `Interceptor` base class, and status-to-error conversion — all owned by the parent module. The
99
+ vendored XHR fork itself is owned by the parent as `http-core-xhr-fork`; this module consumes it.
100
+ - **Consumers:** the parent module only. Nothing outside the package imports `src/request` directly.
101
+
102
+ ## Structure and key files
103
+
104
+ | Path | Responsibility |
105
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
106
+ | `src/request/index.js` | The pipeline. Normalizes options, creates the progress emitters, runs request interceptors, dispatches, runs response interceptors in reverse |
107
+ | `src/request/utils.ts` | `intercept()`, the fold that applies one interceptor phase; and `prepareFetchOptions()`, which shapes options for `fetch` without sending |
108
+ | `src/request/request.js` | Node transport. Delegates to the `request` library and normalizes its callback into a resolved promise |
109
+ | `src/request/request.shim.js` | Browser transport. Reimplements the same option vocabulary over `XMLHttpRequest` via the vendored fork |
110
+
111
+ `src/request/utils.ts` is the module's only TypeScript file; everything else is JavaScript.
112
+
113
+ ## Public surface
114
+
115
+ | Surface | Contract | Consumer | Compatibility commitment | Source |
116
+ | --- | --- | --- | --- | --- |
117
+ | `default` (the pipeline) | `http-core-request-transport` | Parent module only | Internal. Takes an options object, returns a promise for a response | `src/request/index.js` |
118
+ | `intercept` | `http-core-request-transport` | Parent module, own transports | Internal. `(options, interceptors, key, res?)` folds one interceptor phase | `src/request/utils.ts` |
119
+ | `prepareFetchOptions` | `http-core-request-transport` | Parent module | Internal. Returns the same mutated options object, fetch-ready | `src/request/utils.ts` |
120
+ | `default` (transports) | `http-core-request-transport` | The pipeline only | Internal. Both transports must resolve — never reject — and must accept the same option vocabulary | `src/request/request.js`, `src/request/request.shim.js` |
121
+ | `options.request` hook | `http-core-request-transport` | Any caller | **Caller-visible.** Supplying it replaces the built-in transport entirely while the interceptor chain still runs | `src/request/index.js` |
122
+
123
+ Everything here is internal except the `options.request` hook, which is reachable by any caller of
124
+ the parent's `request` export and is therefore a published behavior. Every row routes to
125
+ `http-core-request-transport` in `contract_catalog.definitions`, the single contract this module
126
+ provides. It requires `http-core-sdk`, `http-core-progress-events`, `http-core-xhr-fork`,
127
+ `webex-common-sdk`, and `nodejs-request-library`, all recorded in the Dependencies table below. The
128
+ repository-wide index of the same ids is
129
+ [Public and consumer surfaces](../../../docs/architecture.md#public-and-consumer-surfaces).
130
+
131
+ ### Configuration surface
132
+
133
+ The module reads no environment variables and owns no config file. Its entire configuration surface
134
+ is per-request option keys, and there are no rollout or feature flags gating any of its behavior.
135
+
136
+ | Option | Platform | Effect | Default |
137
+ | --- | --- | --- | --- |
138
+ | `options.timeout` | Both | Node passes it to the underlying client; the browser starts a timer that aborts the request and raises `ETIMEDOUT` | `0` in the browser, meaning no timeout |
139
+ | `options.withCredentials` | Both | Becomes a cookie jar in Node and the XHR credentials flag in the browser | `false`, set explicitly so it is not inferred from CORS |
140
+ | `options.jar` | Both | The alias callers actually use for the same cookie behavior | unset |
141
+ | `options.cors` | Browser | Enables cross-origin mode on the XHR wrapper | `true` |
142
+ | `options.responseType` | Both | `buffer` and `blob` switch the response to binary handling | unset |
143
+ | `options.request` | Both | Replaces the transport entirely | unset |
144
+ | `options.interceptors` | Both | The chain folded around the dispatch | supplied by the parent module |
145
+
146
+ ## Dependencies
147
+
148
+ | Dependency | Why it is required | Failure behavior |
149
+ | ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
150
+ | `request` (npm) | The Node transport | Its callback error is caught and normalized into a `statusCode: 0` response |
151
+ | `src/lib/xhr.js` (`http-core-xhr-fork`) | The browser transport's XHR wrapper | Its error callback produces a `statusCode: 0` response |
152
+ | `src/lib/interceptor.js` (`http-core-interceptor-extension`) | Type and `logOptions` contract for the fold in `utils.ts` | Compile-time dependency in TypeScript; `logOptions` is called per interceptor |
153
+ | `src/progress-event.js` (`http-core-progress-events`) | The payload emitted for download progress | Emitted on the caller's emitter; no effect on the response promise |
154
+ | `src/lib/detect.js` | MIME detection for buffer and blob request bodies. Awaited before dispatch by the Node transport only; the browser call is not awaited, see `MOD-024` | Falls back to `application/octet-stream` rather than failing |
155
+ | `@webex/common` (`isBuffer`) | Buffer detection for request and response bodies in the Node transport | Load-time dependency |
156
+ | `qs` | Query-string and urlencoded-form serialization in the browser | Load-time dependency |
157
+ | `safe-buffer` | Reconstructs buffers the `request` library returns in a degraded form | Load-time dependency |
158
+ | `events` (`EventEmitter`) | The per-request download and upload emitters | Node built-in, polyfilled by bundlers for the browser |
159
+
160
+ ## Requirements
161
+
162
+ | ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence |
163
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------- | ---------- |
164
+ | `MOD-001` | `options.url` is copied to `options.uri` and then set to `null` | Callers may use either name; the transports read only `uri` | `src/request/index.js` | `test/unit/spec/request/index.js` | none | Present |
165
+ | `MOD-002` | `options.headers` defaults to an empty object | Downstream code assigns into it unconditionally and would throw on `undefined` | `src/request/index.js` | `test/unit/spec/request/index.js` | none | Present |
166
+ | `MOD-003` | A fresh `download` and `upload` `EventEmitter` is created on every request | Progress must be scoped to one request; a shared emitter would cross-report between concurrent requests | `src/request/index.js` | `test/unit/spec/request/index.js` | none | Present |
167
+ | `MOD-004` | Request interceptors run in array order before dispatch; response interceptors run in reverse order after it | Gives interceptors proper nesting, so the outermost sees the request first and the response last | `src/request/index.js` | `test/unit/spec/request/index.js` | none | Present |
168
+ | `MOD-005` | A caller-supplied `options.request` replaces the built-in transport, while the interceptor chain still runs | Lets a consumer route through a channel this package does not know about, such as an iframe `postMessage` | `src/request/index.js` | `test/unit/spec/request/index.js` | none | Present |
169
+ | `MOD-006` | `intercept()` folds a phase over the chain, routing a rejection to the next interceptor's `on<Key>Error` handler | An interceptor can observe and recover from a failure raised by an earlier one | `src/request/utils.ts` | `test/unit/spec/request/utils.js` | Gap: the rejection-routing branch itself is not directly asserted | Present |
170
+ | `MOD-007` | `intercept()` calls `logOptions` on every interceptor in both phases | Verbose network logging must show the options as each interceptor sees them | `src/request/utils.ts` | `test/integration/spec/interceptor.js` | none | Present |
171
+ | `MOD-008` | `prepareFetchOptions` sets `accept` and `content-type` to `application/json` without overriding headers the caller set | Caller intent wins; the default only fills a gap | `src/request/utils.ts` | `test/unit/spec/request/utils.js` | none | Present |
172
+ | `MOD-009` | `prepareFetchOptions` skips the `content-type` header and body serialization for `GET` and `HEAD` | Those methods carry no body, and sending the header would misrepresent the request | `src/request/utils.ts` | none found | Gap: the existing tests pass no `method`, so this branch is never taken | Present |
173
+ | `MOD-010` | `prepareFetchOptions` sets `keepalive: true` and creates the two emitters | Metrics submissions must survive page unload, which `keepalive` enables | `src/request/utils.ts` | `test/unit/spec/request/utils.js` | none | Present |
174
+ | `MOD-011` | The Node transport sets `encoding: null` when `responseType` is `buffer` or `blob` | The `request` library otherwise returns a decoded string and corrupts binary payloads | `src/request/request.js` | `test/integration/spec/request.js` | none | Present |
175
+ | `MOD-012` | The Node transport maps `withCredentials` to the library's `jar` option | Keeps one cookie option name across both platforms | `src/request/request.js` | `test/integration/spec/request.js` | none | Present |
176
+ | `MOD-013` | The Node transport detects and sets `content-type` for a buffer request body before sending | A buffer carries no type, and sending the wrong one causes the service to reject the upload | `src/request/request.js` | `test/integration/spec/request.js` | none | Present |
177
+ | `MOD-014` | Both transports **resolve** a synthetic `{statusCode: 0, options, headers, method, url, body: error}` on transport failure | Keeps rejection in one place — the parent's status interceptor — so every failure flows the same path | `src/request/request.js`, `src/request/request.shim.js` | `test/integration/spec/request.js` | Gap: the browser branch is untested and the test file says so explicitly | Present |
178
+ | `MOD-015` | The Node transport emits `progress` on `options.download` per response chunk, sized from `content-length` | Callers downloading large payloads need incremental progress | `src/request/request.js` | `test/integration/spec/request.js` | none | Present |
179
+ | `MOD-016` | The browser transport builds `Authorization: Bearer …` from `options.auth.bearer`, or `Basic …` via `btoa` from user and pass | The `request` library does this natively in Node, so the browser must reproduce it to keep one API | `src/request/request.shim.js` | `test/unit/spec/request/request.shim.js`, `test/integration/spec/request.js` | none | Present |
180
+ | `MOD-017` | The browser transport defaults `cors: true`, `withCredentials: false`, and `timeout: 0` | The XHR wrapper would otherwise imply credentials from `cors`, sending cookies the caller never requested | `src/request/request.shim.js` | none found | Gap: the defaulting logic is untested despite its security relevance | Present |
181
+ | `MOD-018` | The browser transport maps `responseType: 'buffer'` to `'arraybuffer'` | `buffer` is this package's cross-platform name; the browser only knows `arraybuffer` | `src/request/request.shim.js` | `test/integration/spec/request.js` | none | Present |
182
+ | `MOD-019` | The browser transport serializes `qs` onto the URI, `form` as urlencoded, and `formData` as `FormData`, converting `ArrayBuffer` entries to `Blob` | Reproduces the `request` library's payload vocabulary on top of XHR | `src/request/request.shim.js` | `test/integration/spec/request.js` | none | Present |
183
+ | `MOD-020` | The browser transport binds upload progress only for `PATCH`, `POST`, and `PUT` | Only those methods carry a body worth reporting progress for | `src/request/request.shim.js` | none found | Gap: neither the bound nor the unbound case is tested | Present |
184
+ | `MOD-021` | The browser transport attempts a JSON parse of the response body when `params.json` was not set | The XHR wrapper will not deserialize without that flag, and services reply with JSON regardless | `src/request/request.shim.js` | none found | Gap: untested, including the swallowed parse failure | Present |
185
+ | `MOD-022` | The browser transport logs the request at `debug`, a `>= 400` result with its body at `warn`, and a success at `debug` | Gives operators a failure signal without logging every successful body | `src/request/request.shim.js` | none found | Gap: log levels are unasserted | Present |
186
+ | `MOD-023` | The transport file is selected at build time by the `browser` field, not by a runtime check | Bundlers exclude the Node transport and the `request` library from browser builds entirely | `package.json` | none found | Gap: no test asserts the mapping stays correct | Present |
187
+ | `MOD-024` | The browser transport's `setContentType` is `async` but is called without `await`, so for a `Blob` or `ArrayBuffer` body only the synchronous `json: false` assignment lands before dispatch; the detected `content-type` header is written after `xhr()` has already been invoked | Not deliberate. The Node transport awaits the same detection before dispatch (`MOD-013`), so the platforms disagree; recorded as current behavior rather than as intent | `src/request/request.shim.js` | none found | Defect: a buffer or blob upload from the browser can leave without the detected `content-type`. Neither branch is tested | Present |
188
+
189
+ ## Design overview
190
+
191
+ The pipeline is deliberately thin. `src/request/index.js` is about thirty lines and does four
192
+ things: normalize two option aliases, attach the emitters, and run the interceptor chain on each
193
+ side of a single dispatch call. Everything genuinely difficult lives either in `utils.ts` or in the
194
+ transports.
195
+
196
+ **The fold is the whole interceptor mechanism.** `intercept()` in `src/request/utils.ts` reduces the
197
+ interceptor array into a promise chain, attaching both a success and a failure handler at each step.
198
+ That single structure produces the behavior callers rely on: handlers run in sequence, each one sees
199
+ the previous one's output, and a rejection anywhere skips forward to the next `on<Key>Error` handler
200
+ rather than abandoning the chain. Reverse order for the response phase is achieved not in the fold
201
+ but at the call site, with `options.interceptors.slice().reverse()` — the copy matters, because
202
+ reversing in place would corrupt the caller's array for the next request.
203
+
204
+ **The two transports are the same contract implemented twice.** Both accept the option vocabulary of
205
+ the `request` library and both must resolve rather than reject. In Node that is nearly free, since
206
+ the library already speaks that vocabulary — the transport mostly translates a callback into a
207
+ promise and patches up buffers the library returns in a degraded form. In the browser none of it is
208
+ free: `setAuth`, `setCookies`, `setQs`, `setPayload`, `setResponseType`, and `setDefaults` in
209
+ `src/request/request.shim.js` exist purely to rebuild that vocabulary on top of XHR. This asymmetry
210
+ is the module's main maintenance burden and the reason parity is its central constraint.
211
+
212
+ **`prepareFetchOptions` is a third, partial path.** It shares the request-interceptor phase and the
213
+ emitter setup with the main pipeline, then stops short of sending. It exists for metrics submission,
214
+ where options are built at one moment and dispatched at another. It notably does not share the
215
+ response phase, so nothing on that path converts a status into an error.
216
+
217
+ ## Data flow and sequence coverage
218
+
219
+ The transport is HTTP. Three operation groups differ in actors, ordering, and outcome, so each is
220
+ diagrammed separately: the standard dispatch, a failure dispatch, and the prepare-only path.
221
+
222
+ | Operation group | Entry and outcome | Diagram or evidence | Failure and recovery coverage |
223
+ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------- |
224
+ | Standard dispatch | `request(options)` → resolved response after both interceptor phases | Diagram below | Interceptor rejection routed to `onRequestError`/`onResponseError` |
225
+ | Failure dispatch | Transport error → resolved `statusCode: 0` response, not a rejection | Diagram below | Covers Node and browser transport errors, plus the browser timeout path |
226
+ | Prepared fetch options | `prepareFetchOptions(options)` → mutated options, nothing sent | `src/request/utils.ts`, `test/unit/spec/request/utils.js` | Request-phase rejection propagates; no response phase exists on this path |
227
+
228
+ **Standard dispatch**
229
+
230
+ ```mermaid
231
+ sequenceDiagram
232
+ participant P as Parent module
233
+ participant X as src/request/index.js
234
+ participant U as src/request/utils.ts
235
+ participant T as transport
236
+ P->>X: _request(options)
237
+ X->>X: url to uri, default headers, create emitters
238
+ X->>U: intercept(options, interceptors, "Request")
239
+ loop each interceptor, forward order
240
+ U->>U: logOptions then onRequest
241
+ end
242
+ U-->>X: options
243
+ alt options.request supplied
244
+ X->>P: options.request(options)
245
+ else built-in transport
246
+ X->>T: _request(options)
247
+ T-->>X: response
248
+ end
249
+ X->>U: intercept(options, reversed, "Response", response)
250
+ loop each interceptor, reverse order
251
+ U->>U: logOptions then onResponse
252
+ end
253
+ U-->>P: response
254
+ ```
255
+
256
+ **Failure dispatch**
257
+
258
+ ```mermaid
259
+ sequenceDiagram
260
+ participant X as src/request/index.js
261
+ participant T as transport
262
+ participant N as network
263
+ participant U as src/request/utils.ts
264
+ X->>T: _request(options)
265
+ T->>N: send
266
+ alt network or CORS failure
267
+ N-->>T: error
268
+ T->>T: log warn
269
+ T-->>X: resolve {statusCode: 0, body: error}
270
+ else browser timeout elapsed
271
+ T->>T: abort, build ETIMEDOUT error
272
+ T-->>X: resolve {statusCode: 0, body: error}
273
+ else success
274
+ N-->>T: response
275
+ T-->>X: resolve response
276
+ end
277
+ X->>U: response interceptor phase (runs either way)
278
+ U-->>X: parent converts statusCode 0 to NetworkOrCORSError
279
+ ```
280
+
281
+ The second diagram is the one that explains the module's most surprising behavior: the response
282
+ interceptor phase runs identically whether the request succeeded or the network was unreachable.
283
+
284
+ ## Class and component relationships
285
+
286
+ ```mermaid
287
+ classDiagram
288
+ class Pipeline["src/request/index.js"] {
289
+ +default(options) Promise
290
+ }
291
+ class Utils["src/request/utils.ts"] {
292
+ +intercept(options, interceptors, key, res) Promise
293
+ +prepareFetchOptions(options) Promise
294
+ }
295
+ class NodeTransport["src/request/request.js"] {
296
+ -prepareOptions(options)
297
+ -doRequest(options)
298
+ +default(options) Promise
299
+ }
300
+ class BrowserTransport["src/request/request.shim.js"] {
301
+ -setAuth(params, o)
302
+ -setCookies(params, o)
303
+ -setQs(params, o)
304
+ -setPayload(params, o)
305
+ -setDefaults(params, o)
306
+ -processResponseJson(response, params)
307
+ +default(options) Promise
308
+ }
309
+ class Interceptor
310
+ class ProgressEvent
311
+ Pipeline --> Utils : folds interceptor phases
312
+ Pipeline --> NodeTransport : node build
313
+ Pipeline --> BrowserTransport : browser build
314
+ Utils ..> Interceptor : invokes hooks
315
+ NodeTransport --> ProgressEvent : emits download progress
316
+ BrowserTransport ..> ProgressEvent : forwards XHR progress
317
+ ```
318
+
319
+ `NodeTransport` and `BrowserTransport` are never both present in one bundle — the `browser` field in
320
+ `package.json` substitutes one for the other at build time.
321
+
322
+ ## Use cases and flows
323
+
324
+ | Use case | Actor or caller | Primary steps and outcome | Failure or boundary behavior | Evidence |
325
+ | -------- | --------------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
326
+ | `UC-001` | Parent module | Dispatch a JSON `GET`; both interceptor phases run and a parsed response is returned | A `>= 400` status still returns here; the parent converts it | `src/request/index.js`, `test/integration/spec/request.js` |
327
+ | `UC-002` | Caller uploading | `POST` a file via `formData`; `ArrayBuffer` entries become `Blob`s and upload progress is emitted | Progress binds only for `PATCH`, `POST`, `PUT` | `src/request/request.shim.js`, `test/integration/spec/request.js` |
328
+ | `UC-003` | Caller downloading | `GET` with `responseType: 'buffer'`; the body arrives as a buffer and download progress is emitted per chunk | Progress is unmeasurable when `content-length` is absent — `lengthComputable` is `false` | `src/request/request.js`, `test/integration/spec/request.js` |
329
+ | `UC-004` | Any caller | The host is unreachable; a `statusCode: 0` response is resolved and the parent rejects with `NetworkOrCORSError` | No rejection originates in this module | `src/request/request.js`, `test/integration/spec/request.js` |
330
+ | `UC-005` | Caller with own transport | Supply `options.request`; interceptors run and the custom function performs the exchange | The built-in transport is still imported and loaded by `src/request/index.js`; it is simply never invoked | `src/request/index.js`, `test/unit/spec/request/index.js` |
331
+ | `UC-006` | Metrics submission | Call `prepareFetchOptions` to build options now, send them later | No response phase, so a `4xx` is never converted to an error on this path | `src/request/utils.ts`, `test/unit/spec/request/utils.js` |
332
+ | `UC-007` | Authenticated caller | Pass `options.auth.bearer`; the transport builds the `Authorization` header | With user and pass instead, a base64 `Basic` header is built | `src/request/request.shim.js`, `test/unit/spec/request/request.shim.js` |
333
+
334
+ ### Cross-boundary use-case flow
335
+
336
+ | Boundary | Transport | Ordering | Compatibility | Timeout and retry | Recovery |
337
+ | ---------------------------------- | -------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
338
+ | Node process → HTTP service | `request` library over Node HTTP | One dispatch per call; progress events interleave with the response | Option vocabulary is the library's own | `options.timeout` passes through; no retry | Errors become a `statusCode: 0` response after a `warn` log |
339
+ | Browser → HTTP service | `XMLHttpRequest` via the vendored fork | Same, plus an upload-progress stream for body-carrying methods | Vocabulary is reimplemented by hand and must track the Node side | `options.timeout` starts a timer that aborts and raises `ETIMEDOUT`; default is `0` (none) | Errors and timeouts both become a `statusCode: 0` response |
340
+ | Either → caller-supplied transport | Whatever `options.request` implements | Interceptor phases still bracket the call | The caller owns the contract; this module makes no guarantees about its result | Entirely caller-owned | Entirely caller-owned |
341
+
342
+ Cookie handling is the sharpest compatibility edge: `options.jar` means a cookie jar in Node and
343
+ `withCredentials` in the browser. They are not equivalent — the browser flag is subject to CORS
344
+ rules the Node jar is not.
345
+
346
+ ## Concurrency and reactive flow
347
+
348
+ - **Execution model:** promise-based, on the host event loop. The Node transport wraps a callback
349
+ API in a `Promise`; the browser transport wraps XHR event handlers the same way. Progress arrives
350
+ as `EventEmitter` events, independent of the response promise.
351
+ - **Ordering guarantees:** interceptors are strictly sequential within a phase — forward for the
352
+ request, reverse for the response. Progress events carry no ordering guarantee relative to the
353
+ response promise and may still be emitted as it settles. Across requests there is no ordering.
354
+ - **Idempotency and retry:** neither is implemented. Every call performs exactly one dispatch. The
355
+ browser transport's `cbOnce` guard ensures the callback fires once even if both `onload` and
356
+ `onreadystatechange` trigger, which is a single-delivery guarantee rather than deduplication.
357
+ - **Shared-state protection:** none is needed; each request owns its options and emitters. The one
358
+ hazard is the interceptor array, which belongs to the caller — hence the defensive `.slice()`
359
+ before reversing in `src/request/index.js`.
360
+ - **Blocking restrictions:** interceptor hooks are awaited in sequence, so a slow hook delays the
361
+ request. Browser requests are always asynchronous: the vendored fork supports a synchronous mode
362
+ and guards its timeout path behind that flag, but this transport never forwards the option, so the
363
+ timeout path is always reachable. See Pitfalls and constraints.
364
+
365
+ ## Caller-visible failure modes
366
+
367
+ | Condition | Signal or result | Caller behavior | Retry or recovery | Evidence |
368
+ | ------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
369
+ | Network unreachable, DNS failure, or CORS | Resolved `{statusCode: 0, body: <error>}`; parent rejects `NetworkOrCORSError` | Treat as unreachable, not as a server answer | Caller-owned | `src/request/request.js`, `test/integration/spec/request.js` |
370
+ | Browser timeout elapsed | Request aborted; `ETIMEDOUT` error becomes `statusCode: 0` | Same handling as any transport failure | Caller-owned | `src/lib/xhr.js`, `src/request/request.shim.js` |
371
+ | Request interceptor rejects | Rejection routed to the next `onRequestError`; unhandled ones propagate | Handle in a later interceptor or at the call site | Interceptor-owned | `src/request/utils.ts` |
372
+ | Response interceptor rejects | Rejection routed to the next `onResponseError` in reverse order | Same | Interceptor-owned | `src/request/utils.ts` |
373
+ | Custom `options.request` throws or rejects | Propagates; the response phase does not run | Caller owns the contract entirely | Caller-owned | `src/request/index.js` |
374
+ | Response body is not valid JSON (browser) | The parse failure is swallowed and the raw body is kept | Inspect the body type before assuming an object | None needed | `src/request/request.shim.js` |
375
+ | `content-length` absent on a download | Progress still emits, with `lengthComputable: false` | Show indeterminate progress rather than a percentage | None needed | `src/request/request.js`, `src/progress-event.js` |
376
+
377
+ ## Pitfalls and constraints
378
+
379
+ - **The two transports must be changed together.** The `browser` field in `package.json` swaps them
380
+ at build time, so a change to one is invisible on the other platform until a user hits it. This is
381
+ the single most likely source of defects in this module.
382
+ - **Neither transport may reject.** Resolving a `statusCode: 0` response is load-bearing: the
383
+ parent's status interceptor is the only place rejections originate. A transport that rejects
384
+ bypasses every `onResponseError` handler in the chain.
385
+ - **`options.interceptors` belongs to the caller.** `src/request/index.js` copies before reversing.
386
+ Removing the `.slice()` would permanently reverse the caller's array.
387
+ - **The options object is mutated throughout.** `url` is nulled, `headers` filled, emitters attached.
388
+ Reusing one options object across requests carries state between them.
389
+ - **`jar` is not the same thing on both platforms.** A cookie jar in Node, `withCredentials` in the
390
+ browser — and the browser form is constrained by CORS in ways the jar is not.
391
+ - **CORS defaults on but credentials default off.** `setDefaults` in `src/request/request.shim.js`
392
+ sets `withCredentials: false` explicitly because the underlying wrapper would otherwise infer it
393
+ from `cors: true` and send cookies the caller never asked to send.
394
+ - **The prepared-fetch path has no response phase.** Nothing converts a status into an error there,
395
+ so a `4xx` submitted via `setTimingsAndFetch` resolves normally.
396
+ - **The vendored fork's synchronous mode is unreachable from here.** `src/lib/xhr.js` implements a
397
+ `sync` option and disables its timeout timer when that option is set, but the browser transport's
398
+ `pick()` in `src/request/request.shim.js` does not copy `sync` into the params it passes, and
399
+ `setDefaults` does not supply it either. A caller setting `options.sync` gets an ordinary
400
+ asynchronous request. Treat that code path in the fork as dead unless the transport is
401
+ deliberately changed to forward the option.
402
+ - **The Node transport rebuilds degraded buffers.** The `request` library sometimes returns objects
403
+ shaped like buffers that are not buffers; the transport reconstructs them. Do not remove that
404
+ branch without confirming the library's current behavior.
405
+
406
+ ## Module-specific rules
407
+
408
+ - **Do:** mirror every behavioral change across `src/request/request.js` and
409
+ `src/request/request.shim.js`, and add or extend tests on both sides. Parity is the module's
410
+ primary constraint.
411
+ - **Do:** keep the option vocabulary aligned with the `request` library's names. New options should
412
+ follow its conventions so the two transports can keep one surface.
413
+ - **Do:** resolve a `statusCode: 0` response for any transport-level failure, including new ones.
414
+ - **Do:** copy `options.interceptors` before reordering it.
415
+ - **Do not:** reject from a transport, or convert a status into an error here. That belongs to the
416
+ parent's `HttpStatusInterceptor`.
417
+ - **Do not:** add retry, backoff, or circuit-breaking to this module. It performs exactly one
418
+ dispatch per call by design.
419
+ - **Do not:** import from the parent's entry point. The dependency runs one way, parent to child.
420
+
421
+ ## Key design trade-off
422
+
423
+ | Chosen trade-off | Preserved invariant or benefit | Cost or limitation | Decision evidence |
424
+ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
425
+ | Transports resolve a synthetic failure response instead of rejecting | Rejection originates in exactly one place, so every failure reaches `onResponseError` handlers uniformly | A resolved promise may carry a failure, so no code may treat "resolved" as "succeeded" | `src/request/request.js`, `src/request/request.shim.js` |
426
+ | Select the transport at build time via the `browser` field rather than at runtime | Browser bundles exclude the Node transport and the `request` library entirely, keeping the shipped bundle smaller | The two implementations can diverge without any test failing on the platform being developed on | `package.json` |
427
+ | Reimplement the `request` option vocabulary on top of XHR | One public API across platforms; callers migrating from that library need no changes | An entire translation layer must be maintained by hand and kept in sync with an unmaintained library's semantics | `src/request/request.shim.js` |
428
+ | Expose `options.request` as a transport escape hatch | A consumer can route over a channel this package does not know about, such as `postMessage` to a parent frame | The result is unvalidated — it bypasses both transports while still flowing through the response interceptor phase | `src/request/index.js` |
429
+ | Reverse the response phase with a copy of the interceptor array | Proper nesting: the outermost interceptor sees the request first and the response last | An array copy per request, and a subtle bug waiting for anyone who removes the `.slice()` | `src/request/index.js` |
430
+ | Share the request phase but not the response phase with `prepareFetchOptions` | Metrics can build a request now and send it later while still getting interceptor-applied headers | Two paths with different failure semantics; the prepared path never produces an `HttpError` | `src/request/utils.ts` |
431
+
432
+ ## Verification
433
+
434
+ | Requirement or invariant | Test level | Positive evidence | Negative or boundary evidence | Gap |
435
+ | ------------------------ | ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
436
+ | `MOD-001` | Unit + Integration | `test/unit/spec/request/index.js` | `test/integration/spec/request.js` | none |
437
+ | `MOD-002` | Unit | `test/unit/spec/request/index.js` | none found | Pre-supplied headers are not asserted to survive |
438
+ | `MOD-003` | Unit | `test/unit/spec/request/index.js` | none found | none |
439
+ | `MOD-004` | Unit | `test/unit/spec/request/index.js` | none found | Order is inferred from a two-call count, not asserted directly |
440
+ | `MOD-005` | Unit | `test/unit/spec/request/index.js` | `test/unit/spec/request/index.js` (built-in transport not called) | none |
441
+ | `MOD-006` | Unit | `test/unit/spec/request/utils.js` | none found | The error-routing branch is uncovered |
442
+ | `MOD-007` | Integration | `test/integration/spec/interceptor.js` | none found | none |
443
+ | `MOD-008` | Unit | `test/unit/spec/request/utils.js` | `test/unit/spec/request/utils.js` (caller headers preserved) | none |
444
+ | `MOD-009` | — | none found | none found | No test passes a `method`, so the `GET`/`HEAD` skip never executes |
445
+ | `MOD-010` | Unit | `test/unit/spec/request/utils.js` | none found | none |
446
+ | `MOD-011` | Integration | `test/integration/spec/request.js` | none found | Node only; no browser equivalent |
447
+ | `MOD-012` | Integration | `test/integration/spec/request.js` | none found | none |
448
+ | `MOD-013` | Integration | `test/integration/spec/request.js` | none found | The detection failure path is uncovered |
449
+ | `MOD-014` | Integration | `test/integration/spec/request.js` (node only) | none found | The browser branch is untested, as the test file notes in a comment |
450
+ | `MOD-015` | Integration | `test/integration/spec/request.js` | none found | The missing-`content-length` case is uncovered |
451
+ | `MOD-016` | Unit + Integration | `test/unit/spec/request/request.shim.js` (basic), `test/integration/spec/request.js` (bearer and basic) | none found | none |
452
+ | `MOD-017` | — | none found | none found | The explicit `withCredentials: false` default is unasserted despite its security role |
453
+ | `MOD-018` | Integration | `test/integration/spec/request.js` | none found | none |
454
+ | `MOD-019` | Integration | `test/integration/spec/request.js` (qs, form, formData) | none found | `ArrayBuffer`-to-`Blob` conversion is not directly asserted |
455
+ | `MOD-020` | — | none found | none found | Neither the bound nor the unbound method set is tested |
456
+ | `MOD-021` | — | none found | none found | Including the deliberately swallowed parse failure |
457
+ | `MOD-022` | — | none found | none found | Log levels are unasserted |
458
+ | `MOD-023` | — | none found | none found | No test would catch a broken `browser` mapping in `package.json` |
459
+ | `MOD-024` | — | none found | none found | The unawaited call is unasserted, and no test observes the outgoing `content-type` in the browser |
460
+
461
+ **Coverage gaps worth acting on.** The concentration is one-sided: the Node transport is reachable
462
+ by the Jest unit tier and the Mocha integration tier, while most browser-transport behavior is
463
+ verified only by the Karma tier, which runs the same integration specs. Eight requirements have no
464
+ test at all — `MOD-009`, `MOD-017`, and `MOD-020` through `MOD-024` — and all but one are
465
+ browser-side.
466
+
467
+ Three deserve attention beyond the count. `MOD-017` governs whether cookies are sent on a
468
+ cross-origin request, and it is entirely unasserted. `MOD-014`'s browser half is knowingly untested;
469
+ `test/integration/spec/request.js` says so in a comment and suggests moving the error-reformatting
470
+ logic out of the platform-specific implementations to make it testable — a refactor that would
471
+ reduce exactly the parity risk this module's constraints are built around. `MOD-024` is not a
472
+ coverage gap but a defect the absence of coverage hides: `setContentType` in
473
+ `src/request/request.shim.js` is `async` and is called without `await`, so the detected
474
+ `content-type` is assigned after the request has been dispatched. This specification records the
475
+ behavior as it is; correcting it is a transport change and belongs with a browser-tier test.
476
+
477
+ Generator-side field measurement is complete for this module and every critical field is present.
478
+ What remains is not measurement: independent semantic validation by `codex` ran on 2026-09-23 and
479
+ returned `pass-with-warnings`; the prior B1 and B2 findings were resolved and no Blocking finding
480
+ remains. Promotion from `Partial` to `Specced` remains held because a characterization baseline
481
+ should be recorded before any risky modification.