@nemu.pm/tachiyomi-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Image codec for Node.js/Bun environments.
3
+ * Uses the `canvas` package (node-canvas).
4
+ */
5
+
6
+ import { createCanvas, loadImage, ImageData } from "canvas";
7
+
8
+ export async function decodeImageAsync(base64: string): Promise<{ width: number; height: number; pixels: string } | null> {
9
+ try {
10
+ const binary = atob(base64);
11
+ const bytes = new Uint8Array(binary.length);
12
+ for (let i = 0; i < binary.length; i++) {
13
+ bytes[i] = binary.charCodeAt(i);
14
+ }
15
+
16
+ const img = await loadImage(Buffer.from(bytes));
17
+ const canvas = createCanvas(img.width, img.height);
18
+ const ctx = canvas.getContext("2d");
19
+ ctx.drawImage(img, 0, 0);
20
+ const imageData = ctx.getImageData(0, 0, img.width, img.height);
21
+
22
+ // Convert RGBA to ARGB (Android Bitmap format)
23
+ const pixels = new Int32Array(img.width * img.height);
24
+ const rgba = imageData.data;
25
+ for (let i = 0; i < pixels.length; i++) {
26
+ const r = rgba[i * 4];
27
+ const g = rgba[i * 4 + 1];
28
+ const b = rgba[i * 4 + 2];
29
+ const a = rgba[i * 4 + 3];
30
+ pixels[i] = ((a << 24) | (r << 16) | (g << 8) | b) | 0;
31
+ }
32
+
33
+ // Convert to base64
34
+ const pixelBuffer = new Uint8Array(pixels.buffer);
35
+ let pixelBase64 = "";
36
+ const CHUNK_SIZE = 32768;
37
+ for (let i = 0; i < pixelBuffer.length; i += CHUNK_SIZE) {
38
+ const chunk = pixelBuffer.subarray(i, Math.min(i + CHUNK_SIZE, pixelBuffer.length));
39
+ pixelBase64 += String.fromCharCode.apply(null, chunk as unknown as number[]);
40
+ }
41
+
42
+ return { width: img.width, height: img.height, pixels: btoa(pixelBase64) };
43
+ } catch (e) {
44
+ console.error("[ImageCodec] decode error:", e);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function encodeImageAsync(
50
+ pixelsBase64: string,
51
+ width: number,
52
+ height: number,
53
+ format: string,
54
+ quality: number
55
+ ): Promise<string | null> {
56
+ try {
57
+ const binary = atob(pixelsBase64);
58
+ const pixelBuffer = new Uint8Array(binary.length);
59
+ for (let i = 0; i < binary.length; i++) {
60
+ pixelBuffer[i] = binary.charCodeAt(i);
61
+ }
62
+ const pixels = new Int32Array(pixelBuffer.buffer);
63
+
64
+ // Convert ARGB to RGBA
65
+ const rgba = new Uint8ClampedArray(width * height * 4);
66
+ for (let i = 0; i < pixels.length; i++) {
67
+ const pixel = pixels[i];
68
+ rgba[i * 4] = (pixel >> 16) & 0xff;
69
+ rgba[i * 4 + 1] = (pixel >> 8) & 0xff;
70
+ rgba[i * 4 + 2] = pixel & 0xff;
71
+ rgba[i * 4 + 3] = (pixel >> 24) & 0xff;
72
+ }
73
+
74
+ const canvas = createCanvas(width, height);
75
+ const ctx = canvas.getContext("2d");
76
+ const imageData = new ImageData(rgba, width, height);
77
+ ctx.putImageData(imageData, 0, 0);
78
+
79
+ const buffer = format === "jpeg"
80
+ ? canvas.toBuffer("image/jpeg", { quality: quality / 100 })
81
+ : canvas.toBuffer("image/png");
82
+
83
+ const outputBytes = new Uint8Array(buffer);
84
+ let result = "";
85
+ const CHUNK_SIZE = 32768;
86
+ for (let i = 0; i < outputBytes.length; i += CHUNK_SIZE) {
87
+ const chunk = outputBytes.subarray(i, Math.min(i + CHUNK_SIZE, outputBytes.length));
88
+ result += String.fromCharCode.apply(null, chunk as unknown as number[]);
89
+ }
90
+ return btoa(result);
91
+ } catch (e) {
92
+ console.error("[ImageCodec] encode error:", e);
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // Sync wrappers - in Node we can't truly block, so these return null
98
+ // Tests should use async versions directly
99
+ function decodeImageSync(base64: string): { width: number; height: number; pixels: string } | null {
100
+ console.warn("[ImageCodec] Sync decode not supported in Node.js, use decodeImageAsync instead");
101
+ return null;
102
+ }
103
+
104
+ function encodeImageSync(
105
+ pixelsBase64: string,
106
+ width: number,
107
+ height: number,
108
+ format: string,
109
+ quality: number
110
+ ): string | null {
111
+ console.warn("[ImageCodec] Sync encode not supported in Node.js, use encodeImageAsync instead");
112
+ return null;
113
+ }
114
+
115
+ export function registerImageCodec(): void {
116
+ (globalThis as Record<string, unknown>).tachiyomiDecodeImage = decodeImageSync;
117
+ (globalThis as Record<string, unknown>).tachiyomiEncodeImage = encodeImageSync;
118
+ }
119
+
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Image codec - re-exports browser implementation as default.
3
+ * Node.js users get the node version via conditional exports.
4
+ */
5
+ export { decodeImageAsync, encodeImageAsync, registerImageCodec } from "./image-codec.browser.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @tachiyomi-js/runtime - Node.js/Bun entry point
3
+ *
4
+ * Uses the `canvas` package for image manipulation.
5
+ * Install with: npm install canvas@next
6
+ */
7
+
8
+ // Register node image codec on import
9
+ import { registerImageCodec } from './image-codec.node';
10
+ registerImageCodec();
11
+
12
+ // Re-export everything from the main module (types + runtime)
13
+ export type {
14
+ Manga,
15
+ Chapter,
16
+ Page,
17
+ MangasPage,
18
+ SourceInfo,
19
+ ExtensionManifest,
20
+ Author,
21
+ FilterState,
22
+ FilterHeader,
23
+ FilterSeparator,
24
+ FilterText,
25
+ FilterCheckBox,
26
+ FilterTriState,
27
+ FilterSort,
28
+ FilterSelect,
29
+ FilterGroup,
30
+ FilterStateUpdate,
31
+ SettingsSchema,
32
+ PreferenceSchema,
33
+ } from './types';
34
+
35
+ export { MangaStatus, filterToStateUpdate, buildFilterStateJson } from './types';
36
+
37
+ export type {
38
+ HttpBridge,
39
+ HttpRequest,
40
+ HttpResponse,
41
+ } from './http';
42
+
43
+ export {
44
+ TachiyomiRuntime,
45
+ createRuntime,
46
+ type ExtensionInstance,
47
+ } from './runtime';
48
+
49
+ // Also export async image functions (useful for testing in Node)
50
+ export { decodeImageAsync, encodeImageAsync } from './image-codec.node';
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @tachiyomi-js/runtime
3
+ *
4
+ * Runtime for loading and executing Tachiyomi extensions compiled to JavaScript.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { createRuntime, type Manga, type MangasPage } from '@tachiyomi-js/runtime';
9
+ *
10
+ * // Create runtime with your HTTP implementation
11
+ * const runtime = createRuntime({
12
+ * request(req, wantBytes) {
13
+ * // Implement sync HTTP (e.g., XMLHttpRequest in a Web Worker)
14
+ * return { status: 200, statusText: 'OK', headers: {}, body: '...' };
15
+ * }
16
+ * });
17
+ *
18
+ * // Load an extension
19
+ * const ext = runtime.loadExtension(extensionJsCode);
20
+ *
21
+ * // Get sources
22
+ * const sources = ext.getSources();
23
+ * console.log(sources[0].name); // "MangaDex"
24
+ *
25
+ * // Fetch manga
26
+ * const popular: MangasPage = ext.getPopularManga(sources[0].id, 1);
27
+ * console.log(popular.mangas[0].title);
28
+ * ```
29
+ *
30
+ * @packageDocumentation
31
+ */
32
+
33
+ // Register browser image codec on import
34
+ import { registerImageCodec } from './image-codec.browser';
35
+ registerImageCodec();
36
+
37
+ // Types
38
+ export type {
39
+ Manga,
40
+ Chapter,
41
+ Page,
42
+ MangasPage,
43
+ SourceInfo,
44
+ ExtensionManifest,
45
+ Author,
46
+ FilterState,
47
+ FilterHeader,
48
+ FilterSeparator,
49
+ FilterText,
50
+ FilterCheckBox,
51
+ FilterTriState,
52
+ FilterSort,
53
+ FilterSelect,
54
+ FilterGroup,
55
+ FilterStateUpdate,
56
+ SettingsSchema,
57
+ PreferenceSchema,
58
+ } from './types';
59
+
60
+ // Enums and functions (exported as values)
61
+ export { MangaStatus, filterToStateUpdate, buildFilterStateJson } from './types';
62
+
63
+ // HTTP Bridge
64
+ export type {
65
+ HttpBridge,
66
+ HttpRequest,
67
+ HttpResponse,
68
+ } from './http';
69
+
70
+ // Runtime
71
+ export {
72
+ TachiyomiRuntime,
73
+ createRuntime,
74
+ type ExtensionInstance,
75
+ } from './runtime';
package/src/runtime.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Tachiyomi Extension Runtime
3
+ *
4
+ * Provides a clean API for loading and executing Tachiyomi extensions.
5
+ */
6
+
7
+ import type { HttpBridge, KotlinHttpResult } from './http';
8
+ import type {
9
+ Manga,
10
+ Chapter,
11
+ Page,
12
+ MangasPage,
13
+ SourceInfo,
14
+ FilterState,
15
+ SettingsSchema,
16
+ } from './types';
17
+
18
+ // ============================================================================
19
+ // Extension Instance Interface
20
+ // ============================================================================
21
+
22
+ /**
23
+ * A loaded extension instance with methods to interact with sources.
24
+ */
25
+ export interface ExtensionInstance {
26
+ /** Get all sources provided by this extension */
27
+ getSources(): SourceInfo[];
28
+
29
+ /** Get popular manga from a source */
30
+ getPopularManga(sourceId: string, page: number): MangasPage;
31
+
32
+ /** Get latest updates from a source */
33
+ getLatestUpdates(sourceId: string, page: number): MangasPage;
34
+
35
+ /** Search for manga */
36
+ searchManga(sourceId: string, page: number, query: string): MangasPage;
37
+
38
+ /** Get full manga details */
39
+ getMangaDetails(sourceId: string, manga: Pick<Manga, 'url'>): Manga;
40
+
41
+ /** Get chapter list for a manga */
42
+ getChapterList(sourceId: string, manga: Pick<Manga, 'url'>): Chapter[];
43
+
44
+ /** Get page list for a chapter */
45
+ getPageList(sourceId: string, chapter: Pick<Chapter, 'url'>): Page[];
46
+
47
+ /** Get available filters for a source */
48
+ getFilterList(sourceId: string): FilterState[];
49
+
50
+ /** Reset filters to default state */
51
+ resetFilters(sourceId: string): void;
52
+
53
+ /** Apply filter state (for stateful filters) */
54
+ applyFilterState(sourceId: string, filterState: FilterState[]): void;
55
+
56
+ /** Get settings schema for a source */
57
+ getSettingsSchema(sourceId: string): SettingsSchema | null;
58
+
59
+ /** Update a preference value */
60
+ setPreference(sourceId: string, key: string, value: unknown): void;
61
+
62
+ /** Get source headers (includes Referer from headersBuilder) */
63
+ getHeaders(sourceId: string): Record<string, string>;
64
+
65
+ /**
66
+ * Fetch image through source's client with interceptors.
67
+ * Required for sources with image descrambling/protection.
68
+ * Returns base64-encoded image bytes.
69
+ */
70
+ fetchImage(sourceId: string, pageUrl: string, imageUrl: string): string;
71
+ }
72
+
73
+ // ============================================================================
74
+ // Kotlin/JS Exports Interface
75
+ // ============================================================================
76
+
77
+ /** Internal interface matching what Kotlin/JS exports */
78
+ interface KotlinExports {
79
+ getManifest(): string;
80
+ getPopularManga(sourceId: string, page: number): string;
81
+ getLatestUpdates(sourceId: string, page: number): string;
82
+ searchManga(sourceId: string, page: number, query: string): string;
83
+ getMangaDetails(sourceId: string, mangaUrl: string): string;
84
+ getChapterList(sourceId: string, mangaUrl: string): string;
85
+ getPageList(sourceId: string, chapterUrl: string): string;
86
+ getFilterList(sourceId: string): string;
87
+ resetFilters(sourceId: string): string;
88
+ applyFilterState(sourceId: string, filterStateJson: string): string;
89
+ getHeaders(sourceId: string): string;
90
+ fetchImage(sourceId: string, pageUrl: string, imageUrl: string): string;
91
+ getSettingsSchema?(sourceId: string): string;
92
+ setPreference?(sourceId: string, key: string, valueJson: string): string;
93
+ }
94
+
95
+ // ============================================================================
96
+ // Result Unwrapping
97
+ // ============================================================================
98
+
99
+ interface KotlinResult<T> {
100
+ ok: boolean;
101
+ data?: T;
102
+ error?: unknown;
103
+ }
104
+
105
+ function unwrapResult<T>(json: string): T {
106
+ const result: KotlinResult<T> = JSON.parse(json);
107
+ if (!result.ok) {
108
+ const errMsg = typeof result.error === 'string'
109
+ ? result.error
110
+ : JSON.stringify(result.error);
111
+ throw new Error(errMsg || 'Unknown extension error');
112
+ }
113
+ return result.data as T;
114
+ }
115
+
116
+ // ============================================================================
117
+ // Runtime Implementation
118
+ // ============================================================================
119
+
120
+ /**
121
+ * Tachiyomi extension runtime.
122
+ *
123
+ * Create one runtime per application, then use it to load extensions.
124
+ */
125
+ export class TachiyomiRuntime {
126
+ private httpBridge: HttpBridge;
127
+
128
+ constructor(httpBridge: HttpBridge) {
129
+ this.httpBridge = httpBridge;
130
+ this.installHttpBridge();
131
+ }
132
+
133
+ /**
134
+ * Install the HTTP bridge on globalThis for Kotlin/JS to call.
135
+ */
136
+ private installHttpBridge(): void {
137
+ const bridge = this.httpBridge;
138
+
139
+ (globalThis as any).tachiyomiHttpRequest = (
140
+ url: string,
141
+ method: string,
142
+ headersJson: string,
143
+ body: string | null,
144
+ wantBytes: boolean
145
+ ): KotlinHttpResult => {
146
+ try {
147
+ const headers = JSON.parse(headersJson || '{}');
148
+ const response = bridge.request({ url, method, headers, body }, wantBytes);
149
+
150
+ return {
151
+ status: response.status,
152
+ statusText: response.statusText,
153
+ headersJson: JSON.stringify(response.headers),
154
+ body: response.body,
155
+ error: null,
156
+ };
157
+ } catch (e: any) {
158
+ return {
159
+ status: 0,
160
+ statusText: '',
161
+ headersJson: '{}',
162
+ body: '',
163
+ error: e.message || 'HTTP request failed',
164
+ };
165
+ }
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Load an extension from its JavaScript code.
171
+ *
172
+ * @param code - The compiled extension JavaScript
173
+ * @returns An ExtensionInstance for interacting with the extension's sources
174
+ */
175
+ loadExtension(code: string): ExtensionInstance {
176
+ // Execute the extension code
177
+ const fn = new Function(code);
178
+ fn();
179
+
180
+ // Find the exports in globalThis
181
+ // Extensions export to globalThis['moduleName'].tachiyomi.generated
182
+ const g = globalThis as any;
183
+ let exports: KotlinExports | null = null;
184
+
185
+ for (const key of Object.keys(g)) {
186
+ if (g[key]?.tachiyomi?.generated) {
187
+ exports = g[key].tachiyomi.generated;
188
+ break;
189
+ }
190
+ }
191
+
192
+ if (!exports || typeof exports.getManifest !== 'function') {
193
+ throw new Error('Invalid extension: could not find tachiyomi.generated exports');
194
+ }
195
+
196
+ return new ExtensionInstanceImpl(exports);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Internal implementation of ExtensionInstance
202
+ */
203
+ class ExtensionInstanceImpl implements ExtensionInstance {
204
+ private exports: KotlinExports;
205
+ private sourcesCache: SourceInfo[] | null = null;
206
+
207
+ constructor(exports: KotlinExports) {
208
+ this.exports = exports;
209
+ }
210
+
211
+ getSources(): SourceInfo[] {
212
+ if (this.sourcesCache) return this.sourcesCache;
213
+ const json = this.exports.getManifest();
214
+ this.sourcesCache = unwrapResult<SourceInfo[]>(json);
215
+ return this.sourcesCache;
216
+ }
217
+
218
+ getPopularManga(sourceId: string, page: number): MangasPage {
219
+ const json = this.exports.getPopularManga(sourceId, page);
220
+ return unwrapResult<MangasPage>(json);
221
+ }
222
+
223
+ getLatestUpdates(sourceId: string, page: number): MangasPage {
224
+ const json = this.exports.getLatestUpdates(sourceId, page);
225
+ return unwrapResult<MangasPage>(json);
226
+ }
227
+
228
+ searchManga(sourceId: string, page: number, query: string): MangasPage {
229
+ const json = this.exports.searchManga(sourceId, page, query);
230
+ return unwrapResult<MangasPage>(json);
231
+ }
232
+
233
+ getMangaDetails(sourceId: string, manga: Pick<Manga, 'url'>): Manga {
234
+ const json = this.exports.getMangaDetails(sourceId, manga.url);
235
+ return unwrapResult<Manga>(json);
236
+ }
237
+
238
+ getChapterList(sourceId: string, manga: Pick<Manga, 'url'>): Chapter[] {
239
+ const json = this.exports.getChapterList(sourceId, manga.url);
240
+ return unwrapResult<Chapter[]>(json);
241
+ }
242
+
243
+ getPageList(sourceId: string, chapter: Pick<Chapter, 'url'>): Page[] {
244
+ const json = this.exports.getPageList(sourceId, chapter.url);
245
+ return unwrapResult<Page[]>(json);
246
+ }
247
+
248
+ getFilterList(sourceId: string): FilterState[] {
249
+ const json = this.exports.getFilterList(sourceId);
250
+ return unwrapResult<FilterState[]>(json);
251
+ }
252
+
253
+ resetFilters(sourceId: string): void {
254
+ const json = this.exports.resetFilters(sourceId);
255
+ unwrapResult<{ ok: boolean }>(json);
256
+ }
257
+
258
+ applyFilterState(sourceId: string, filterState: FilterState[]): void {
259
+ const json = this.exports.applyFilterState(sourceId, JSON.stringify(filterState));
260
+ unwrapResult<{ ok: boolean }>(json);
261
+ }
262
+
263
+ getSettingsSchema(sourceId: string): SettingsSchema | null {
264
+ if (!this.exports.getSettingsSchema) return null;
265
+ try {
266
+ const json = this.exports.getSettingsSchema(sourceId);
267
+ return unwrapResult<SettingsSchema>(json);
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ setPreference(sourceId: string, key: string, value: unknown): void {
274
+ if (!this.exports.setPreference) return;
275
+ const json = this.exports.setPreference(sourceId, key, JSON.stringify(value));
276
+ unwrapResult<{ ok: boolean }>(json);
277
+ }
278
+
279
+ getHeaders(sourceId: string): Record<string, string> {
280
+ const json = this.exports.getHeaders(sourceId);
281
+ return unwrapResult<Record<string, string>>(json);
282
+ }
283
+
284
+ fetchImage(sourceId: string, pageUrl: string, imageUrl: string): string {
285
+ const json = this.exports.fetchImage(sourceId, pageUrl, imageUrl);
286
+ return unwrapResult<string>(json);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Create a Tachiyomi runtime with the given HTTP bridge.
292
+ *
293
+ * @example
294
+ * ```typescript
295
+ * import { createRuntime } from '@tachiyomi-js/runtime';
296
+ *
297
+ * const runtime = createRuntime({
298
+ * request(req, wantBytes) {
299
+ * // Your HTTP implementation here
300
+ * const xhr = new XMLHttpRequest();
301
+ * xhr.open(req.method, req.url, false);
302
+ * // ...
303
+ * return { status: 200, body: '...', headers: {}, statusText: 'OK' };
304
+ * }
305
+ * });
306
+ *
307
+ * const ext = runtime.loadExtension(extensionCode);
308
+ * const sources = ext.getSources();
309
+ * ```
310
+ */
311
+ export function createRuntime(httpBridge: HttpBridge): TachiyomiRuntime {
312
+ return new TachiyomiRuntime(httpBridge);
313
+ }
314
+