quickwin 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +204 -204
- package/README.md +203 -203
- package/examples/PdfCanvas.tsx +83 -83
- package/examples/exec_server.ts +35 -35
- package/examples/pdf_preview2.ts +375 -375
- package/examples/pdf_viewer.tsx +279 -279
- package/examples/test_gallery.tsx +285 -285
- package/examples/test_react_lazy.tsx +63 -63
- package/examples/test_react_listbox.tsx +71 -71
- package/examples/test_root_window.tsx +41 -41
- package/examples/test_tab.tsx +75 -75
- package/examples/tray_demo.tsx +81 -81
- package/lib/assert.ts +6 -6
- package/lib/cache-utils.ts +40 -40
- package/lib/dumpRects.ts +25 -25
- package/lib/fetch.ts +502 -502
- package/lib/http-common.ts +399 -399
- package/lib/http-server.ts +424 -424
- package/lib/polyfill.ts +171 -171
- package/lib/react-qw/components/index.ts +17 -17
- package/lib/react-qw/index.ts +90 -90
- package/lib/react-qw/jsx.d.ts +34 -34
- package/lib/react-qw/layout.ts +111 -111
- package/lib/react-qw/props.ts +36 -36
- package/lib/react-qw/reconciler.ts +373 -373
- package/lib/stream.ts +21 -21
- package/lib/text-codec.ts +171 -171
- package/lib/text-measure.ts +54 -54
- package/lib/url.ts +201 -201
- package/lib/vendor/web-streams/ponyfill.d.mts +829 -829
- package/lib/vendor/web-streams/ponyfill.mjs +8 -8
- package/lib/websocket.js +8 -0
- package/lib/websocket.ts +563 -555
- package/package.json +50 -44
- package/qwin-nowasm-x86.exe +0 -0
- package/qwin-nowasm.exe +0 -0
- package/qwin-x86.exe +0 -0
- package/qwin.exe +0 -0
- package/test/run.ts +96 -96
- package/test/test_basic.ts +9 -9
- package/test/test_basic_fetch.ts +33 -33
- package/test/test_brotli.ts +52 -52
- package/test/test_fetch_cache.ts +187 -187
- package/test/test_ffi.ts +174 -174
- package/test/test_frame_encoding.ts +132 -132
- package/test/test_helper.ts +80 -80
- package/test/test_http_import.ts +74 -74
- package/test/test_http_server.ts +136 -136
- package/test/test_import_dir.ts +5 -5
- package/test/test_ipv6.ts +129 -129
- package/test/test_meta_url.ts +4 -4
- package/test/test_mupdf_render.ts +74 -74
- package/test/test_mupdf_twice.ts +81 -81
- package/test/test_mupdf_wasm.ts +30 -30
- package/test/test_net_event.ts +55 -55
- package/test/test_net_fetch.ts +238 -238
- package/test/test_net_websocket.ts +164 -164
- package/test/test_polyfill.ts +159 -159
- package/test/test_url.ts +183 -183
- package/test/test_wasm_basic.ts +73 -73
- package/test/test_wasm_import_global.ts +45 -45
- package/test/test_wasm_sjlj.ts +125 -125
- package/test/test_wasm_types.ts +124 -124
- package/test/test_worker.ts +24 -24
- package/test/test_worker_fetch.ts +27 -27
- package/test/test_worker_net.ts +54 -54
- package/test/test_worker_wasm_concurrent.ts +45 -45
- package/test/worker_fetch.ts +13 -13
- package/test/worker_helper.ts +1 -1
- package/test/worker_http.ts +20 -20
- package/test/worker_import_lib.ts +13 -13
- package/test/worker_module.ts +17 -17
- package/test/worker_wasm_concurrent.ts +22 -22
- package/tsconfig.json +27 -27
- package/vendor/mupdf-wasm/mupdf-wasm.d.ts +571 -571
- package/vendor/mupdf-wasm/mupdf-wasm.js +2749 -2749
- package/vendor/mupdf-wasm/mupdf.d.ts +939 -939
- package/vendor/mupdf-wasm/mupdf.js +3317 -3317
package/lib/fetch.ts
CHANGED
|
@@ -1,502 +1,502 @@
|
|
|
1
|
-
import * as sock from 'sock'
|
|
2
|
-
import * as wolfssl from 'wolfssl'
|
|
3
|
-
import * as brotli from 'brotli'
|
|
4
|
-
import { parseIni, toIni } from '../lib/cache-utils.js'
|
|
5
|
-
import {
|
|
6
|
-
_concat,
|
|
7
|
-
_toReadableStream,
|
|
8
|
-
_readStream,
|
|
9
|
-
_hasChunkedEnd,
|
|
10
|
-
decodeChunked,
|
|
11
|
-
HeadersImpl,
|
|
12
|
-
RequestImpl,
|
|
13
|
-
ResponseImpl,
|
|
14
|
-
} from './http-common.js'
|
|
15
|
-
|
|
16
|
-
// ── HTTP Response Parser ──
|
|
17
|
-
|
|
18
|
-
interface ParsedResponse {
|
|
19
|
-
status: number
|
|
20
|
-
statusText: string
|
|
21
|
-
headers: HeadersImpl
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function parseHeaders(data: string): ParsedResponse | null {
|
|
25
|
-
const headerEnd = data.indexOf('\r\n\r\n')
|
|
26
|
-
if (headerEnd < 0) return null
|
|
27
|
-
|
|
28
|
-
const headerPart = data.slice(0, headerEnd)
|
|
29
|
-
const lines = headerPart.split('\r\n')
|
|
30
|
-
const statusLine = lines[0]!
|
|
31
|
-
const match = statusLine.match(/^HTTP\/\d\.\d\s+(\d+)\s+(.*)$/)
|
|
32
|
-
if (!match) throw new Error('Invalid HTTP response: ' + statusLine)
|
|
33
|
-
|
|
34
|
-
const status = parseInt(match[1]!, 10)
|
|
35
|
-
const statusText = match[2]!
|
|
36
|
-
|
|
37
|
-
const headers = new HeadersImpl()
|
|
38
|
-
for (let i = 1; i < lines.length; i++) {
|
|
39
|
-
const colonIndex = lines[i]!.indexOf(':')
|
|
40
|
-
if (colonIndex > 0) {
|
|
41
|
-
const name = lines[i]!.slice(0, colonIndex).trim()
|
|
42
|
-
const value = lines[i]!.slice(colonIndex + 1).trim()
|
|
43
|
-
headers.append(name, value)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return { status, statusText, headers }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ── State machine constants ──
|
|
51
|
-
|
|
52
|
-
const ST_CONNECTING = 0
|
|
53
|
-
const ST_HANDSHAKE = 1
|
|
54
|
-
const ST_RECV_HEADERS = 3
|
|
55
|
-
const ST_RECV_BODY = 4
|
|
56
|
-
const ST_DONE = 5
|
|
57
|
-
|
|
58
|
-
// ── Main fetch request ──
|
|
59
|
-
|
|
60
|
-
async function fetchRequest(req: RequestImpl): Promise<ResponseImpl> {
|
|
61
|
-
const parsedUrl = new URL(req.url)
|
|
62
|
-
const method = req.method
|
|
63
|
-
const headers = new HeadersImpl(req.headers)
|
|
64
|
-
const timeout = req.timeout || 30000
|
|
65
|
-
const isHTTPS = parsedUrl.protocol === 'https:'
|
|
66
|
-
|
|
67
|
-
const defaultPort = isHTTPS ? 443 : 80
|
|
68
|
-
const hostname = parsedUrl.hostname.includes(':') ? `[${parsedUrl.hostname}]` : parsedUrl.hostname
|
|
69
|
-
const hostHeader = parsedUrl.port && parsedUrl.port !== String(defaultPort)
|
|
70
|
-
? hostname + ':' + parsedUrl.port
|
|
71
|
-
: hostname
|
|
72
|
-
if (!headers.has('host')) headers.set('Host', hostHeader)
|
|
73
|
-
if (!headers.has('user-agent')) headers.set('User-Agent', navigator.userAgent)
|
|
74
|
-
if (!headers.has('connection')) headers.set('Connection', 'close')
|
|
75
|
-
if (!headers.has('accept-encoding')) headers.set('Accept-Encoding', 'br')
|
|
76
|
-
let bodyBytes: Uint8Array | null = null
|
|
77
|
-
if (req.body) {
|
|
78
|
-
bodyBytes = await _readStream(req.body)
|
|
79
|
-
}
|
|
80
|
-
if (bodyBytes && !headers.has('content-length')) headers.set('Content-Length', String(bodyBytes.length))
|
|
81
|
-
|
|
82
|
-
return new Promise((resolve, reject) => {
|
|
83
|
-
let request = method + ' ' + parsedUrl.pathname + (parsedUrl.search || '') + ' HTTP/1.1\r\n'
|
|
84
|
-
headers.forEach((value: string, name: string) => {
|
|
85
|
-
request += name + ': ' + value + '\r\n'
|
|
86
|
-
})
|
|
87
|
-
request += '\r\n'
|
|
88
|
-
|
|
89
|
-
const requestBytes = new TextEncoder().encode(request)
|
|
90
|
-
const httpRequest = _concat(
|
|
91
|
-
bodyBytes ? [requestBytes, bodyBytes] : [requestBytes]
|
|
92
|
-
).buffer
|
|
93
|
-
|
|
94
|
-
let s: sock.SockHandle | null = null
|
|
95
|
-
let ssl: wolfssl.WOLFSSL | null = null
|
|
96
|
-
let ctx: wolfssl.WOLFSSL_CTX | null = null
|
|
97
|
-
let state = ST_CONNECTING
|
|
98
|
-
let resolved = false
|
|
99
|
-
let timerId: TimerId | undefined
|
|
100
|
-
let stream: ReadableStream<Uint8Array> | null = null
|
|
101
|
-
let _controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
102
|
-
let headerRaw: Uint8Array = new Uint8Array(0)
|
|
103
|
-
let isChunked = false
|
|
104
|
-
let contentLength = 0
|
|
105
|
-
let chunkedParts: Uint8Array[] = []
|
|
106
|
-
let receivedBytes = 0
|
|
107
|
-
|
|
108
|
-
const cleanupSocket = (): void => {
|
|
109
|
-
state = ST_DONE
|
|
110
|
-
if (ssl) { wolfssl.wolfSSL_free(ssl); ssl = null }
|
|
111
|
-
if (ctx) { wolfssl.wolfSSL_CTX_free(ctx); ctx = null }
|
|
112
|
-
if (s !== null && s >= 0) { sock.closesocket(s); s = null }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const cleanup = (): void => {
|
|
116
|
-
if (timerId) { clearTimeout(timerId); timerId = undefined }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const doResolve = (response: ResponseImpl): void => {
|
|
120
|
-
if (!resolved) { resolved = true; cleanup(); resolve(response) }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const doReject = (error: Error): void => {
|
|
124
|
-
if (!resolved) { resolved = true; cleanup(); cleanupSocket(); reject(error) }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
timerId = setTimeout(() => {
|
|
128
|
-
doReject(new Error('Request timeout'))
|
|
129
|
-
}, timeout)
|
|
130
|
-
|
|
131
|
-
s = sock.socket()
|
|
132
|
-
if (s < 0) { doReject(new Error('Failed to create socket')); return }
|
|
133
|
-
const fd = s
|
|
134
|
-
|
|
135
|
-
sock.set_on_event(fd, (event: { lNetworkEvents: number; iErrorCode: number[] }) => {
|
|
136
|
-
if (state === ST_DONE) return
|
|
137
|
-
|
|
138
|
-
if (event.lNetworkEvents & sock.FdEvent.FD_CONNECT) {
|
|
139
|
-
const err = event.iErrorCode[0]
|
|
140
|
-
if (err !== 0) { doReject(new Error('Connection failed: ' + err)); return }
|
|
141
|
-
|
|
142
|
-
if (isHTTPS) {
|
|
143
|
-
const tlsMethod = wolfssl.wolfTLSv1_2_client_method()
|
|
144
|
-
ctx = wolfssl.wolfSSL_CTX_new(tlsMethod)
|
|
145
|
-
if (!ctx) { doReject(new Error('SSL_CTX_new failed')); return }
|
|
146
|
-
wolfssl.wolfSSL_CTX_set_verify(ctx, wolfssl.VerifyMode.SSL_VERIFY_NONE)
|
|
147
|
-
ssl = wolfssl.wolfSSL_new(ctx)
|
|
148
|
-
if (!ssl) { doReject(new Error('SSL_new failed')); return }
|
|
149
|
-
wolfssl.wolfSSL_set_fd(ssl, sock.get_fd(fd))
|
|
150
|
-
if (parsedUrl.hostname) wolfssl.wolfSSL_UseSNI(ssl, wolfssl.SniType.WOLFSSL_SNI_HOST_NAME, parsedUrl.hostname)
|
|
151
|
-
state = ST_HANDSHAKE
|
|
152
|
-
} else {
|
|
153
|
-
sock.send(fd, httpRequest)
|
|
154
|
-
state = ST_RECV_HEADERS
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if ((event.lNetworkEvents & sock.FdEvent.FD_READ) || (event.lNetworkEvents & sock.FdEvent.FD_WRITE)) {
|
|
159
|
-
if (state === ST_HANDSHAKE) {
|
|
160
|
-
if (!ssl) { doReject(new Error('TLS not initialized')); return }
|
|
161
|
-
const ret = wolfssl.wolfSSL_connect(ssl)
|
|
162
|
-
if (ret === wolfssl.ReturnCode.SSL_SUCCESS) {
|
|
163
|
-
wolfssl.wolfSSL_write(ssl, httpRequest)
|
|
164
|
-
state = ST_RECV_HEADERS
|
|
165
|
-
} else {
|
|
166
|
-
const err = wolfssl.wolfSSL_get_error(ssl, ret)
|
|
167
|
-
if (err !== wolfssl.ErrorCode.WOLFSSL_ERROR_WANT_READ &&
|
|
168
|
-
err !== wolfssl.ErrorCode.WOLFSSL_ERROR_WANT_WRITE) {
|
|
169
|
-
doReject(new Error('TLS handshake failed: ' + err))
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
else if (state === ST_RECV_HEADERS) {
|
|
174
|
-
while (true) {
|
|
175
|
-
if (s !== null && s < 0 && !ssl) break
|
|
176
|
-
let data: ArrayBuffer | null
|
|
177
|
-
if (isHTTPS && ssl) {
|
|
178
|
-
data = wolfssl.wolfSSL_read(ssl, 8192)
|
|
179
|
-
} else if (s !== null && s >= 0) {
|
|
180
|
-
data = sock.recv(s, 8192)
|
|
181
|
-
} else { break }
|
|
182
|
-
if (!data || data.byteLength === 0) break
|
|
183
|
-
const incoming = new Uint8Array(data)
|
|
184
|
-
headerRaw = _concat([headerRaw, incoming])
|
|
185
|
-
|
|
186
|
-
let headerEnd = -1
|
|
187
|
-
for (let i = 0; i < headerRaw.length - 3; i++) {
|
|
188
|
-
if (headerRaw[i] === 0x0D && headerRaw[i+1] === 0x0A && headerRaw[i+2] === 0x0D && headerRaw[i+3] === 0x0A) {
|
|
189
|
-
headerEnd = i; break
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (headerEnd >= 0) {
|
|
193
|
-
const headerStr = new TextDecoder('utf-8').decode(headerRaw.subarray(0, headerEnd + 4))
|
|
194
|
-
const parsed = parseHeaders(headerStr)
|
|
195
|
-
if (!parsed) { doReject(new Error('Failed to parse HTTP headers')); return }
|
|
196
|
-
|
|
197
|
-
const trailingBodyBytes = headerRaw.subarray(headerEnd + 4)
|
|
198
|
-
|
|
199
|
-
isChunked = (parsed.headers.get('transfer-encoding') || '').toLowerCase().includes('chunked')
|
|
200
|
-
contentLength = isChunked ? 0 : parseInt(
|
|
201
|
-
parsed.headers.get('content-length') || '0', 10
|
|
202
|
-
)
|
|
203
|
-
_controller = null
|
|
204
|
-
stream = new ReadableStream<Uint8Array>({
|
|
205
|
-
start(ctrl: ReadableStreamDefaultController<Uint8Array>) { _controller = ctrl },
|
|
206
|
-
cancel() { cleanup(); cleanupSocket() }
|
|
207
|
-
})
|
|
208
|
-
if (trailingBodyBytes.length > 0) {
|
|
209
|
-
if (isChunked) {
|
|
210
|
-
chunkedParts = [trailingBodyBytes]
|
|
211
|
-
if (_hasChunkedEnd(trailingBodyBytes)) {
|
|
212
|
-
const decoded = decodeChunked(trailingBodyBytes)
|
|
213
|
-
_controller!.enqueue(decoded)
|
|
214
|
-
_controller!.close()
|
|
215
|
-
state = ST_DONE
|
|
216
|
-
cleanupSocket()
|
|
217
|
-
}
|
|
218
|
-
} else {
|
|
219
|
-
receivedBytes += trailingBodyBytes.length
|
|
220
|
-
_controller!.enqueue(trailingBodyBytes)
|
|
221
|
-
if (contentLength > 0 && receivedBytes >= contentLength) {
|
|
222
|
-
_controller!.close()
|
|
223
|
-
state = ST_DONE
|
|
224
|
-
cleanupSocket()
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const response = new ResponseImpl(
|
|
230
|
-
stream, { status: parsed.status, statusText: parsed.statusText, headers: parsed.headers }
|
|
231
|
-
)
|
|
232
|
-
if (state !== ST_DONE) {
|
|
233
|
-
state = ST_RECV_BODY
|
|
234
|
-
}
|
|
235
|
-
doResolve(response)
|
|
236
|
-
// Break out of header recv loop — any remaining data
|
|
237
|
-
// in the socket will be handled by the ST_RECV_BODY path below
|
|
238
|
-
break
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
else if (state === ST_RECV_BODY && stream) {
|
|
243
|
-
while (true) {
|
|
244
|
-
if (s !== null && s < 0 && !ssl) break
|
|
245
|
-
let data: ArrayBuffer | null
|
|
246
|
-
if (isHTTPS && ssl) {
|
|
247
|
-
data = wolfssl.wolfSSL_read(ssl, 8192)
|
|
248
|
-
} else if (s !== null && s >= 0) {
|
|
249
|
-
data = sock.recv(s, 8192)
|
|
250
|
-
} else { break }
|
|
251
|
-
if (!data || data.byteLength === 0) break
|
|
252
|
-
if (isChunked) {
|
|
253
|
-
chunkedParts.push(new Uint8Array(data))
|
|
254
|
-
const combined = _concat(chunkedParts)
|
|
255
|
-
if (_hasChunkedEnd(combined)) {
|
|
256
|
-
const decoded = decodeChunked(combined)
|
|
257
|
-
_controller!.enqueue(decoded)
|
|
258
|
-
_controller!.close()
|
|
259
|
-
stream = null
|
|
260
|
-
state = ST_DONE
|
|
261
|
-
cleanupSocket()
|
|
262
|
-
break
|
|
263
|
-
}
|
|
264
|
-
} else {
|
|
265
|
-
receivedBytes += data.byteLength
|
|
266
|
-
_controller!.enqueue(new Uint8Array(data))
|
|
267
|
-
if (contentLength > 0 && receivedBytes >= contentLength) {
|
|
268
|
-
_controller!.close()
|
|
269
|
-
state = ST_DONE
|
|
270
|
-
cleanupSocket()
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (event.lNetworkEvents & sock.FdEvent.FD_CLOSE) {
|
|
278
|
-
if (state === ST_DONE) return
|
|
279
|
-
if (state === ST_RECV_HEADERS) {
|
|
280
|
-
doReject(new Error('Connection closed before response'))
|
|
281
|
-
} else if (state === ST_RECV_BODY && stream) {
|
|
282
|
-
let remainingParts: Uint8Array[] = []
|
|
283
|
-
while (true) {
|
|
284
|
-
if (s !== null && s < 0 && !ssl) break
|
|
285
|
-
let data: ArrayBuffer | null
|
|
286
|
-
if (isHTTPS && ssl) {
|
|
287
|
-
data = wolfssl.wolfSSL_read(ssl, 8192)
|
|
288
|
-
} else if (s !== null && s >= 0) {
|
|
289
|
-
data = sock.recv(s, 8192)
|
|
290
|
-
} else { break }
|
|
291
|
-
if (!data || data.byteLength === 0) break
|
|
292
|
-
remainingParts.push(new Uint8Array(data))
|
|
293
|
-
}
|
|
294
|
-
if (isChunked) {
|
|
295
|
-
chunkedParts.push(...remainingParts)
|
|
296
|
-
const decoded = decodeChunked(_concat(chunkedParts))
|
|
297
|
-
_controller!.enqueue(decoded)
|
|
298
|
-
} else if (remainingParts.length > 0) {
|
|
299
|
-
_controller!.enqueue(_concat(remainingParts))
|
|
300
|
-
}
|
|
301
|
-
_controller!.close()
|
|
302
|
-
stream = null
|
|
303
|
-
state = ST_DONE
|
|
304
|
-
cleanupSocket()
|
|
305
|
-
} else {
|
|
306
|
-
// ST_CONNECTING or ST_HANDSHAKE — socket died early,
|
|
307
|
-
// reject so the Promise doesn't hang forever
|
|
308
|
-
doReject(new Error('Connection closed'))
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
const ip = sock.resolve(parsedUrl.hostname)
|
|
314
|
-
if (!ip) {
|
|
315
|
-
doReject(new Error('DNS resolution failed for: ' + parsedUrl.hostname))
|
|
316
|
-
return
|
|
317
|
-
}
|
|
318
|
-
sock.connect(s, ip, parseInt(parsedUrl.port, 10) || (isHTTPS ? 443 : 80))
|
|
319
|
-
})
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ── Public fetch (with redirect handling and caching) ──
|
|
323
|
-
|
|
324
|
-
function parseMaxAge(cc: string): number {
|
|
325
|
-
const m = cc.match(/max-age=(\d+)/)
|
|
326
|
-
return m ? parseInt(m[1]!, 10) : 0
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
interface CacheMeta {
|
|
330
|
-
storedAt: number;
|
|
331
|
-
maxAge: number;
|
|
332
|
-
status: number;
|
|
333
|
-
statusText: string;
|
|
334
|
-
headers: Record<string, string>;
|
|
335
|
-
etag?: string;
|
|
336
|
-
lastModified?: string;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function doFetch(url: string | Request, init: RequestInit, redirectCount: number): Promise<ResponseImpl> {
|
|
340
|
-
let req = new RequestImpl(url, init)
|
|
341
|
-
const redirectMode = req.redirect || 'follow'
|
|
342
|
-
const maxRedirects = redirectMode === 'follow' ? (req.maxRedirects || 5) : 0
|
|
343
|
-
const cache = typeof __httpCache__ !== 'undefined' ? __httpCache__ : null
|
|
344
|
-
const method = req.method
|
|
345
|
-
const currentUrl = req.url
|
|
346
|
-
|
|
347
|
-
// ── Cache lookup (GET only) ──
|
|
348
|
-
let cachedMeta: CacheMeta | null = null
|
|
349
|
-
|
|
350
|
-
if (cache && method === 'GET') {
|
|
351
|
-
const metaStr = cache.readMeta(currentUrl)
|
|
352
|
-
if (metaStr) {
|
|
353
|
-
const ini = parseIni(metaStr)
|
|
354
|
-
cachedMeta = {
|
|
355
|
-
storedAt: parseInt(ini.meta.storedAt || '0', 10),
|
|
356
|
-
maxAge: parseInt(ini.meta.maxAge || '0', 10),
|
|
357
|
-
status: parseInt(ini.meta.status || '200', 10),
|
|
358
|
-
statusText: ini.meta.statusText || 'OK',
|
|
359
|
-
headers: ini.headers,
|
|
360
|
-
etag: ini.headers['etag'] || undefined,
|
|
361
|
-
lastModified: ini.headers['last-modified'] || undefined,
|
|
362
|
-
}
|
|
363
|
-
const age = Math.floor(Date.now() / 1000) - cachedMeta.storedAt
|
|
364
|
-
if (cachedMeta.maxAge > 0 && age < cachedMeta.maxAge) {
|
|
365
|
-
const body = cache.readBody(currentUrl)
|
|
366
|
-
if (body) {
|
|
367
|
-
ini.meta.lastAccess = String(Math.floor(Date.now() / 1000))
|
|
368
|
-
cache.writeMeta(currentUrl, toIni(ini.headers, ini.meta))
|
|
369
|
-
const resp = new ResponseImpl(
|
|
370
|
-
_toReadableStream(new Uint8Array(body)), { status: cachedMeta.status, statusText: cachedMeta.statusText, headers: new HeadersImpl(cachedMeta.headers || {}) }
|
|
371
|
-
)
|
|
372
|
-
resp._url = currentUrl
|
|
373
|
-
return resp
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
if (cachedMeta.etag) req.headers.set('If-None-Match', cachedMeta.etag)
|
|
377
|
-
if (cachedMeta.lastModified) req.headers.set('If-Modified-Since', cachedMeta.lastModified)
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const response = await fetchRequest(req)
|
|
382
|
-
|
|
383
|
-
response._url = currentUrl
|
|
384
|
-
|
|
385
|
-
// ── Handle brotli Content-Encoding ──
|
|
386
|
-
const contentEncoding = response.headers.get('content-encoding') || ''
|
|
387
|
-
if (contentEncoding.includes('br')) {
|
|
388
|
-
const compressedBody = await response.arrayBuffer()
|
|
389
|
-
const decompressedBody = brotli.decompress(compressedBody)
|
|
390
|
-
const newHeaders = new HeadersImpl()
|
|
391
|
-
response.headers.forEach((v: string, k: string) => {
|
|
392
|
-
if (k !== 'content-encoding') newHeaders.set(k, v)
|
|
393
|
-
})
|
|
394
|
-
newHeaders.set('content-length', String(decompressedBody.byteLength))
|
|
395
|
-
const stream = _toReadableStream(new Uint8Array(decompressedBody))
|
|
396
|
-
response._applyDecompressedBody(stream, decompressedBody, newHeaders)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ── Handle 304 Not Modified ──
|
|
400
|
-
if (response.status === 304 && cachedMeta && cache) {
|
|
401
|
-
const body = cache.readBody(currentUrl)
|
|
402
|
-
if (body) {
|
|
403
|
-
cachedMeta.storedAt = Math.floor(Date.now() / 1000)
|
|
404
|
-
response.headers.forEach((value: string, name: string) => {
|
|
405
|
-
cachedMeta.headers[name] = value
|
|
406
|
-
})
|
|
407
|
-
const now = String(Math.floor(Date.now() / 1000))
|
|
408
|
-
const iniMeta: Record<string, string> = {
|
|
409
|
-
url: currentUrl,
|
|
410
|
-
storedAt: String(cachedMeta.storedAt),
|
|
411
|
-
maxAge: String(cachedMeta.maxAge),
|
|
412
|
-
lastAccess: now,
|
|
413
|
-
status: String(cachedMeta.status),
|
|
414
|
-
statusText: cachedMeta.statusText,
|
|
415
|
-
}
|
|
416
|
-
cache.writeMeta(currentUrl, toIni(cachedMeta.headers, iniMeta))
|
|
417
|
-
const resp = new ResponseImpl(
|
|
418
|
-
_toReadableStream(new Uint8Array(body)), { status: cachedMeta.status, statusText: cachedMeta.statusText, headers: new HeadersImpl(cachedMeta.headers) }
|
|
419
|
-
)
|
|
420
|
-
resp._url = currentUrl
|
|
421
|
-
return resp
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// ── Cache 200 GET responses ──
|
|
426
|
-
if (cache && method === 'GET' && response.status === 200) {
|
|
427
|
-
const body = await response.arrayBuffer()
|
|
428
|
-
const cc = response.headers.get('cache-control') || ''
|
|
429
|
-
const maxAge = parseMaxAge(cc)
|
|
430
|
-
if (maxAge > 0) {
|
|
431
|
-
const resHeaders = Object.fromEntries(response.headers)
|
|
432
|
-
const now = String(Math.floor(Date.now() / 1000))
|
|
433
|
-
const iniMeta: Record<string, string> = {
|
|
434
|
-
url: currentUrl,
|
|
435
|
-
storedAt: now,
|
|
436
|
-
maxAge: String(maxAge),
|
|
437
|
-
lastAccess: now,
|
|
438
|
-
status: String(response.status),
|
|
439
|
-
statusText: response.statusText,
|
|
440
|
-
}
|
|
441
|
-
cache.writeBodyOnly(currentUrl, body)
|
|
442
|
-
cache.writeMeta(currentUrl, toIni(resHeaders, iniMeta))
|
|
443
|
-
}
|
|
444
|
-
const resp = new ResponseImpl(
|
|
445
|
-
_toReadableStream(new Uint8Array(body)), { status: response.status, statusText: response.statusText, headers: response.headers }
|
|
446
|
-
)
|
|
447
|
-
resp._url = currentUrl
|
|
448
|
-
return resp
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ── Redirect handling ──
|
|
452
|
-
const isRedirect = response.status === 301 || response.status === 302 ||
|
|
453
|
-
response.status === 303 || response.status === 307 || response.status === 308
|
|
454
|
-
|
|
455
|
-
if (isRedirect) {
|
|
456
|
-
if (redirectMode === 'error') {
|
|
457
|
-
response.body.cancel('redirect')
|
|
458
|
-
throw new TypeError('Redirect not allowed for: ' + currentUrl)
|
|
459
|
-
}
|
|
460
|
-
if (redirectMode === 'manual') {
|
|
461
|
-
response._redirected = true
|
|
462
|
-
return response
|
|
463
|
-
}
|
|
464
|
-
if (redirectCount >= maxRedirects) {
|
|
465
|
-
response.body.cancel('redirect')
|
|
466
|
-
throw new TypeError('Redirect count exceeded')
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const location = response.headers.get('location')
|
|
470
|
-
if (!location) throw new Error('Redirect response missing Location header')
|
|
471
|
-
|
|
472
|
-
response.body.cancel('redirect')
|
|
473
|
-
const newUrl = new URL(location, currentUrl).href
|
|
474
|
-
const isGet = response.status === 301 || response.status === 302 || response.status === 303
|
|
475
|
-
const newInit: RequestInit = {
|
|
476
|
-
...init,
|
|
477
|
-
method: isGet ? 'GET' : req.method,
|
|
478
|
-
headers: req.headers,
|
|
479
|
-
body: isGet ? undefined : (req.body ?? undefined),
|
|
480
|
-
}
|
|
481
|
-
const result = await doFetch(newUrl, newInit, redirectCount + 1)
|
|
482
|
-
result._redirected = true
|
|
483
|
-
return result
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (redirectCount > 0) response._redirected = true
|
|
487
|
-
return response
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
async function fetch(url: string | Request, init: RequestInit = {}): Promise<ResponseImpl> {
|
|
491
|
-
return doFetch(url, init, 0)
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// ── Global declarations ──
|
|
495
|
-
|
|
496
|
-
declare global {
|
|
497
|
-
var fetch: (url: string | Request, init?: RequestInit) => Promise<Response>;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// ── Register globals ──
|
|
501
|
-
|
|
502
|
-
globalThis.fetch = fetch
|
|
1
|
+
import * as sock from 'sock'
|
|
2
|
+
import * as wolfssl from 'wolfssl'
|
|
3
|
+
import * as brotli from 'brotli'
|
|
4
|
+
import { parseIni, toIni } from '../lib/cache-utils.js'
|
|
5
|
+
import {
|
|
6
|
+
_concat,
|
|
7
|
+
_toReadableStream,
|
|
8
|
+
_readStream,
|
|
9
|
+
_hasChunkedEnd,
|
|
10
|
+
decodeChunked,
|
|
11
|
+
HeadersImpl,
|
|
12
|
+
RequestImpl,
|
|
13
|
+
ResponseImpl,
|
|
14
|
+
} from './http-common.js'
|
|
15
|
+
|
|
16
|
+
// ── HTTP Response Parser ──
|
|
17
|
+
|
|
18
|
+
interface ParsedResponse {
|
|
19
|
+
status: number
|
|
20
|
+
statusText: string
|
|
21
|
+
headers: HeadersImpl
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseHeaders(data: string): ParsedResponse | null {
|
|
25
|
+
const headerEnd = data.indexOf('\r\n\r\n')
|
|
26
|
+
if (headerEnd < 0) return null
|
|
27
|
+
|
|
28
|
+
const headerPart = data.slice(0, headerEnd)
|
|
29
|
+
const lines = headerPart.split('\r\n')
|
|
30
|
+
const statusLine = lines[0]!
|
|
31
|
+
const match = statusLine.match(/^HTTP\/\d\.\d\s+(\d+)\s+(.*)$/)
|
|
32
|
+
if (!match) throw new Error('Invalid HTTP response: ' + statusLine)
|
|
33
|
+
|
|
34
|
+
const status = parseInt(match[1]!, 10)
|
|
35
|
+
const statusText = match[2]!
|
|
36
|
+
|
|
37
|
+
const headers = new HeadersImpl()
|
|
38
|
+
for (let i = 1; i < lines.length; i++) {
|
|
39
|
+
const colonIndex = lines[i]!.indexOf(':')
|
|
40
|
+
if (colonIndex > 0) {
|
|
41
|
+
const name = lines[i]!.slice(0, colonIndex).trim()
|
|
42
|
+
const value = lines[i]!.slice(colonIndex + 1).trim()
|
|
43
|
+
headers.append(name, value)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { status, statusText, headers }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── State machine constants ──
|
|
51
|
+
|
|
52
|
+
const ST_CONNECTING = 0
|
|
53
|
+
const ST_HANDSHAKE = 1
|
|
54
|
+
const ST_RECV_HEADERS = 3
|
|
55
|
+
const ST_RECV_BODY = 4
|
|
56
|
+
const ST_DONE = 5
|
|
57
|
+
|
|
58
|
+
// ── Main fetch request ──
|
|
59
|
+
|
|
60
|
+
async function fetchRequest(req: RequestImpl): Promise<ResponseImpl> {
|
|
61
|
+
const parsedUrl = new URL(req.url)
|
|
62
|
+
const method = req.method
|
|
63
|
+
const headers = new HeadersImpl(req.headers)
|
|
64
|
+
const timeout = req.timeout || 30000
|
|
65
|
+
const isHTTPS = parsedUrl.protocol === 'https:'
|
|
66
|
+
|
|
67
|
+
const defaultPort = isHTTPS ? 443 : 80
|
|
68
|
+
const hostname = parsedUrl.hostname.includes(':') ? `[${parsedUrl.hostname}]` : parsedUrl.hostname
|
|
69
|
+
const hostHeader = parsedUrl.port && parsedUrl.port !== String(defaultPort)
|
|
70
|
+
? hostname + ':' + parsedUrl.port
|
|
71
|
+
: hostname
|
|
72
|
+
if (!headers.has('host')) headers.set('Host', hostHeader)
|
|
73
|
+
if (!headers.has('user-agent')) headers.set('User-Agent', navigator.userAgent)
|
|
74
|
+
if (!headers.has('connection')) headers.set('Connection', 'close')
|
|
75
|
+
if (!headers.has('accept-encoding')) headers.set('Accept-Encoding', 'br')
|
|
76
|
+
let bodyBytes: Uint8Array | null = null
|
|
77
|
+
if (req.body) {
|
|
78
|
+
bodyBytes = await _readStream(req.body)
|
|
79
|
+
}
|
|
80
|
+
if (bodyBytes && !headers.has('content-length')) headers.set('Content-Length', String(bodyBytes.length))
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
let request = method + ' ' + parsedUrl.pathname + (parsedUrl.search || '') + ' HTTP/1.1\r\n'
|
|
84
|
+
headers.forEach((value: string, name: string) => {
|
|
85
|
+
request += name + ': ' + value + '\r\n'
|
|
86
|
+
})
|
|
87
|
+
request += '\r\n'
|
|
88
|
+
|
|
89
|
+
const requestBytes = new TextEncoder().encode(request)
|
|
90
|
+
const httpRequest = _concat(
|
|
91
|
+
bodyBytes ? [requestBytes, bodyBytes] : [requestBytes]
|
|
92
|
+
).buffer
|
|
93
|
+
|
|
94
|
+
let s: sock.SockHandle | null = null
|
|
95
|
+
let ssl: wolfssl.WOLFSSL | null = null
|
|
96
|
+
let ctx: wolfssl.WOLFSSL_CTX | null = null
|
|
97
|
+
let state = ST_CONNECTING
|
|
98
|
+
let resolved = false
|
|
99
|
+
let timerId: TimerId | undefined
|
|
100
|
+
let stream: ReadableStream<Uint8Array> | null = null
|
|
101
|
+
let _controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
102
|
+
let headerRaw: Uint8Array = new Uint8Array(0)
|
|
103
|
+
let isChunked = false
|
|
104
|
+
let contentLength = 0
|
|
105
|
+
let chunkedParts: Uint8Array[] = []
|
|
106
|
+
let receivedBytes = 0
|
|
107
|
+
|
|
108
|
+
const cleanupSocket = (): void => {
|
|
109
|
+
state = ST_DONE
|
|
110
|
+
if (ssl) { wolfssl.wolfSSL_free(ssl); ssl = null }
|
|
111
|
+
if (ctx) { wolfssl.wolfSSL_CTX_free(ctx); ctx = null }
|
|
112
|
+
if (s !== null && s >= 0) { sock.closesocket(s); s = null }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cleanup = (): void => {
|
|
116
|
+
if (timerId) { clearTimeout(timerId); timerId = undefined }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const doResolve = (response: ResponseImpl): void => {
|
|
120
|
+
if (!resolved) { resolved = true; cleanup(); resolve(response) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const doReject = (error: Error): void => {
|
|
124
|
+
if (!resolved) { resolved = true; cleanup(); cleanupSocket(); reject(error) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
timerId = setTimeout(() => {
|
|
128
|
+
doReject(new Error('Request timeout'))
|
|
129
|
+
}, timeout)
|
|
130
|
+
|
|
131
|
+
s = sock.socket()
|
|
132
|
+
if (s < 0) { doReject(new Error('Failed to create socket')); return }
|
|
133
|
+
const fd = s
|
|
134
|
+
|
|
135
|
+
sock.set_on_event(fd, (event: { lNetworkEvents: number; iErrorCode: number[] }) => {
|
|
136
|
+
if (state === ST_DONE) return
|
|
137
|
+
|
|
138
|
+
if (event.lNetworkEvents & sock.FdEvent.FD_CONNECT) {
|
|
139
|
+
const err = event.iErrorCode[0]
|
|
140
|
+
if (err !== 0) { doReject(new Error('Connection failed: ' + err)); return }
|
|
141
|
+
|
|
142
|
+
if (isHTTPS) {
|
|
143
|
+
const tlsMethod = wolfssl.wolfTLSv1_2_client_method()
|
|
144
|
+
ctx = wolfssl.wolfSSL_CTX_new(tlsMethod)
|
|
145
|
+
if (!ctx) { doReject(new Error('SSL_CTX_new failed')); return }
|
|
146
|
+
wolfssl.wolfSSL_CTX_set_verify(ctx, wolfssl.VerifyMode.SSL_VERIFY_NONE)
|
|
147
|
+
ssl = wolfssl.wolfSSL_new(ctx)
|
|
148
|
+
if (!ssl) { doReject(new Error('SSL_new failed')); return }
|
|
149
|
+
wolfssl.wolfSSL_set_fd(ssl, sock.get_fd(fd))
|
|
150
|
+
if (parsedUrl.hostname) wolfssl.wolfSSL_UseSNI(ssl, wolfssl.SniType.WOLFSSL_SNI_HOST_NAME, parsedUrl.hostname)
|
|
151
|
+
state = ST_HANDSHAKE
|
|
152
|
+
} else {
|
|
153
|
+
sock.send(fd, httpRequest)
|
|
154
|
+
state = ST_RECV_HEADERS
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if ((event.lNetworkEvents & sock.FdEvent.FD_READ) || (event.lNetworkEvents & sock.FdEvent.FD_WRITE)) {
|
|
159
|
+
if (state === ST_HANDSHAKE) {
|
|
160
|
+
if (!ssl) { doReject(new Error('TLS not initialized')); return }
|
|
161
|
+
const ret = wolfssl.wolfSSL_connect(ssl)
|
|
162
|
+
if (ret === wolfssl.ReturnCode.SSL_SUCCESS) {
|
|
163
|
+
wolfssl.wolfSSL_write(ssl, httpRequest)
|
|
164
|
+
state = ST_RECV_HEADERS
|
|
165
|
+
} else {
|
|
166
|
+
const err = wolfssl.wolfSSL_get_error(ssl, ret)
|
|
167
|
+
if (err !== wolfssl.ErrorCode.WOLFSSL_ERROR_WANT_READ &&
|
|
168
|
+
err !== wolfssl.ErrorCode.WOLFSSL_ERROR_WANT_WRITE) {
|
|
169
|
+
doReject(new Error('TLS handshake failed: ' + err))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (state === ST_RECV_HEADERS) {
|
|
174
|
+
while (true) {
|
|
175
|
+
if (s !== null && s < 0 && !ssl) break
|
|
176
|
+
let data: ArrayBuffer | null
|
|
177
|
+
if (isHTTPS && ssl) {
|
|
178
|
+
data = wolfssl.wolfSSL_read(ssl, 8192)
|
|
179
|
+
} else if (s !== null && s >= 0) {
|
|
180
|
+
data = sock.recv(s, 8192)
|
|
181
|
+
} else { break }
|
|
182
|
+
if (!data || data.byteLength === 0) break
|
|
183
|
+
const incoming = new Uint8Array(data)
|
|
184
|
+
headerRaw = _concat([headerRaw, incoming])
|
|
185
|
+
|
|
186
|
+
let headerEnd = -1
|
|
187
|
+
for (let i = 0; i < headerRaw.length - 3; i++) {
|
|
188
|
+
if (headerRaw[i] === 0x0D && headerRaw[i+1] === 0x0A && headerRaw[i+2] === 0x0D && headerRaw[i+3] === 0x0A) {
|
|
189
|
+
headerEnd = i; break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (headerEnd >= 0) {
|
|
193
|
+
const headerStr = new TextDecoder('utf-8').decode(headerRaw.subarray(0, headerEnd + 4))
|
|
194
|
+
const parsed = parseHeaders(headerStr)
|
|
195
|
+
if (!parsed) { doReject(new Error('Failed to parse HTTP headers')); return }
|
|
196
|
+
|
|
197
|
+
const trailingBodyBytes = headerRaw.subarray(headerEnd + 4)
|
|
198
|
+
|
|
199
|
+
isChunked = (parsed.headers.get('transfer-encoding') || '').toLowerCase().includes('chunked')
|
|
200
|
+
contentLength = isChunked ? 0 : parseInt(
|
|
201
|
+
parsed.headers.get('content-length') || '0', 10
|
|
202
|
+
)
|
|
203
|
+
_controller = null
|
|
204
|
+
stream = new ReadableStream<Uint8Array>({
|
|
205
|
+
start(ctrl: ReadableStreamDefaultController<Uint8Array>) { _controller = ctrl },
|
|
206
|
+
cancel() { cleanup(); cleanupSocket() }
|
|
207
|
+
})
|
|
208
|
+
if (trailingBodyBytes.length > 0) {
|
|
209
|
+
if (isChunked) {
|
|
210
|
+
chunkedParts = [trailingBodyBytes]
|
|
211
|
+
if (_hasChunkedEnd(trailingBodyBytes)) {
|
|
212
|
+
const decoded = decodeChunked(trailingBodyBytes)
|
|
213
|
+
_controller!.enqueue(decoded)
|
|
214
|
+
_controller!.close()
|
|
215
|
+
state = ST_DONE
|
|
216
|
+
cleanupSocket()
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
receivedBytes += trailingBodyBytes.length
|
|
220
|
+
_controller!.enqueue(trailingBodyBytes)
|
|
221
|
+
if (contentLength > 0 && receivedBytes >= contentLength) {
|
|
222
|
+
_controller!.close()
|
|
223
|
+
state = ST_DONE
|
|
224
|
+
cleanupSocket()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const response = new ResponseImpl(
|
|
230
|
+
stream, { status: parsed.status, statusText: parsed.statusText, headers: parsed.headers }
|
|
231
|
+
)
|
|
232
|
+
if (state !== ST_DONE) {
|
|
233
|
+
state = ST_RECV_BODY
|
|
234
|
+
}
|
|
235
|
+
doResolve(response)
|
|
236
|
+
// Break out of header recv loop — any remaining data
|
|
237
|
+
// in the socket will be handled by the ST_RECV_BODY path below
|
|
238
|
+
break
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (state === ST_RECV_BODY && stream) {
|
|
243
|
+
while (true) {
|
|
244
|
+
if (s !== null && s < 0 && !ssl) break
|
|
245
|
+
let data: ArrayBuffer | null
|
|
246
|
+
if (isHTTPS && ssl) {
|
|
247
|
+
data = wolfssl.wolfSSL_read(ssl, 8192)
|
|
248
|
+
} else if (s !== null && s >= 0) {
|
|
249
|
+
data = sock.recv(s, 8192)
|
|
250
|
+
} else { break }
|
|
251
|
+
if (!data || data.byteLength === 0) break
|
|
252
|
+
if (isChunked) {
|
|
253
|
+
chunkedParts.push(new Uint8Array(data))
|
|
254
|
+
const combined = _concat(chunkedParts)
|
|
255
|
+
if (_hasChunkedEnd(combined)) {
|
|
256
|
+
const decoded = decodeChunked(combined)
|
|
257
|
+
_controller!.enqueue(decoded)
|
|
258
|
+
_controller!.close()
|
|
259
|
+
stream = null
|
|
260
|
+
state = ST_DONE
|
|
261
|
+
cleanupSocket()
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
receivedBytes += data.byteLength
|
|
266
|
+
_controller!.enqueue(new Uint8Array(data))
|
|
267
|
+
if (contentLength > 0 && receivedBytes >= contentLength) {
|
|
268
|
+
_controller!.close()
|
|
269
|
+
state = ST_DONE
|
|
270
|
+
cleanupSocket()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (event.lNetworkEvents & sock.FdEvent.FD_CLOSE) {
|
|
278
|
+
if (state === ST_DONE) return
|
|
279
|
+
if (state === ST_RECV_HEADERS) {
|
|
280
|
+
doReject(new Error('Connection closed before response'))
|
|
281
|
+
} else if (state === ST_RECV_BODY && stream) {
|
|
282
|
+
let remainingParts: Uint8Array[] = []
|
|
283
|
+
while (true) {
|
|
284
|
+
if (s !== null && s < 0 && !ssl) break
|
|
285
|
+
let data: ArrayBuffer | null
|
|
286
|
+
if (isHTTPS && ssl) {
|
|
287
|
+
data = wolfssl.wolfSSL_read(ssl, 8192)
|
|
288
|
+
} else if (s !== null && s >= 0) {
|
|
289
|
+
data = sock.recv(s, 8192)
|
|
290
|
+
} else { break }
|
|
291
|
+
if (!data || data.byteLength === 0) break
|
|
292
|
+
remainingParts.push(new Uint8Array(data))
|
|
293
|
+
}
|
|
294
|
+
if (isChunked) {
|
|
295
|
+
chunkedParts.push(...remainingParts)
|
|
296
|
+
const decoded = decodeChunked(_concat(chunkedParts))
|
|
297
|
+
_controller!.enqueue(decoded)
|
|
298
|
+
} else if (remainingParts.length > 0) {
|
|
299
|
+
_controller!.enqueue(_concat(remainingParts))
|
|
300
|
+
}
|
|
301
|
+
_controller!.close()
|
|
302
|
+
stream = null
|
|
303
|
+
state = ST_DONE
|
|
304
|
+
cleanupSocket()
|
|
305
|
+
} else {
|
|
306
|
+
// ST_CONNECTING or ST_HANDSHAKE — socket died early,
|
|
307
|
+
// reject so the Promise doesn't hang forever
|
|
308
|
+
doReject(new Error('Connection closed'))
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const ip = sock.resolve(parsedUrl.hostname)
|
|
314
|
+
if (!ip) {
|
|
315
|
+
doReject(new Error('DNS resolution failed for: ' + parsedUrl.hostname))
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
sock.connect(s, ip, parseInt(parsedUrl.port, 10) || (isHTTPS ? 443 : 80))
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Public fetch (with redirect handling and caching) ──
|
|
323
|
+
|
|
324
|
+
function parseMaxAge(cc: string): number {
|
|
325
|
+
const m = cc.match(/max-age=(\d+)/)
|
|
326
|
+
return m ? parseInt(m[1]!, 10) : 0
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface CacheMeta {
|
|
330
|
+
storedAt: number;
|
|
331
|
+
maxAge: number;
|
|
332
|
+
status: number;
|
|
333
|
+
statusText: string;
|
|
334
|
+
headers: Record<string, string>;
|
|
335
|
+
etag?: string;
|
|
336
|
+
lastModified?: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function doFetch(url: string | Request, init: RequestInit, redirectCount: number): Promise<ResponseImpl> {
|
|
340
|
+
let req = new RequestImpl(url, init)
|
|
341
|
+
const redirectMode = req.redirect || 'follow'
|
|
342
|
+
const maxRedirects = redirectMode === 'follow' ? (req.maxRedirects || 5) : 0
|
|
343
|
+
const cache = typeof __httpCache__ !== 'undefined' ? __httpCache__ : null
|
|
344
|
+
const method = req.method
|
|
345
|
+
const currentUrl = req.url
|
|
346
|
+
|
|
347
|
+
// ── Cache lookup (GET only) ──
|
|
348
|
+
let cachedMeta: CacheMeta | null = null
|
|
349
|
+
|
|
350
|
+
if (cache && method === 'GET') {
|
|
351
|
+
const metaStr = cache.readMeta(currentUrl)
|
|
352
|
+
if (metaStr) {
|
|
353
|
+
const ini = parseIni(metaStr)
|
|
354
|
+
cachedMeta = {
|
|
355
|
+
storedAt: parseInt(ini.meta.storedAt || '0', 10),
|
|
356
|
+
maxAge: parseInt(ini.meta.maxAge || '0', 10),
|
|
357
|
+
status: parseInt(ini.meta.status || '200', 10),
|
|
358
|
+
statusText: ini.meta.statusText || 'OK',
|
|
359
|
+
headers: ini.headers,
|
|
360
|
+
etag: ini.headers['etag'] || undefined,
|
|
361
|
+
lastModified: ini.headers['last-modified'] || undefined,
|
|
362
|
+
}
|
|
363
|
+
const age = Math.floor(Date.now() / 1000) - cachedMeta.storedAt
|
|
364
|
+
if (cachedMeta.maxAge > 0 && age < cachedMeta.maxAge) {
|
|
365
|
+
const body = cache.readBody(currentUrl)
|
|
366
|
+
if (body) {
|
|
367
|
+
ini.meta.lastAccess = String(Math.floor(Date.now() / 1000))
|
|
368
|
+
cache.writeMeta(currentUrl, toIni(ini.headers, ini.meta))
|
|
369
|
+
const resp = new ResponseImpl(
|
|
370
|
+
_toReadableStream(new Uint8Array(body)), { status: cachedMeta.status, statusText: cachedMeta.statusText, headers: new HeadersImpl(cachedMeta.headers || {}) }
|
|
371
|
+
)
|
|
372
|
+
resp._url = currentUrl
|
|
373
|
+
return resp
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (cachedMeta.etag) req.headers.set('If-None-Match', cachedMeta.etag)
|
|
377
|
+
if (cachedMeta.lastModified) req.headers.set('If-Modified-Since', cachedMeta.lastModified)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const response = await fetchRequest(req)
|
|
382
|
+
|
|
383
|
+
response._url = currentUrl
|
|
384
|
+
|
|
385
|
+
// ── Handle brotli Content-Encoding ──
|
|
386
|
+
const contentEncoding = response.headers.get('content-encoding') || ''
|
|
387
|
+
if (contentEncoding.includes('br')) {
|
|
388
|
+
const compressedBody = await response.arrayBuffer()
|
|
389
|
+
const decompressedBody = brotli.decompress(compressedBody)
|
|
390
|
+
const newHeaders = new HeadersImpl()
|
|
391
|
+
response.headers.forEach((v: string, k: string) => {
|
|
392
|
+
if (k !== 'content-encoding') newHeaders.set(k, v)
|
|
393
|
+
})
|
|
394
|
+
newHeaders.set('content-length', String(decompressedBody.byteLength))
|
|
395
|
+
const stream = _toReadableStream(new Uint8Array(decompressedBody))
|
|
396
|
+
response._applyDecompressedBody(stream, decompressedBody, newHeaders)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Handle 304 Not Modified ──
|
|
400
|
+
if (response.status === 304 && cachedMeta && cache) {
|
|
401
|
+
const body = cache.readBody(currentUrl)
|
|
402
|
+
if (body) {
|
|
403
|
+
cachedMeta.storedAt = Math.floor(Date.now() / 1000)
|
|
404
|
+
response.headers.forEach((value: string, name: string) => {
|
|
405
|
+
cachedMeta.headers[name] = value
|
|
406
|
+
})
|
|
407
|
+
const now = String(Math.floor(Date.now() / 1000))
|
|
408
|
+
const iniMeta: Record<string, string> = {
|
|
409
|
+
url: currentUrl,
|
|
410
|
+
storedAt: String(cachedMeta.storedAt),
|
|
411
|
+
maxAge: String(cachedMeta.maxAge),
|
|
412
|
+
lastAccess: now,
|
|
413
|
+
status: String(cachedMeta.status),
|
|
414
|
+
statusText: cachedMeta.statusText,
|
|
415
|
+
}
|
|
416
|
+
cache.writeMeta(currentUrl, toIni(cachedMeta.headers, iniMeta))
|
|
417
|
+
const resp = new ResponseImpl(
|
|
418
|
+
_toReadableStream(new Uint8Array(body)), { status: cachedMeta.status, statusText: cachedMeta.statusText, headers: new HeadersImpl(cachedMeta.headers) }
|
|
419
|
+
)
|
|
420
|
+
resp._url = currentUrl
|
|
421
|
+
return resp
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Cache 200 GET responses ──
|
|
426
|
+
if (cache && method === 'GET' && response.status === 200) {
|
|
427
|
+
const body = await response.arrayBuffer()
|
|
428
|
+
const cc = response.headers.get('cache-control') || ''
|
|
429
|
+
const maxAge = parseMaxAge(cc)
|
|
430
|
+
if (maxAge > 0) {
|
|
431
|
+
const resHeaders = Object.fromEntries(response.headers)
|
|
432
|
+
const now = String(Math.floor(Date.now() / 1000))
|
|
433
|
+
const iniMeta: Record<string, string> = {
|
|
434
|
+
url: currentUrl,
|
|
435
|
+
storedAt: now,
|
|
436
|
+
maxAge: String(maxAge),
|
|
437
|
+
lastAccess: now,
|
|
438
|
+
status: String(response.status),
|
|
439
|
+
statusText: response.statusText,
|
|
440
|
+
}
|
|
441
|
+
cache.writeBodyOnly(currentUrl, body)
|
|
442
|
+
cache.writeMeta(currentUrl, toIni(resHeaders, iniMeta))
|
|
443
|
+
}
|
|
444
|
+
const resp = new ResponseImpl(
|
|
445
|
+
_toReadableStream(new Uint8Array(body)), { status: response.status, statusText: response.statusText, headers: response.headers }
|
|
446
|
+
)
|
|
447
|
+
resp._url = currentUrl
|
|
448
|
+
return resp
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Redirect handling ──
|
|
452
|
+
const isRedirect = response.status === 301 || response.status === 302 ||
|
|
453
|
+
response.status === 303 || response.status === 307 || response.status === 308
|
|
454
|
+
|
|
455
|
+
if (isRedirect) {
|
|
456
|
+
if (redirectMode === 'error') {
|
|
457
|
+
response.body.cancel('redirect')
|
|
458
|
+
throw new TypeError('Redirect not allowed for: ' + currentUrl)
|
|
459
|
+
}
|
|
460
|
+
if (redirectMode === 'manual') {
|
|
461
|
+
response._redirected = true
|
|
462
|
+
return response
|
|
463
|
+
}
|
|
464
|
+
if (redirectCount >= maxRedirects) {
|
|
465
|
+
response.body.cancel('redirect')
|
|
466
|
+
throw new TypeError('Redirect count exceeded')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const location = response.headers.get('location')
|
|
470
|
+
if (!location) throw new Error('Redirect response missing Location header')
|
|
471
|
+
|
|
472
|
+
response.body.cancel('redirect')
|
|
473
|
+
const newUrl = new URL(location, currentUrl).href
|
|
474
|
+
const isGet = response.status === 301 || response.status === 302 || response.status === 303
|
|
475
|
+
const newInit: RequestInit = {
|
|
476
|
+
...init,
|
|
477
|
+
method: isGet ? 'GET' : req.method,
|
|
478
|
+
headers: req.headers,
|
|
479
|
+
body: isGet ? undefined : (req.body ?? undefined),
|
|
480
|
+
}
|
|
481
|
+
const result = await doFetch(newUrl, newInit, redirectCount + 1)
|
|
482
|
+
result._redirected = true
|
|
483
|
+
return result
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (redirectCount > 0) response._redirected = true
|
|
487
|
+
return response
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function fetch(url: string | Request, init: RequestInit = {}): Promise<ResponseImpl> {
|
|
491
|
+
return doFetch(url, init, 0)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── Global declarations ──
|
|
495
|
+
|
|
496
|
+
declare global {
|
|
497
|
+
var fetch: (url: string | Request, init?: RequestInit) => Promise<Response>;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Register globals ──
|
|
501
|
+
|
|
502
|
+
globalThis.fetch = fetch
|