mioku-service-webui 2.0.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,475 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { Hono } from "hono";
4
+
5
+ const OFFICIAL_REGISTRY_PATH = path.join(
6
+ process.cwd(),
7
+ "official-registry.json",
8
+ );
9
+ const NPM_SEARCH_URL = "https://registry.npmjs.org/-/v1/search";
10
+ const NPM_PACKAGE_URL = "https://registry.npmjs.org";
11
+ const CACHE_TTL_MS = 10 * 60 * 1000;
12
+ const DEFAULT_LIMIT = 20;
13
+ const MAX_LIMIT = 50;
14
+ const DEFAULT_SEARCH_SIZE = 100;
15
+ const SERVICE_ROOT = "src/services";
16
+
17
+ interface OfficialPluginEntry {
18
+ builtin?: boolean;
19
+ npm?: string;
20
+ }
21
+
22
+ interface OfficialServiceEntry {
23
+ builtin?: boolean;
24
+ npm?: string;
25
+ }
26
+
27
+ interface OfficialRegistry {
28
+ plugins?: Record<string, OfficialPluginEntry>;
29
+ services?: Record<string, OfficialServiceEntry>;
30
+ }
31
+
32
+ interface NpmPackageLinks {
33
+ npm?: string;
34
+ homepage?: string;
35
+ repository?: string;
36
+ bugs?: string;
37
+ }
38
+
39
+ interface NpmSearchPackage {
40
+ name: string;
41
+ version?: string;
42
+ description?: string;
43
+ date?: string;
44
+ keywords?: string[];
45
+ links?: NpmPackageLinks;
46
+ }
47
+
48
+ interface NpmSearchObject {
49
+ package: NpmSearchPackage;
50
+ score?: Record<string, number>;
51
+ searchScore?: number;
52
+ }
53
+
54
+ interface StoreItem {
55
+ name: string;
56
+ npm: string;
57
+ type: "plugin" | "service";
58
+ description: string;
59
+ version: string;
60
+ keywords: string[];
61
+ tags: string[];
62
+ official: boolean;
63
+ builtin: boolean;
64
+ repo: string;
65
+ homepage: string;
66
+ npmUrl: string;
67
+ date: string;
68
+ searchScore: number;
69
+ }
70
+
71
+ interface StoreListResponse {
72
+ items: StoreItem[];
73
+ total: number;
74
+ offset: number;
75
+ limit: number;
76
+ hasMore: boolean;
77
+ q: string;
78
+ type: "plugin" | "service" | "all";
79
+ }
80
+
81
+ let cachedOfficialRegistry: OfficialRegistry | null = null;
82
+ let officialRegistryCachedAt = 0;
83
+ const npmSearchCache = new Map<string, { cachedAt: number; data: NpmSearchObject[] }>();
84
+ const npmPackageCache = new Map<string, { cachedAt: number; data: any }>();
85
+
86
+ function isCacheFresh(cachedAt: number): boolean {
87
+ return Date.now() - cachedAt < CACHE_TTL_MS;
88
+ }
89
+
90
+ function readOfficialRegistry(force = false): OfficialRegistry {
91
+ if (!force && cachedOfficialRegistry && isCacheFresh(officialRegistryCachedAt)) {
92
+ return cachedOfficialRegistry;
93
+ }
94
+
95
+ try {
96
+ const raw = fs.readFileSync(OFFICIAL_REGISTRY_PATH, "utf-8");
97
+ const parsed = JSON.parse(raw) as OfficialRegistry;
98
+ cachedOfficialRegistry = parsed;
99
+ officialRegistryCachedAt = Date.now();
100
+ return parsed;
101
+ } catch (error: any) {
102
+ throw new Error(`读取官方注册表失败: ${error?.message || error}`);
103
+ }
104
+ }
105
+
106
+ function normalizeType(input: string | undefined): "plugin" | "service" | "all" {
107
+ if (input === "plugin" || input === "service" || input === "all") {
108
+ return input;
109
+ }
110
+ return "all";
111
+ }
112
+
113
+ function parsePositiveInt(input: string | undefined, fallback: number): number {
114
+ const value = Number(input);
115
+ if (!Number.isFinite(value) || value < 0) {
116
+ return fallback;
117
+ }
118
+ return Math.floor(value);
119
+ }
120
+
121
+ function normalizeLimit(input: string | undefined): number {
122
+ const value = parsePositiveInt(input, DEFAULT_LIMIT);
123
+ return Math.min(Math.max(value, 1), MAX_LIMIT);
124
+ }
125
+
126
+ function normalizeOffset(input: string | undefined): number {
127
+ return parsePositiveInt(input, 0);
128
+ }
129
+
130
+ function normalizeQuery(input: string | undefined): string {
131
+ return String(input || "").trim();
132
+ }
133
+
134
+ function buildOfficialMaps(registry: OfficialRegistry): {
135
+ plugins: Map<string, OfficialPluginEntry>;
136
+ services: Map<string, OfficialServiceEntry>;
137
+ } {
138
+ const plugins = new Map<string, OfficialPluginEntry>();
139
+ const services = new Map<string, OfficialServiceEntry>();
140
+
141
+ for (const [, entry] of Object.entries(registry.plugins || {})) {
142
+ if (entry?.npm) {
143
+ plugins.set(entry.npm, entry);
144
+ }
145
+ }
146
+
147
+ for (const [, entry] of Object.entries(registry.services || {})) {
148
+ if (entry?.npm) {
149
+ services.set(entry.npm, entry);
150
+ }
151
+ }
152
+
153
+ return { plugins, services };
154
+ }
155
+
156
+ function isPluginPackage(name: string): boolean {
157
+ return name.startsWith("mioku-plugin-");
158
+ }
159
+
160
+ function isServicePackage(name: string): boolean {
161
+ return name.startsWith("mioku-service-");
162
+ }
163
+
164
+ function inferTypeFromPackageName(name: string): "plugin" | "service" | null {
165
+ if (isPluginPackage(name)) return "plugin";
166
+ if (isServicePackage(name)) return "service";
167
+ return null;
168
+ }
169
+
170
+ function inferDisplayName(packageName: string, type: "plugin" | "service"): string {
171
+ const prefix = type === "plugin" ? "mioku-plugin-" : "mioku-service-";
172
+ return packageName.startsWith(prefix)
173
+ ? packageName.slice(prefix.length)
174
+ : packageName;
175
+ }
176
+
177
+ function normalizeKeywords(keywords: unknown): string[] {
178
+ if (!Array.isArray(keywords)) return [];
179
+ return keywords
180
+ .map((item) => String(item || "").trim())
181
+ .filter(Boolean);
182
+ }
183
+
184
+ function extractTags(keywords: string[]): string[] {
185
+ return keywords.filter((keyword) => keyword !== "mioku");
186
+ }
187
+
188
+ function normalizeRepositoryUrl(repository: any): string {
189
+ if (!repository) return "";
190
+ let raw = "";
191
+ if (typeof repository === "string") {
192
+ raw = repository;
193
+ } else if (typeof repository?.url === "string") {
194
+ raw = repository.url;
195
+ }
196
+ if (!raw) return "";
197
+
198
+ let url = raw.trim().replace(/^git\+/, "");
199
+ if (url.startsWith("git@")) {
200
+ const matched = url.match(/^git@([^:]+):(.+)$/);
201
+ if (matched) {
202
+ url = `https://${matched[1]}/${matched[2]}`;
203
+ }
204
+ }
205
+ if (url.startsWith("ssh://git@")) {
206
+ url = url.replace(/^ssh:\/\/git@/, "https://").replace(/:/, "/");
207
+ }
208
+ return url.replace(/\.git$/, "");
209
+ }
210
+
211
+ function scoreStoreItem(item: StoreItem, query: string): number {
212
+ const q = query.toLowerCase();
213
+ if (!q) return item.searchScore || 0;
214
+
215
+ const fields = [
216
+ item.name.toLowerCase(),
217
+ item.npm.toLowerCase(),
218
+ item.description.toLowerCase(),
219
+ ...item.keywords.map((keyword) => keyword.toLowerCase()),
220
+ ];
221
+
222
+ let score = item.searchScore || 0;
223
+ for (const field of fields) {
224
+ if (field === q) score += 200;
225
+ else if (field.startsWith(q)) score += 80;
226
+ else if (field.includes(q)) score += 30;
227
+ }
228
+ if (item.official) score += 10;
229
+ return score;
230
+ }
231
+
232
+ function compareStoreItems(a: StoreItem, b: StoreItem, query: string): number {
233
+ if (a.official !== b.official) {
234
+ return a.official ? -1 : 1;
235
+ }
236
+
237
+ const scoreDiff = scoreStoreItem(b, query) - scoreStoreItem(a, query);
238
+ if (scoreDiff !== 0) {
239
+ return scoreDiff;
240
+ }
241
+
242
+ const dateA = a.date ? new Date(a.date).getTime() : 0;
243
+ const dateB = b.date ? new Date(b.date).getTime() : 0;
244
+ if (dateA !== dateB) {
245
+ return dateB - dateA;
246
+ }
247
+
248
+ return a.npm.localeCompare(b.npm);
249
+ }
250
+
251
+ async function fetchJson(url: string): Promise<any> {
252
+ const res = await fetch(url, {
253
+ headers: {
254
+ Accept: "application/json",
255
+ "User-Agent": "mioku-store",
256
+ },
257
+ });
258
+ if (!res.ok) {
259
+ throw new Error(`HTTP_${res.status}`);
260
+ }
261
+ return res.json();
262
+ }
263
+
264
+ async function searchNpmPackages(type: "plugin" | "service" | "all", query: string, force = false): Promise<NpmSearchObject[]> {
265
+ const cacheKey = `${type}:${query}`;
266
+ const cached = npmSearchCache.get(cacheKey);
267
+ if (!force && cached && isCacheFresh(cached.cachedAt)) {
268
+ return cached.data;
269
+ }
270
+
271
+ const terms: string[] = ["mioku"];
272
+ if (query) terms.push(query);
273
+
274
+ const url = new URL(NPM_SEARCH_URL);
275
+ url.searchParams.set("text", terms.join(" "));
276
+ url.searchParams.set("size", String(DEFAULT_SEARCH_SIZE));
277
+
278
+ const data = await fetchJson(url.toString());
279
+ const objects = Array.isArray(data?.objects) ? (data.objects as NpmSearchObject[]) : [];
280
+ npmSearchCache.set(cacheKey, { cachedAt: Date.now(), data: objects });
281
+ return objects;
282
+ }
283
+
284
+ async function fetchNpmPackage(name: string, force = false): Promise<any> {
285
+ const cached = npmPackageCache.get(name);
286
+ if (!force && cached && isCacheFresh(cached.cachedAt)) {
287
+ return cached.data;
288
+ }
289
+
290
+ const data = await fetchJson(`${NPM_PACKAGE_URL}/${encodeURIComponent(name)}`);
291
+ npmPackageCache.set(name, { cachedAt: Date.now(), data });
292
+ return data;
293
+ }
294
+
295
+ function toStoreItem(
296
+ pkg: NpmSearchPackage,
297
+ officialMaps: ReturnType<typeof buildOfficialMaps>,
298
+ ): StoreItem | null {
299
+ const npm = String(pkg?.name || "").trim();
300
+ const type = inferTypeFromPackageName(npm);
301
+ if (!npm || !type) return null;
302
+
303
+ const keywords = normalizeKeywords(pkg.keywords);
304
+ const officialEntry = type === "plugin" ? officialMaps.plugins.get(npm) : officialMaps.services.get(npm);
305
+
306
+ return {
307
+ name: inferDisplayName(npm, type),
308
+ npm,
309
+ type,
310
+ description: String(pkg.description || "").trim(),
311
+ version: String(pkg.version || "").trim(),
312
+ keywords,
313
+ tags: extractTags(keywords),
314
+ official: Boolean(officialEntry),
315
+ builtin: Boolean(officialEntry?.builtin),
316
+ repo: normalizeRepositoryUrl(pkg.links?.repository),
317
+ homepage: String(pkg.links?.homepage || "").trim(),
318
+ npmUrl: String(pkg.links?.npm || `https://www.npmjs.com/package/${npm}`),
319
+ date: String(pkg.date || "").trim(),
320
+ searchScore: Number(pkg ? 0 : 0),
321
+ };
322
+ }
323
+
324
+ async function getStoreList(params: {
325
+ type: "plugin" | "service" | "all";
326
+ query: string;
327
+ offset: number;
328
+ limit: number;
329
+ force?: boolean;
330
+ }): Promise<StoreListResponse> {
331
+ const officialRegistry = readOfficialRegistry(params.force);
332
+ const officialMaps = buildOfficialMaps(officialRegistry);
333
+ const searchResults = await searchNpmPackages(params.type, params.query, params.force);
334
+
335
+ const items = searchResults
336
+ .map((entry) => {
337
+ const item = toStoreItem(entry.package, officialMaps);
338
+ if (!item) return null;
339
+ return {
340
+ ...item,
341
+ searchScore: Number(entry.searchScore || entry.score?.final || 0),
342
+ };
343
+ })
344
+ .filter((item): item is StoreItem => Boolean(item))
345
+ .filter((item) => {
346
+ if (params.type !== "all" && item.type !== params.type) {
347
+ return false;
348
+ }
349
+ if (!params.query) return true;
350
+ const q = params.query.toLowerCase();
351
+ return (
352
+ item.name.toLowerCase().includes(q) ||
353
+ item.npm.toLowerCase().includes(q) ||
354
+ item.description.toLowerCase().includes(q) ||
355
+ item.keywords.some((keyword) => keyword.toLowerCase().includes(q))
356
+ );
357
+ })
358
+ .sort((a, b) => compareStoreItems(a, b, params.query));
359
+
360
+ const total = items.length;
361
+ const sliced = items.slice(params.offset, params.offset + params.limit);
362
+
363
+ return {
364
+ items: sliced,
365
+ total,
366
+ offset: params.offset,
367
+ limit: params.limit,
368
+ hasMore: params.offset + params.limit < total,
369
+ q: params.query,
370
+ type: params.type,
371
+ };
372
+ }
373
+
374
+ function inferManagedTargetFromPackage(name: string): "plugin" | "service" {
375
+ return isServicePackage(name) ? "service" : "plugin";
376
+ }
377
+
378
+ function getServiceInstallPath(pkg: any, packageName: string): string {
379
+ const mioku = pkg?.mioku;
380
+ const serviceName = String(mioku?.serviceName || inferDisplayName(packageName, "service") || "").trim();
381
+ if (!serviceName) {
382
+ return "";
383
+ }
384
+ return `${SERVICE_ROOT}/${serviceName}`;
385
+ }
386
+
387
+ export function createStoreRoutes() {
388
+ const app = new Hono();
389
+
390
+ app.get("/official", async (c) => {
391
+ const force = c.req.query("force") === "1";
392
+ try {
393
+ const registry = readOfficialRegistry(force);
394
+ return c.json({ ok: true, data: registry });
395
+ } catch (error: any) {
396
+ return c.json({ ok: false, error: error?.message || "FETCH_FAILED" }, 502);
397
+ }
398
+ });
399
+
400
+ app.get("/search", async (c) => {
401
+ const force = c.req.query("force") === "1";
402
+ const query = normalizeQuery(c.req.query("q"));
403
+ const type = normalizeType(c.req.query("type"));
404
+ const offset = normalizeOffset(c.req.query("offset"));
405
+ const limit = normalizeLimit(c.req.query("limit"));
406
+
407
+ try {
408
+ const result = await getStoreList({
409
+ type,
410
+ query,
411
+ offset,
412
+ limit,
413
+ force,
414
+ });
415
+ return c.json({ ok: true, data: result });
416
+ } catch (error: any) {
417
+ return c.json({ ok: false, error: error?.message || "FETCH_FAILED" }, 502);
418
+ }
419
+ });
420
+
421
+ app.get("/package/:name", async (c) => {
422
+ const force = c.req.query("force") === "1";
423
+ const packageName = String(c.req.param("name") || "").trim();
424
+ if (!packageName) {
425
+ return c.json({ ok: false, error: "PACKAGE_NAME_REQUIRED" }, 400);
426
+ }
427
+
428
+ try {
429
+ const officialRegistry = readOfficialRegistry(force);
430
+ const officialMaps = buildOfficialMaps(officialRegistry);
431
+ const data = await fetchNpmPackage(packageName, force);
432
+ const latestVersion = String(data?.["dist-tags"]?.latest || "").trim();
433
+ const latest = latestVersion ? data?.versions?.[latestVersion] || {} : {};
434
+ const type = inferTypeFromPackageName(packageName);
435
+ if (!type) {
436
+ return c.json({ ok: false, error: "UNSUPPORTED_PACKAGE" }, 400);
437
+ }
438
+
439
+ const keywords = normalizeKeywords(latest?.keywords);
440
+ const officialEntry = type === "plugin" ? officialMaps.plugins.get(packageName) : officialMaps.services.get(packageName);
441
+ const repositoryUrl = normalizeRepositoryUrl(latest?.repository);
442
+
443
+ return c.json({
444
+ ok: true,
445
+ data: {
446
+ name: inferDisplayName(packageName, type),
447
+ npm: packageName,
448
+ type,
449
+ version: latestVersion,
450
+ description: String(latest?.description || data?.description || "").trim(),
451
+ keywords,
452
+ tags: extractTags(keywords),
453
+ official: Boolean(officialEntry),
454
+ builtin: Boolean(officialEntry?.builtin),
455
+ repo: repositoryUrl,
456
+ homepage: String(latest?.homepage || "").trim(),
457
+ npmUrl: `https://www.npmjs.com/package/${packageName}`,
458
+ readme: String(data?.readme || "").trim(),
459
+ license: String(latest?.license || "").trim(),
460
+ dependencies: latest?.dependencies || {},
461
+ requiredServices: Array.isArray(latest?.mioku?.services) ? latest.mioku.services : [],
462
+ installTarget: inferManagedTargetFromPackage(packageName),
463
+ installPath:
464
+ type === "plugin"
465
+ ? `plugins/${inferDisplayName(packageName, "plugin")}`
466
+ : getServiceInstallPath(latest, packageName),
467
+ },
468
+ });
469
+ } catch (error: any) {
470
+ return c.json({ ok: false, error: error?.message || "FETCH_FAILED" }, 502);
471
+ }
472
+ });
473
+
474
+ return app;
475
+ }
@@ -0,0 +1,35 @@
1
+ import { Hono } from "hono";
2
+ import {
3
+ checkWebUIReleaseUpdate,
4
+ getWebUISettings,
5
+ updateWebUIDistFromRelease,
6
+ updateWebUISettings,
7
+ } from "../system";
8
+
9
+ export function createWebUISettingsRoutes() {
10
+ const app = new Hono();
11
+
12
+ app.get("/", (c) => {
13
+ const data = getWebUISettings();
14
+ return c.json({ ok: true, data });
15
+ });
16
+
17
+ app.put("/", async (c) => {
18
+ const body = await c.req.json();
19
+ const data = updateWebUISettings(body ?? {});
20
+ return c.json({ ok: true, data });
21
+ });
22
+
23
+ app.get("/update/check", async (c) => {
24
+ const force = c.req.query("force") === "1";
25
+ const data = await checkWebUIReleaseUpdate(force);
26
+ return c.json({ ok: true, data });
27
+ });
28
+
29
+ app.post("/update/apply", async (c) => {
30
+ const data = await updateWebUIDistFromRelease();
31
+ return c.json({ ok: true, data });
32
+ });
33
+
34
+ return app;
35
+ }