@nemu.pm/tachiyomi-runtime 0.1.0 → 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 +31 -0
- package/dist/async/index.d.ts +15 -0
- package/dist/async/index.d.ts.map +1 -0
- package/dist/async/index.js +122 -0
- package/dist/async/index.js.map +1 -0
- package/dist/async/index.node.d.ts +14 -0
- package/dist/async/index.node.d.ts.map +1 -0
- package/dist/async/index.node.js +318 -0
- package/dist/async/index.node.js.map +1 -0
- package/dist/async/types.d.ts +68 -0
- package/dist/async/types.d.ts.map +1 -0
- package/dist/async/types.js +2 -0
- package/dist/async/types.js.map +1 -0
- package/dist/async/worker.d.ts +36 -0
- package/dist/async/worker.d.ts.map +1 -0
- package/dist/async/worker.js +337 -0
- package/dist/async/worker.js.map +1 -0
- package/dist/http/sync-node.d.ts +19 -0
- package/dist/http/sync-node.d.ts.map +1 -0
- package/dist/http/sync-node.js +99 -0
- package/dist/http/sync-node.js.map +1 -0
- package/dist/http/sync-xhr.d.ts +18 -0
- package/dist/http/sync-xhr.d.ts.map +1 -0
- package/dist/http/sync-xhr.js +68 -0
- package/dist/http/sync-xhr.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +33 -2
- package/src/async/index.node.ts +376 -0
- package/src/async/index.ts +179 -0
- package/src/async/types.ts +89 -0
- package/src/async/worker.ts +391 -0
- package/src/http/sync-node.ts +127 -0
- package/src/http/sync-xhr.ts +85 -0
- package/src/index.ts +1 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Worker for running Tachiyomi Kotlin/JS extension sources
|
|
3
|
+
* Sync XHR works in workers, enabling blocking HTTP calls from Kotlin/JS
|
|
4
|
+
*/
|
|
5
|
+
import * as Comlink from "comlink";
|
|
6
|
+
import { createRuntime, type ExtensionInstance } from "../runtime";
|
|
7
|
+
import { createSyncXhrBridge } from "../http/sync-xhr";
|
|
8
|
+
import type {
|
|
9
|
+
MangasPage,
|
|
10
|
+
Manga,
|
|
11
|
+
Chapter,
|
|
12
|
+
Page,
|
|
13
|
+
FilterState,
|
|
14
|
+
SourceInfo,
|
|
15
|
+
ExtensionManifest,
|
|
16
|
+
} from "../types";
|
|
17
|
+
|
|
18
|
+
// ============ Preferences Storage ============
|
|
19
|
+
// SharedPreferences implementation for Kotlin/JS
|
|
20
|
+
|
|
21
|
+
const prefsStorage = new Map<string, Map<string, unknown>>();
|
|
22
|
+
let pendingPrefChanges: Array<{ name: string; key: string; value: unknown }> = [];
|
|
23
|
+
|
|
24
|
+
class SharedPreferencesImpl {
|
|
25
|
+
private name: string;
|
|
26
|
+
private data: Map<string, unknown>;
|
|
27
|
+
|
|
28
|
+
constructor(name: string) {
|
|
29
|
+
this.name = name;
|
|
30
|
+
if (!prefsStorage.has(name)) {
|
|
31
|
+
prefsStorage.set(name, new Map());
|
|
32
|
+
}
|
|
33
|
+
this.data = prefsStorage.get(name)!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getString(key: string, defValue: string | null): string | null {
|
|
37
|
+
const val = this.data.get(key);
|
|
38
|
+
return typeof val === "string" ? val : defValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getBoolean(key: string, defValue: boolean): boolean {
|
|
42
|
+
const val = this.data.get(key);
|
|
43
|
+
return typeof val === "boolean" ? val : defValue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getInt(key: string, defValue: number): number {
|
|
47
|
+
const val = this.data.get(key);
|
|
48
|
+
return typeof val === "number" ? Math.floor(val) : defValue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getLong(key: string, defValue: number): number {
|
|
52
|
+
return this.getInt(key, defValue);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getFloat(key: string, defValue: number): number {
|
|
56
|
+
const val = this.data.get(key);
|
|
57
|
+
return typeof val === "number" ? val : defValue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getStringSet(key: string, defValue: string[] | null): string[] | null {
|
|
61
|
+
const val = this.data.get(key);
|
|
62
|
+
return Array.isArray(val) ? val : defValue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getAll(): Record<string, unknown> {
|
|
66
|
+
const result: Record<string, unknown> = {};
|
|
67
|
+
for (const [k, v] of this.data.entries()) {
|
|
68
|
+
result[k] = v;
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
contains(key: string): boolean {
|
|
74
|
+
return this.data.has(key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
edit(): SharedPreferencesEditor {
|
|
78
|
+
return new SharedPreferencesEditor(this.name, this.data);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class SharedPreferencesEditor {
|
|
83
|
+
private name: string;
|
|
84
|
+
private data: Map<string, unknown>;
|
|
85
|
+
private changes: Map<string, unknown | null> = new Map();
|
|
86
|
+
|
|
87
|
+
constructor(name: string, data: Map<string, unknown>) {
|
|
88
|
+
this.name = name;
|
|
89
|
+
this.data = data;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
putString(key: string, value: string | null): this {
|
|
93
|
+
this.changes.set(key, value);
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
putBoolean(key: string, value: boolean): this {
|
|
98
|
+
this.changes.set(key, value);
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
putInt(key: string, value: number): this {
|
|
103
|
+
this.changes.set(key, Math.floor(value));
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
putLong(key: string, value: number): this {
|
|
108
|
+
return this.putInt(key, value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
putFloat(key: string, value: number): this {
|
|
112
|
+
this.changes.set(key, value);
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
putStringSet(key: string, value: string[] | null): this {
|
|
117
|
+
this.changes.set(key, value);
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
remove(key: string): this {
|
|
122
|
+
this.changes.set(key, null);
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clear(): this {
|
|
127
|
+
for (const key of this.data.keys()) {
|
|
128
|
+
this.changes.set(key, null);
|
|
129
|
+
}
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
apply(): void {
|
|
134
|
+
this.commit();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
commit(): boolean {
|
|
138
|
+
for (const [key, value] of this.changes) {
|
|
139
|
+
if (value === null) {
|
|
140
|
+
this.data.delete(key);
|
|
141
|
+
} else {
|
|
142
|
+
this.data.set(key, value);
|
|
143
|
+
}
|
|
144
|
+
pendingPrefChanges.push({ name: this.name, key, value });
|
|
145
|
+
}
|
|
146
|
+
this.changes.clear();
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Expose SharedPreferences factory to Kotlin/JS
|
|
152
|
+
(globalThis as Record<string, unknown>).getSharedPreferences = (name: string) => {
|
|
153
|
+
return new SharedPreferencesImpl(name);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ============ Worker API ============
|
|
157
|
+
|
|
158
|
+
let proxyUrlFn: ((url: string) => string) | null = null;
|
|
159
|
+
let runtime: ReturnType<typeof createRuntime> | null = null;
|
|
160
|
+
let extension: ExtensionInstance | null = null;
|
|
161
|
+
let manifest: ExtensionManifest | null = null;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Initialize the runtime with proxy URL
|
|
165
|
+
*/
|
|
166
|
+
function initRuntime(proxyUrl: string | null): void {
|
|
167
|
+
proxyUrlFn = proxyUrl ? (url: string) => `${proxyUrl}${encodeURIComponent(url)}` : null;
|
|
168
|
+
const httpBridge = createSyncXhrBridge({ proxyUrl: proxyUrlFn ?? undefined });
|
|
169
|
+
runtime = createRuntime(httpBridge);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Worker API exposed via Comlink
|
|
174
|
+
*/
|
|
175
|
+
const workerApi = {
|
|
176
|
+
// ============ Preferences Methods ============
|
|
177
|
+
|
|
178
|
+
initPreferences(prefsName: string, values: Record<string, unknown>): void {
|
|
179
|
+
const prefs = new Map<string, unknown>();
|
|
180
|
+
for (const [key, value] of Object.entries(values)) {
|
|
181
|
+
prefs.set(key, value);
|
|
182
|
+
}
|
|
183
|
+
prefsStorage.set(prefsName, prefs);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
flushPrefChanges(): Array<{ name: string; key: string; value: unknown }> {
|
|
187
|
+
const changes = pendingPrefChanges;
|
|
188
|
+
pendingPrefChanges = [];
|
|
189
|
+
return changes;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
getSettingsSchema(sourceId: string): string | null {
|
|
193
|
+
if (!extension) return null;
|
|
194
|
+
try {
|
|
195
|
+
const schema = extension.getSettingsSchema(sourceId);
|
|
196
|
+
return schema ? JSON.stringify(schema) : null;
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error("[Tachiyomi Worker] getSettingsSchema error:", e);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// ============ Load Extension ============
|
|
204
|
+
|
|
205
|
+
async load(
|
|
206
|
+
jsUrl: string,
|
|
207
|
+
manifestData: ExtensionManifest,
|
|
208
|
+
proxyUrl: string | null
|
|
209
|
+
): Promise<{ success: boolean; manifest?: ExtensionManifest }> {
|
|
210
|
+
try {
|
|
211
|
+
console.log("[Tachiyomi Worker] Loading JS from:", jsUrl);
|
|
212
|
+
|
|
213
|
+
// Initialize runtime with proxy
|
|
214
|
+
initRuntime(proxyUrl);
|
|
215
|
+
if (!runtime) throw new Error("Failed to initialize runtime");
|
|
216
|
+
|
|
217
|
+
manifest = manifestData;
|
|
218
|
+
|
|
219
|
+
// Fetch the extension code
|
|
220
|
+
const response = await fetch(jsUrl);
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(`Failed to fetch extension: ${response.status}`);
|
|
223
|
+
}
|
|
224
|
+
const code = await response.text();
|
|
225
|
+
|
|
226
|
+
// Load extension using the runtime
|
|
227
|
+
extension = runtime.loadExtension(code);
|
|
228
|
+
|
|
229
|
+
// Get sources metadata
|
|
230
|
+
const sources = extension.getSources();
|
|
231
|
+
manifest.sources = sources as SourceInfo[];
|
|
232
|
+
|
|
233
|
+
console.log("[Tachiyomi Worker] Loaded, sources:", sources.length, sources.map(s => s.name).slice(0, 5));
|
|
234
|
+
|
|
235
|
+
return { success: sources.length > 0, manifest };
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.error("[Tachiyomi Worker] Failed to load:", e);
|
|
238
|
+
return { success: false };
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
isLoaded(): boolean {
|
|
243
|
+
return extension !== null;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
getManifest(): ExtensionManifest | null {
|
|
247
|
+
return manifest;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
getSources(): SourceInfo[] {
|
|
251
|
+
return manifest?.sources ?? [];
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// ============ Filter Methods ============
|
|
255
|
+
|
|
256
|
+
getFilterList(sourceId: string): FilterState[] {
|
|
257
|
+
if (!extension) return [];
|
|
258
|
+
try {
|
|
259
|
+
return extension.getFilterList(sourceId);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
console.error("[Tachiyomi Worker] getFilterList error:", e);
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
resetFilters(sourceId: string): boolean {
|
|
267
|
+
if (!extension) return false;
|
|
268
|
+
try {
|
|
269
|
+
extension.resetFilters(sourceId);
|
|
270
|
+
return true;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.error("[Tachiyomi Worker] resetFilters error:", e);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
applyFilterState(sourceId: string, filterStateJson: string): boolean {
|
|
278
|
+
if (!extension) return false;
|
|
279
|
+
try {
|
|
280
|
+
const filterState = JSON.parse(filterStateJson) as FilterState[];
|
|
281
|
+
extension.applyFilterState(sourceId, filterState);
|
|
282
|
+
return true;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error("[Tachiyomi Worker] applyFilterState error:", e);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// ============ Data Methods ============
|
|
290
|
+
|
|
291
|
+
getPopularManga(sourceId: string, page: number): MangasPage {
|
|
292
|
+
if (!extension) return { mangas: [], hasNextPage: false };
|
|
293
|
+
try {
|
|
294
|
+
return extension.getPopularManga(sourceId, page);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
console.error("[Tachiyomi Worker] getPopularManga error:", e);
|
|
297
|
+
return { mangas: [], hasNextPage: false };
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
getLatestUpdates(sourceId: string, page: number): MangasPage {
|
|
302
|
+
if (!extension) return { mangas: [], hasNextPage: false };
|
|
303
|
+
try {
|
|
304
|
+
return extension.getLatestUpdates(sourceId, page);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.error("[Tachiyomi Worker] getLatestUpdates error:", e);
|
|
307
|
+
return { mangas: [], hasNextPage: false };
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
searchManga(sourceId: string, page: number, query: string): MangasPage {
|
|
312
|
+
if (!extension) return { mangas: [], hasNextPage: false };
|
|
313
|
+
try {
|
|
314
|
+
return extension.searchManga(sourceId, page, query);
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error("[Tachiyomi Worker] searchManga error:", e);
|
|
317
|
+
return { mangas: [], hasNextPage: false };
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
searchMangaWithFilters(sourceId: string, page: number, query: string, filterStateJson: string): MangasPage {
|
|
322
|
+
if (!extension) return { mangas: [], hasNextPage: false };
|
|
323
|
+
try {
|
|
324
|
+
// Apply filters before searching
|
|
325
|
+
if (filterStateJson && filterStateJson !== "[]") {
|
|
326
|
+
const filterState = JSON.parse(filterStateJson) as FilterState[];
|
|
327
|
+
extension.applyFilterState(sourceId, filterState);
|
|
328
|
+
}
|
|
329
|
+
return extension.searchManga(sourceId, page, query);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
console.error("[Tachiyomi Worker] searchMangaWithFilters error:", e);
|
|
332
|
+
return { mangas: [], hasNextPage: false };
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
getMangaDetails(sourceId: string, mangaUrl: string): Manga | null {
|
|
337
|
+
if (!extension) return null;
|
|
338
|
+
try {
|
|
339
|
+
return extension.getMangaDetails(sourceId, { url: mangaUrl });
|
|
340
|
+
} catch (e) {
|
|
341
|
+
console.error("[Tachiyomi Worker] getMangaDetails error:", e);
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
getChapterList(sourceId: string, mangaUrl: string): Chapter[] {
|
|
347
|
+
if (!extension) return [];
|
|
348
|
+
try {
|
|
349
|
+
return extension.getChapterList(sourceId, { url: mangaUrl });
|
|
350
|
+
} catch (e) {
|
|
351
|
+
console.error("[Tachiyomi Worker] getChapterList error:", e);
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
getPageList(sourceId: string, chapterUrl: string): Page[] {
|
|
357
|
+
if (!extension) return [];
|
|
358
|
+
try {
|
|
359
|
+
return extension.getPageList(sourceId, { url: chapterUrl });
|
|
360
|
+
} catch (e) {
|
|
361
|
+
console.error("[Tachiyomi Worker] getPageList error:", e);
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
fetchImage(sourceId: string, pageUrl: string, pageImageUrl: string): string {
|
|
367
|
+
if (!extension) {
|
|
368
|
+
throw new Error("Extension not loaded");
|
|
369
|
+
}
|
|
370
|
+
// Always returns base64 bytes (like Mihon's getImage)
|
|
371
|
+
return extension.fetchImage(sourceId, pageUrl, pageImageUrl);
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
getHeaders(sourceId: string): Record<string, string> {
|
|
375
|
+
if (!extension) {
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
return extension.getHeaders(sourceId);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
console.warn("[Tachiyomi Worker] getHeaders error:", e);
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
Comlink.expose(workerApi);
|
|
388
|
+
|
|
389
|
+
/** Type alias for Comlink API */
|
|
390
|
+
export type WorkerApi = typeof workerApi;
|
|
391
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js/Bun sync HTTP using child_process
|
|
3
|
+
* Uses curl for actual HTTP requests
|
|
4
|
+
*/
|
|
5
|
+
import type { HttpBridge, HttpRequest, HttpResponse } from "../http";
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
|
|
8
|
+
export interface SyncNodeOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Proxy URL base - transforms target URL
|
|
11
|
+
* Not typically needed in Node.js (no CORS)
|
|
12
|
+
* Example: "https://proxy.example.com/?url="
|
|
13
|
+
*/
|
|
14
|
+
proxyUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create an HttpBridge using synchronous child_process (curl)
|
|
19
|
+
* Works in Node.js and Bun
|
|
20
|
+
*/
|
|
21
|
+
export function createSyncNodeBridge(options: SyncNodeOptions = {}): HttpBridge {
|
|
22
|
+
const { proxyUrl } = options;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
request(req: HttpRequest, wantBytes: boolean): HttpResponse {
|
|
26
|
+
try {
|
|
27
|
+
const url = proxyUrl ? `${proxyUrl}${encodeURIComponent(req.url)}` : req.url;
|
|
28
|
+
|
|
29
|
+
// Build curl command
|
|
30
|
+
const args: string[] = [
|
|
31
|
+
"-s", // silent
|
|
32
|
+
"-S", // show errors
|
|
33
|
+
"-L", // follow redirects
|
|
34
|
+
"-D", "-", // dump headers to stdout
|
|
35
|
+
"-X", req.method,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Add headers
|
|
39
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
40
|
+
args.push("-H", `${key}: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Add body for POST/PUT
|
|
44
|
+
if (req.body && (req.method === "POST" || req.method === "PUT" || req.method === "PATCH")) {
|
|
45
|
+
args.push("-d", req.body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Request binary output if needed
|
|
49
|
+
if (wantBytes) {
|
|
50
|
+
args.push("-o", "-"); // output to stdout
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
args.push(url);
|
|
54
|
+
|
|
55
|
+
// Execute curl
|
|
56
|
+
const result = spawnSync("curl", args, {
|
|
57
|
+
encoding: wantBytes ? "buffer" : "utf-8",
|
|
58
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (result.error) {
|
|
62
|
+
throw result.error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parse response (headers + body)
|
|
66
|
+
const output = wantBytes
|
|
67
|
+
? (result.stdout as Buffer)
|
|
68
|
+
: (result.stdout as string);
|
|
69
|
+
|
|
70
|
+
// Find header/body separator
|
|
71
|
+
let headerEnd: number;
|
|
72
|
+
let bodyStart: number;
|
|
73
|
+
const separator = "\r\n\r\n";
|
|
74
|
+
|
|
75
|
+
if (wantBytes) {
|
|
76
|
+
const buf = output as Buffer;
|
|
77
|
+
headerEnd = buf.indexOf(separator);
|
|
78
|
+
bodyStart = headerEnd + 4;
|
|
79
|
+
} else {
|
|
80
|
+
headerEnd = (output as string).indexOf(separator);
|
|
81
|
+
bodyStart = headerEnd + 4;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Parse headers
|
|
85
|
+
const headerSection = wantBytes
|
|
86
|
+
? (output as Buffer).slice(0, headerEnd).toString("utf-8")
|
|
87
|
+
: (output as string).slice(0, headerEnd);
|
|
88
|
+
|
|
89
|
+
const headerLines = headerSection.split("\r\n");
|
|
90
|
+
const statusLine = headerLines[0] ?? "";
|
|
91
|
+
const statusMatch = statusLine.match(/HTTP\/[\d.]+ (\d+) (.*)/);
|
|
92
|
+
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
|
93
|
+
const statusText = statusMatch ? statusMatch[2] : "";
|
|
94
|
+
|
|
95
|
+
const headers: Record<string, string> = {};
|
|
96
|
+
for (let i = 1; i < headerLines.length; i++) {
|
|
97
|
+
const line = headerLines[i];
|
|
98
|
+
const idx = line.indexOf(": ");
|
|
99
|
+
if (idx > 0) {
|
|
100
|
+
const key = line.substring(0, idx).toLowerCase();
|
|
101
|
+
const value = line.substring(idx + 2);
|
|
102
|
+
headers[key] = headers[key] ? `${headers[key]}, ${value}` : value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Extract body
|
|
107
|
+
let body: string;
|
|
108
|
+
if (wantBytes) {
|
|
109
|
+
const bodyBuf = (output as Buffer).slice(bodyStart);
|
|
110
|
+
body = bodyBuf.toString("base64");
|
|
111
|
+
} else {
|
|
112
|
+
body = (output as string).slice(bodyStart);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { status, statusText, headers, body };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return {
|
|
118
|
+
status: 0,
|
|
119
|
+
statusText: String(e),
|
|
120
|
+
headers: {},
|
|
121
|
+
body: "",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser sync HTTP using XMLHttpRequest
|
|
3
|
+
* Must run in a Web Worker for sync XHR to work
|
|
4
|
+
*/
|
|
5
|
+
import type { HttpBridge, HttpRequest, HttpResponse } from "../http";
|
|
6
|
+
|
|
7
|
+
export interface SyncXhrOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Proxy URL function - transforms target URL for CORS bypass
|
|
10
|
+
* Example: (url) => `https://proxy.example.com/?url=${encodeURIComponent(url)}`
|
|
11
|
+
*/
|
|
12
|
+
proxyUrl?: (url: string) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create an HttpBridge using synchronous XMLHttpRequest
|
|
17
|
+
* Only works in Web Workers (sync XHR is deprecated on main thread)
|
|
18
|
+
*/
|
|
19
|
+
export function createSyncXhrBridge(options: SyncXhrOptions = {}): HttpBridge {
|
|
20
|
+
const { proxyUrl } = options;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
request(req: HttpRequest, wantBytes: boolean): HttpResponse {
|
|
24
|
+
try {
|
|
25
|
+
const xhr = new XMLHttpRequest();
|
|
26
|
+
const url = proxyUrl ? proxyUrl(req.url) : req.url;
|
|
27
|
+
xhr.open(req.method, url, false); // false = synchronous
|
|
28
|
+
xhr.responseType = wantBytes ? "arraybuffer" : "text";
|
|
29
|
+
|
|
30
|
+
// Set headers with x-proxy- prefix for CORS proxy
|
|
31
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
32
|
+
try {
|
|
33
|
+
xhr.setRequestHeader(`x-proxy-${key}`, value);
|
|
34
|
+
} catch {
|
|
35
|
+
// Some headers can't be set in browsers
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
xhr.send(req.body);
|
|
40
|
+
|
|
41
|
+
// Collect response headers
|
|
42
|
+
const responseHeaders: Record<string, string> = {};
|
|
43
|
+
const headerLines = xhr.getAllResponseHeaders().split("\r\n");
|
|
44
|
+
for (const line of headerLines) {
|
|
45
|
+
const idx = line.indexOf(": ");
|
|
46
|
+
if (idx > 0) {
|
|
47
|
+
const key = line.substring(0, idx).toLowerCase();
|
|
48
|
+
const value = line.substring(idx + 2);
|
|
49
|
+
responseHeaders[key] = responseHeaders[key]
|
|
50
|
+
? `${responseHeaders[key]}, ${value}`
|
|
51
|
+
: value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Get body - text or base64
|
|
56
|
+
let responseBody: string;
|
|
57
|
+
if (wantBytes) {
|
|
58
|
+
const bytes = new Uint8Array(xhr.response as ArrayBuffer);
|
|
59
|
+
let binary = "";
|
|
60
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
61
|
+
binary += String.fromCharCode(bytes[i]);
|
|
62
|
+
}
|
|
63
|
+
responseBody = btoa(binary);
|
|
64
|
+
} else {
|
|
65
|
+
responseBody = xhr.responseText;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
status: xhr.status,
|
|
70
|
+
statusText: xhr.statusText,
|
|
71
|
+
headers: responseHeaders,
|
|
72
|
+
body: responseBody,
|
|
73
|
+
};
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return {
|
|
76
|
+
status: 0,
|
|
77
|
+
statusText: String(e),
|
|
78
|
+
headers: {},
|
|
79
|
+
body: "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
package/src/index.ts
CHANGED