@morlay/dsh-client-modules 0.0.0-stage → 0.2.7
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 +21 -0
- package/README.md +23 -2
- package/app-boot/base.css +187 -0
- package/app-boot/boot-page.ts +110 -0
- package/app-boot/index.html +15 -0
- package/app-boot/index.ts +101 -0
- package/app-boot/node-stub.cjs +19 -0
- package/app-boot/node-stub.ts +23 -0
- package/dist/client.cjs +65 -0
- package/dist/client.d.cts +9 -0
- package/dist/client.d.mts +9 -0
- package/dist/client.mjs +56 -0
- package/dist/index.cjs +1445 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.mjs +1442 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -3
- package/src/__tests__/bundle.spec.ts +235 -0
- package/src/__tests__/resolve.spec.ts +127 -0
- package/src/bundle.ts +708 -0
- package/src/client/__tests__/modules-service.spec.ts +43 -0
- package/src/client/__tests__/terminal-recovery.spec.ts +68 -0
- package/src/client/index.ts +101 -0
- package/src/config.ts +70 -0
- package/src/dirs.ts +97 -0
- package/src/index.ts +695 -0
- package/src/resolve.ts +418 -0
- package/src/serve-vite.ts +211 -0
- package/src/trace.ts +25 -0
- package/src/vite-plugin.ts +240 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
// 开发态的 client 资源按**文件**寻址:`/plugins/{pkgName}/{version}/{filepath}`。仓库自己的包都是 TS/ESM
|
|
6
|
+
// 源码(`exports` 在 patch 阶段已改指 `src`),所以逐个文件转译直出即可——浏览器原生 ESM 自己解析依赖,
|
|
7
|
+
// 前端资源之间不再受模块表的行集(`inject`)限制。`version` 段取自包清单:既参与寻址,也天然当
|
|
8
|
+
// cache-busting,不必再挂 `?rev=`。
|
|
9
|
+
//
|
|
10
|
+
// 转译与依赖图交给 vite(dev server 的模块图 + 依赖预打包),这里只做**对外地址**的解析。
|
|
11
|
+
|
|
12
|
+
/** 路由前缀(沿用上游的 `/plugins`,页面上不改 URL 形态)。 */
|
|
13
|
+
export const PLUGIN_ROUTE = "/plugins";
|
|
14
|
+
|
|
15
|
+
/** 仓库里的包:包根按真实路径记(软链已解开),供越界校验。 */
|
|
16
|
+
export interface PackageRoot {
|
|
17
|
+
name: string;
|
|
18
|
+
version: string;
|
|
19
|
+
root: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `@deepseek-ai/dsh-x` → `@deepseek-ai+dsh-x`:包名里的 `/` 在 URL 段里要转义。 */
|
|
23
|
+
/**
|
|
24
|
+
* **出口名 ↔ 产物文件名**的约定:出口产物的文件名是 `<出口名>.js`。
|
|
25
|
+
*
|
|
26
|
+
* 这条规则**只有一处**(这里):`urlOf` 生成地址、产物路由解析地址,两边都调它——各写一遍"补 `.js` / 剥
|
|
27
|
+
* `.js`"就会漂(`remote` 与 `remote.js` 两种地址,明明产出过却查不到)。
|
|
28
|
+
*/
|
|
29
|
+
export function entryFileNameOf(exportName: string): string {
|
|
30
|
+
return `${exportName}.js`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 地址里的文件名 → 出口名({@link entryFileNameOf} 的反向)。
|
|
35
|
+
*
|
|
36
|
+
* @param file - 地址里最后一段(`client.js`、`remote.js`、`chunks/…`)。
|
|
37
|
+
* @returns 出口名;没有扩展名的按原样(`index` 是包级引用的缺省出口)。
|
|
38
|
+
*/
|
|
39
|
+
export function exportNameOfFileName(file: string): string {
|
|
40
|
+
return file.replace(/\.js$/u, "") || "index";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function encodePackageName(name: string): string {
|
|
44
|
+
return name.replace("/", "+");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function decodePackageName(segment: string): string {
|
|
48
|
+
return segment.replace("+", "/");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 按「包与出口」寻址的解析结果:`/plugins/<pkg>/<export>`。
|
|
53
|
+
*
|
|
54
|
+
* 与"按文件"不同:一个出口对应**一份产物**(rolldown 打包,包内相对导入 inline、跨包引用 external 成
|
|
55
|
+
* 对方出口的 URL)。出口名与文件名不同构(`./client` → `src/client/index.ts`、`./remote` →
|
|
56
|
+
* `lib/typert.remote-client.ts`),所以寻址只能按清单的 `exports` 来,不能按文件。
|
|
57
|
+
*/
|
|
58
|
+
export interface ExportAddress {
|
|
59
|
+
name: string;
|
|
60
|
+
/** 出口名:根出口写成 `.`,其余是清单里的键(`./client`)。 */
|
|
61
|
+
exportName: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 权威寻址:`/plugins/{pkg}/{version}/{export}?rev=…`。
|
|
66
|
+
*
|
|
67
|
+
* - `pkg` 里的 `/` 转义成 `+`(`@scope/name` → `@scope+name`);
|
|
68
|
+
* - `export` 默认 `_default`(= 清单的 `"."` 出口),其余是出口名(`client` → `"./client"`);
|
|
69
|
+
* - `rev` 是 query,可省(服务端按内容算得出来)。
|
|
70
|
+
*
|
|
71
|
+
* 三方包与仓库包**同格式**:访问这件事上它们没有区别。
|
|
72
|
+
*/
|
|
73
|
+
export interface PluginAddress {
|
|
74
|
+
name: string;
|
|
75
|
+
version: string;
|
|
76
|
+
/** 出口名:`"."` 或 `"./client"` 这类清单键。 */
|
|
77
|
+
exportName: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** URL 段里的默认出口写法。 */
|
|
81
|
+
export const DEFAULT_EXPORT_SEGMENT = "_default";
|
|
82
|
+
|
|
83
|
+
export function parsePluginAddress(url: string): PluginAddress | undefined {
|
|
84
|
+
const path = url.split("?")[0] ?? "";
|
|
85
|
+
if (!path.startsWith(`${PLUGIN_ROUTE}/`)) return undefined;
|
|
86
|
+
const segments = path
|
|
87
|
+
.slice(PLUGIN_ROUTE.length + 1)
|
|
88
|
+
.split("/")
|
|
89
|
+
.filter((segment) => segment !== "");
|
|
90
|
+
// 不是按包寻址的都放行(上游的 combo `??a,b` 那类;如今资源由 vite 直接服务,这条路只留兜底)。
|
|
91
|
+
if (segments[0]?.startsWith("??") === true || segments[0]?.startsWith(".") === true)
|
|
92
|
+
return undefined;
|
|
93
|
+
const [pkgSegment, version, ...exportSegments] = segments;
|
|
94
|
+
if (pkgSegment === undefined || version === undefined) return undefined;
|
|
95
|
+
const exportSegment = exportSegments.join("/");
|
|
96
|
+
return {
|
|
97
|
+
name: decodePackageName(pkgSegment),
|
|
98
|
+
version,
|
|
99
|
+
exportName:
|
|
100
|
+
exportSegment === "" || exportSegment === DEFAULT_EXPORT_SEGMENT ? "." : `./${exportSegment}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 条件导出里挑**运行期**入口:`import` > `default` > `require` > `types`。
|
|
106
|
+
*
|
|
107
|
+
* 条件是**可嵌套**的(`zustand` 的 `./vanilla` 出口就是
|
|
108
|
+
* `{ import: { types, default }, default: { types, default } }`),所以命中条件后要继续往里挑——
|
|
109
|
+
* 只看顶层字符串会跳过 `import` 落到 `default`,拿到 CJS 那支(`vanilla.js` 而不是 `esm/vanilla.mjs`),
|
|
110
|
+
* 产物里就只剩 `export default require_…`,消费方要的具名导出一个都没有。
|
|
111
|
+
*/
|
|
112
|
+
function pickCondition(entry: unknown): string | undefined {
|
|
113
|
+
if (typeof entry === "string") return entry;
|
|
114
|
+
// **数组**形态(`@oxc-project/runtime` 的每个出口都是 `[{ node, import, default }, "./src/…"]`):
|
|
115
|
+
// node 的语义是"依次尝试",这里取第一个答得上来的。
|
|
116
|
+
if (Array.isArray(entry)) {
|
|
117
|
+
for (const item of entry) {
|
|
118
|
+
const picked = pickCondition(item);
|
|
119
|
+
if (picked !== undefined) return picked;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (entry === null || typeof entry !== "object") return undefined;
|
|
124
|
+
const record = entry as Record<string, unknown>;
|
|
125
|
+
for (const key of ["import", "default", "require", "types"]) {
|
|
126
|
+
if (!(key in record)) continue;
|
|
127
|
+
const picked = pickCondition(record[key]);
|
|
128
|
+
if (picked !== undefined) return picked;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 按文件寻址的解析结果:包名 + 包内相对路径(`src/client/index.ts` 这种)。 */
|
|
134
|
+
export interface FileAddress {
|
|
135
|
+
name: string;
|
|
136
|
+
version: string;
|
|
137
|
+
filepath: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 工作区包索引:包名 → 包根。
|
|
142
|
+
*
|
|
143
|
+
* `pnpm` 的布局只保证"**能解析的**才在"——vendor 里的包、以及当前包没声明为依赖的工作区包,从 importer
|
|
144
|
+
* 出发都解析不到。但它们是**仓库内的包**(有自己的 `exports`、自己的 `./client` 出口),不该被当成三方。
|
|
145
|
+
* 所以扫一遍仓库内的清单,懒建一次索引。
|
|
146
|
+
*/
|
|
147
|
+
let workspaceIndex: Promise<Map<string, string>> | undefined;
|
|
148
|
+
|
|
149
|
+
/** 从某个文件向上找仓库根(`pnpm-workspace.yaml` 那一层)。 */
|
|
150
|
+
export async function workspaceRootOf(from: string): Promise<string | undefined> {
|
|
151
|
+
let directory = dirname(from);
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (
|
|
154
|
+
await readFile(join(directory, "pnpm-workspace.yaml"), "utf8").then(
|
|
155
|
+
() => true,
|
|
156
|
+
() => false,
|
|
157
|
+
)
|
|
158
|
+
) {
|
|
159
|
+
return directory;
|
|
160
|
+
}
|
|
161
|
+
const parent = dirname(directory);
|
|
162
|
+
if (parent === directory) return undefined;
|
|
163
|
+
directory = parent;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 仓库内**命中给定 glob** 的包(包名 + 包根)。
|
|
169
|
+
*
|
|
170
|
+
* 预打包与联邦寻址都要它:非开发模块不只是"行",还有 `ui-primitives` 这种共享库;版本表也要覆盖
|
|
171
|
+
* 那些没进行清单、却会被外置说明符提到的包(cordis、client-store…)。
|
|
172
|
+
*
|
|
173
|
+
* @param from - 解析基准(用来找仓库根)。
|
|
174
|
+
* @param patterns - 匹配包名的正则。
|
|
175
|
+
* @returns 命中的包。
|
|
176
|
+
*/
|
|
177
|
+
export async function workspacePackagesMatching(
|
|
178
|
+
from: string,
|
|
179
|
+
patterns: readonly RegExp[],
|
|
180
|
+
): Promise<{ name: string; root: string }[]> {
|
|
181
|
+
const index = await workspaceIndexOf(from);
|
|
182
|
+
return [...index.entries()]
|
|
183
|
+
.filter(([name]) => patterns.some((pattern) => pattern.test(name)))
|
|
184
|
+
.map(([name, root]) => ({ name, root }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function workspaceIndexOf(from: string): Promise<Map<string, string>> {
|
|
188
|
+
workspaceIndex ??= (async () => {
|
|
189
|
+
const index = new Map<string, string>();
|
|
190
|
+
const root = await workspaceRootOf(from);
|
|
191
|
+
if (root === undefined) return index;
|
|
192
|
+
const { glob } = await import("node:fs/promises");
|
|
193
|
+
for (const pattern of [
|
|
194
|
+
"packages/*/*/package.json",
|
|
195
|
+
"vendor/*/packages/*/*/package.json",
|
|
196
|
+
// 上游把自己的依赖 vendors 在仓库内(cordis、cosmokit…),它们也参与联邦寻址。
|
|
197
|
+
"vendor/*/vendor/*/package.json",
|
|
198
|
+
]) {
|
|
199
|
+
for await (const file of glob(pattern, { cwd: root })) {
|
|
200
|
+
const path = join(root, file);
|
|
201
|
+
const manifest = JSON.parse(await readFile(path, "utf8").catch(() => "{}")) as {
|
|
202
|
+
name?: string;
|
|
203
|
+
};
|
|
204
|
+
if (manifest.name !== undefined) index.set(manifest.name, dirname(path));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return index;
|
|
208
|
+
})();
|
|
209
|
+
return await workspaceIndex;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 包名 → 包根:走 node 的解析(pnpm 已经把依赖摆好了),**解析入口**再向上找最近的清单。
|
|
214
|
+
*
|
|
215
|
+
* 不能用 `resolve('<name>/package.json')`:现代包(`clsx` 这类)的 `exports` 通常不暴露
|
|
216
|
+
* `./package.json`,那样明明装着也解析不出来。
|
|
217
|
+
*/
|
|
218
|
+
export async function packageRootOf(from: string, name: string): Promise<PackageRoot | undefined> {
|
|
219
|
+
const bare = name
|
|
220
|
+
.split("/")
|
|
221
|
+
.slice(0, name.startsWith("@") ? 2 : 1)
|
|
222
|
+
.join("/");
|
|
223
|
+
// 虚拟模块的 id(`\0…`)不是路径:`createRequire` 拿到它会抛,直接跳过这层解析。
|
|
224
|
+
const require = from.startsWith("\0") ? undefined : createRequire(from);
|
|
225
|
+
try {
|
|
226
|
+
if (require === undefined) throw new Error("virtual id: 不走 node 解析");
|
|
227
|
+
// 只解析**包名**:带子路径的说明符(`pkg/sub`)会要求清单里有对应 `exports` 键,而我们要的是包根。
|
|
228
|
+
const resolved = require.resolve(bare);
|
|
229
|
+
const owned = await owningPackageOf(resolved);
|
|
230
|
+
if (owned !== undefined) return owned;
|
|
231
|
+
} catch {
|
|
232
|
+
// 落到下面的候选目录:**没有 `.` 出口**的包(`@oxc-project/runtime` 只有 `./helpers/*`)解析不了
|
|
233
|
+
// 包名本身,明明装着却"解析不到"。
|
|
234
|
+
}
|
|
235
|
+
for (const directory of require?.resolve.paths(bare) ?? []) {
|
|
236
|
+
const manifest = join(directory, bare, "package.json");
|
|
237
|
+
if (
|
|
238
|
+
await readFile(manifest, "utf8").then(
|
|
239
|
+
() => true,
|
|
240
|
+
() => false,
|
|
241
|
+
)
|
|
242
|
+
) {
|
|
243
|
+
const owned = await owningPackageOf(manifest);
|
|
244
|
+
if (owned !== undefined) return owned;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const root = (await workspaceIndexOf(from)).get(name);
|
|
248
|
+
return root === undefined ? undefined : await owningPackageOf(join(root, "package.json"));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 按包清单的 `exports` 解析子路径(`./client` → `./src/client/index.ts`)。
|
|
253
|
+
*
|
|
254
|
+
* 不能自己拼 `src/<subpath>`:出口名与文件名**不同构**(`./client` 指向 `src/client/index.ts`、
|
|
255
|
+
* `./remote` 指向 `lib/typert.remote-client.ts` 这类都由清单说了算)。
|
|
256
|
+
*/
|
|
257
|
+
export async function exportTargetOf(
|
|
258
|
+
root: string,
|
|
259
|
+
subpath: string | undefined,
|
|
260
|
+
): Promise<string | undefined> {
|
|
261
|
+
try {
|
|
262
|
+
const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
|
263
|
+
exports?: Record<string, unknown>;
|
|
264
|
+
main?: string;
|
|
265
|
+
module?: string;
|
|
266
|
+
};
|
|
267
|
+
const key = subpath === undefined ? "." : `./${subpath}`;
|
|
268
|
+
// `exports` 的三种形状(都要认):
|
|
269
|
+
// · 以 `.` 开头的键映射(标准)→ 取 `exports["."]` / `exports["./sub"]`;
|
|
270
|
+
// · **条件对象本身**(`{ development, default }`,`mdast-util-*` 就是这形状)→ 它**就是**根出口;
|
|
271
|
+
// · 没有 `exports` → 按 node 规则退回 `module` → `main`(老包如 `fortune-sheet`)。
|
|
272
|
+
const table = manifest.exports;
|
|
273
|
+
const isSubpathMap =
|
|
274
|
+
table !== undefined &&
|
|
275
|
+
Object.keys(table).some((name) => name === "." || name.startsWith("./"));
|
|
276
|
+
const entry =
|
|
277
|
+
(isSubpathMap ? table?.[key] : key === "." ? table : undefined) ??
|
|
278
|
+
(key === "." ? (manifest.module ?? manifest.main) : undefined);
|
|
279
|
+
const target =
|
|
280
|
+
typeof entry === "string"
|
|
281
|
+
? entry
|
|
282
|
+
: entry === null || typeof entry !== "object"
|
|
283
|
+
? undefined
|
|
284
|
+
: // 条件按**运行期**优先级挑:`import`(ESM 入口,`"type": "module"` 的包都走它)→
|
|
285
|
+
// `default` → `require`。`types` 是类型声明(`index.d.ts`),拿它去打包会得到 3460 个
|
|
286
|
+
// PARSE_ERROR——rolldown 把声明文件当 JS 解析,所以它只能垫底。
|
|
287
|
+
pickCondition(entry as Record<string, unknown>);
|
|
288
|
+
if (typeof target !== "string") return undefined;
|
|
289
|
+
return resolveInPackage({ name: "", version: "", root }, target.replace(/^\.\//u, ""));
|
|
290
|
+
} catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 包清单里我们用到的那些字段(其余原样留着,调用方按需读)。 */
|
|
296
|
+
export interface PackageManifest {
|
|
297
|
+
name: string;
|
|
298
|
+
version: string;
|
|
299
|
+
/** `dsh.client` 声明:有它才是 client 行(上游 `client-modules` 的 roster 判据)。 */
|
|
300
|
+
dsh?: { client?: unknown };
|
|
301
|
+
exports?: Record<string, unknown>;
|
|
302
|
+
main?: string;
|
|
303
|
+
module?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 读包清单。 */
|
|
307
|
+
export async function manifestOf(root: string): Promise<PackageManifest | undefined> {
|
|
308
|
+
try {
|
|
309
|
+
const manifest = JSON.parse(
|
|
310
|
+
await readFile(join(root, "package.json"), "utf8"),
|
|
311
|
+
) as Partial<PackageManifest>;
|
|
312
|
+
if (manifest.name === undefined) return undefined;
|
|
313
|
+
return { ...manifest, name: manifest.name, version: manifest.version ?? "0" };
|
|
314
|
+
} catch {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 向上找最近的包(软链已由 node 解析解开):返回清单与包根。 */
|
|
320
|
+
export async function owningPackageOf(file: string): Promise<PackageRoot | undefined> {
|
|
321
|
+
let directory = dirname(file);
|
|
322
|
+
while (directory !== dirname(directory)) {
|
|
323
|
+
const manifest = await manifestOf(directory);
|
|
324
|
+
if (manifest !== undefined) return { ...manifest, root: directory };
|
|
325
|
+
directory = dirname(directory);
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 包根 + 包内路径 → 绝对路径;越出包根一律拒绝(寻址来自 URL,不能穿透到仓库别处)。 */
|
|
331
|
+
export function resolveInPackage(pkg: PackageRoot, filepath: string): string | undefined {
|
|
332
|
+
const target = resolvePath(pkg.root, filepath);
|
|
333
|
+
const inside = relative(pkg.root, target);
|
|
334
|
+
return inside === "" || inside.startsWith("..") || inside.startsWith(sep) ? undefined : target;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 包内**物理路径**回退:清单的 `exports` 没覆盖这个子路径时,按约定位置找文件。
|
|
339
|
+
*
|
|
340
|
+
* 老包很常见:`picomatch` 没有 `exports`、只有 `main`,而 `picomatch/posix` 就是 `posix.js`;
|
|
341
|
+
* 只声明了部分子路径的包同理。寻址按 URL 来(`<pkg>@<version>/<子路径>.js`),所以这里按子路径找文件。
|
|
342
|
+
*/
|
|
343
|
+
export async function fileInPackage(root: string, subpath: string): Promise<string | undefined> {
|
|
344
|
+
const pkg = { name: "", version: "", root };
|
|
345
|
+
for (const candidate of [
|
|
346
|
+
subpath,
|
|
347
|
+
`${subpath}.js`,
|
|
348
|
+
`${subpath}.mjs`,
|
|
349
|
+
join(subpath, "index.js"),
|
|
350
|
+
join(subpath, "index.mjs"),
|
|
351
|
+
join(subpath, "index.ts"),
|
|
352
|
+
]) {
|
|
353
|
+
const file = resolveInPackage(pkg, candidate);
|
|
354
|
+
if (
|
|
355
|
+
file !== undefined &&
|
|
356
|
+
(await readFile(file, "utf8").then(
|
|
357
|
+
() => true,
|
|
358
|
+
() => false,
|
|
359
|
+
))
|
|
360
|
+
)
|
|
361
|
+
return file;
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 一个 specifier 的寻址结果:包名、包内路径、以及它对应的 URL。 */
|
|
367
|
+
export interface ResolvedSpecifier {
|
|
368
|
+
name: string;
|
|
369
|
+
filepath: string;
|
|
370
|
+
url: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 说明符里的包名:`@scope/name` 取两段、`name` 取一段。作用域包**没有子路径**时,
|
|
375
|
+
* `indexOf("/", slash + 1)` 会返回 -1——直接 `slice` 会削掉最后一个字符,这里显式判掉。
|
|
376
|
+
*/
|
|
377
|
+
export function packageNameOf(specifier: string): string {
|
|
378
|
+
if (!specifier.startsWith("@")) return specifier.split("/")[0] ?? specifier;
|
|
379
|
+
const slash = specifier.indexOf("/");
|
|
380
|
+
if (slash < 0) return specifier;
|
|
381
|
+
const second = specifier.indexOf("/", slash + 1);
|
|
382
|
+
return second < 0 ? specifier : specifier.slice(0, second);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 包根 → 它**浏览器面**的文件。
|
|
387
|
+
*
|
|
388
|
+
* @param root - 包根目录。
|
|
389
|
+
* @param subpath - 出口名(不含包名,如 `client`、`brand-font.css`);省略表示包的根出口。
|
|
390
|
+
* @returns 该出口对应的文件;清单没这个出口、且约定路径也不存在时返回 undefined。
|
|
391
|
+
*/
|
|
392
|
+
export async function clientHalfOf(root: string, subpath?: string): Promise<string | undefined> {
|
|
393
|
+
const exists = async (file: string): Promise<boolean> =>
|
|
394
|
+
await readFile(file, "utf8").then(
|
|
395
|
+
() => true,
|
|
396
|
+
() => false,
|
|
397
|
+
);
|
|
398
|
+
if (subpath !== undefined) {
|
|
399
|
+
const byExports = await exportTargetOf(root, subpath);
|
|
400
|
+
if (byExports !== undefined) return byExports;
|
|
401
|
+
// 清单没覆盖的按物理路径试(`src/<subpath>/index.ts` 是 client 半的约定位置)。
|
|
402
|
+
const pkg = { name: "", version: "", root };
|
|
403
|
+
for (const candidate of [
|
|
404
|
+
join("src", subpath, "index.ts"),
|
|
405
|
+
join(subpath, "index.ts"),
|
|
406
|
+
subpath,
|
|
407
|
+
]) {
|
|
408
|
+
const file = resolveInPackage(pkg, candidate);
|
|
409
|
+
if (file !== undefined && (await exists(file))) return file;
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
// 根出口:`dsh.client` 声明的包用它的 `./client`;其余用约定路径 `src/client/index.ts`。
|
|
414
|
+
const byClient = await exportTargetOf(root, "client");
|
|
415
|
+
if (byClient !== undefined) return byClient;
|
|
416
|
+
const conventional = join(root, "src", "client", "index.ts");
|
|
417
|
+
return (await exists(conventional)) ? conventional : undefined;
|
|
418
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev 形态的客户端资源服务:**全权交给 vite**。
|
|
3
|
+
*
|
|
4
|
+
* 我们只提供两件它不知道的事:① 哪些包是 client 行、它们的源码入口在哪;② 页面 HTML 的骨架与自己那套
|
|
5
|
+
* 入口(`/plugins/.app/entry.js`)。其余(依赖预打包、CJS→ESM、具名导出、外置 require、共享 chunk、
|
|
6
|
+
* 内容 hash 缓存、HMR)都是 vite dev server 的现成能力——自己实现这些正是前面绕远路的地方。
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
12
|
+
import { traceOf } from "./trace.ts";
|
|
13
|
+
import { createServer, type ServerOptions, type ViteDevServer } from "vite";
|
|
14
|
+
import { clientModulesAlias } from "./bundle.ts";
|
|
15
|
+
import { dshClientRowsPlugin, ROWS_VIRTUAL } from "./vite-plugin.ts";
|
|
16
|
+
import { packageRootOf } from "./resolve.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 行清单来源:cordis Loader 的 entries 里声明 `dsh.client` 的那些包。
|
|
20
|
+
*
|
|
21
|
+
* 只给**包名**——包的位置与 client 半的入口交给 vite 自己 resolve(插件里用 `this.resolve`)。
|
|
22
|
+
*/
|
|
23
|
+
export interface RowsSource {
|
|
24
|
+
/** 行清单:包名 → client 半的源文件(各 entry 用自己的 baseUrl 解析)。 */
|
|
25
|
+
rows: () => Promise<Map<string, string>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowsOf(source: RowsSource): Promise<Map<string, string>> {
|
|
29
|
+
return source.rows();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 起一个 vite dev server(middleware 模式),并把 `/plugins` 前缀下的请求交给它。 */
|
|
33
|
+
/** vite 的 HMR WebSocket 路径:专有路径,避免与上游自身的 upgrade 路由抢。 */
|
|
34
|
+
export const HMR_PATH = "/__dsh_hmr";
|
|
35
|
+
|
|
36
|
+
/** vite 期望的 http server 类型(`server.hmr.server`):HMR 的 WebSocket 挂在它的 `upgrade` 上。 */
|
|
37
|
+
type HmrServer = NonNullable<
|
|
38
|
+
Extract<NonNullable<ServerOptions["hmr"]>, { server?: unknown }>["server"]
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
export interface ViteHandoff {
|
|
42
|
+
/** 有没有挂 HMR(无端口形态没有同源的 http server 可挂,见 `hmr: false`)——页面决定要不要注入客户端。 */
|
|
43
|
+
hmr: boolean;
|
|
44
|
+
/** 把 `/plugins/*` 的请求交给 vite(返回 true 表示它接管了)。 */
|
|
45
|
+
serve: (req: IncomingMessage, res: ServerResponse) => boolean;
|
|
46
|
+
/** 让 vite 渲染一份 HTML(插件的 `transformIndexHtml` 会在这里注入入口 script)。 */
|
|
47
|
+
transformHtml: (html: string) => Promise<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function startViteDev(
|
|
51
|
+
ctx: Context,
|
|
52
|
+
options: {
|
|
53
|
+
repoRoot: string;
|
|
54
|
+
base: string;
|
|
55
|
+
rows: RowsSource;
|
|
56
|
+
resolvePluginUrl: (url: string) => Promise<string | undefined>;
|
|
57
|
+
/** 裸说明符 → 联邦地址(三方与"非开发模块"都由它接管,见 `ClientRowsPluginOptions.depsUrl`)。 */
|
|
58
|
+
depsUrl: (specifier: string) => Promise<string | undefined>;
|
|
59
|
+
/** 宿主进程的 http server:HMR 的 WebSocket 要挂在它的 `upgrade` 上。 */
|
|
60
|
+
httpServer?: HmrServer | undefined;
|
|
61
|
+
},
|
|
62
|
+
): Promise<ViteHandoff> {
|
|
63
|
+
const trace = traceOf(ctx, "vite");
|
|
64
|
+
const { repoRoot, base } = options;
|
|
65
|
+
/**
|
|
66
|
+
* 页面与入口所在的真实目录(vite 的 root)。
|
|
67
|
+
*
|
|
68
|
+
* 按**本包自己的位置**推,不从 `repoRoot` 拼:打包形态下 `repoRoot` 是 profile 目录
|
|
69
|
+
* (`$DSH_HOME/profiles/desktop`),从它拼出的 `packages/client/…/app-boot` 并不存在——vite 的 root
|
|
70
|
+
* 一空,`/index.html` 404、入口也转译不了,页面就只剩一具空壳。这条事实与 `index.ts` 的 `appRoot`
|
|
71
|
+
* 同源(那里也从包根推)。
|
|
72
|
+
*/
|
|
73
|
+
const appRoot = fileURLToPath(new URL("../app-boot", import.meta.url));
|
|
74
|
+
/** 页面所在的端口(HMR 的 WebSocket 必须与页面**同源同端口**,否则客户端连不上)。 */
|
|
75
|
+
const pagePort = ((): number | undefined => {
|
|
76
|
+
const address = options.httpServer?.address();
|
|
77
|
+
if (address !== null && address !== undefined && typeof address === "object")
|
|
78
|
+
return address.port;
|
|
79
|
+
const fromEnv = process.env["PORT"];
|
|
80
|
+
return fromEnv === undefined ? undefined : Number(fromEnv);
|
|
81
|
+
})();
|
|
82
|
+
// **当前**实例:vite 自己 `restart()` 后会建一个新 server,插件的 `configureServer` 再回调过来。
|
|
83
|
+
// 不跟这个引用的话,重启之后路由还指着已关闭的旧实例(请求开始成片失败,宿主也可能因此退出)。
|
|
84
|
+
let current: ViteDevServer | undefined;
|
|
85
|
+
const server: ViteDevServer = await createServer({
|
|
86
|
+
// **root 收在 app-boot**:仓库里的源码都在 root 之外,vite 因此只能用 `/@fs/<绝对路径>` 引用它们
|
|
87
|
+
// ——URL 空间只剩 `@` 前缀(`@/…`、`@client-modules/…`、`/@fs/…`、`/@id/…`、`/@vite/…`),不会有
|
|
88
|
+
// 裸的 `/packages/…`、`/vendor/…` 混进来。页面与入口就是这个目录里的两个文件。
|
|
89
|
+
root: appRoot,
|
|
90
|
+
// 先让 vite 用**它自己的 URL 空间**(`/@vite`、`/@id`、`/@fs` 都在根上);对外那层 `/plugins/...`
|
|
91
|
+
// 等页面跑起来再作为映射加回来。
|
|
92
|
+
//
|
|
93
|
+
// **不要**把 base 改成 `/@/` 去"统一前缀":vite 会给所有不以 base 开头的绝对 URL 再补一层,
|
|
94
|
+
// 联邦地址(`/@client-modules/…`)会变成 `/@/@client-modules/…`,两条链全断。
|
|
95
|
+
base: "/",
|
|
96
|
+
// 浏览器没有 `process`:客户端半常读 `process.env.NODE_ENV`(上游 client 打包也做同样的替换)。
|
|
97
|
+
define: {
|
|
98
|
+
"process.env.NODE_ENV": JSON.stringify(process.env["NODE_ENV"] ?? "development"),
|
|
99
|
+
"process.env": "{ env: {} }",
|
|
100
|
+
// 裸 `process` 同样要给空壳(逐文件服务的源码里也会读它)。
|
|
101
|
+
process: "{ env: {} }",
|
|
102
|
+
},
|
|
103
|
+
publicDir: false,
|
|
104
|
+
// **不做 history fallback**(`spa` 会把没解析出来的路径静默回一份 index.html):请求都在 `@` 前缀
|
|
105
|
+
// 的 URL 空间里(`@/…`、`@client-modules/…`、`/@fs/…`),谁没解析出来就该**当场 404**——否则浏览器
|
|
106
|
+
// 把 HTML 当模块执行,报出来的是一堆看不懂的错,而真正没命中的那个 URL 连线索都没有。
|
|
107
|
+
appType: "mpa",
|
|
108
|
+
// 依赖优化:**非开发模块**(`dsh.dev.exclude`)的入口交给 vite 预打包——一次构建、共享 chunk、
|
|
109
|
+
// 落盘 `<root>/node_modules/.vite/deps`、按内容 hash 失效;改着的包不在其中,仍逐文件走 vite(HMR)。
|
|
110
|
+
// 缓存目录用默认的 `node_modules/.vite`:跨进程、跨 profile 共用一份,不按进程隔离。
|
|
111
|
+
//
|
|
112
|
+
// **不用 vite 的 optimizeDeps**:客户端资源由我们自己的联邦链产出(`@client-modules/…`),vite 只管
|
|
113
|
+
// 页面与本地文件(`@/…`)。开着它会去预打包 workspace 源码,形成第二条链(浏览器里出现
|
|
114
|
+
// `node_modules/.vite/deps/@_packages_profile…` 就是这么来的)。
|
|
115
|
+
// **不读也不监听配置文件**:配置全是内联的(下面这些字段)。默认它会在 root 下找 `vite.config.*`
|
|
116
|
+
// 并 watch,改动就 `restart()` 自己——而这个 dev server 跑在 dsh 宿主进程里,重启会连带把整条
|
|
117
|
+
// 服务拖下水。关掉这条路径。
|
|
118
|
+
configFile: false,
|
|
119
|
+
resolve: {
|
|
120
|
+
// `@/…` → 仓库根、`node:*` → 求值安全的空替身(与联邦构建同一套约定,替身形态见 `node-stub.*`)。
|
|
121
|
+
alias: clientModulesAlias({ repoRoot, nodeStub: join(appRoot, "node-stub.ts") }),
|
|
122
|
+
},
|
|
123
|
+
server: {
|
|
124
|
+
middlewareMode: true,
|
|
125
|
+
// **关掉预转换**:vite 会对模块的静态 import 递归预热(`transformRequest`),而我们的联邦地址是
|
|
126
|
+
// **外部** URL(由自己的路由按请求预编译)——它照样去 load,于是日志里一串
|
|
127
|
+
// `Pre-transform error: Failed to load url /@client-modules/…`。预热换来的并行度不值得这份噪音。
|
|
128
|
+
preTransformRequests: false,
|
|
129
|
+
// HMR 的 WebSocket 挂到宿主的 http server 上:**路径专有**(避免与上游自身的 upgrade 路由抢),
|
|
130
|
+
// **端口与页面一致**(客户端连的就是页面这个端口)。调用方要先在 webServer 上注册同名 upgrade
|
|
131
|
+
// 路由——上游对未注册的升级请求会直接 destroy socket,vite 就握不上手。
|
|
132
|
+
...(options.httpServer === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: {
|
|
135
|
+
hmr: {
|
|
136
|
+
server: options.httpServer,
|
|
137
|
+
path: HMR_PATH,
|
|
138
|
+
...(pagePort === undefined ? {} : { clientPort: pagePort }),
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
// 整个仓库都在 root 内,`/@fs/…` 的许可自然覆盖(vite 对 root 之外的文件会**放行**到 next,
|
|
142
|
+
// 表现为一串"没被 vite 接手"的 404)。
|
|
143
|
+
fs: { strict: false, allow: [repoRoot] },
|
|
144
|
+
},
|
|
145
|
+
// **不用** vite 的 `optimizeDeps`:裸说明符已由插件统一改写成 `@deps/`(我们自己预打包的产物),
|
|
146
|
+
// vite 再打一份就会与聚合产物里的那份形成两个实例。
|
|
147
|
+
// **不用** vite 的 `optimizeDeps`(关掉自动发现):非开发模块与三方都由我们的联邦链产出,裸说明符
|
|
148
|
+
// 已在插件里改写成 `@client-modules/…`,这里没有任何东西需要预打包。开着只会多出第二条链(浏览器里
|
|
149
|
+
// 同时出现 `.vite/deps/react.js` 与我们的 `@client-modules/react@…/index.js`)与一次全仓扫描。
|
|
150
|
+
// **彻底不用** vite 的依赖优化器:裸说明符已由插件统一改写成 `@client-modules/…`(我们自己的联邦链产出),
|
|
151
|
+
// 这里没有任何东西需要它预打包。`noDiscovery: true` 只关了"自动发现",扫描器仍会去解析那些包名——而行清单
|
|
152
|
+
// 是**虚拟模块**、里面写的包名 vite 按 `package.json` 的去向解析,落到工作区解析面上就报
|
|
153
|
+
// `Cannot find package … imported from …/package.json`(上游 workspace 内部包只存在于 vendor / 闭包)。
|
|
154
|
+
// `disabled` 是官方给的"整个关掉"(标了 deprecated,但语义正是这个)。
|
|
155
|
+
optimizeDeps: { disabled: true, noDiscovery: true, include: [] },
|
|
156
|
+
plugins: [
|
|
157
|
+
dshClientRowsPlugin({
|
|
158
|
+
repoRoot,
|
|
159
|
+
rows: () => rowsOf(options.rows),
|
|
160
|
+
resolvePluginUrl: options.resolvePluginUrl,
|
|
161
|
+
depsUrl: options.depsUrl,
|
|
162
|
+
onServer: (devServer) => {
|
|
163
|
+
current = devServer;
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
current = server;
|
|
169
|
+
ctx.logger.info("client-modules: vite dev server 就绪(root=%s)", repoRoot);
|
|
170
|
+
/** 页面入口(root=app-boot,所以就是 `/index.html`)。 */
|
|
171
|
+
const pageEntry = "/index.html";
|
|
172
|
+
const serve = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
173
|
+
if ((req.url ?? "").split("?")[0] === "/" || (req.url ?? "").split("?")[0] === "/index.html") {
|
|
174
|
+
req.url = pageEntry;
|
|
175
|
+
}
|
|
176
|
+
// `/plugins/*` 由 host 那层自己的路由先处理(那里挂的是对外形态的资源),其余全交给 vite——
|
|
177
|
+
// 它要服务的形态很多(虚拟模块 `/@id`、取文件 `/@fs`、预打包 `/node_modules/.vite/deps`、HMR
|
|
178
|
+
// 通道),白名单只会不断漏。
|
|
179
|
+
if ((req.url ?? "").startsWith(`${base}/`)) return false;
|
|
180
|
+
// 单次请求出错不该把宿主带走:vite 的 middlewares 里任何未捕获异常都只记一条 500。
|
|
181
|
+
try {
|
|
182
|
+
// 非 2xx 才留痕(正常请求不吵):vite 没接住时能立刻看到是哪条 URL。
|
|
183
|
+
res.on("finish", () => {
|
|
184
|
+
if (res.statusCode < 400) return;
|
|
185
|
+
trace("%s %s → %s", req.method ?? "", req.url ?? "", res.statusCode);
|
|
186
|
+
});
|
|
187
|
+
(current ?? server).middlewares(req, res, () => {
|
|
188
|
+
// vite 没接住:留下线索(桌面形态经自定义协议进来,请求形态与 http 不同,这条日志能立刻定位)。
|
|
189
|
+
trace("miss %s %s", req.method ?? "", req.url ?? "");
|
|
190
|
+
if (!res.headersSent) res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
191
|
+
if (!res.writableEnded) res.end("client-modules: vite 没有这条路由\n");
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
ctx.logger.error(error);
|
|
195
|
+
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
196
|
+
if (!res.writableEnded) res.end(`dev-client: vite 处理失败:${String(error)}\n`);
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
serve,
|
|
202
|
+
transformHtml: async (html: string) => await server.transformIndexHtml(`${base}/`, html),
|
|
203
|
+
hmr: options.httpServer !== undefined,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 虚拟入口的浏览器 URL(HTML 里引用它)。 */
|
|
208
|
+
export const ROWS_URL = `@id/__x00__${ROWS_VIRTUAL}`;
|
|
209
|
+
|
|
210
|
+
/** 包根解析用(`packageRootOf` 在这里只用一次,便于将来换成 vite 的 resolver)。 */
|
|
211
|
+
export { packageRootOf };
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 诊断细节(逐包预编译、逐请求那一类)的具名 logger。
|
|
5
|
+
*
|
|
6
|
+
* 名字交给 logger(`ctx.logger(name)`);**值走参数**、不拼进文本——cordis 的 logger 没有"每次调用带
|
|
7
|
+
* meta"这回事(`meta` 是 logger 实例级的),所以结构化的东西作为 `%o` 的参数传(exporter 拿到的
|
|
8
|
+
* `args` 里是结构与值,而不是一段 `key=value` 文本)。键名若出现,用 `.` 分层、不带空格。
|
|
9
|
+
*
|
|
10
|
+
* **是否调用**由本开关决定:运行时的日志门槛不由我们定(loader 注册的 exporter 允许 DEBUG),而这类细节
|
|
11
|
+
* 一次启动两千多行、每请求一行,默认不调,`DSH_TRACE=1` 时再调。出问题的几行走 `warn`。
|
|
12
|
+
* @param ctx - 这条链的上下文。
|
|
13
|
+
* @param name - 这个 logger 的名字(如 `federation` / `vite`)。
|
|
14
|
+
* @returns `(format, ...args)` 形态的诊断出口;开关没开时什么都不做。
|
|
15
|
+
*/
|
|
16
|
+
export function traceOf(
|
|
17
|
+
ctx: Context,
|
|
18
|
+
name: string,
|
|
19
|
+
): (format: string, ...args: readonly unknown[]) => void {
|
|
20
|
+
if (process.env["DSH_TRACE"] !== "1") return () => {};
|
|
21
|
+
const logger = ctx.logger(name);
|
|
22
|
+
return (format: string, ...args: readonly unknown[]) => {
|
|
23
|
+
logger.debug(format, ...args);
|
|
24
|
+
};
|
|
25
|
+
}
|