@conciergeprotocol/server 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -2
- package/package.json +40 -4
- package/src/check.js +16 -0
- package/src/check.mjs +91 -0
- package/src/cli.js +9 -0
- package/src/config.mjs +112 -0
- package/src/http.mjs +43 -0
- package/src/index.d.ts +106 -0
- package/src/index.js +137 -0
- package/src/node-server.mjs +414 -0
- package/src/oauth.mjs +677 -0
- package/src/store.mjs +358 -0
- package/src/web.mjs +56 -0
package/src/store.mjs
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const SCHEMA = `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
7
|
+
id TEXT PRIMARY KEY,
|
|
8
|
+
caller_id TEXT NOT NULL,
|
|
9
|
+
account TEXT NOT NULL DEFAULT '',
|
|
10
|
+
client_id TEXT NOT NULL DEFAULT '',
|
|
11
|
+
idempotency_key TEXT,
|
|
12
|
+
previous_id TEXT,
|
|
13
|
+
message TEXT NOT NULL,
|
|
14
|
+
context_json TEXT NOT NULL,
|
|
15
|
+
status TEXT NOT NULL,
|
|
16
|
+
reply TEXT,
|
|
17
|
+
error_json TEXT,
|
|
18
|
+
created_at TEXT NOT NULL,
|
|
19
|
+
finished_at TEXT
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS messages_by_caller ON messages (caller_id, created_at);
|
|
22
|
+
CREATE UNIQUE INDEX IF NOT EXISTS messages_idempotency
|
|
23
|
+
ON messages (caller_id, account, client_id, idempotency_key)
|
|
24
|
+
WHERE idempotency_key IS NOT NULL;
|
|
25
|
+
CREATE TABLE IF NOT EXISTS grants (
|
|
26
|
+
token_hash TEXT PRIMARY KEY,
|
|
27
|
+
account TEXT NOT NULL,
|
|
28
|
+
client_id TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
31
|
+
code_hash TEXT PRIMARY KEY,
|
|
32
|
+
account TEXT NOT NULL,
|
|
33
|
+
oauth_client TEXT NOT NULL,
|
|
34
|
+
cap_from TEXT NOT NULL,
|
|
35
|
+
redirect_uri TEXT NOT NULL,
|
|
36
|
+
code_challenge TEXT NOT NULL,
|
|
37
|
+
expires_at TEXT NOT NULL
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS oauth_devices (
|
|
40
|
+
device_hash TEXT PRIMARY KEY,
|
|
41
|
+
user_code TEXT NOT NULL UNIQUE,
|
|
42
|
+
oauth_client TEXT NOT NULL,
|
|
43
|
+
cap_from TEXT NOT NULL,
|
|
44
|
+
client_name TEXT NOT NULL,
|
|
45
|
+
verified_host TEXT NOT NULL DEFAULT '',
|
|
46
|
+
status TEXT NOT NULL,
|
|
47
|
+
account TEXT NOT NULL DEFAULT '',
|
|
48
|
+
last_poll_at TEXT NOT NULL DEFAULT '',
|
|
49
|
+
expires_at TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS confirmations (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
caller_id TEXT NOT NULL,
|
|
54
|
+
account TEXT NOT NULL,
|
|
55
|
+
client_id TEXT NOT NULL,
|
|
56
|
+
message_id TEXT NOT NULL,
|
|
57
|
+
summary TEXT NOT NULL,
|
|
58
|
+
details_json TEXT NOT NULL,
|
|
59
|
+
status TEXT NOT NULL,
|
|
60
|
+
result_json TEXT,
|
|
61
|
+
expires_at TEXT NOT NULL,
|
|
62
|
+
created_at TEXT NOT NULL,
|
|
63
|
+
finished_at TEXT
|
|
64
|
+
);
|
|
65
|
+
`
|
|
66
|
+
|
|
67
|
+
// Columns added after the first release. Older databases get them on migrate.
|
|
68
|
+
const GRANT_COLUMNS = [
|
|
69
|
+
"ALTER TABLE grants ADD COLUMN kind TEXT NOT NULL DEFAULT 'access'",
|
|
70
|
+
"ALTER TABLE grants ADD COLUMN oauth_client TEXT NOT NULL DEFAULT ''",
|
|
71
|
+
"ALTER TABLE grants ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''",
|
|
72
|
+
"ALTER TABLE grants ADD COLUMN grant_id TEXT NOT NULL DEFAULT ''",
|
|
73
|
+
"ALTER TABLE grants ADD COLUMN scope TEXT NOT NULL DEFAULT 'read'",
|
|
74
|
+
"ALTER TABLE oauth_codes ADD COLUMN scope TEXT NOT NULL DEFAULT 'read'",
|
|
75
|
+
"ALTER TABLE oauth_devices ADD COLUMN scope TEXT NOT NULL DEFAULT 'read'",
|
|
76
|
+
'ALTER TABLE messages ADD COLUMN confirmation_id TEXT',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
export function tokenHash(token) {
|
|
80
|
+
return createHash('sha256').update(token).digest('hex')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function newMessageId() {
|
|
84
|
+
return `msg_${randomBytes(8).toString('hex')}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isUniqueViolation(err) {
|
|
88
|
+
const text = `${err.code || ''} ${err.message || ''}`
|
|
89
|
+
return text.includes('UNIQUE') || err.code === '23505' || err.errcode === 2067
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mapMessage(row) {
|
|
93
|
+
if (!row) return null
|
|
94
|
+
return {
|
|
95
|
+
id: row.id,
|
|
96
|
+
caller_id: row.caller_id,
|
|
97
|
+
account: row.account || '',
|
|
98
|
+
client_id: row.client_id || '',
|
|
99
|
+
idempotency_key: row.idempotency_key,
|
|
100
|
+
previous_id: row.previous_id || null,
|
|
101
|
+
message: row.message,
|
|
102
|
+
context: JSON.parse(row.context_json || '{}'),
|
|
103
|
+
status: row.status,
|
|
104
|
+
reply: row.reply ?? null,
|
|
105
|
+
error: row.error_json ? JSON.parse(row.error_json) : null,
|
|
106
|
+
created_at: row.created_at,
|
|
107
|
+
finished_at: row.finished_at ?? null,
|
|
108
|
+
confirmation_id: row.confirmation_id || null,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function mapConfirmation(row) {
|
|
113
|
+
if (!row) return null
|
|
114
|
+
return {
|
|
115
|
+
id: row.id,
|
|
116
|
+
caller_id: row.caller_id,
|
|
117
|
+
account: row.account,
|
|
118
|
+
client_id: row.client_id,
|
|
119
|
+
message_id: row.message_id,
|
|
120
|
+
summary: row.summary,
|
|
121
|
+
authorization_details: JSON.parse(row.details_json || '[]'),
|
|
122
|
+
status: row.status,
|
|
123
|
+
result: row.result_json ? JSON.parse(row.result_json) : null,
|
|
124
|
+
expires_at: row.expires_at,
|
|
125
|
+
created_at: row.created_at,
|
|
126
|
+
finished_at: row.finished_at ?? null,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function newConfirmationId() {
|
|
131
|
+
return `cfm_${randomBytes(18).toString('base64url')}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sqliteStore(db) {
|
|
135
|
+
const run = (sql, params = []) => db.prepare(sql).run(...params)
|
|
136
|
+
const get = (sql, params = []) => db.prepare(sql).get(...params)
|
|
137
|
+
return driver({ run, get, close: () => db.close() })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function postgresStore(connectionString) {
|
|
141
|
+
let pg
|
|
142
|
+
try {
|
|
143
|
+
pg = await import('pg')
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error('Postgres needs the optional pg package. Install pg, then set DATABASE_URL.')
|
|
146
|
+
}
|
|
147
|
+
const pool = new pg.default.Pool({ connectionString })
|
|
148
|
+
const run = async (sql, params = []) => { await pool.query(toPg(sql), params) }
|
|
149
|
+
const get = async (sql, params = []) => (await pool.query(toPg(sql), params)).rows[0]
|
|
150
|
+
return driver({ run, get, close: () => pool.end() })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toPg(sql) {
|
|
154
|
+
let n = 0
|
|
155
|
+
return sql.replace(/\?/g, () => `$${++n}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function driver({ run, get, close }) {
|
|
159
|
+
return {
|
|
160
|
+
close,
|
|
161
|
+
async migrate() {
|
|
162
|
+
for (const statement of SCHEMA.split(';')) {
|
|
163
|
+
const sql = statement.trim()
|
|
164
|
+
if (sql) await run(sql)
|
|
165
|
+
}
|
|
166
|
+
// Databases created before follow-ups existed lack this column.
|
|
167
|
+
for (const sql of ['ALTER TABLE messages ADD COLUMN previous_id TEXT', ...GRANT_COLUMNS]) {
|
|
168
|
+
try {
|
|
169
|
+
await run(sql)
|
|
170
|
+
} catch {
|
|
171
|
+
// Already there.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
/**
|
|
176
|
+
* `clientId` is the CAP-From id the token is bound to. `oauthClient` is the
|
|
177
|
+
* client id URL that logged in, empty for tokens set in cap.json. Tokens from
|
|
178
|
+
* one login share a `grantId`, so revoking one revokes the rest. `scope` is
|
|
179
|
+
* `read` or `write`.
|
|
180
|
+
*/
|
|
181
|
+
async upsertGrant({ token, account, clientId, kind = 'access', oauthClient = '', expiresAt = '', grantId = '', scope = 'read' }) {
|
|
182
|
+
await run(
|
|
183
|
+
`INSERT INTO grants (token_hash, account, client_id, kind, oauth_client, expires_at, grant_id, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
184
|
+
ON CONFLICT (token_hash) DO UPDATE SET account = excluded.account, client_id = excluded.client_id,
|
|
185
|
+
kind = excluded.kind, oauth_client = excluded.oauth_client, expires_at = excluded.expires_at,
|
|
186
|
+
grant_id = excluded.grant_id, scope = excluded.scope`,
|
|
187
|
+
[tokenHash(token), account, clientId, kind, oauthClient, expiresAt, grantId, scope],
|
|
188
|
+
)
|
|
189
|
+
},
|
|
190
|
+
async revokeGrant(token) {
|
|
191
|
+
await run('DELETE FROM grants WHERE token_hash = ?', [tokenHash(token)])
|
|
192
|
+
},
|
|
193
|
+
async revokeGrantId(grantId) {
|
|
194
|
+
if (grantId) await run('DELETE FROM grants WHERE grant_id = ?', [grantId])
|
|
195
|
+
},
|
|
196
|
+
async grantByToken(token) {
|
|
197
|
+
if (!token) return null
|
|
198
|
+
const row = await get(
|
|
199
|
+
'SELECT token_hash, account, client_id, kind, oauth_client, expires_at, grant_id, scope FROM grants WHERE token_hash = ?',
|
|
200
|
+
[tokenHash(token)],
|
|
201
|
+
)
|
|
202
|
+
if (!row) return null
|
|
203
|
+
if (row.expires_at && Date.parse(row.expires_at) <= Date.now()) return null
|
|
204
|
+
return row
|
|
205
|
+
},
|
|
206
|
+
async insertCode({ code, account, oauthClient, capFrom, redirectUri, codeChallenge, expiresAt, scope = 'read' }) {
|
|
207
|
+
await run(
|
|
208
|
+
`INSERT INTO oauth_codes (code_hash, account, oauth_client, cap_from, redirect_uri, code_challenge, expires_at, scope)
|
|
209
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
210
|
+
[tokenHash(code), account, oauthClient, capFrom, redirectUri, codeChallenge, expiresAt, scope],
|
|
211
|
+
)
|
|
212
|
+
},
|
|
213
|
+
// A code works once: it is deleted as it is read.
|
|
214
|
+
async takeCode(code) {
|
|
215
|
+
if (!code) return null
|
|
216
|
+
const row = await get('DELETE FROM oauth_codes WHERE code_hash = ? RETURNING *', [tokenHash(code)])
|
|
217
|
+
if (!row || Date.parse(row.expires_at) <= Date.now()) return null
|
|
218
|
+
return row
|
|
219
|
+
},
|
|
220
|
+
// `scope` is what the caller asked for until the person decides, then what they granted.
|
|
221
|
+
async insertDevice({ deviceCode, userCode, oauthClient, capFrom, clientName, verifiedHost, expiresAt, scope = 'read' }) {
|
|
222
|
+
await run(
|
|
223
|
+
`INSERT INTO oauth_devices (device_hash, user_code, oauth_client, cap_from, client_name, verified_host, status, expires_at, scope)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
|
225
|
+
[tokenHash(deviceCode), userCode, oauthClient, capFrom, clientName, verifiedHost, expiresAt, scope],
|
|
226
|
+
)
|
|
227
|
+
},
|
|
228
|
+
async deviceByUserCode(userCode) {
|
|
229
|
+
const row = await get('SELECT * FROM oauth_devices WHERE user_code = ?', [userCode])
|
|
230
|
+
if (!row || Date.parse(row.expires_at) <= Date.now()) return null
|
|
231
|
+
return row
|
|
232
|
+
},
|
|
233
|
+
async deviceByCode(deviceCode) {
|
|
234
|
+
if (!deviceCode) return null
|
|
235
|
+
return (await get('SELECT * FROM oauth_devices WHERE device_hash = ?', [tokenHash(deviceCode)])) || null
|
|
236
|
+
},
|
|
237
|
+
// Only a pending login can be decided, and only once.
|
|
238
|
+
async decideDevice(userCode, { status, account = '', expiresAt = null, scope = null }) {
|
|
239
|
+
const row = await get(
|
|
240
|
+
`UPDATE oauth_devices SET status = ?, account = ?, expires_at = COALESCE(?, expires_at), scope = COALESCE(?, scope)
|
|
241
|
+
WHERE user_code = ? AND status = 'pending' RETURNING *`,
|
|
242
|
+
[status, account, expiresAt, scope, userCode],
|
|
243
|
+
)
|
|
244
|
+
return row || null
|
|
245
|
+
},
|
|
246
|
+
async touchDevice(deviceCode, at) {
|
|
247
|
+
await run('UPDATE oauth_devices SET last_poll_at = ? WHERE device_hash = ?', [at, tokenHash(deviceCode)])
|
|
248
|
+
},
|
|
249
|
+
// An approved login yields a token once: it is deleted as it is read.
|
|
250
|
+
async takeApprovedDevice(deviceCode) {
|
|
251
|
+
return (await get(
|
|
252
|
+
`DELETE FROM oauth_devices WHERE device_hash = ? AND status = 'approved' RETURNING *`,
|
|
253
|
+
[tokenHash(deviceCode)],
|
|
254
|
+
)) || null
|
|
255
|
+
},
|
|
256
|
+
async countSince(callerId, sinceIso) {
|
|
257
|
+
const row = await get(
|
|
258
|
+
'SELECT COUNT(*) AS n FROM messages WHERE caller_id = ? AND created_at >= ?',
|
|
259
|
+
[callerId, sinceIso],
|
|
260
|
+
)
|
|
261
|
+
return Number(row?.n || 0)
|
|
262
|
+
},
|
|
263
|
+
async byIdempotency({ callerId, account, clientId, key }) {
|
|
264
|
+
return mapMessage(await get(
|
|
265
|
+
`SELECT * FROM messages
|
|
266
|
+
WHERE caller_id = ? AND account = ? AND client_id = ? AND idempotency_key = ?`,
|
|
267
|
+
[callerId, account, clientId, key],
|
|
268
|
+
))
|
|
269
|
+
},
|
|
270
|
+
async insert(msg) {
|
|
271
|
+
try {
|
|
272
|
+
await run(
|
|
273
|
+
`INSERT INTO messages (
|
|
274
|
+
id, caller_id, account, client_id, idempotency_key, previous_id,
|
|
275
|
+
message, context_json, status, reply, error_json, created_at, finished_at
|
|
276
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
277
|
+
[
|
|
278
|
+
msg.id,
|
|
279
|
+
msg.caller_id,
|
|
280
|
+
msg.account || '',
|
|
281
|
+
msg.client_id || '',
|
|
282
|
+
msg.idempotency_key || null,
|
|
283
|
+
msg.previous_id || null,
|
|
284
|
+
msg.message,
|
|
285
|
+
JSON.stringify(msg.context || {}),
|
|
286
|
+
msg.status,
|
|
287
|
+
msg.reply ?? null,
|
|
288
|
+
msg.error ? JSON.stringify(msg.error) : null,
|
|
289
|
+
msg.created_at,
|
|
290
|
+
msg.finished_at || null,
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
return { message: msg, created: true }
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (!isUniqueViolation(err) || !msg.idempotency_key) throw err
|
|
296
|
+
const existing = await this.byIdempotency({
|
|
297
|
+
callerId: msg.caller_id,
|
|
298
|
+
account: msg.account || '',
|
|
299
|
+
clientId: msg.client_id || '',
|
|
300
|
+
key: msg.idempotency_key,
|
|
301
|
+
})
|
|
302
|
+
return { message: existing, created: false }
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
async finish(id, { status, reply, error, finishedAt, confirmationId = null }) {
|
|
306
|
+
await run(
|
|
307
|
+
'UPDATE messages SET status = ?, reply = ?, error_json = ?, finished_at = ?, confirmation_id = ? WHERE id = ?',
|
|
308
|
+
[status, reply ?? null, error ? JSON.stringify(error) : null, finishedAt, confirmationId, id],
|
|
309
|
+
)
|
|
310
|
+
},
|
|
311
|
+
async byId(id) {
|
|
312
|
+
return mapMessage(await get('SELECT * FROM messages WHERE id = ?', [id]))
|
|
313
|
+
},
|
|
314
|
+
async insertConfirmation(c) {
|
|
315
|
+
await run(
|
|
316
|
+
`INSERT INTO confirmations (id, caller_id, account, client_id, message_id, summary, details_json, status, expires_at, created_at)
|
|
317
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
|
318
|
+
[c.id, c.caller_id, c.account, c.client_id, c.message_id, c.summary, JSON.stringify(c.authorization_details || []), c.expires_at, c.created_at],
|
|
319
|
+
)
|
|
320
|
+
},
|
|
321
|
+
async confirmationById(id) {
|
|
322
|
+
return mapConfirmation(await get('SELECT * FROM confirmations WHERE id = ?', [id]))
|
|
323
|
+
},
|
|
324
|
+
// Only a pending, unexpired request can be decided, and only once.
|
|
325
|
+
async claimConfirmation(id, status) {
|
|
326
|
+
return mapConfirmation(await get(
|
|
327
|
+
`UPDATE confirmations SET status = ? WHERE id = ? AND status = 'pending' AND expires_at > ? RETURNING *`,
|
|
328
|
+
[status, id, new Date().toISOString()],
|
|
329
|
+
))
|
|
330
|
+
},
|
|
331
|
+
async finishConfirmation(id, { status, result = null }) {
|
|
332
|
+
await run(
|
|
333
|
+
'UPDATE confirmations SET status = ?, result_json = ?, finished_at = ? WHERE id = ?',
|
|
334
|
+
[status, result ? JSON.stringify(result) : null, new Date().toISOString(), id],
|
|
335
|
+
)
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function createStore({ databaseUrl, sqlitePath }) {
|
|
341
|
+
if (databaseUrl && /^postgres(ql)?:\/\//.test(databaseUrl)) {
|
|
342
|
+
const store = await postgresStore(databaseUrl)
|
|
343
|
+
await store.migrate()
|
|
344
|
+
return store
|
|
345
|
+
}
|
|
346
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
347
|
+
const path = sqlitePath || ':memory:'
|
|
348
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
|
|
349
|
+
const db = new DatabaseSync(path)
|
|
350
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
351
|
+
const store = sqliteStore(db)
|
|
352
|
+
await store.migrate()
|
|
353
|
+
return store
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function startOfUtcDay(date = new Date()) {
|
|
357
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())).toISOString()
|
|
358
|
+
}
|
package/src/web.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run the Node request handler for one Fetch API request.
|
|
5
|
+
* The body stays paused until the handler reads it.
|
|
6
|
+
*/
|
|
7
|
+
export function handleWeb(handle, request) {
|
|
8
|
+
const url = new URL(request.url)
|
|
9
|
+
const headers = {}
|
|
10
|
+
request.headers.forEach((value, key) => {
|
|
11
|
+
headers[key] = value
|
|
12
|
+
})
|
|
13
|
+
const req = Readable.from(bodyChunks(request))
|
|
14
|
+
req.method = request.method
|
|
15
|
+
req.url = `${url.pathname}${url.search}`
|
|
16
|
+
req.headers = headers
|
|
17
|
+
|
|
18
|
+
const chunks = []
|
|
19
|
+
let status = 200
|
|
20
|
+
let responseHeaders = {}
|
|
21
|
+
const res = {
|
|
22
|
+
headersSent: false,
|
|
23
|
+
writeHead(code, next) {
|
|
24
|
+
status = code
|
|
25
|
+
responseHeaders = next || {}
|
|
26
|
+
this.headersSent = true
|
|
27
|
+
},
|
|
28
|
+
end(body) {
|
|
29
|
+
if (body != null && body !== '') chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)))
|
|
30
|
+
this.headersSent = true
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return handle(req, res).then(() => {
|
|
35
|
+
const webHeaders = new Headers()
|
|
36
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
37
|
+
if (value != null) webHeaders.set(key, String(value))
|
|
38
|
+
}
|
|
39
|
+
const raw = Buffer.concat(chunks)
|
|
40
|
+
return new Response(status === 204 ? null : raw, { status, headers: webHeaders })
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function* bodyChunks(request) {
|
|
45
|
+
if (!request.body) return
|
|
46
|
+
const reader = request.body.getReader()
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
const { done, value } = await reader.read()
|
|
50
|
+
if (done) return
|
|
51
|
+
yield value
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
reader.releaseLock()
|
|
55
|
+
}
|
|
56
|
+
}
|