@joplo-app/server 0.0.0-stage → 0.1.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/LICENSE +55 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.js +218 -0
- package/dist/contract.d.ts +76 -0
- package/dist/contract.js +1 -0
- package/dist/dev.d.ts +100 -0
- package/dist/dev.js +667 -0
- package/dist/export.d.ts +82 -0
- package/dist/export.js +245 -0
- package/dist/home.d.ts +49 -0
- package/dist/home.js +168 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +3 -0
- package/dist/page-report.js +82 -0
- package/dist/project.d.ts +14 -0
- package/dist/project.js +79 -0
- package/dist/render.d.ts +9 -0
- package/dist/render.js +150 -0
- package/dist/selection.d.ts +35 -0
- package/dist/selection.js +123 -0
- package/dist/vite-shared.d.ts +29 -0
- package/dist/vite-shared.js +100 -0
- package/dist/web-assets.d.ts +35 -0
- package/dist/web-assets.js +127 -0
- package/package.json +53 -3
- package/README.md +0 -3
package/dist/dev.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `joplo dev`:本机只运行一个看板进程(D39),同时服务多个项目。
|
|
3
|
+
*
|
|
4
|
+
* - 界面:一个 Vite 开发服务(packages/web 源码或 @joplo-app/web 的 dist),HMR。
|
|
5
|
+
* - 项目:每个项目一个不监听端口的 Vite 加载器(middlewareMode),ssrLoadModule 载入作者 TSX →
|
|
6
|
+
* core.loadAuthoring → core.compile(…, 'dev')。项目按需编译:首页选中、打开演示页或注册时才启动。
|
|
7
|
+
* - 热更新:界面服务的文件监听覆盖所有已启动的项目目录;某个项目的文件变化 → 只重新编译这个项目 →
|
|
8
|
+
* HMR 自定义事件 `joplo:ir`(带 projectId)推送,看板只接收当前项目的事件。
|
|
9
|
+
* - 记住项目:`<JOPLO_HOME>/projects.json`;运行中的看板:`<JOPLO_HOME>/server.json`(见 home.ts)。
|
|
10
|
+
* createDev 缺省不持久化(renderStep、测试);startDev(CLI)持久化,并在已有存活看板时只注册、不启动第二个进程。
|
|
11
|
+
*
|
|
12
|
+
* 安全(沿用 M0):只监听 127.0.0.1;每个请求校验 Host;写接口校验 Origin、Content-Type 与本地令牌。
|
|
13
|
+
* 端口被占用时直接报错(strictPort),不静默换端口。
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
20
|
+
import { createLogger, createServer } from 'vite';
|
|
21
|
+
import { JoploError } from '@joplo-app/core';
|
|
22
|
+
import { ExportHub } from "./export.js";
|
|
23
|
+
import { Registry, clearServerInfo, joploHome, normalizeSettings, pidAlive, projectIdOf, readServerInfo, readSettings, writeServerInfo, writeSettings } from "./home.js";
|
|
24
|
+
import { compileProject, invalidateProjectModules } from "./project.js";
|
|
25
|
+
import { SelectionBody, readSelection, resolveSelection, writeSelection } from "./selection.js";
|
|
26
|
+
import { IGNORED_DIRS, cssSourcePlugin, isProjectSource, joploProjectPlugin, kitAliases, locateWebAssets, realProjectDir } from "./vite-shared.js";
|
|
27
|
+
export const IR_EVENT = 'joplo:ir';
|
|
28
|
+
export const PROJECTS_EVENT = 'joplo:projects';
|
|
29
|
+
const MAX_BODY = 64 * 1024;
|
|
30
|
+
const SECURITY_HEADERS = {
|
|
31
|
+
'x-content-type-options': 'nosniff',
|
|
32
|
+
'referrer-policy': 'no-referrer',
|
|
33
|
+
'cache-control': 'no-store',
|
|
34
|
+
};
|
|
35
|
+
export function sendJson(res, status, body) {
|
|
36
|
+
res.writeHead(status, { ...SECURITY_HEADERS, 'content-type': 'application/json; charset=utf-8' });
|
|
37
|
+
res.end(JSON.stringify(body));
|
|
38
|
+
}
|
|
39
|
+
function safeEqual(a, b) {
|
|
40
|
+
const ab = Buffer.from(a);
|
|
41
|
+
const bb = Buffer.from(b);
|
|
42
|
+
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
|
|
43
|
+
}
|
|
44
|
+
/** Content-Type 必须是 application/json,只允许附带 charset=utf-8 参数。 */
|
|
45
|
+
export function isJsonContentType(ct) {
|
|
46
|
+
if (!ct)
|
|
47
|
+
return false;
|
|
48
|
+
const [type, ...params] = ct.split(';').map((x) => x.trim().toLowerCase());
|
|
49
|
+
return type === 'application/json' && params.every((p) => p === '' || /^charset="?utf-8"?$/.test(p));
|
|
50
|
+
}
|
|
51
|
+
function readBody(req) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let size = 0;
|
|
54
|
+
const chunks = [];
|
|
55
|
+
req.on('data', (c) => {
|
|
56
|
+
size += c.length;
|
|
57
|
+
if (size > MAX_BODY * 16) {
|
|
58
|
+
req.destroy();
|
|
59
|
+
reject(new Error('请求体过大'));
|
|
60
|
+
}
|
|
61
|
+
else if (size <= MAX_BODY)
|
|
62
|
+
chunks.push(c);
|
|
63
|
+
});
|
|
64
|
+
req.on('end', () => resolve(size > MAX_BODY ? { tooLarge: true } : { tooLarge: false, text: Buffer.concat(chunks).toString('utf8') }));
|
|
65
|
+
req.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function makeLogger(log) {
|
|
69
|
+
const base = createLogger('warn', { allowClearScreen: false });
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
info: () => undefined,
|
|
73
|
+
warn: (msg) => log(`[joplo] ${msg}`),
|
|
74
|
+
warnOnce: (msg) => log(`[joplo] ${msg}`),
|
|
75
|
+
error: (msg) => log(`[joplo] ${msg}`),
|
|
76
|
+
clearScreen: () => undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const MIME = {
|
|
80
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
81
|
+
'.css': 'text/css; charset=utf-8',
|
|
82
|
+
'.woff2': 'font/woff2',
|
|
83
|
+
'.woff': 'font/woff',
|
|
84
|
+
'.svg': 'image/svg+xml',
|
|
85
|
+
'.png': 'image/png',
|
|
86
|
+
'.json': 'application/json; charset=utf-8',
|
|
87
|
+
};
|
|
88
|
+
/** dist 形态:/assets/* 原样返回预构建文件(不经过 Vite 的模块转换)。返回 true 表示已处理。 */
|
|
89
|
+
function serveDistAsset(web, pathname, res) {
|
|
90
|
+
if (!pathname.startsWith('/assets/'))
|
|
91
|
+
return false;
|
|
92
|
+
const assets = path.join(web.appDir, 'assets');
|
|
93
|
+
let rel;
|
|
94
|
+
try {
|
|
95
|
+
rel = decodeURIComponent(pathname.slice('/assets/'.length));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const file = path.resolve(assets, rel);
|
|
101
|
+
if (!file.startsWith(assets + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
102
|
+
return false;
|
|
103
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream', 'x-content-type-options': 'nosniff', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
104
|
+
fs.createReadStream(file).pipe(res);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** dist 形态:在看板入口脚本之前创建 HMR 句柄、导入作者 className 的 CSS。 */
|
|
108
|
+
export function injectDistBoot(html, web) {
|
|
109
|
+
const css = '/@fs/' + web.projectCss.split(path.sep).join('/').replace(/^\/+/, '');
|
|
110
|
+
const boot = `<script type="module">import { createHotContext } from '/@vite/client';\nimport ${JSON.stringify(css)};\nglobalThis.__JOPLO_HOT__ = createHotContext('/@joplo-app/board');</script>`;
|
|
111
|
+
const at = html.search(/<script\b/i);
|
|
112
|
+
return at < 0 ? html.replace('</head>', `${boot}\n</head>`) : html.slice(0, at) + boot + '\n ' + html.slice(at);
|
|
113
|
+
}
|
|
114
|
+
async function webPlugins(web) {
|
|
115
|
+
if (web.mode === 'dist')
|
|
116
|
+
return [tailwindcss()];
|
|
117
|
+
// 只有在仓库里编译看板源码时才需要 React 插件(devDependency)
|
|
118
|
+
const { default: react } = await import('@vitejs/plugin-react');
|
|
119
|
+
return [react(), tailwindcss()];
|
|
120
|
+
}
|
|
121
|
+
/** 不持久化时的项目记录(只在内存里,进程结束即消失)。 */
|
|
122
|
+
class MemoryRegistry {
|
|
123
|
+
items = [];
|
|
124
|
+
list() {
|
|
125
|
+
return [...this.items].sort((a, b) => b.lastOpened.localeCompare(a.lastOpened));
|
|
126
|
+
}
|
|
127
|
+
touch(dir, name) {
|
|
128
|
+
const prev = this.items.find((p) => p.dir === dir);
|
|
129
|
+
const rec = { dir, name: name ?? prev?.name ?? path.basename(dir), lastOpened: new Date().toISOString() };
|
|
130
|
+
this.items = [rec, ...this.items.filter((p) => p.dir !== dir)];
|
|
131
|
+
return rec;
|
|
132
|
+
}
|
|
133
|
+
rename(dir, name) {
|
|
134
|
+
const rec = this.items.find((p) => p.dir === dir);
|
|
135
|
+
if (rec)
|
|
136
|
+
rec.name = name;
|
|
137
|
+
}
|
|
138
|
+
remove(dir) {
|
|
139
|
+
const n = this.items.length;
|
|
140
|
+
this.items = this.items.filter((p) => p.dir !== dir);
|
|
141
|
+
return this.items.length !== n;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const projectUrl = (base, id) => `${base}#/${encodeURIComponent(id)}`;
|
|
145
|
+
export async function createHub(opts = {}) {
|
|
146
|
+
const log = opts.log ?? ((m) => process.stderr.write(m + '\n'));
|
|
147
|
+
const watch = opts.watch !== false;
|
|
148
|
+
const port = opts.port ?? 4790;
|
|
149
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
150
|
+
const web = locateWebAssets();
|
|
151
|
+
const registry = opts.home ? new Registry(opts.home) : new MemoryRegistry();
|
|
152
|
+
let memSettings = { version: 1 };
|
|
153
|
+
const slots = new Map();
|
|
154
|
+
let actualPort = port;
|
|
155
|
+
let closed = false;
|
|
156
|
+
let server;
|
|
157
|
+
const allowedHosts = () => new Set([`127.0.0.1:${actualPort}`, `localhost:${actualPort}`]);
|
|
158
|
+
const allowedOrigins = () => new Set([`http://127.0.0.1:${actualPort}`, `http://localhost:${actualPort}`]);
|
|
159
|
+
const baseUrl = () => `http://127.0.0.1:${actualPort}/`;
|
|
160
|
+
const activeDirs = () => [...slots.values()].filter((s) => !s.closed).map((s) => s.dir);
|
|
161
|
+
const slotOfFile = (file) => [...slots.values()].find((s) => !s.closed && isProjectSource(s.dir, file)) ?? null;
|
|
162
|
+
const recordOf = (id) => registry.list().find((r) => projectIdOf(r.dir) === id) ?? null;
|
|
163
|
+
const summaries = () => registry.list().map((r) => {
|
|
164
|
+
const id = projectIdOf(r.dir);
|
|
165
|
+
const s = slots.get(id);
|
|
166
|
+
const exists = fs.existsSync(r.dir);
|
|
167
|
+
const errors = s ? s.payload.diagnostics.filter((d) => d.severity === 'error').length : 0;
|
|
168
|
+
const status = !exists ? 'missing' : !s ? 'idle' : s.compiling && s.payload.version === 0 ? 'compiling' : errors > 0 || !s.payload.ok ? 'error' : 'ok';
|
|
169
|
+
return { id, dir: r.dir, name: r.name, lastOpened: r.lastOpened, status, errors: status === 'error' ? Math.max(errors, 1) : errors, version: s?.payload.version ?? 0 };
|
|
170
|
+
});
|
|
171
|
+
const broadcastProjects = () => {
|
|
172
|
+
if (!closed)
|
|
173
|
+
server?.ws.send({ type: 'custom', event: PROJECTS_EVENT, data: { projects: summaries() } });
|
|
174
|
+
};
|
|
175
|
+
// ---------- 项目:加载器与编译 ----------
|
|
176
|
+
async function startLoader(slot) {
|
|
177
|
+
slot.loader = await createServer({
|
|
178
|
+
configFile: false,
|
|
179
|
+
envDir: false,
|
|
180
|
+
root: slot.dir,
|
|
181
|
+
appType: 'custom',
|
|
182
|
+
customLogger: makeLogger(log),
|
|
183
|
+
resolve: { alias: kitAliases(web, slot.dir) },
|
|
184
|
+
plugins: [joploProjectPlugin(slot.dir, web)],
|
|
185
|
+
// 只在服务端载入模块:不开 HMR、不开 WebSocket(多个项目同时运行时不能争用端口)、不监听
|
|
186
|
+
server: { middlewareMode: true, hmr: false, ws: false, watch: null },
|
|
187
|
+
optimizeDeps: { noDiscovery: true, include: [] },
|
|
188
|
+
cacheDir: path.join(slot.dir, '.joplo/vite'),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async function recompile(slot) {
|
|
192
|
+
const loader = slot.loader;
|
|
193
|
+
invalidateProjectModules(loader, slot.dir);
|
|
194
|
+
const out = await compileProject(loader, slot.dir, 'dev');
|
|
195
|
+
const prev = slot.payload;
|
|
196
|
+
slot.payload = { version: prev.version + 1, ok: out.project !== null, project: out.project ?? prev.project, diagnostics: out.diagnostics, home: out.home ?? prev.home };
|
|
197
|
+
const errors = out.diagnostics.filter((d) => d.severity === 'error');
|
|
198
|
+
if (errors.length)
|
|
199
|
+
log(`[joplo] ${path.basename(slot.dir)} 编译出错(${errors.length}):${errors.map((d) => `${d.at ?? ''} ${d.message}`).join(';').slice(0, 400)}`);
|
|
200
|
+
if (out.project && out.project.name)
|
|
201
|
+
registry.rename(slot.dir, out.project.name);
|
|
202
|
+
if (!closed)
|
|
203
|
+
server?.ws.send({ type: 'custom', event: IR_EVENT, data: { projectId: slot.id, ...slot.payload } });
|
|
204
|
+
broadcastProjects();
|
|
205
|
+
}
|
|
206
|
+
/** 串行化:编译进行中又有变化时,结束后再编一次。 */
|
|
207
|
+
function run(slot) {
|
|
208
|
+
if (slot.closed || closed)
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
if (slot.running) {
|
|
211
|
+
slot.dirty = true;
|
|
212
|
+
return slot.running;
|
|
213
|
+
}
|
|
214
|
+
slot.compiling = true;
|
|
215
|
+
slot.running = (async () => {
|
|
216
|
+
do {
|
|
217
|
+
slot.dirty = false;
|
|
218
|
+
try {
|
|
219
|
+
await recompile(slot);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
log(`[joplo] 编译异常:${e instanceof Error ? e.message : String(e)}`);
|
|
223
|
+
}
|
|
224
|
+
} while (slot.dirty && !slot.closed);
|
|
225
|
+
})().finally(() => {
|
|
226
|
+
slot.running = null;
|
|
227
|
+
slot.compiling = false;
|
|
228
|
+
});
|
|
229
|
+
return slot.running;
|
|
230
|
+
}
|
|
231
|
+
function activate(dir) {
|
|
232
|
+
const id = projectIdOf(dir);
|
|
233
|
+
const existing = slots.get(id);
|
|
234
|
+
if (existing && !existing.closed)
|
|
235
|
+
return existing;
|
|
236
|
+
const slot = {
|
|
237
|
+
id,
|
|
238
|
+
dir,
|
|
239
|
+
loader: null,
|
|
240
|
+
ready: Promise.resolve(),
|
|
241
|
+
payload: { version: 0, ok: false, project: null, diagnostics: [], home: null },
|
|
242
|
+
compiling: true,
|
|
243
|
+
closed: false,
|
|
244
|
+
running: null,
|
|
245
|
+
dirty: false,
|
|
246
|
+
timer: null,
|
|
247
|
+
};
|
|
248
|
+
slots.set(id, slot);
|
|
249
|
+
slot.ready = (async () => {
|
|
250
|
+
await startLoader(slot);
|
|
251
|
+
if (watch && server)
|
|
252
|
+
server.watcher.add(dir);
|
|
253
|
+
await run(slot);
|
|
254
|
+
})().catch((e) => log(`[joplo] 项目启动失败:${e instanceof Error ? e.message : String(e)}`));
|
|
255
|
+
invalidateCss();
|
|
256
|
+
return slot;
|
|
257
|
+
}
|
|
258
|
+
async function deactivate(slot) {
|
|
259
|
+
slot.closed = true;
|
|
260
|
+
if (slot.timer)
|
|
261
|
+
clearTimeout(slot.timer);
|
|
262
|
+
await slot.running?.catch(() => undefined);
|
|
263
|
+
await slot.loader?.close().catch(() => undefined);
|
|
264
|
+
if (watch && server)
|
|
265
|
+
server.watcher.unwatch(slot.dir);
|
|
266
|
+
slots.delete(slot.id);
|
|
267
|
+
}
|
|
268
|
+
/** 新项目的目录加入 Tailwind @source:让 CSS 入口重新转换。 */
|
|
269
|
+
function invalidateCss() {
|
|
270
|
+
if (!server)
|
|
271
|
+
return;
|
|
272
|
+
const entry = web.mode === 'source' ? web.indexCss : web.projectCss;
|
|
273
|
+
const env = server.environments.client;
|
|
274
|
+
const mods = env.moduleGraph.getModulesByFile(entry);
|
|
275
|
+
if (!mods)
|
|
276
|
+
return;
|
|
277
|
+
for (const m of mods)
|
|
278
|
+
void env.reloadModule(m).catch(() => undefined);
|
|
279
|
+
}
|
|
280
|
+
async function ensure(id) {
|
|
281
|
+
let slot = slots.get(id);
|
|
282
|
+
if (!slot) {
|
|
283
|
+
const rec = recordOf(id);
|
|
284
|
+
if (!rec)
|
|
285
|
+
throw new JoploError('NOT_FOUND', `没有这个项目:${id}`);
|
|
286
|
+
if (!fs.existsSync(rec.dir))
|
|
287
|
+
throw new JoploError('NOT_FOUND', `找不到项目目录:${rec.dir}`);
|
|
288
|
+
slot = activate(rec.dir);
|
|
289
|
+
}
|
|
290
|
+
await slot.ready;
|
|
291
|
+
return slot;
|
|
292
|
+
}
|
|
293
|
+
function addProject(dirIn, o = {}) {
|
|
294
|
+
const dir = realProjectDir(dirIn);
|
|
295
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
296
|
+
throw new JoploError('NOT_FOUND', `项目目录不存在:${dir}`);
|
|
297
|
+
const id = projectIdOf(dir);
|
|
298
|
+
const prev = registry.list().find((r) => r.dir === dir);
|
|
299
|
+
registry.touch(dir, prev?.name);
|
|
300
|
+
const slot = o.compile === false ? (slots.get(id) ?? null) : activate(dir);
|
|
301
|
+
broadcastProjects();
|
|
302
|
+
return { id, url: projectUrl(baseUrl(), id), slot };
|
|
303
|
+
}
|
|
304
|
+
const exportsHub = new ExportHub({
|
|
305
|
+
baseUrl,
|
|
306
|
+
log,
|
|
307
|
+
deps: opts.exportDeps,
|
|
308
|
+
onEvent: () => undefined,
|
|
309
|
+
});
|
|
310
|
+
// ---------- HTTP ----------
|
|
311
|
+
const deny = (res, status, message) => sendJson(res, status, { error: { code: 'PERMISSION_DENIED', message } });
|
|
312
|
+
/** 写接口:Origin(有则必须是看板自己)、令牌、Content-Type。返回 false 表示已拒绝。 */
|
|
313
|
+
const guardWrite = (req, res, tokenStatus = 403, needJson = true) => {
|
|
314
|
+
const origin = req.headers.origin;
|
|
315
|
+
if (origin !== undefined && !allowedOrigins().has(origin))
|
|
316
|
+
return deny(res, 403, `Origin 不被允许:${origin}`), false;
|
|
317
|
+
const t = req.headers['x-joplo-token'];
|
|
318
|
+
if (typeof t !== 'string' || !safeEqual(t, token))
|
|
319
|
+
return deny(res, tokenStatus, '缺少或错误的本地令牌(x-joplo-token)'), false;
|
|
320
|
+
if (needJson && !isJsonContentType(req.headers['content-type']))
|
|
321
|
+
return sendJson(res, 415, { error: { code: 'USAGE', message: 'Content-Type 必须是 application/json' } }), false;
|
|
322
|
+
return true;
|
|
323
|
+
};
|
|
324
|
+
const jsonBody = async (req, res) => {
|
|
325
|
+
const raw = await readBody(req);
|
|
326
|
+
if (raw.tooLarge)
|
|
327
|
+
return sendJson(res, 413, { error: { code: 'USAGE', message: `请求体过大(上限 ${MAX_BODY} 字节)` } }), { ok: false };
|
|
328
|
+
try {
|
|
329
|
+
return { ok: true, body: JSON.parse(raw.text) };
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
return sendJson(res, 400, { error: { code: 'USAGE', message: `请求体不是合法 JSON:${e instanceof Error ? e.message : String(e)}` } }), { ok: false };
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const fail = (res, e) => {
|
|
336
|
+
const code = e instanceof JoploError ? e.code : 'INTERNAL';
|
|
337
|
+
const status = code === 'NOT_FOUND' ? 404 : code === 'USAGE' ? 400 : code === 'RENDER_FAILED' ? 503 : 500;
|
|
338
|
+
sendJson(res, status, { error: { code, message: e instanceof Error ? e.message : String(e) } });
|
|
339
|
+
};
|
|
340
|
+
async function handleProject(id, rest, req, res) {
|
|
341
|
+
const method = req.method ?? 'GET';
|
|
342
|
+
if (rest === 'ir' && method === 'GET') {
|
|
343
|
+
const slot = await ensure(id);
|
|
344
|
+
return sendJson(res, 200, { projectId: id, ...slot.payload });
|
|
345
|
+
}
|
|
346
|
+
if (rest === 'selection' && method === 'GET') {
|
|
347
|
+
const slot = await ensure(id);
|
|
348
|
+
return sendJson(res, 200, { selection: readSelection(slot.dir) });
|
|
349
|
+
}
|
|
350
|
+
if (rest === 'selection' && method === 'PUT') {
|
|
351
|
+
if (!guardWrite(req, res, 401))
|
|
352
|
+
return;
|
|
353
|
+
const b = await jsonBody(req, res);
|
|
354
|
+
if (!b.ok)
|
|
355
|
+
return;
|
|
356
|
+
const parsed = SelectionBody.safeParse(b.body);
|
|
357
|
+
if (!parsed.success)
|
|
358
|
+
return sendJson(res, 400, { error: { code: 'USAGE', message: '选中内容格式不正确', issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })) } });
|
|
359
|
+
const slot = await ensure(id);
|
|
360
|
+
if (parsed.data.selection === null) {
|
|
361
|
+
writeSelection(slot.dir, null);
|
|
362
|
+
return sendJson(res, 200, { ok: true, selection: null });
|
|
363
|
+
}
|
|
364
|
+
const project = slot.payload.project;
|
|
365
|
+
const r = project ? resolveSelection(project, parsed.data.selection) : { ok: false, problem: '项目还没有成功编译过' };
|
|
366
|
+
if (!r.ok) {
|
|
367
|
+
// 不变量:被拒绝时文件写为 null(不能保留旧选中而界面显示新选中)
|
|
368
|
+
writeSelection(slot.dir, null);
|
|
369
|
+
return sendJson(res, 409, { error: { code: 'INVALID_REFERENCE', message: `选中与当前项目不符:${r.problem}` }, current: null });
|
|
370
|
+
}
|
|
371
|
+
writeSelection(slot.dir, r.selection);
|
|
372
|
+
return sendJson(res, 200, { ok: true, selection: r.selection });
|
|
373
|
+
}
|
|
374
|
+
if (rest === 'open' && method === 'POST') {
|
|
375
|
+
if (!guardWrite(req, res))
|
|
376
|
+
return;
|
|
377
|
+
const b = await jsonBody(req, res);
|
|
378
|
+
if (!b.ok)
|
|
379
|
+
return;
|
|
380
|
+
const slot = await ensure(id);
|
|
381
|
+
const at = b.body?.at;
|
|
382
|
+
const m = typeof at === 'string' ? /^(.+?):(\d+)(?::(\d+))?$/.exec(at) : null;
|
|
383
|
+
if (!m)
|
|
384
|
+
return sendJson(res, 400, { error: { code: 'USAGE', message: 'at 应为 文件:行[:列]' } });
|
|
385
|
+
const file = path.resolve(slot.dir, m[1]);
|
|
386
|
+
if (!isProjectSource(slot.dir, file) || !fs.existsSync(file))
|
|
387
|
+
return sendJson(res, 400, { error: { code: 'USAGE', message: `不是项目里的源文件:${m[1]}` } });
|
|
388
|
+
// 交给 Vite 自带的 launch-editor(按 LAUNCH_EDITOR / EDITOR 或正在运行的编辑器打开)
|
|
389
|
+
req.__joploOpen = true;
|
|
390
|
+
req.url = `/__open-in-editor?file=${encodeURIComponent(`${file}:${m[2]}:${m[3] ?? 1}`)}`;
|
|
391
|
+
req.method = 'GET';
|
|
392
|
+
return 'next';
|
|
393
|
+
}
|
|
394
|
+
if (rest.startsWith('export/')) {
|
|
395
|
+
const slot = await ensure(id);
|
|
396
|
+
return exportsHub.handle(rest.slice('export/'.length), req, res, { projectId: id, dir: slot.dir, project: slot.payload.project, guardWrite: (needJson = true) => guardWrite(req, res, 403, needJson), jsonBody: () => jsonBody(req, res), fail: (e) => fail(res, e) });
|
|
397
|
+
}
|
|
398
|
+
return sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `没有这个接口:${method} /__joplo/p/${id}/${rest}` } });
|
|
399
|
+
}
|
|
400
|
+
const boardPlugin = {
|
|
401
|
+
name: 'joplo:board',
|
|
402
|
+
configureServer(srv) {
|
|
403
|
+
srv.middlewares.use(async (req, res, next) => {
|
|
404
|
+
try {
|
|
405
|
+
const host = req.headers.host ?? '';
|
|
406
|
+
if (!allowedHosts().has(host))
|
|
407
|
+
return deny(res, 403, `Host 头不被允许:${host || '(空)'}`);
|
|
408
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${actualPort}`);
|
|
409
|
+
if (web.mode === 'dist' && (req.method === 'GET' || req.method === 'HEAD') && serveDistAsset(web, url.pathname, res))
|
|
410
|
+
return;
|
|
411
|
+
// Vite 的打开编辑器中间件只能经由 /__joplo/p/<id>/open(校验令牌与路径)进入
|
|
412
|
+
if (url.pathname === '/__open-in-editor')
|
|
413
|
+
return req.__joploOpen ? next() : deny(res, 403, '请通过看板的“在编辑器中打开”');
|
|
414
|
+
if (!url.pathname.startsWith('/__joplo/'))
|
|
415
|
+
return next();
|
|
416
|
+
const method = req.method ?? 'GET';
|
|
417
|
+
if (url.pathname === '/__joplo/ping' && method === 'GET')
|
|
418
|
+
return sendJson(res, 200, { joplo: true, pid: process.pid });
|
|
419
|
+
if (url.pathname === '/__joplo/projects' && method === 'GET')
|
|
420
|
+
return sendJson(res, 200, { projects: summaries() });
|
|
421
|
+
// 看板设置(D51):主题选择,存于 <JOPLO_HOME>/settings.json(没有 home 时只在内存里)
|
|
422
|
+
if (url.pathname === '/__joplo/settings' && method === 'GET')
|
|
423
|
+
return sendJson(res, 200, { settings: opts.home ? readSettings(opts.home) : memSettings });
|
|
424
|
+
if (url.pathname === '/__joplo/settings' && method === 'PUT') {
|
|
425
|
+
if (!guardWrite(req, res))
|
|
426
|
+
return;
|
|
427
|
+
const b = await jsonBody(req, res);
|
|
428
|
+
if (!b.ok)
|
|
429
|
+
return;
|
|
430
|
+
const next = b.body?.settings;
|
|
431
|
+
memSettings = opts.home ? writeSettings(opts.home, next) : normalizeSettings(next);
|
|
432
|
+
return sendJson(res, 200, { settings: memSettings });
|
|
433
|
+
}
|
|
434
|
+
if (url.pathname === '/__joplo/projects' && method === 'POST') {
|
|
435
|
+
if (!guardWrite(req, res))
|
|
436
|
+
return;
|
|
437
|
+
const b = await jsonBody(req, res);
|
|
438
|
+
if (!b.ok)
|
|
439
|
+
return;
|
|
440
|
+
const dir = b.body?.dir;
|
|
441
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir))
|
|
442
|
+
return sendJson(res, 400, { error: { code: 'USAGE', message: 'dir 必须是项目目录的绝对路径' } });
|
|
443
|
+
const r = addProject(dir);
|
|
444
|
+
return sendJson(res, 200, { id: r.id, url: r.url });
|
|
445
|
+
}
|
|
446
|
+
const del = /^\/__joplo\/projects\/([^/]+)$/.exec(url.pathname);
|
|
447
|
+
if (del && method === 'DELETE') {
|
|
448
|
+
if (!guardWrite(req, res, 403, false))
|
|
449
|
+
return;
|
|
450
|
+
const id = decodeURIComponent(del[1]);
|
|
451
|
+
const rec = recordOf(id);
|
|
452
|
+
if (!rec)
|
|
453
|
+
return sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `没有这个项目:${id}` } });
|
|
454
|
+
const slot = slots.get(id);
|
|
455
|
+
if (slot)
|
|
456
|
+
await deactivate(slot);
|
|
457
|
+
registry.remove(rec.dir); // 只删记录,不碰项目目录
|
|
458
|
+
broadcastProjects();
|
|
459
|
+
return sendJson(res, 200, { ok: true });
|
|
460
|
+
}
|
|
461
|
+
const pm = /^\/__joplo\/p\/([^/]+)\/(.+)$/.exec(url.pathname);
|
|
462
|
+
if (pm) {
|
|
463
|
+
const r = await handleProject(decodeURIComponent(pm[1]), pm[2], req, res);
|
|
464
|
+
if (r === 'next')
|
|
465
|
+
return next();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
return sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `没有这个接口:${method} ${url.pathname}` } });
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
if (!res.headersSent)
|
|
472
|
+
fail(res, e);
|
|
473
|
+
else
|
|
474
|
+
res.end();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
},
|
|
478
|
+
transformIndexHtml(html) {
|
|
479
|
+
const out = html.replace('__JOPLO_TOKEN__', token);
|
|
480
|
+
return web.mode === 'dist' ? injectDistBoot(out, web) : out;
|
|
481
|
+
},
|
|
482
|
+
// 项目源文件只在项目加载器里使用:它们的变化由下面的重新编译处理,不交给 Vite 的默认 HMR。
|
|
483
|
+
// client 环境里只保留非项目模块(例如以项目目录为 @source 的 index.css),新 className 照常热更新。
|
|
484
|
+
hotUpdate(ctx) {
|
|
485
|
+
if (!slotOfFile(ctx.file))
|
|
486
|
+
return;
|
|
487
|
+
if (this.environment.name !== 'client')
|
|
488
|
+
return [];
|
|
489
|
+
return ctx.modules.filter((m) => !m.file || !slotOfFile(m.file));
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
server = await createServer({
|
|
493
|
+
configFile: false,
|
|
494
|
+
envDir: false,
|
|
495
|
+
root: web.mode === 'dist' ? web.appDir : web.webDir,
|
|
496
|
+
mode: 'development',
|
|
497
|
+
appType: 'spa',
|
|
498
|
+
clearScreen: false,
|
|
499
|
+
customLogger: makeLogger(log),
|
|
500
|
+
resolve: { alias: web.mode === 'source' ? kitAliases(web, web.webDir) : [] },
|
|
501
|
+
plugins: [cssSourcePlugin(activeDirs, web), boardPlugin, ...(await webPlugins(web))],
|
|
502
|
+
// dist 形态不往 node_modules 里写缓存(可能只读,或被多个项目共享)
|
|
503
|
+
cacheDir: web.mode === 'dist' ? path.join(opts.home ?? path.join(process.cwd(), '.joplo'), 'vite') : path.join(web.webDir, 'node_modules/.vite-joplo'),
|
|
504
|
+
optimizeDeps: web.mode === 'dist'
|
|
505
|
+
? { noDiscovery: true, include: [] }
|
|
506
|
+
: {
|
|
507
|
+
include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-dev-runtime', 'react/jsx-runtime', 'motion', 'motion/react', 'roughjs', 'lucide-react', 'clsx', 'tailwind-merge', '@dagrejs/dagre', 'modern-screenshot', '@joplo-app/core > zod'],
|
|
508
|
+
},
|
|
509
|
+
server: {
|
|
510
|
+
host: '127.0.0.1',
|
|
511
|
+
port,
|
|
512
|
+
strictPort: true,
|
|
513
|
+
open: false,
|
|
514
|
+
...(web.mode === 'dist' ? { fs: { allow: [web.webDir] } } : {}),
|
|
515
|
+
...(watch ? { watch: { ignored: (p) => { const s = slotOfFile(p) ?? [...slots.values()].find((x) => p.startsWith(x.dir + path.sep)); return !!s && IGNORED_DIRS.includes(p.slice(s.dir.length + 1).split(path.sep)[0] ?? ''); } } } : { watch: null }),
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
try {
|
|
519
|
+
await server.listen();
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
await server.close().catch(() => undefined);
|
|
523
|
+
const err = e;
|
|
524
|
+
if (err.code === 'EADDRINUSE' || /already in use/i.test(err.message ?? ''))
|
|
525
|
+
throw new JoploError('USAGE', `端口 ${port} 已被占用,请换一个端口(--port),或先关闭占用它的进程`, { details: { code: 'EADDRINUSE' } });
|
|
526
|
+
throw e;
|
|
527
|
+
}
|
|
528
|
+
const addr = server.httpServer?.address();
|
|
529
|
+
actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
530
|
+
if (opts.home)
|
|
531
|
+
writeServerInfo(opts.home, { port: actualPort, pid: process.pid, token });
|
|
532
|
+
if (watch) {
|
|
533
|
+
server.watcher.on('all', (_ev, file) => {
|
|
534
|
+
const slot = slotOfFile(path.resolve(file));
|
|
535
|
+
if (!slot)
|
|
536
|
+
return;
|
|
537
|
+
if (slot.timer)
|
|
538
|
+
clearTimeout(slot.timer);
|
|
539
|
+
slot.timer = setTimeout(() => void run(slot), 60);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
get url() {
|
|
544
|
+
return baseUrl();
|
|
545
|
+
},
|
|
546
|
+
get port() {
|
|
547
|
+
return actualPort;
|
|
548
|
+
},
|
|
549
|
+
token,
|
|
550
|
+
server,
|
|
551
|
+
addProject,
|
|
552
|
+
ensure,
|
|
553
|
+
projects: summaries,
|
|
554
|
+
close: async () => {
|
|
555
|
+
closed = true;
|
|
556
|
+
await exportsHub.close();
|
|
557
|
+
for (const s of [...slots.values()])
|
|
558
|
+
await deactivate(s);
|
|
559
|
+
await server.close();
|
|
560
|
+
if (opts.home)
|
|
561
|
+
clearServerInfo(opts.home, process.pid);
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/** 启动一个看板进程并打开一个项目(等首次编译完成)。缺省不持久化。 */
|
|
566
|
+
export async function createDev(opts) {
|
|
567
|
+
const hub = await createHub({ port: opts.port, log: opts.log, watch: opts.watch, home: opts.home ?? null, exportDeps: opts.exportDeps });
|
|
568
|
+
try {
|
|
569
|
+
const { id, url, slot } = hub.addProject(opts.projectDir);
|
|
570
|
+
await slot.ready;
|
|
571
|
+
return { url: hub.url, projectUrl: url, projectId: id, token: hub.token, port: hub.port, server: hub.server, hub, payload: () => slot.payload, close: () => hub.close() };
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
await hub.close();
|
|
575
|
+
throw e;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/** 已有存活的看板:用令牌注册项目,返回项目链接;没有或不可用时返回 null。 */
|
|
579
|
+
export async function attachToRunning(home, projectDir, log) {
|
|
580
|
+
const info = readServerInfo(home);
|
|
581
|
+
if (!info)
|
|
582
|
+
return null;
|
|
583
|
+
if (!pidAlive(info.pid)) {
|
|
584
|
+
clearServerInfo(home, info.pid);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const base = `http://127.0.0.1:${info.port}`;
|
|
588
|
+
try {
|
|
589
|
+
const ping = await fetch(`${base}/__joplo/ping`, { signal: AbortSignal.timeout(1500) });
|
|
590
|
+
const body = (await ping.json());
|
|
591
|
+
if (!ping.ok || body.joplo !== true || body.pid !== info.pid)
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const res = await fetch(`${base}/__joplo/projects`, {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
headers: { 'content-type': 'application/json', 'x-joplo-token': info.token },
|
|
600
|
+
body: JSON.stringify({ dir: realProjectDir(projectDir) }),
|
|
601
|
+
signal: AbortSignal.timeout(10000),
|
|
602
|
+
});
|
|
603
|
+
const body = (await res.json().catch(() => null));
|
|
604
|
+
if (!res.ok || !body?.url)
|
|
605
|
+
throw new JoploError('UNSUPPORTED_ACTION', `无法把项目加入正在运行的看板(${base},pid ${info.pid}):${body?.error?.message ?? `HTTP ${res.status}`}`);
|
|
606
|
+
log(`[joplo] 已加入正在运行的看板(pid ${info.pid})`);
|
|
607
|
+
return { url: body.url, port: info.port };
|
|
608
|
+
}
|
|
609
|
+
function openBrowser(url, log) {
|
|
610
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
611
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
612
|
+
try {
|
|
613
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
log(`[joplo] 无法自动打开浏览器,请手动访问 ${url}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* CLI 的 `joplo dev`:同一个 JOPLO_HOME 下只运行一个看板进程。
|
|
621
|
+
* 已有存活的看板 → 注册项目、(--open 时)打开浏览器,返回 attached;否则启动新的看板并登记项目。
|
|
622
|
+
*/
|
|
623
|
+
export async function startDev(opts) {
|
|
624
|
+
const log = opts.log ?? ((m) => process.stderr.write(m + '\n'));
|
|
625
|
+
const home = opts.home ?? joploHome();
|
|
626
|
+
const projectDir = realProjectDir(opts.projectDir);
|
|
627
|
+
if (!fs.existsSync(projectDir))
|
|
628
|
+
throw new JoploError('NOT_FOUND', `项目目录不存在:${projectDir}`);
|
|
629
|
+
const attach = async () => {
|
|
630
|
+
const a = await attachToRunning(home, projectDir, log);
|
|
631
|
+
if (!a)
|
|
632
|
+
return null;
|
|
633
|
+
if (opts.open)
|
|
634
|
+
openBrowser(a.url, log);
|
|
635
|
+
return { url: a.url, attached: true, close: async () => undefined };
|
|
636
|
+
};
|
|
637
|
+
const existing = await attach();
|
|
638
|
+
if (existing)
|
|
639
|
+
return existing;
|
|
640
|
+
let hub;
|
|
641
|
+
try {
|
|
642
|
+
hub = await createHub({ port: opts.port, log, home, exportDeps: opts.exportDeps });
|
|
643
|
+
}
|
|
644
|
+
catch (e) {
|
|
645
|
+
// 另一个 joplo dev 恰好同时启动:端口被它占了,改为加入它
|
|
646
|
+
if (e instanceof JoploError && e.details?.code === 'EADDRINUSE') {
|
|
647
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
648
|
+
const again = await attach();
|
|
649
|
+
if (again)
|
|
650
|
+
return again;
|
|
651
|
+
}
|
|
652
|
+
throw e;
|
|
653
|
+
}
|
|
654
|
+
const { url } = hub.addProject(projectDir);
|
|
655
|
+
const cleanup = () => clearServerInfo(home, process.pid);
|
|
656
|
+
process.once('exit', cleanup);
|
|
657
|
+
if (opts.open)
|
|
658
|
+
openBrowser(url, log);
|
|
659
|
+
return {
|
|
660
|
+
url,
|
|
661
|
+
attached: false,
|
|
662
|
+
close: async () => {
|
|
663
|
+
process.off('exit', cleanup);
|
|
664
|
+
await hub.close();
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|