@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,376 @@
1
+ /**
2
+ * Node.js/Bun async runtime
3
+ *
4
+ * No Worker needed - can use sync HTTP directly in Node.js.
5
+ * All methods are still async for API consistency.
6
+ */
7
+ import { createRuntime, type ExtensionInstance } from "../runtime";
8
+ import { createSyncNodeBridge } from "../http/sync-node";
9
+ import type { AsyncTachiyomiSource, AsyncLoadOptions, LoadedExtension } from "./types";
10
+ import type { ExtensionManifest, SourceInfo, FilterState } from "../types";
11
+
12
+ // Re-export types
13
+ export type { AsyncTachiyomiSource, AsyncLoadOptions, LoadedExtension } from "./types";
14
+
15
+ // ============ Preferences Storage ============
16
+ // SharedPreferences implementation for Kotlin/JS (Node.js version)
17
+
18
+ const prefsStorage = new Map<string, Map<string, unknown>>();
19
+ let pendingPrefChanges: Array<{ name: string; key: string; value: unknown }> = [];
20
+
21
+ class SharedPreferencesImpl {
22
+ private name: string;
23
+ private data: Map<string, unknown>;
24
+
25
+ constructor(name: string) {
26
+ this.name = name;
27
+ if (!prefsStorage.has(name)) {
28
+ prefsStorage.set(name, new Map());
29
+ }
30
+ this.data = prefsStorage.get(name)!;
31
+ }
32
+
33
+ getString(key: string, defValue: string | null): string | null {
34
+ const val = this.data.get(key);
35
+ return typeof val === "string" ? val : defValue;
36
+ }
37
+
38
+ getBoolean(key: string, defValue: boolean): boolean {
39
+ const val = this.data.get(key);
40
+ return typeof val === "boolean" ? val : defValue;
41
+ }
42
+
43
+ getInt(key: string, defValue: number): number {
44
+ const val = this.data.get(key);
45
+ return typeof val === "number" ? Math.floor(val) : defValue;
46
+ }
47
+
48
+ getLong(key: string, defValue: number): number {
49
+ return this.getInt(key, defValue);
50
+ }
51
+
52
+ getFloat(key: string, defValue: number): number {
53
+ const val = this.data.get(key);
54
+ return typeof val === "number" ? val : defValue;
55
+ }
56
+
57
+ getStringSet(key: string, defValue: string[] | null): string[] | null {
58
+ const val = this.data.get(key);
59
+ return Array.isArray(val) ? val : defValue;
60
+ }
61
+
62
+ getAll(): Record<string, unknown> {
63
+ const result: Record<string, unknown> = {};
64
+ for (const [k, v] of this.data.entries()) {
65
+ result[k] = v;
66
+ }
67
+ return result;
68
+ }
69
+
70
+ contains(key: string): boolean {
71
+ return this.data.has(key);
72
+ }
73
+
74
+ edit(): SharedPreferencesEditor {
75
+ return new SharedPreferencesEditor(this.name, this.data);
76
+ }
77
+ }
78
+
79
+ class SharedPreferencesEditor {
80
+ private name: string;
81
+ private data: Map<string, unknown>;
82
+ private changes: Map<string, unknown | null> = new Map();
83
+
84
+ constructor(name: string, data: Map<string, unknown>) {
85
+ this.name = name;
86
+ this.data = data;
87
+ }
88
+
89
+ putString(key: string, value: string | null): this {
90
+ this.changes.set(key, value);
91
+ return this;
92
+ }
93
+
94
+ putBoolean(key: string, value: boolean): this {
95
+ this.changes.set(key, value);
96
+ return this;
97
+ }
98
+
99
+ putInt(key: string, value: number): this {
100
+ this.changes.set(key, Math.floor(value));
101
+ return this;
102
+ }
103
+
104
+ putLong(key: string, value: number): this {
105
+ return this.putInt(key, value);
106
+ }
107
+
108
+ putFloat(key: string, value: number): this {
109
+ this.changes.set(key, value);
110
+ return this;
111
+ }
112
+
113
+ putStringSet(key: string, value: string[] | null): this {
114
+ this.changes.set(key, value);
115
+ return this;
116
+ }
117
+
118
+ remove(key: string): this {
119
+ this.changes.set(key, null);
120
+ return this;
121
+ }
122
+
123
+ clear(): this {
124
+ for (const key of this.data.keys()) {
125
+ this.changes.set(key, null);
126
+ }
127
+ return this;
128
+ }
129
+
130
+ apply(): void {
131
+ this.commit();
132
+ }
133
+
134
+ commit(): boolean {
135
+ for (const [key, value] of this.changes) {
136
+ if (value === null) {
137
+ this.data.delete(key);
138
+ } else {
139
+ this.data.set(key, value);
140
+ }
141
+ pendingPrefChanges.push({ name: this.name, key, value });
142
+ }
143
+ this.changes.clear();
144
+ return true;
145
+ }
146
+ }
147
+
148
+ // Expose SharedPreferences factory to Kotlin/JS
149
+ (globalThis as Record<string, unknown>).getSharedPreferences = (name: string) => {
150
+ return new SharedPreferencesImpl(name);
151
+ };
152
+
153
+ /**
154
+ * Load a Tachiyomi extension asynchronously (Node.js/Bun version)
155
+ *
156
+ * No Worker needed - sync HTTP works directly in Node.js.
157
+ *
158
+ * @param jsUrl - URL to the compiled extension JavaScript
159
+ * @param manifest - Extension manifest
160
+ * @param options - Proxy URL and preferences configuration
161
+ */
162
+ export async function loadExtension(
163
+ jsUrl: string,
164
+ manifest: ExtensionManifest,
165
+ options: AsyncLoadOptions = {}
166
+ ): Promise<LoadedExtension> {
167
+ const { proxyUrl, preferences } = options;
168
+
169
+ // Initialize preferences if provided
170
+ if (preferences) {
171
+ const prefs = new Map<string, unknown>();
172
+ for (const [key, value] of Object.entries(preferences.values)) {
173
+ prefs.set(key, value);
174
+ }
175
+ prefsStorage.set(preferences.name, prefs);
176
+ }
177
+
178
+ // Create HTTP bridge
179
+ const httpBridge = createSyncNodeBridge({ proxyUrl });
180
+ const runtime = createRuntime(httpBridge);
181
+
182
+ // Fetch extension code
183
+ const response = await fetch(jsUrl);
184
+ if (!response.ok) {
185
+ throw new Error(`Failed to fetch extension: ${response.status} ${response.statusText}`);
186
+ }
187
+ const code = await response.text();
188
+
189
+ // Load extension
190
+ const extension = runtime.loadExtension(code);
191
+ const sources = extension.getSources() as SourceInfo[];
192
+
193
+ // Update manifest with sources
194
+ const updatedManifest: ExtensionManifest = {
195
+ ...manifest,
196
+ sources,
197
+ };
198
+
199
+ console.log("[Tachiyomi Node] Loaded, sources:", sources.length, sources.map(s => s.name).slice(0, 5));
200
+
201
+ return createLoadedExtension(extension, updatedManifest);
202
+ }
203
+
204
+ function createLoadedExtension(
205
+ extension: ExtensionInstance,
206
+ manifest: ExtensionManifest
207
+ ): LoadedExtension {
208
+ const sources = manifest.sources ?? [];
209
+
210
+ return {
211
+ manifest,
212
+ sources,
213
+
214
+ getSource(sourceId: string): AsyncTachiyomiSource {
215
+ const sourceInfo = sources.find(s => s.id === sourceId);
216
+ if (!sourceInfo) {
217
+ throw new Error(`Source not found: ${sourceId} in ${manifest.name}`);
218
+ }
219
+
220
+ return {
221
+ sourceId,
222
+ sourceInfo,
223
+ manifest,
224
+
225
+ // Filter methods
226
+ async getFilterList() {
227
+ try {
228
+ return extension.getFilterList(sourceId);
229
+ } catch (e) {
230
+ console.error("[Tachiyomi Node] getFilterList error:", e);
231
+ return [];
232
+ }
233
+ },
234
+
235
+ async resetFilters() {
236
+ try {
237
+ extension.resetFilters(sourceId);
238
+ return true;
239
+ } catch (e) {
240
+ console.error("[Tachiyomi Node] resetFilters error:", e);
241
+ return false;
242
+ }
243
+ },
244
+
245
+ async applyFilterState(filterStateJson) {
246
+ try {
247
+ const filterState = JSON.parse(filterStateJson) as FilterState[];
248
+ extension.applyFilterState(sourceId, filterState);
249
+ return true;
250
+ } catch (e) {
251
+ console.error("[Tachiyomi Node] applyFilterState error:", e);
252
+ return false;
253
+ }
254
+ },
255
+
256
+ // Browse methods
257
+ async getPopularManga(page) {
258
+ try {
259
+ return extension.getPopularManga(sourceId, page);
260
+ } catch (e) {
261
+ console.error("[Tachiyomi Node] getPopularManga error:", e);
262
+ return { mangas: [], hasNextPage: false };
263
+ }
264
+ },
265
+
266
+ async getLatestUpdates(page) {
267
+ try {
268
+ return extension.getLatestUpdates(sourceId, page);
269
+ } catch (e) {
270
+ console.error("[Tachiyomi Node] getLatestUpdates error:", e);
271
+ return { mangas: [], hasNextPage: false };
272
+ }
273
+ },
274
+
275
+ // Search methods
276
+ async searchManga(page, query) {
277
+ try {
278
+ return extension.searchManga(sourceId, page, query);
279
+ } catch (e) {
280
+ console.error("[Tachiyomi Node] searchManga error:", e);
281
+ return { mangas: [], hasNextPage: false };
282
+ }
283
+ },
284
+
285
+ async searchMangaWithFilters(page, query, filterStateJson) {
286
+ try {
287
+ if (filterStateJson && filterStateJson !== "[]") {
288
+ const filterState = JSON.parse(filterStateJson) as FilterState[];
289
+ extension.applyFilterState(sourceId, filterState);
290
+ }
291
+ return extension.searchManga(sourceId, page, query);
292
+ } catch (e) {
293
+ console.error("[Tachiyomi Node] searchMangaWithFilters error:", e);
294
+ return { mangas: [], hasNextPage: false };
295
+ }
296
+ },
297
+
298
+ // Content methods
299
+ async getMangaDetails(mangaUrl) {
300
+ try {
301
+ return extension.getMangaDetails(sourceId, { url: mangaUrl });
302
+ } catch (e) {
303
+ console.error("[Tachiyomi Node] getMangaDetails error:", e);
304
+ return null;
305
+ }
306
+ },
307
+
308
+ async getChapterList(mangaUrl) {
309
+ try {
310
+ return extension.getChapterList(sourceId, { url: mangaUrl });
311
+ } catch (e) {
312
+ console.error("[Tachiyomi Node] getChapterList error:", e);
313
+ return [];
314
+ }
315
+ },
316
+
317
+ async getPageList(chapterUrl) {
318
+ try {
319
+ return extension.getPageList(sourceId, { url: chapterUrl });
320
+ } catch (e) {
321
+ console.error("[Tachiyomi Node] getPageList error:", e);
322
+ return [];
323
+ }
324
+ },
325
+
326
+ async fetchImage(pageUrl, pageImageUrl) {
327
+ // Always returns base64 bytes (like Mihon's getImage)
328
+ return extension.fetchImage(sourceId, pageUrl, pageImageUrl);
329
+ },
330
+
331
+ async getHeaders() {
332
+ try {
333
+ return extension.getHeaders(sourceId);
334
+ } catch (e) {
335
+ console.warn("[Tachiyomi Node] getHeaders error:", e);
336
+ return {};
337
+ }
338
+ },
339
+
340
+ // Preferences methods
341
+ async initPreferences(prefsName, values) {
342
+ const prefs = new Map<string, unknown>();
343
+ for (const [key, value] of Object.entries(values)) {
344
+ prefs.set(key, value);
345
+ }
346
+ prefsStorage.set(prefsName, prefs);
347
+ },
348
+
349
+ async flushPrefChanges() {
350
+ const changes = pendingPrefChanges;
351
+ pendingPrefChanges = [];
352
+ return changes;
353
+ },
354
+
355
+ async getSettingsSchema() {
356
+ try {
357
+ const schema = extension.getSettingsSchema(sourceId);
358
+ return schema ? JSON.stringify(schema) : null;
359
+ } catch (e) {
360
+ console.error("[Tachiyomi Node] getSettingsSchema error:", e);
361
+ return null;
362
+ }
363
+ },
364
+
365
+ dispose() {
366
+ // No-op in Node.js - no worker to terminate
367
+ },
368
+ };
369
+ },
370
+
371
+ dispose() {
372
+ // No-op in Node.js - no worker to terminate
373
+ },
374
+ };
375
+ }
376
+
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Browser async runtime
3
+ *
4
+ * Creates a Web Worker internally, exposes clean async API.
5
+ * Consumer doesn't need to manage workers.
6
+ */
7
+ import * as Comlink from "comlink";
8
+ import type { WorkerApi } from "./worker";
9
+ import type { AsyncTachiyomiSource, AsyncLoadOptions, LoadedExtension } from "./types";
10
+ import type { ExtensionManifest, SourceInfo } from "../types";
11
+
12
+ // Re-export types
13
+ export type { AsyncTachiyomiSource, AsyncLoadOptions, LoadedExtension } from "./types";
14
+
15
+ // Cache loaded extensions (one worker per jsUrl)
16
+ const loadedExtensions = new Map<string, {
17
+ worker: Worker;
18
+ workerApi: Comlink.Remote<WorkerApi>;
19
+ manifest: ExtensionManifest;
20
+ }>();
21
+
22
+ /**
23
+ * Load a Tachiyomi extension asynchronously (browser version)
24
+ *
25
+ * Creates a Web Worker internally to handle sync HTTP.
26
+ * Returns a LoadedExtension that can create sources.
27
+ *
28
+ * @param jsUrl - URL to the compiled extension JavaScript
29
+ * @param manifest - Extension manifest
30
+ * @param options - Proxy URL and preferences configuration
31
+ */
32
+ export async function loadExtension(
33
+ jsUrl: string,
34
+ manifest: ExtensionManifest,
35
+ options: AsyncLoadOptions = {}
36
+ ): Promise<LoadedExtension> {
37
+ const { proxyUrl, preferences } = options;
38
+
39
+ // Check cache
40
+ const cached = loadedExtensions.get(jsUrl);
41
+ if (cached) {
42
+ return createLoadedExtension(cached.workerApi, cached.manifest, cached.worker, jsUrl);
43
+ }
44
+
45
+ // Create worker
46
+ const worker = new Worker(
47
+ new URL("./worker.js", import.meta.url),
48
+ { type: "module" }
49
+ );
50
+
51
+ // Wrap with Comlink
52
+ const workerApi = Comlink.wrap<WorkerApi>(worker);
53
+
54
+ // Initialize preferences if provided
55
+ if (preferences) {
56
+ await workerApi.initPreferences(preferences.name, preferences.values);
57
+ }
58
+
59
+ // Load extension in worker
60
+ const result = await workerApi.load(
61
+ jsUrl,
62
+ manifest,
63
+ proxyUrl ?? null
64
+ );
65
+
66
+ if (!result.success || !result.manifest) {
67
+ worker.terminate();
68
+ throw new Error(`Failed to load Tachiyomi extension: ${manifest.name}`);
69
+ }
70
+
71
+ // Cache
72
+ const ext = { worker, workerApi, manifest: result.manifest };
73
+ loadedExtensions.set(jsUrl, ext);
74
+
75
+ return createLoadedExtension(workerApi, result.manifest, worker, jsUrl);
76
+ }
77
+
78
+ function createLoadedExtension(
79
+ workerApi: Comlink.Remote<WorkerApi>,
80
+ manifest: ExtensionManifest,
81
+ worker: Worker,
82
+ jsUrl: string
83
+ ): LoadedExtension {
84
+ const sources = manifest.sources ?? [];
85
+
86
+ return {
87
+ manifest,
88
+ sources,
89
+
90
+ getSource(sourceId: string): AsyncTachiyomiSource {
91
+ const sourceInfo = sources.find(s => s.id === sourceId);
92
+ if (!sourceInfo) {
93
+ throw new Error(`Source not found: ${sourceId} in ${manifest.name}`);
94
+ }
95
+
96
+ return {
97
+ sourceId,
98
+ sourceInfo,
99
+ manifest,
100
+
101
+ // Filter methods
102
+ async getFilterList() {
103
+ return workerApi.getFilterList(sourceId);
104
+ },
105
+
106
+ async resetFilters() {
107
+ return workerApi.resetFilters(sourceId);
108
+ },
109
+
110
+ async applyFilterState(filterStateJson) {
111
+ return workerApi.applyFilterState(sourceId, filterStateJson);
112
+ },
113
+
114
+ // Browse methods
115
+ async getPopularManga(page) {
116
+ return workerApi.getPopularManga(sourceId, page);
117
+ },
118
+
119
+ async getLatestUpdates(page) {
120
+ return workerApi.getLatestUpdates(sourceId, page);
121
+ },
122
+
123
+ // Search methods
124
+ async searchManga(page, query) {
125
+ return workerApi.searchManga(sourceId, page, query);
126
+ },
127
+
128
+ async searchMangaWithFilters(page, query, filterStateJson) {
129
+ return workerApi.searchMangaWithFilters(sourceId, page, query, filterStateJson);
130
+ },
131
+
132
+ // Content methods
133
+ async getMangaDetails(mangaUrl) {
134
+ return workerApi.getMangaDetails(sourceId, mangaUrl);
135
+ },
136
+
137
+ async getChapterList(mangaUrl) {
138
+ return workerApi.getChapterList(sourceId, mangaUrl);
139
+ },
140
+
141
+ async getPageList(chapterUrl) {
142
+ return workerApi.getPageList(sourceId, chapterUrl);
143
+ },
144
+
145
+ async fetchImage(pageUrl, pageImageUrl) {
146
+ return workerApi.fetchImage(sourceId, pageUrl, pageImageUrl);
147
+ },
148
+
149
+ async getHeaders() {
150
+ return workerApi.getHeaders(sourceId);
151
+ },
152
+
153
+ // Preferences methods
154
+ async initPreferences(prefsName, values) {
155
+ await workerApi.initPreferences(prefsName, values);
156
+ },
157
+
158
+ async flushPrefChanges() {
159
+ return workerApi.flushPrefChanges();
160
+ },
161
+
162
+ async getSettingsSchema() {
163
+ return workerApi.getSettingsSchema(sourceId);
164
+ },
165
+
166
+ dispose() {
167
+ // Individual source disposal is a no-op
168
+ // Use LoadedExtension.dispose() to terminate worker
169
+ },
170
+ };
171
+ },
172
+
173
+ dispose() {
174
+ loadedExtensions.delete(jsUrl);
175
+ worker.terminate();
176
+ },
177
+ };
178
+ }
179
+
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Async runtime types for Tachiyomi sources
3
+ */
4
+ import type {
5
+ Manga,
6
+ Chapter,
7
+ Page,
8
+ MangasPage,
9
+ SourceInfo,
10
+ ExtensionManifest,
11
+ FilterState,
12
+ } from "../types";
13
+
14
+ /**
15
+ * Async Tachiyomi source interface
16
+ * All methods return Promises for use on main thread
17
+ */
18
+ export interface AsyncTachiyomiSource {
19
+ readonly sourceId: string;
20
+ readonly sourceInfo: SourceInfo;
21
+ readonly manifest: ExtensionManifest;
22
+
23
+ // Filter methods
24
+ getFilterList(): Promise<FilterState[]>;
25
+ resetFilters(): Promise<boolean>;
26
+ applyFilterState(filterStateJson: string): Promise<boolean>;
27
+
28
+ // Browse methods
29
+ getPopularManga(page: number): Promise<MangasPage>;
30
+ getLatestUpdates(page: number): Promise<MangasPage>;
31
+
32
+ // Search methods
33
+ searchManga(page: number, query: string): Promise<MangasPage>;
34
+ searchMangaWithFilters(page: number, query: string, filterStateJson: string): Promise<MangasPage>;
35
+
36
+ // Content methods
37
+ getMangaDetails(mangaUrl: string): Promise<Manga | null>;
38
+ getChapterList(mangaUrl: string): Promise<Chapter[]>;
39
+ getPageList(chapterUrl: string): Promise<Page[]>;
40
+ fetchImage(pageUrl: string, pageImageUrl: string): Promise<string>;
41
+ getHeaders(): Promise<Record<string, string>>;
42
+
43
+ // Preferences methods
44
+ initPreferences(prefsName: string, values: Record<string, unknown>): Promise<void>;
45
+ flushPrefChanges(): Promise<Array<{ name: string; key: string; value: unknown }>>;
46
+ getSettingsSchema(): Promise<string | null>;
47
+
48
+ /** Terminate the source and release resources */
49
+ dispose(): void;
50
+ }
51
+
52
+ /**
53
+ * Options for loading an async source
54
+ */
55
+ export interface AsyncLoadOptions {
56
+ /**
57
+ * Proxy URL base for CORS bypass (browser only)
58
+ * The target URL will be appended (URL-encoded)
59
+ * Example: "https://proxy.example.com/?url="
60
+ */
61
+ proxyUrl?: string;
62
+
63
+ /**
64
+ * Initial preferences values
65
+ */
66
+ preferences?: {
67
+ name: string;
68
+ values: Record<string, unknown>;
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Result of loading an extension
74
+ */
75
+ export interface LoadedExtension {
76
+ manifest: ExtensionManifest;
77
+ sources: SourceInfo[];
78
+
79
+ /**
80
+ * Get an async source by ID
81
+ */
82
+ getSource(sourceId: string): AsyncTachiyomiSource;
83
+
84
+ /**
85
+ * Dispose all sources and terminate worker
86
+ */
87
+ dispose(): void;
88
+ }
89
+