@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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @tachiyomi-js/runtime
2
+ * @nemu.pm/tachiyomi-runtime
3
3
  *
4
4
  * Runtime for loading and executing Tachiyomi extensions compiled to JavaScript.
5
5
  *