@camstack/addon-export-alexa 1.2.132 → 1.2.133

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,382 @@
1
+ /**
2
+ * The Alexa token broker (D753).
3
+ *
4
+ * The skill's LWA client secret authenticates every grant exchange and every
5
+ * refresh at api.amazon.com. Before D753 a copy of it lived in every hub — in
6
+ * public source and in a public npm package. Now it lives ONLY here (read from
7
+ * Secrets Manager), together with each user's LWA refresh token, and a hub
8
+ * gets exactly what it needs to push events: a short-lived access token.
9
+ *
10
+ * A hub authenticates to the broker with a PER-LINK credential
11
+ * `csb1.<linkId>.<secret>` minted at link time:
12
+ * - no shared secret: each link has its own, so one leaked hub exposes one
13
+ * link, never the skill;
14
+ * - only sha256(secret) is stored, compared in constant time;
15
+ * - revocable: delete the item (`/broker/revoke`, or the item's TTL lapses);
16
+ * - rotatable: `/broker/rotate` swaps the secret atomically, and every
17
+ * re-link mints a fresh one (the old item expires on its idle TTL).
18
+ *
19
+ * HTTP routes (Function URL, POST only):
20
+ * /broker/token Bearer <credential> → { accessToken, expiresAt }
21
+ * /broker/migrate { refreshToken } → { brokerCredential, brokerUrl, accessToken, expiresAt }
22
+ * /broker/rotate Bearer <credential> → { brokerCredential }
23
+ * /broker/revoke Bearer <credential> → { ok: true }
24
+ *
25
+ * `/broker/migrate` is the zero-touch path for a hub linked before D753: it
26
+ * still holds a refresh token (and, until it updates, the leaked secret). The
27
+ * broker proves the token is a live grant OF THIS SKILL by refreshing it with
28
+ * the skill's secret — possession of that token is already the full power of
29
+ * the grant, so the broker hands back nothing the caller did not have. The hub
30
+ * then deletes its copy. Disable with `ALLOW_MIGRATION=0` once every hub moved.
31
+ *
32
+ * Nothing here logs a token, a code, a credential or a secret.
33
+ */
34
+ 'use strict'
35
+
36
+ const crypto = require('node:crypto')
37
+ const { encodeFormUrlEncoded, jsonResponse, parseJsonBody, readBearer } = require('./util.js')
38
+
39
+ const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token'
40
+ const CREDENTIAL_PREFIX = 'csb1'
41
+ /** Hand out a cached access token only while it has at least this much life left. */
42
+ const ACCESS_TOKEN_MIN_LIFE_MS = 5 * 60 * 1000
43
+ /** A pending (mid-AcceptGrant) item that never activated is gone after this. */
44
+ const PENDING_TTL_SEC = 60 * 60
45
+ /** An active link nobody refreshed for this long is purged by DynamoDB TTL. */
46
+ const IDLE_TTL_SEC = 180 * 24 * 60 * 60
47
+
48
+ class BrokerError extends Error {
49
+ constructor(status, code, message) {
50
+ super(message)
51
+ this.status = status
52
+ this.code = code
53
+ }
54
+ }
55
+
56
+ // ── Credentials ──────────────────────────────────────────────────────
57
+
58
+ function sha256Hex(value) {
59
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
60
+ }
61
+
62
+ /** @param {(n: number) => Buffer} randomBytes */
63
+ function mintCredential(randomBytes, linkId) {
64
+ const id = linkId || randomBytes(16).toString('base64url')
65
+ const secret = randomBytes(32).toString('base64url')
66
+ return {
67
+ linkId: id,
68
+ secretHash: sha256Hex(secret),
69
+ credential: `${CREDENTIAL_PREFIX}.${id}.${secret}`,
70
+ }
71
+ }
72
+
73
+ const B64URL = /^[A-Za-z0-9_-]+$/
74
+
75
+ function parseCredential(raw) {
76
+ if (typeof raw !== 'string') return null
77
+ const parts = raw.split('.')
78
+ if (parts.length !== 3 || parts[0] !== CREDENTIAL_PREFIX) return null
79
+ const [, linkId, secret] = parts
80
+ if (!B64URL.test(linkId) || !B64URL.test(secret)) return null
81
+ if (linkId.length < 16 || linkId.length > 64 || secret.length < 32 || secret.length > 128) {
82
+ return null
83
+ }
84
+ return { linkId, secret }
85
+ }
86
+
87
+ function secretMatches(secret, storedHash) {
88
+ if (typeof storedHash !== 'string' || storedHash.length !== 64) return false
89
+ const a = Buffer.from(sha256Hex(secret), 'hex')
90
+ const b = Buffer.from(storedHash, 'hex')
91
+ return a.length === b.length && crypto.timingSafeEqual(a, b)
92
+ }
93
+
94
+ // ── LWA ──────────────────────────────────────────────────────────────
95
+
96
+ async function postLwa(deps, params, creds) {
97
+ const resp = await deps.fetch(LWA_TOKEN_URL, {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
100
+ body: encodeFormUrlEncoded({
101
+ ...params,
102
+ client_id: creds.clientId,
103
+ client_secret: creds.clientSecret,
104
+ }),
105
+ })
106
+ const text = await resp.text().catch(() => '')
107
+ let body = null
108
+ try {
109
+ body = JSON.parse(text)
110
+ } catch {
111
+ body = null
112
+ }
113
+ return { ok: resp.ok, status: resp.status, body }
114
+ }
115
+
116
+ function lwaErrorCode(res) {
117
+ return res.body && typeof res.body.error === 'string' ? res.body.error : null
118
+ }
119
+
120
+ /**
121
+ * One call to the LWA token endpoint with the skill's credentials. On
122
+ * `invalid_client` with a Secrets-Manager-sourced secret, drops the cache and
123
+ * retries once with a freshly read value — so a rotation takes effect on the
124
+ * very next call instead of after the cache TTL.
125
+ */
126
+ async function lwaTokenCall(deps, params) {
127
+ const creds = await deps.secrets.lwa()
128
+ if (!creds) {
129
+ throw new BrokerError(500, 'unconfigured', 'LWA client credentials are not configured')
130
+ }
131
+ let res = await postLwa(deps, params, creds)
132
+ if (lwaErrorCode(res) === 'invalid_client' && creds.source === 'secrets-manager') {
133
+ deps.secrets.invalidate('lwa')
134
+ const fresh = await deps.secrets.lwa()
135
+ if (fresh && fresh.clientSecret !== creds.clientSecret) {
136
+ deps.log.warn('broker: LWA rejected the cached client secret; retried with a fresh read')
137
+ res = await postLwa(deps, params, fresh)
138
+ }
139
+ }
140
+ return res
141
+ }
142
+
143
+ function readLwaTokens(res, fallbackRefreshToken) {
144
+ const b = res.body || {}
145
+ if (typeof b.access_token !== 'string' || b.access_token.length === 0) return null
146
+ const expiresIn = typeof b.expires_in === 'number' && b.expires_in > 0 ? b.expires_in : 3600
147
+ const refreshToken =
148
+ typeof b.refresh_token === 'string' && b.refresh_token.length > 0
149
+ ? b.refresh_token
150
+ : fallbackRefreshToken
151
+ if (!refreshToken) return null
152
+ return { accessToken: b.access_token, refreshToken, expiresInSec: expiresIn }
153
+ }
154
+
155
+ /** Exchange an AcceptGrant code. Throws BrokerError on any failure. */
156
+ async function exchangeGrantCode(deps, code, redirectUri) {
157
+ const res = await lwaTokenCall(deps, {
158
+ grant_type: 'authorization_code',
159
+ code,
160
+ redirect_uri: redirectUri,
161
+ })
162
+ const tokens = res.ok ? readLwaTokens(res, null) : null
163
+ if (!tokens) {
164
+ throw new BrokerError(502, 'lwa_exchange_failed', `LWA exchange ${res.status} ${lwaErrorCode(res) || ''}`.trim())
165
+ }
166
+ return tokens
167
+ }
168
+
169
+ /** Refresh a grant. `invalid_grant` → 410 (the user disabled the skill / Amazon revoked it). */
170
+ async function refreshGrant(deps, refreshToken) {
171
+ const res = await lwaTokenCall(deps, { grant_type: 'refresh_token', refresh_token: refreshToken })
172
+ if (!res.ok) {
173
+ if (lwaErrorCode(res) === 'invalid_grant') {
174
+ throw new BrokerError(410, 'grant_revoked', 'Amazon no longer honours this grant; re-link the skill')
175
+ }
176
+ throw new BrokerError(502, 'lwa_unavailable', `LWA refresh ${res.status} ${lwaErrorCode(res) || ''}`.trim())
177
+ }
178
+ const tokens = readLwaTokens(res, refreshToken)
179
+ if (!tokens) throw new BrokerError(502, 'lwa_malformed', 'LWA refresh answered without an access token')
180
+ return tokens
181
+ }
182
+
183
+ // ── Link lifecycle ───────────────────────────────────────────────────
184
+
185
+ function nowSec(deps) {
186
+ return Math.floor(deps.now() / 1000)
187
+ }
188
+
189
+ /**
190
+ * Create a link item for freshly obtained tokens. `status` is `pending` for an
191
+ * AcceptGrant (activated only after the hub accepted the credential) and
192
+ * `active` for a migration (the caller already proved possession).
193
+ */
194
+ async function createLink(deps, opts) {
195
+ const minted = mintCredential(deps.randomBytes)
196
+ const now = deps.now()
197
+ const item = {
198
+ linkId: minted.linkId,
199
+ secretHash: minted.secretHash,
200
+ status: opts.status,
201
+ refreshToken: opts.tokens.refreshToken,
202
+ accessToken: opts.tokens.accessToken,
203
+ accessExpiresAt: now + opts.tokens.expiresInSec * 1000,
204
+ ...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
205
+ origin: opts.origin,
206
+ createdAt: now,
207
+ lastUsedAt: now,
208
+ ttl: nowSec(deps) + (opts.status === 'pending' ? PENDING_TTL_SEC : IDLE_TTL_SEC),
209
+ version: 1,
210
+ }
211
+ await deps.store.create(item)
212
+ return { item, credential: minted.credential }
213
+ }
214
+
215
+ async function activateLink(deps, item) {
216
+ const next = { ...item, status: 'active', ttl: nowSec(deps) + IDLE_TTL_SEC, version: item.version + 1 }
217
+ const ok = await deps.store.replace(next, item.version)
218
+ if (!ok) throw new BrokerError(409, 'conflict', 'link changed while activating')
219
+ return next
220
+ }
221
+
222
+ /** Resolve the request's credential to an ACTIVE item, or throw 401. */
223
+ async function authenticate(deps, event) {
224
+ const parsed = parseCredential(readBearer(event))
225
+ if (!parsed) throw new BrokerError(401, 'invalid_credential', 'broker credential missing or malformed')
226
+ const item = await deps.store.get(parsed.linkId)
227
+ // One answer for "no such link", "not active" and "wrong secret" — the
228
+ // response must not tell a prober which linkIds exist.
229
+ if (!item || item.status !== 'active' || !secretMatches(parsed.secret, item.secretHash)) {
230
+ throw new BrokerError(401, 'invalid_credential', 'broker credential not recognised')
231
+ }
232
+ return item
233
+ }
234
+
235
+ /** A valid access token for the item, refreshing (and persisting) when needed. */
236
+ async function currentAccessToken(deps, item) {
237
+ const now = deps.now()
238
+ if (item.accessToken && item.accessExpiresAt - now > ACCESS_TOKEN_MIN_LIFE_MS) return item
239
+ let tokens
240
+ try {
241
+ tokens = await refreshGrant(deps, item.refreshToken)
242
+ } catch (err) {
243
+ if (err instanceof BrokerError && err.code === 'grant_revoked') {
244
+ await deps.store.remove(item.linkId)
245
+ deps.log.warn('broker: grant revoked by Amazon; link removed', { link: shortId(item.linkId) })
246
+ }
247
+ throw err
248
+ }
249
+ const next = {
250
+ ...item,
251
+ accessToken: tokens.accessToken,
252
+ refreshToken: tokens.refreshToken,
253
+ accessExpiresAt: now + tokens.expiresInSec * 1000,
254
+ lastUsedAt: now,
255
+ ttl: nowSec(deps) + IDLE_TTL_SEC,
256
+ version: item.version + 1,
257
+ }
258
+ if (await deps.store.replace(next, item.version)) return next
259
+ // Someone refreshed concurrently. Their write carries the newest refresh
260
+ // token; serve what they stored rather than overwrite it.
261
+ const fresh = await deps.store.get(item.linkId)
262
+ if (fresh && fresh.status === 'active' && fresh.accessExpiresAt > now) return fresh
263
+ throw new BrokerError(503, 'conflict', 'concurrent refresh; retry')
264
+ }
265
+
266
+ function shortId(linkId) {
267
+ return typeof linkId === 'string' ? linkId.slice(0, 6) : '?'
268
+ }
269
+
270
+ // ── HTTP routes ──────────────────────────────────────────────────────
271
+
272
+ const ROUTES = {
273
+ '/broker/token': routeToken,
274
+ '/broker/migrate': routeMigrate,
275
+ '/broker/rotate': routeRotate,
276
+ '/broker/revoke': routeRevoke,
277
+ }
278
+
279
+ function isBrokerPath(path) {
280
+ return Object.prototype.hasOwnProperty.call(ROUTES, path)
281
+ }
282
+
283
+ async function handleBrokerRoute(event, deps) {
284
+ if (event.requestContext.http.method !== 'POST') {
285
+ return jsonResponse(405, { error: 'method_not_allowed' })
286
+ }
287
+ if (!deps.store) {
288
+ return jsonResponse(503, { error: 'broker_unconfigured', message: 'TOKENS_TABLE is not set' })
289
+ }
290
+ try {
291
+ return await ROUTES[event.rawPath](event, deps)
292
+ } catch (err) {
293
+ if (err instanceof BrokerError) {
294
+ if (err.status >= 500) deps.log.warn('broker: request failed', { route: event.rawPath, code: err.code })
295
+ return jsonResponse(err.status, { error: err.code, message: err.message })
296
+ }
297
+ throw err
298
+ }
299
+ }
300
+
301
+ async function routeToken(event, deps) {
302
+ const item = await authenticate(deps, event)
303
+ const current = await currentAccessToken(deps, item)
304
+ return jsonResponse(200, { accessToken: current.accessToken, expiresAt: current.accessExpiresAt })
305
+ }
306
+
307
+ async function routeMigrate(event, deps) {
308
+ if (deps.env.ALLOW_MIGRATION === '0') {
309
+ return jsonResponse(403, { error: 'migration_disabled', message: 're-link the skill instead' })
310
+ }
311
+ const brokerUrl = deps.publicUrl
312
+ if (!brokerUrl) {
313
+ return jsonResponse(503, { error: 'broker_unconfigured', message: 'broker public URL unknown' })
314
+ }
315
+ const body = parseJsonBody(event)
316
+ const refreshToken = body && typeof body.refreshToken === 'string' ? body.refreshToken : ''
317
+ if (refreshToken.length === 0 || refreshToken.length > 4096) {
318
+ return jsonResponse(400, { error: 'invalid_request', message: 'refreshToken is required' })
319
+ }
320
+ let tokens
321
+ try {
322
+ tokens = await refreshGrant(deps, refreshToken)
323
+ } catch (err) {
324
+ if (err instanceof BrokerError && err.code === 'grant_revoked') {
325
+ throw new BrokerError(400, 'invalid_grant', 'not a live grant of this skill')
326
+ }
327
+ throw err
328
+ }
329
+ const hubUrl = body && typeof body.hubUrl === 'string' ? body.hubUrl.slice(0, 512) : undefined
330
+ const { item, credential } = await createLink(deps, {
331
+ status: 'active',
332
+ tokens,
333
+ hubUrl,
334
+ origin: 'migration',
335
+ })
336
+ deps.log.info('broker: migrated a pre-broker grant', { link: shortId(item.linkId) })
337
+ return jsonResponse(200, {
338
+ brokerCredential: credential,
339
+ brokerUrl,
340
+ accessToken: item.accessToken,
341
+ expiresAt: item.accessExpiresAt,
342
+ })
343
+ }
344
+
345
+ async function routeRotate(event, deps) {
346
+ const item = await authenticate(deps, event)
347
+ const minted = mintCredential(deps.randomBytes, item.linkId)
348
+ const next = { ...item, secretHash: minted.secretHash, version: item.version + 1 }
349
+ if (!(await deps.store.replace(next, item.version))) {
350
+ throw new BrokerError(409, 'conflict', 'link changed while rotating; retry')
351
+ }
352
+ deps.log.info('broker: credential rotated', { link: shortId(item.linkId) })
353
+ return jsonResponse(200, { brokerCredential: minted.credential })
354
+ }
355
+
356
+ async function routeRevoke(event, deps) {
357
+ const item = await authenticate(deps, event)
358
+ await deps.store.remove(item.linkId)
359
+ deps.log.info('broker: link revoked by its hub', { link: shortId(item.linkId) })
360
+ return jsonResponse(200, { ok: true })
361
+ }
362
+
363
+ module.exports = {
364
+ BrokerError,
365
+ LWA_TOKEN_URL,
366
+ ACCESS_TOKEN_MIN_LIFE_MS,
367
+ PENDING_TTL_SEC,
368
+ IDLE_TTL_SEC,
369
+ mintCredential,
370
+ parseCredential,
371
+ secretMatches,
372
+ sha256Hex,
373
+ lwaTokenCall,
374
+ exchangeGrantCode,
375
+ refreshGrant,
376
+ createLink,
377
+ activateLink,
378
+ currentAccessToken,
379
+ isBrokerPath,
380
+ handleBrokerRoute,
381
+ shortId,
382
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `timedFetch` — the Lambda's only outbound HTTP primitive.
3
+ *
4
+ * Uses https.request with a no-keep-alive Agent. Node's built-in fetch
5
+ * (undici-backed) reuses pooled sockets aggressively; in AWS Lambda the
6
+ * invocation-to-invocation gaps can leave pooled sockets stale, and the next
7
+ * fetch throws DOMException AbortError in <1s. https.request gives us explicit
8
+ * control.
9
+ *
10
+ * NEVER logs request or response bodies, headers or query strings: they carry
11
+ * OAuth codes, LWA tokens and broker credentials. The timing line records only
12
+ * scheme, host, path, status and sub-timings.
13
+ */
14
+ 'use strict'
15
+
16
+ const https = require('node:https')
17
+ const http = require('node:http')
18
+ const { URL } = require('node:url')
19
+
20
+ const FETCH_TIMEOUT_MS = 6000
21
+ const NO_KEEPALIVE_AGENT = new https.Agent({ keepAlive: false })
22
+
23
+ async function timedFetch(url, init = {}) {
24
+ // Returns a fetch-compatible response object: { ok, status, statusText,
25
+ // text(), json() }. The handler only uses .text() / status.
26
+ //
27
+ // Emits a single structured timing log line on completion (or failure),
28
+ // measuring DNS / TCP / TLS / request-sent / first-byte / last-byte sub-
29
+ // timings. Read these from CloudWatch with:
30
+ // filter @message like /timedFetch_timing/
31
+ // to see end-to-end where the wall-clock seconds go (DNS hang vs TCP RTT
32
+ // vs TLS handshake vs hub processing time vs body download).
33
+ return new Promise((resolve, reject) => {
34
+ let settled = false
35
+ const parsed = new URL(url)
36
+ const isHttps = parsed.protocol === 'https:'
37
+ const lib = isHttps ? https : http
38
+ const headers = { ...(init.headers || {}), connection: 'close' }
39
+ if (init.body !== undefined && headers['content-length'] === undefined) {
40
+ headers['content-length'] = Buffer.byteLength(init.body)
41
+ }
42
+ const T0 = process.hrtime.bigint()
43
+ const marks = {
44
+ dns: null,
45
+ tcpConnect: null,
46
+ tlsHandshake: null,
47
+ requestSent: null,
48
+ firstByte: null,
49
+ lastByte: null,
50
+ }
51
+ const ms = (a, b) => Number((b - a) / 1_000_000n)
52
+ const logTiming = (status, extras = {}) => {
53
+ const T_end = process.hrtime.bigint()
54
+ const total = ms(T0, T_end)
55
+ const stages = {
56
+ dns: marks.dns ? ms(T0, marks.dns) : null,
57
+ tcp: marks.tcpConnect ? ms(T0, marks.tcpConnect) : null,
58
+ tls: marks.tlsHandshake ? ms(T0, marks.tlsHandshake) : null,
59
+ reqSent: marks.requestSent ? ms(T0, marks.requestSent) : null,
60
+ firstByte: marks.firstByte ? ms(T0, marks.firstByte) : null,
61
+ lastByte: marks.lastByte ? ms(T0, marks.lastByte) : null,
62
+ }
63
+ // Stage durations between marks, easier to read at a glance:
64
+ const between = {
65
+ dns_ms: stages.dns,
66
+ tcp_handshake_ms:
67
+ stages.tcp != null && stages.dns != null ? stages.tcp - stages.dns : stages.tcp,
68
+ tls_handshake_ms: stages.tls != null && stages.tcp != null ? stages.tls - stages.tcp : null,
69
+ request_send_ms:
70
+ stages.reqSent != null && (stages.tls ?? stages.tcp) != null
71
+ ? stages.reqSent - (stages.tls ?? stages.tcp)
72
+ : null,
73
+ wait_first_byte_ms:
74
+ stages.firstByte != null && stages.reqSent != null
75
+ ? stages.firstByte - stages.reqSent
76
+ : null,
77
+ download_ms:
78
+ stages.lastByte != null && stages.firstByte != null
79
+ ? stages.lastByte - stages.firstByte
80
+ : null,
81
+ }
82
+ console.log(
83
+ 'timedFetch_timing',
84
+ JSON.stringify({
85
+ url: `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`,
86
+ method: (init.method || 'GET').toUpperCase(),
87
+ status,
88
+ total_ms: total,
89
+ stages_cum_ms: stages,
90
+ between_ms: between,
91
+ ...extras,
92
+ }),
93
+ )
94
+ }
95
+ const req = lib.request(
96
+ {
97
+ protocol: parsed.protocol,
98
+ hostname: parsed.hostname,
99
+ port: parsed.port || (isHttps ? 443 : 80),
100
+ path: `${parsed.pathname}${parsed.search}`,
101
+ method: (init.method || 'GET').toUpperCase(),
102
+ headers,
103
+ agent: isHttps ? NO_KEEPALIVE_AGENT : undefined,
104
+ },
105
+ (res) => {
106
+ marks.firstByte = process.hrtime.bigint()
107
+ const chunks = []
108
+ res.on('data', (c) => chunks.push(c))
109
+ res.on('end', () => {
110
+ if (settled) return
111
+ settled = true
112
+ clearTimeout(timer)
113
+ marks.lastByte = process.hrtime.bigint()
114
+ const body = Buffer.concat(chunks).toString('utf8')
115
+ const headersApi = {
116
+ get: (name) => {
117
+ const v = res.headers[String(name).toLowerCase()]
118
+ if (Array.isArray(v)) return v.join(', ')
119
+ return v == null ? null : String(v)
120
+ },
121
+ }
122
+ logTiming(res.statusCode, { responseBodyBytes: body.length })
123
+ resolve({
124
+ ok: res.statusCode >= 200 && res.statusCode < 300,
125
+ status: res.statusCode,
126
+ statusText: res.statusMessage || '',
127
+ headers: headersApi,
128
+ text: async () => body,
129
+ json: async () => JSON.parse(body),
130
+ })
131
+ })
132
+ res.on('error', (err) => {
133
+ if (settled) return
134
+ settled = true
135
+ clearTimeout(timer)
136
+ logTiming('res_error', { error: err && err.message })
137
+ reject(err)
138
+ })
139
+ },
140
+ )
141
+ req.on('socket', (socket) => {
142
+ socket.on('lookup', () => {
143
+ marks.dns = process.hrtime.bigint()
144
+ })
145
+ socket.on('connect', () => {
146
+ marks.tcpConnect = process.hrtime.bigint()
147
+ })
148
+ socket.on('secureConnect', () => {
149
+ marks.tlsHandshake = process.hrtime.bigint()
150
+ })
151
+ })
152
+ req.on('finish', () => {
153
+ marks.requestSent = process.hrtime.bigint()
154
+ })
155
+ req.on('error', (err) => {
156
+ if (settled) return
157
+ settled = true
158
+ clearTimeout(timer)
159
+ logTiming('req_error', { error: err && err.message, code: err && err.code })
160
+ reject(err)
161
+ })
162
+ const timer = setTimeout(() => {
163
+ if (settled) return
164
+ settled = true
165
+ req.destroy(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
166
+ logTiming('timeout', { timeout_ms: FETCH_TIMEOUT_MS })
167
+ reject(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
168
+ }, FETCH_TIMEOUT_MS)
169
+ if (init.body !== undefined) req.write(init.body)
170
+ req.end()
171
+ })
172
+ }
173
+
174
+ module.exports = { timedFetch, FETCH_TIMEOUT_MS }