@camstack/addon-export-alexa 1.2.132 → 1.2.134
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/export-alexa.addon.js +858 -307
- package/dist/export-alexa.addon.mjs +859 -307
- package/lambda-template/README.md +166 -103
- package/lambda-template/aws.js +37 -0
- package/lambda-template/broker.js +382 -0
- package/lambda-template/http.js +174 -0
- package/lambda-template/index.js +330 -379
- package/lambda-template/secrets.js +105 -0
- package/lambda-template/token-store.js +142 -0
- package/lambda-template/util.js +181 -0
- package/package.json +1 -1
|
@@ -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 }
|