@minus-ai/dev-vite-plugin 0.1.0-beta.1

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/src/index.ts ADDED
@@ -0,0 +1,647 @@
1
+ // @minus-ai/dev-vite-plugin — skill 开发/构建一体化 vite 插件
2
+ //
3
+ // dev 模式:proxy 所有 API 请求到平台网关,注入 dev 配置
4
+ // build 模式:externalize react/@minus/*,归档 builds/{version}/
5
+
6
+ import { createProxyMiddleware } from 'http-proxy-middleware';
7
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync, cpSync, renameSync, realpathSync, statSync } from 'node:fs';
8
+ import { resolve, join } from 'node:path';
9
+ import type { Plugin, UserConfig } from 'vite';
10
+ import type { IncomingMessage, ServerResponse } from 'node:http';
11
+ import http from 'node:http';
12
+ import https from 'node:https';
13
+
14
+ export interface MinusDevOptions {
15
+ /** 平台网关地址。不传时从 .minus/skill.json 的 gateway 字段读取。 */
16
+ gateway?: string;
17
+ /** skill 版本号。不传时从 pipeline.py 读取。 */
18
+ version?: string;
19
+ /** 本地后端地址(如 http://localhost:4100)。设置后 /skill/{id}/{ver}/api/* 会转发到本地后端并剥掉前缀。 */
20
+ localBackend?: string;
21
+ }
22
+
23
+ function readSkillJson(skillDir: string): { skillId?: string; version?: string; gateway?: string } {
24
+ const p = join(skillDir, '.minus', 'skill.json');
25
+ if (!existsSync(p)) return {};
26
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
27
+ }
28
+
29
+ function readPipelineVersion(skillDir: string): string | null {
30
+ const path = join(skillDir, 'pipeline.py');
31
+ if (!existsSync(path)) return null;
32
+ const src = readFileSync(path, 'utf8');
33
+ const m = src.match(/^\s*version\s*=\s*["']([^"']+)["']/m);
34
+ return m ? m[1] : null;
35
+ }
36
+
37
+ function promoteToBuilds(skillDir: string): void {
38
+ const meta = readSkillJson(skillDir);
39
+ const version = meta.version ?? readPipelineVersion(skillDir);
40
+ if (!version) {
41
+ console.log('[minus-dev] No version found, skipping promote.');
42
+ return;
43
+ }
44
+
45
+ const staticDir = join(skillDir, 'static');
46
+ if (!existsSync(staticDir)) {
47
+ console.log('[minus-dev] No static/ dir, skipping promote.');
48
+ return;
49
+ }
50
+
51
+ const buildsRoot = join(skillDir, 'builds');
52
+ mkdirSync(buildsRoot, { recursive: true });
53
+ const targetDir = join(buildsRoot, version);
54
+
55
+ if (existsSync(targetDir)) {
56
+ rmSync(targetDir, { recursive: true, force: true });
57
+ }
58
+
59
+ try {
60
+ renameSync(staticDir, targetDir);
61
+ } catch (e: any) {
62
+ if (e.code === 'EXDEV') {
63
+ mkdirSync(targetDir, { recursive: true });
64
+ cpSync(staticDir, targetDir, { recursive: true });
65
+ rmSync(staticDir, { recursive: true, force: true });
66
+ } else {
67
+ throw e;
68
+ }
69
+ }
70
+ console.log(`[minus-dev] static/ → builds/${version}/`);
71
+
72
+ const embedPath = join(targetDir, 'embed.html');
73
+ if (existsSync(embedPath)) {
74
+ const IMPORT_MAP = `<script type="importmap">{"imports":{` +
75
+ `"react":"/runtime/react-all/react-all.js",` +
76
+ `"react-dom":"/runtime/react-all/react-all.js",` +
77
+ `"react-dom/client":"/runtime/react-all/react-all.js",` +
78
+ `"react/jsx-runtime":"/runtime/react-all/react-all.js",` +
79
+ `"react/jsx-dev-runtime":"/runtime/react-all/react-all.js",` +
80
+ `"@minus/widget-framework":"/runtime/widget-framework/index.js",` +
81
+ `"@minus/embed-sdk":"/runtime/embed-sdk/index.js",` +
82
+ `"@minus/platform-widgets":"/runtime/platform-widgets/index.js",` +
83
+ `"@minus/platform-utils":"/runtime/platform-utils/index.js",` +
84
+ `"@minus/platform-hooks":"/runtime/platform-hooks/index.js"` +
85
+ `}}</script>`;
86
+ const ES_MODULE_SHIMS = `<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.1/dist/es-module-shims.js"></script>`;
87
+ let html = readFileSync(embedPath, 'utf8');
88
+ html = html.replace('<head>', `<head>\n${ES_MODULE_SHIMS}\n${IMPORT_MAP}`);
89
+ writeFileSync(embedPath, html, 'utf8');
90
+ console.log('[minus-dev] importmap injected into embed.html');
91
+ }
92
+
93
+ writeFileSync(join(buildsRoot, '.current'), version + '\n', 'utf8');
94
+ }
95
+
96
+ import { cleanupDev, writePid } from './cleanup.ts';
97
+ export { cleanupDev, writePid };
98
+
99
+ export function minusDev(opts: MinusDevOptions = {}): Plugin[] {
100
+ const skillDir = resolve(process.cwd(), '..');
101
+ const meta = readSkillJson(skillDir);
102
+ // 读取项目根目录 .env.local 中的 MINUS_AI_PLATFORM_URL
103
+ let envPlatformUrl: string | undefined;
104
+ const envLocalPath = join(skillDir, '.env.local');
105
+ if (existsSync(envLocalPath)) {
106
+ const envContent = readFileSync(envLocalPath, 'utf8');
107
+ const match = envContent.match(/^MINUS_AI_PLATFORM_URL=(.+)$/m);
108
+ if (match) envPlatformUrl = match[1].trim();
109
+ }
110
+ // 读取开发者 mdk_ key(用于 dev-session cookie 认证)
111
+ let devApiKey: string | undefined;
112
+ const credentialsPath = join(process.env.HOME || '', '.minus', 'credentials.json');
113
+ if (existsSync(credentialsPath)) {
114
+ try {
115
+ const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'));
116
+ if (creds.api_key) devApiKey = creds.api_key;
117
+ } catch { /* ignore */ }
118
+ }
119
+
120
+ const gateway = opts.gateway ?? envPlatformUrl ?? meta.gateway ?? 'http://localhost:18686';
121
+ const version = opts.version ?? meta.version ?? readPipelineVersion(skillDir) ?? '1.0.0';
122
+ const skillId = meta.skillId;
123
+ const localBackend = opts.localBackend;
124
+
125
+ // 找到 sif-platform-template/runtime 目录
126
+ // 策略 1: symlink resolve(npm/monorepo)
127
+ // 策略 2: package.json 里 file: 协议路径反推(pnpm 会复制到 .pnpm store,symlink 失效)
128
+ let localRuntimeDir: string | null = null;
129
+ for (const base of [skillDir, join(skillDir, 'frontend')]) {
130
+ try {
131
+ const pluginLink = join(base, 'node_modules', '@minus', 'dev-vite-plugin');
132
+ const pluginReal = realpathSync(pluginLink);
133
+ const candidate = resolve(pluginReal, '..', '..', 'runtime');
134
+ if (existsSync(candidate)) { localRuntimeDir = candidate; break; }
135
+ } catch { /* continue */ }
136
+ }
137
+ if (!localRuntimeDir) {
138
+ for (const base of [skillDir, join(skillDir, 'frontend')]) {
139
+ try {
140
+ const pkg = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8'));
141
+ const ref = pkg.dependencies?.['@minus-ai/dev-vite-plugin'] ?? pkg.devDependencies?.['@minus-ai/dev-vite-plugin'];
142
+ if (typeof ref === 'string' && ref.startsWith('file:')) {
143
+ const pluginSrc = resolve(base, ref.slice('file:'.length));
144
+ const candidate = resolve(pluginSrc, '..', '..', 'runtime');
145
+ if (existsSync(candidate)) { localRuntimeDir = candidate; break; }
146
+ }
147
+ } catch { /* continue */ }
148
+ }
149
+ }
150
+
151
+ const devPlugin: Plugin = {
152
+ name: 'minus-dev-proxy',
153
+ apply: 'serve',
154
+ enforce: 'pre',
155
+
156
+ configureServer(server) {
157
+ // 自动清理残留进程 + 写 PID,无论 npx vite 还是 npm run dev 都生效
158
+ cleanupDev(skillDir);
159
+ writePid(skillDir);
160
+ server.httpServer?.once('close', () => {
161
+ const pidFile = join(skillDir, '.minus', 'dev.pid');
162
+ try { unlinkSync(pidFile); } catch { /* ok */ }
163
+ });
164
+
165
+ // SSE 端点需要 streaming proxy(不 buffer 响应)
166
+ const streamingProxy = createProxyMiddleware({
167
+ target: gateway,
168
+ changeOrigin: true,
169
+ cookieDomainRewrite: '',
170
+ proxyTimeout: 0,
171
+ timeout: 0,
172
+ });
173
+
174
+ // 普通 API 用 buffered proxy(避免 chunked encoding 问题)
175
+ const bufferedProxy = createProxyMiddleware({
176
+ target: gateway,
177
+ changeOrigin: true,
178
+ cookieDomainRewrite: '',
179
+ selfHandleResponse: true,
180
+ proxyTimeout: 0,
181
+ timeout: 0,
182
+ on: {
183
+ proxyRes: (proxyRes, _req, res) => {
184
+ const chunks: Buffer[] = [];
185
+ let finished = false;
186
+ const finalize = () => {
187
+ if (finished) return;
188
+ finished = true;
189
+ const body = Buffer.concat(chunks);
190
+ const headers = { ...proxyRes.headers };
191
+ delete headers['transfer-encoding'];
192
+ delete headers['connection'];
193
+ headers['content-length'] = String(body.length);
194
+ if (!res.headersSent) res.writeHead(proxyRes.statusCode || 502, headers);
195
+ res.end(body);
196
+ };
197
+ proxyRes.on('data', (c: Buffer) => chunks.push(c));
198
+ proxyRes.on('end', finalize);
199
+ proxyRes.on('aborted', finalize);
200
+ proxyRes.on('error', finalize);
201
+ },
202
+ },
203
+ });
204
+
205
+ // 本地后端代理(剥掉 /skill/{skillId}/{version} 前缀)
206
+ let localStreamingProxy: any = null;
207
+ let localBufferedProxy: any = null;
208
+ if (localBackend) {
209
+ localStreamingProxy = createProxyMiddleware({
210
+ target: localBackend,
211
+ changeOrigin: true,
212
+ proxyTimeout: 0,
213
+ timeout: 0,
214
+ });
215
+ localBufferedProxy = createProxyMiddleware({
216
+ target: localBackend,
217
+ changeOrigin: true,
218
+ selfHandleResponse: true,
219
+ proxyTimeout: 0,
220
+ timeout: 0,
221
+ on: {
222
+ proxyRes: (proxyRes: IncomingMessage, _req: IncomingMessage, res: ServerResponse) => {
223
+ const chunks: Buffer[] = [];
224
+ let finished = false;
225
+ const finalize = () => {
226
+ if (finished) return;
227
+ finished = true;
228
+ const body = Buffer.concat(chunks);
229
+ const headers = { ...proxyRes.headers };
230
+ delete headers['transfer-encoding'];
231
+ delete headers['connection'];
232
+ headers['content-length'] = String(body.length);
233
+ if (!res.headersSent) res.writeHead((proxyRes as any).statusCode || 502, headers);
234
+ res.end(body);
235
+ };
236
+ proxyRes.on('data', (c: Buffer) => chunks.push(c));
237
+ proxyRes.on('end', finalize);
238
+ proxyRes.on('aborted', finalize);
239
+ proxyRes.on('error', finalize);
240
+ },
241
+ },
242
+ });
243
+ }
244
+
245
+ const isSSE = (p: string) => p.includes('/pipeline/stream');
246
+ const localPrefix = skillId ? `/skill/${skillId}/` : null;
247
+
248
+ // dev-session:用 devApiKey 自动注入正确的 MINUS_AI_SID cookie
249
+ let devSessionInstalled = false;
250
+ if (devApiKey) {
251
+ server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => {
252
+ if (devSessionInstalled) return next();
253
+ const cookie = req.headers.cookie || '';
254
+ const sidMatch = cookie.match(/MINUS_AI_SID=([^;]*)/);
255
+ if (sidMatch && sidMatch[1] === devApiKey) { devSessionInstalled = true; return next(); }
256
+ const urlPath = (req.url || '').split('?')[0];
257
+ if (!urlPath.endsWith('.html') && urlPath !== '/' && urlPath !== '') return next();
258
+
259
+ const body = JSON.stringify({ apiKey: devApiKey });
260
+ const gwUrl = new URL('/api/auth/dev-session', gateway);
261
+ const mod = gwUrl.protocol === 'https:' ? https : http;
262
+ const gwReq = mod.request(gwUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (gwRes: IncomingMessage) => {
263
+ const setCookie = gwRes.headers['set-cookie'];
264
+ if (setCookie) {
265
+ devSessionInstalled = true;
266
+ res.setHeader('Set-Cookie', setCookie);
267
+ }
268
+ next();
269
+ });
270
+ gwReq.on('error', () => next());
271
+ gwReq.end(body);
272
+ });
273
+ }
274
+
275
+ // /login → 重定向到平台网关登录页(独立开发模式下 embed-sdk 的 redirectToLogin 会跳到本地 /login)
276
+ server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => {
277
+ const urlPath = (req.url || '').split('?')[0];
278
+ if (urlPath === '/login') {
279
+ if (devApiKey) {
280
+ devSessionInstalled = false;
281
+ res.setHeader('Set-Cookie', 'MINUS_AI_SID=; Path=/; Max-Age=0');
282
+ res.writeHead(302, { Location: '/' });
283
+ res.end();
284
+ } else {
285
+ const devOrigin = `http://localhost:${(server.httpServer?.address() as any)?.port || 5173}`;
286
+ const loginUrl = new URL('/login', gateway);
287
+ loginUrl.searchParams.set('returnTo', devOrigin);
288
+ res.writeHead(302, { Location: loginUrl.toString() });
289
+ res.end();
290
+ }
291
+ return;
292
+ }
293
+ next();
294
+ });
295
+
296
+ server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => {
297
+ const url = req.url || '';
298
+ const urlPath = url.split('?')[0];
299
+
300
+ // dev 环境所有代理请求走 dev workspace
301
+ if (!req.headers['x-workspace-mode']) req.headers['x-workspace-mode'] = 'dev';
302
+
303
+ // 本地后端:/skill/{skillId}/{version}/api/* → 剥掉前缀转发到本地
304
+ if (localPrefix && localBackend && urlPath.startsWith(localPrefix) && urlPath.includes('/api/')) {
305
+ const apiStart = urlPath.indexOf('/api/');
306
+ req.url = url.slice(apiStart);
307
+ return (isSSE(urlPath) ? localStreamingProxy! : localBufferedProxy!)(req, res, next);
308
+ }
309
+
310
+ // /runtime/* → 优先从本地 sif-platform-template/runtime 目录读取
311
+ if (urlPath.startsWith('/runtime/') && localRuntimeDir) {
312
+ let localPath = join(localRuntimeDir, urlPath.slice('/runtime/'.length));
313
+ if (!existsSync(localPath)) {
314
+ // /runtime/pkg/file → try /runtime/pkg/{hash-subdir}/file
315
+ const parts = urlPath.slice('/runtime/'.length).split('/');
316
+ if (parts.length >= 2) {
317
+ const pkgDir = join(localRuntimeDir, parts[0]);
318
+ const fileName = parts.slice(1).join('/');
319
+ if (existsSync(pkgDir)) {
320
+ const subdirs = readdirSync(pkgDir).filter(d => {
321
+ try { return statSync(join(pkgDir, d)).isDirectory(); } catch { return false; }
322
+ });
323
+ if (subdirs.length > 0) {
324
+ const resolved = join(pkgDir, subdirs[0], fileName);
325
+ if (existsSync(resolved)) localPath = resolved;
326
+ }
327
+ }
328
+ }
329
+ }
330
+ if (existsSync(localPath)) {
331
+ const content = readFileSync(localPath);
332
+ const ext = localPath.split('.').pop();
333
+ const mime = ext === 'js' ? 'application/javascript' : ext === 'css' ? 'text/css' : 'application/octet-stream';
334
+ res.writeHead(200, { 'Content-Type': mime, 'Content-Length': content.length });
335
+ res.end(content);
336
+ return;
337
+ }
338
+ }
339
+
340
+ // /api/* /auth/* /skill/* /runtime/* → 转发到平台网关
341
+ if (
342
+ urlPath.startsWith('/api/') || urlPath === '/api' ||
343
+ urlPath.startsWith('/auth/') || urlPath === '/auth' ||
344
+ urlPath.startsWith('/skill/') || urlPath === '/skill' ||
345
+ urlPath.startsWith('/runtime/')
346
+ ) {
347
+ // /api/skills/*/sessions 等 skill 操作端点需要 workspace mode,/api/me/* 不需要
348
+ return (isSSE(urlPath) ? streamingProxy : bufferedProxy)(req, res, next);
349
+ }
350
+
351
+ // / → 重定向到 /embed.html?skill_id=xxx
352
+ if (skillId && (urlPath === '/' || urlPath === '')) {
353
+ res.writeHead(302, { Location: `/embed.html?skill_id=${skillId}` });
354
+ res.end();
355
+ return;
356
+ }
357
+
358
+ return next();
359
+ });
360
+ },
361
+
362
+ // 注入 dev 配置到 HTML;独立模式下模拟 embed host 完成握手
363
+ transformIndexHtml(html) {
364
+ const devConfig = `<script>window.__MINUS_DEV__=${JSON.stringify({ version })}</script>`;
365
+ const importMap = `<script type="importmap">{"imports":{` +
366
+ `"react":"/runtime/react-all/react-all.js",` +
367
+ `"react-dom":"/runtime/react-all/react-all.js",` +
368
+ `"react-dom/client":"/runtime/react-all/react-all.js",` +
369
+ `"react/jsx-runtime":"/runtime/react-all/react-all.js",` +
370
+ `"react/jsx-dev-runtime":"/runtime/react-all/react-all.js",` +
371
+ `"@minus/widget-framework":"/runtime/widget-framework/index.js",` +
372
+ `"@minus/embed-sdk":"/runtime/embed-sdk/index.js",` +
373
+ `"@minus/platform-widgets":"/runtime/platform-widgets/index.js",` +
374
+ `"@minus/platform-utils":"/runtime/platform-utils/index.js",` +
375
+ `"@minus/platform-hooks":"/runtime/platform-hooks/index.js"` +
376
+ `}}</script>`;
377
+ // 当页面不在 iframe 中时,模拟 embed host:
378
+ // 监听 embed.ready → 回复 embed.init(home 模式,无 instanceId)
379
+ const embedShim = `<script>
380
+ (function(){
381
+ if (window.parent !== window) return;
382
+ var skillId = new URLSearchParams(location.search).get("skill_id");
383
+ if (!skillId) return;
384
+ var VER = ${JSON.stringify(version)};
385
+
386
+ function uid(){ return crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); }
387
+ function env(kind, type, payload, reqId){ return { sif:"v1", id: reqId||uid(), kind:kind, type:type, payload:payload, error:null, meta:{ts:Date.now(),from:"parent",skillId:skillId} }; }
388
+ function respond(reqId, payload){ window.postMessage(Object.assign(env("res","",payload,reqId),{type:""}), "*"); }
389
+
390
+ // 通用 req 响应:匹配 id,填充 kind=res
391
+ function sendRes(reqId, type, payload){
392
+ window.postMessage({ sif:"v1", id:reqId, kind:"res", type:type, payload:payload, error:null, meta:{ts:Date.now(),from:"parent",skillId:skillId} }, "*");
393
+ }
394
+ function sendEvt(type, payload){
395
+ window.postMessage(env("evt",type,payload), "*");
396
+ }
397
+
398
+ window.addEventListener("message", function(e){
399
+ var d = e.data;
400
+ if (!d || d.sif !== "v1") return;
401
+ if (d.meta && d.meta.from === "parent") return; // 忽略自己发的
402
+ // 拦截 child 发出的消息,防止 child 的 RpcChannel 收到自己的 req
403
+ e.stopImmediatePropagation();
404
+
405
+ // embed.ready (evt) → 回 embed.init (req)
406
+ if (d.kind === "evt" && d.type === "embed.ready") {
407
+ fetch("/api/skills/" + encodeURIComponent(skillId) + "/versions/" + encodeURIComponent(VER), { credentials:"include" })
408
+ .then(function(r){ return r.ok ? r.json() : null; })
409
+ .catch(function(){ return null; })
410
+ .then(function(skill){
411
+ var flow = skill ? { title:skill.displayName||skillId, description:skill.description||"", skillId:skillId, useCases:skill.useCases||[], tags:skill.tags||[] } : { title:skillId, description:"", skillId:skillId };
412
+ window.postMessage(env("req","embed.init",{ flow:flow, instanceId:null, currentStep:0 }), "*");
413
+ });
414
+ return;
415
+ }
416
+
417
+ // flow.start (req) → 创建 session,启动 SSE
418
+ if (d.kind === "req" && d.type === "flow.start") {
419
+ var reqId = d.id;
420
+ var input = d.payload && d.payload.input || {};
421
+ fetch("/api/skills/" + encodeURIComponent(skillId) + "/versions/" + encodeURIComponent(VER) + "/sessions", {
422
+ method:"POST", credentials:"include",
423
+ headers: {"Content-Type":"application/json"},
424
+ body: JSON.stringify({ entryParams: input })
425
+ })
426
+ .then(function(r){ return r.json(); })
427
+ .then(function(ses){
428
+ sendRes(reqId, "flow.start", { sessionId: ses.id, instanceId: ses.id });
429
+ // 启动 SSE pipeline stream(通过 dev proxy 到本地后端)
430
+ var streamUrl = "/skill/" + encodeURIComponent(skillId) + "/" + VER + "/api/sessions/" + encodeURIComponent(ses.id) + "/pipeline/stream";
431
+ var es = new EventSource(streamUrl);
432
+ es.onmessage = function(ev){
433
+ try {
434
+ var data = JSON.parse(ev.data);
435
+ sendEvt("state.update", data);
436
+ } catch(e){}
437
+ };
438
+ es.onerror = function(){ es.close(); };
439
+ })
440
+ .catch(function(err){
441
+ window.postMessage({ sif:"v1", id:reqId, kind:"res", type:"flow.start", payload:null, error:{code:"INTERNAL_ERROR",message:err.message}, meta:{ts:Date.now(),from:"parent",skillId:skillId} }, "*");
442
+ });
443
+ return;
444
+ }
445
+
446
+ // flow.resolve (req) → 直接 ack
447
+ if (d.kind === "req" && d.type === "flow.resolve") {
448
+ sendRes(d.id, "flow.resolve", { ok:true });
449
+ return;
450
+ }
451
+
452
+ // api.call (req) → 代理 API 调用
453
+ if (d.kind === "req" && d.type === "api.call") {
454
+ var p = d.payload;
455
+ fetch(p.url, { method:p.method||"GET", headers:p.headers||{}, body:p.body||null, credentials:"include" })
456
+ .then(function(r){ return r.text().then(function(t){ return {status:r.status,body:t}; }); })
457
+ .then(function(res){ sendRes(d.id, "api.call", res); })
458
+ .catch(function(err){ sendRes(d.id, "api.call", {status:500,body:err.message}); });
459
+ return;
460
+ }
461
+
462
+ // notify.toast (req) → ack
463
+ if (d.kind === "req" && d.type === "notify.toast") {
464
+ sendRes(d.id, "notify.toast", { ok:true });
465
+ return;
466
+ }
467
+ });
468
+ })();
469
+ </script>`;
470
+ return html.replace('<head>', `<head>\n${importMap}`).replace('</head>', `${devConfig}\n${embedShim}\n</head>`);
471
+ },
472
+ };
473
+
474
+ const EXTERNAL_URL_MAP: Record<string, string> = {
475
+ 'react': '/runtime/react-all/react-all.js',
476
+ 'react-dom': '/runtime/react-all/react-all.js',
477
+ 'react-dom/client': '/runtime/react-all/react-all.js',
478
+ 'react/jsx-runtime': '/runtime/react-all/react-all.js',
479
+ 'react/jsx-dev-runtime': '/runtime/react-all/react-all.js',
480
+ '@minus/widget-framework': '/runtime/widget-framework/index.js',
481
+ '@minus/embed-sdk': '/runtime/embed-sdk/index.js',
482
+ '@minus/platform-widgets': '/runtime/platform-widgets/index.js',
483
+ '@minus/platform-utils': '/runtime/platform-utils/index.js',
484
+ '@minus/platform-hooks': '/runtime/platform-hooks/index.js',
485
+ };
486
+ const EXTERNALS = Object.keys(EXTERNAL_URL_MAP);
487
+
488
+ let isServe = false;
489
+ let devServerOrigin = '';
490
+
491
+ const VIRTUAL_PREFIX = '\0runtime:';
492
+ const exportsCache = new Map<string, string[]>();
493
+
494
+ async function getRemoteExports(source: string): Promise<string[]> {
495
+ if (exportsCache.has(source)) return exportsCache.get(source)!;
496
+ let code: string;
497
+ const relPath = EXTERNAL_URL_MAP[source];
498
+ let localFile: string | null = null;
499
+ if (localRuntimeDir) {
500
+ const direct = join(localRuntimeDir, relPath.slice('/runtime/'.length));
501
+ if (existsSync(direct)) {
502
+ localFile = direct;
503
+ } else {
504
+ // /runtime/pkg/index.js → try /runtime/pkg/{hash}/index.js via latest file
505
+ const parts = relPath.slice('/runtime/'.length).split('/');
506
+ if (parts.length >= 2) {
507
+ const pkgDir = join(localRuntimeDir, parts[0]);
508
+ const fileName = parts.slice(1).join('/');
509
+ if (existsSync(pkgDir)) {
510
+ const latestFile = join(pkgDir, 'latest');
511
+ let hash: string | null = null;
512
+ if (existsSync(latestFile)) {
513
+ hash = readFileSync(latestFile, 'utf8').trim();
514
+ } else {
515
+ const subdirs = readdirSync(pkgDir).filter(d => {
516
+ try { return statSync(join(pkgDir, d)).isDirectory(); } catch { return false; }
517
+ });
518
+ if (subdirs.length > 0) hash = subdirs[0];
519
+ }
520
+ if (hash) {
521
+ const versioned = join(pkgDir, hash, fileName);
522
+ if (existsSync(versioned)) localFile = versioned;
523
+ }
524
+ }
525
+ }
526
+ }
527
+ }
528
+ if (localFile) {
529
+ code = readFileSync(localFile, 'utf8');
530
+ } else {
531
+ const resp = await fetch(gateway + relPath, { redirect: 'follow' });
532
+ code = await resp.text();
533
+ }
534
+ const names: string[] = [];
535
+ for (const m of code.matchAll(/export\s*\{([^}]+)\}/g)) {
536
+ for (const item of m[1].split(',')) {
537
+ const trimmed = item.trim();
538
+ if (!trimmed) continue;
539
+ const asMatch = trimmed.match(/(?:\S+\s+as\s+)?(\S+)/);
540
+ if (asMatch) names.push(asMatch[1]);
541
+ }
542
+ }
543
+ for (const m of code.matchAll(/export\s+(function|const|let|var|class)\s+(\w+)/g)) {
544
+ names.push(m[2]);
545
+ }
546
+ const unique = [...new Set(names)].filter(n => n !== 'default');
547
+ exportsCache.set(source, unique);
548
+ return unique;
549
+ }
550
+
551
+ const externalsPlugin: Plugin = {
552
+ name: 'minus-dev-externals',
553
+ enforce: 'pre',
554
+ config(_config, { command }) {
555
+ if (command !== 'serve') return;
556
+ return {
557
+ optimizeDeps: {
558
+ esbuildOptions: {
559
+ plugins: [{
560
+ name: 'externalize-react',
561
+ setup(build) {
562
+ build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
563
+ if (args.importer) {
564
+ return { path: args.path, external: true };
565
+ }
566
+ });
567
+ },
568
+ }],
569
+ },
570
+ },
571
+ };
572
+ },
573
+ configResolved(config) {
574
+ isServe = config.command === 'serve';
575
+ if (isServe) {
576
+ const port = config.server?.port ?? 5173;
577
+ const host = 'localhost';
578
+ devServerOrigin = `http://${host}:${port}`;
579
+ }
580
+ },
581
+ configureServer(server) {
582
+ // Vite 可能因端口占用自动换端口,httpServer.listen 后拿到真实端口更新 origin
583
+ server.httpServer?.once('listening', () => {
584
+ const addr = server.httpServer?.address();
585
+ if (addr && typeof addr === 'object') {
586
+ devServerOrigin = `http://localhost:${addr.port}`;
587
+ const portsFile = join(skillDir, '.minus', 'dev-ports.json');
588
+ const ports: Record<string, number> = { frontend: addr.port };
589
+ if (localBackend) {
590
+ try { ports.backend = new URL(localBackend).port ? parseInt(new URL(localBackend).port, 10) : 0; } catch { /* skip */ }
591
+ }
592
+ try {
593
+ mkdirSync(join(skillDir, '.minus'), { recursive: true });
594
+ writeFileSync(portsFile, JSON.stringify(ports, null, 2) + '\n');
595
+ } catch { /* non-critical */ }
596
+ }
597
+ });
598
+ },
599
+ resolveId(source) {
600
+ if (!EXTERNAL_URL_MAP[source]) return;
601
+ if (isServe) return VIRTUAL_PREFIX + source;
602
+ return { id: source, external: true };
603
+ },
604
+ async load(id) {
605
+ if (!id.startsWith(VIRTUAL_PREFIX)) return;
606
+ const source = id.slice(VIRTUAL_PREFIX.length);
607
+ const path = EXTERNAL_URL_MAP[source];
608
+ const exports = await getRemoteExports(source);
609
+ const importUrl = `${devServerOrigin}${path}`;
610
+ const lines = [
611
+ `const __m = await import("${importUrl}");`,
612
+ `export default __m.default;`,
613
+ ];
614
+ if (exports.length > 0) {
615
+ lines.push(`const { ${exports.join(', ')} } = __m;`);
616
+ lines.push(`export { ${exports.join(', ')} };`);
617
+ }
618
+ // react-all.js is a production bundle — alias jsxDEV to jsx for dev mode
619
+ if (source === 'react/jsx-dev-runtime' || source === 'react/jsx-runtime') {
620
+ if (!exports.includes('jsxDEV') && exports.includes('jsx')) {
621
+ lines.push(`export const jsxDEV = __m.jsx;`);
622
+ }
623
+ }
624
+ return lines.join('\n');
625
+ },
626
+ };
627
+
628
+ const buildPlugin: Plugin = {
629
+ name: 'minus-dev-build',
630
+ apply: 'build',
631
+
632
+ config(_config, { command }): UserConfig {
633
+ if (command !== 'build') return {};
634
+ return {
635
+ build: {
636
+ rollupOptions: { external: EXTERNALS },
637
+ },
638
+ };
639
+ },
640
+
641
+ closeBundle() {
642
+ promoteToBuilds(skillDir);
643
+ },
644
+ };
645
+
646
+ return [externalsPlugin, devPlugin, buildPlugin];
647
+ }