quickwin 1.0.2 → 1.1.1

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.
Files changed (78) hide show
  1. package/README.en.md +204 -204
  2. package/README.md +203 -203
  3. package/examples/PdfCanvas.tsx +83 -83
  4. package/examples/exec_server.ts +35 -35
  5. package/examples/pdf_preview2.ts +375 -375
  6. package/examples/pdf_viewer.tsx +279 -279
  7. package/examples/test_gallery.tsx +285 -285
  8. package/examples/test_react_lazy.tsx +63 -63
  9. package/examples/test_react_listbox.tsx +71 -71
  10. package/examples/test_root_window.tsx +41 -41
  11. package/examples/test_tab.tsx +75 -75
  12. package/examples/tray_demo.tsx +81 -81
  13. package/lib/assert.ts +6 -6
  14. package/lib/cache-utils.ts +40 -40
  15. package/lib/dumpRects.ts +25 -25
  16. package/lib/fetch.ts +502 -502
  17. package/lib/http-common.ts +399 -399
  18. package/lib/http-server.ts +424 -424
  19. package/lib/polyfill.ts +171 -171
  20. package/lib/react-qw/components/index.ts +17 -17
  21. package/lib/react-qw/index.ts +90 -90
  22. package/lib/react-qw/jsx.d.ts +34 -34
  23. package/lib/react-qw/layout.ts +111 -111
  24. package/lib/react-qw/props.ts +36 -36
  25. package/lib/react-qw/reconciler.ts +373 -373
  26. package/lib/stream.ts +21 -21
  27. package/lib/text-codec.ts +171 -171
  28. package/lib/text-measure.ts +54 -54
  29. package/lib/url.ts +201 -201
  30. package/lib/vendor/web-streams/ponyfill.d.mts +829 -829
  31. package/lib/vendor/web-streams/ponyfill.mjs +8 -8
  32. package/lib/websocket.js +8 -0
  33. package/lib/websocket.ts +563 -555
  34. package/package.json +50 -44
  35. package/qwin-nowasm-x86.exe +0 -0
  36. package/qwin-nowasm.exe +0 -0
  37. package/qwin-x86.exe +0 -0
  38. package/qwin.exe +0 -0
  39. package/test/run.ts +96 -96
  40. package/test/test_basic.ts +9 -9
  41. package/test/test_basic_fetch.ts +33 -33
  42. package/test/test_brotli.ts +52 -52
  43. package/test/test_fetch_cache.ts +187 -187
  44. package/test/test_ffi.ts +174 -174
  45. package/test/test_frame_encoding.ts +132 -132
  46. package/test/test_helper.ts +80 -80
  47. package/test/test_http_import.ts +74 -74
  48. package/test/test_http_server.ts +136 -136
  49. package/test/test_import_dir.ts +5 -5
  50. package/test/test_ipv6.ts +129 -129
  51. package/test/test_meta_url.ts +4 -4
  52. package/test/test_mupdf_render.ts +74 -74
  53. package/test/test_mupdf_twice.ts +81 -81
  54. package/test/test_mupdf_wasm.ts +30 -30
  55. package/test/test_net_event.ts +55 -55
  56. package/test/test_net_fetch.ts +238 -238
  57. package/test/test_net_websocket.ts +164 -164
  58. package/test/test_polyfill.ts +159 -159
  59. package/test/test_url.ts +183 -183
  60. package/test/test_wasm_basic.ts +73 -73
  61. package/test/test_wasm_import_global.ts +45 -45
  62. package/test/test_wasm_sjlj.ts +125 -125
  63. package/test/test_wasm_types.ts +124 -124
  64. package/test/test_worker.ts +24 -24
  65. package/test/test_worker_fetch.ts +27 -27
  66. package/test/test_worker_net.ts +54 -54
  67. package/test/test_worker_wasm_concurrent.ts +45 -45
  68. package/test/worker_fetch.ts +13 -13
  69. package/test/worker_helper.ts +1 -1
  70. package/test/worker_http.ts +20 -20
  71. package/test/worker_import_lib.ts +13 -13
  72. package/test/worker_module.ts +17 -17
  73. package/test/worker_wasm_concurrent.ts +22 -22
  74. package/tsconfig.json +27 -27
  75. package/vendor/mupdf-wasm/mupdf-wasm.d.ts +571 -571
  76. package/vendor/mupdf-wasm/mupdf-wasm.js +2749 -2749
  77. package/vendor/mupdf-wasm/mupdf.d.ts +939 -939
  78. 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