@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/index.ts
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { findPackageJSON } from "node:module";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import type { Duplex } from "node:stream";
|
|
7
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
8
|
+
import { traceOf } from "./trace.ts";
|
|
9
|
+
import { ASSET_MIME, NODE_BUILTIN } from "@morlay/dsh-cordis-assets/assets";
|
|
10
|
+
import { devExcluded, isAggregated } from "./config.ts";
|
|
11
|
+
import {
|
|
12
|
+
appBootDir,
|
|
13
|
+
dataDir,
|
|
14
|
+
cacheDir,
|
|
15
|
+
packageManifestPath,
|
|
16
|
+
profileDir,
|
|
17
|
+
profileManifestPath,
|
|
18
|
+
resolutionManifestPath,
|
|
19
|
+
resolutionRoot,
|
|
20
|
+
} from "./dirs.ts";
|
|
21
|
+
import {
|
|
22
|
+
bundlePackage,
|
|
23
|
+
CLIENT_MODULES_PREFIX,
|
|
24
|
+
clientModulesAlias,
|
|
25
|
+
dependencyEntry,
|
|
26
|
+
urlOf,
|
|
27
|
+
} from "./bundle.ts";
|
|
28
|
+
import { HMR_PATH, startViteDev } from "./serve-vite.ts";
|
|
29
|
+
import {
|
|
30
|
+
clientHalfOf,
|
|
31
|
+
decodePackageName,
|
|
32
|
+
encodePackageName,
|
|
33
|
+
exportTargetOf,
|
|
34
|
+
fileInPackage,
|
|
35
|
+
manifestOf,
|
|
36
|
+
owningPackageOf,
|
|
37
|
+
packageNameOf,
|
|
38
|
+
packageRootOf,
|
|
39
|
+
parsePluginAddress,
|
|
40
|
+
PLUGIN_ROUTE,
|
|
41
|
+
resolveInPackage,
|
|
42
|
+
exportNameOfFileName,
|
|
43
|
+
} from "./resolve.ts";
|
|
44
|
+
|
|
45
|
+
export const name = "client-modules";
|
|
46
|
+
|
|
47
|
+
export const inject = ["loader", "webServer"];
|
|
48
|
+
|
|
49
|
+
// 打包器(`@local/devkit` 的现场打包面,连带 rolldown 与它的原生绑定)按需加载:打包产物里的行
|
|
50
|
+
// 已经切成 dist,这一支一次都不会走——常驻它等于给每个宿主进程白加一份打包器的内存。
|
|
51
|
+
interface RouteHost {
|
|
52
|
+
/** 把注入表(boot 数据、引导器、script 行)渲染进一份 index.html 骨架。 */
|
|
53
|
+
renderIndex(html: string): string;
|
|
54
|
+
/** 注册一条**升级**路由(WebSocket 之类);未注册的升级请求会被上游直接 destroy。 */
|
|
55
|
+
registerUpgrade(route: {
|
|
56
|
+
path: string;
|
|
57
|
+
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void;
|
|
58
|
+
}): () => void;
|
|
59
|
+
registerFallback(
|
|
60
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> | void,
|
|
61
|
+
): () => void;
|
|
62
|
+
register(route: {
|
|
63
|
+
// `prefix` 匹配 `p` 与 `p/<anything>`(上游 webServer 的原生能力):按文件寻址要用它。
|
|
64
|
+
kind: "exact" | "prefix";
|
|
65
|
+
path: string;
|
|
66
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
67
|
+
}): () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** cordis Loader 条目里我们用到的那几项(上游 `client-modules` 用同样几个字段判断行)。 */
|
|
71
|
+
interface LoaderEntry {
|
|
72
|
+
disabled?: boolean;
|
|
73
|
+
fiber?: unknown;
|
|
74
|
+
options?: { name?: string };
|
|
75
|
+
/** 该 entry 的解析基:Loader 用它解析插件名(上游 `client-modules` 的 `resolveSource` 读的就是这里)。 */
|
|
76
|
+
parent?: { tree?: { ctx?: { baseUrl?: string } } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 一行 client 声明:包名与它的 client 半源文件。 */
|
|
80
|
+
export interface ClientRow {
|
|
81
|
+
id: string;
|
|
82
|
+
clientPath: string;
|
|
83
|
+
/** 包版本:联邦地址(`@client-modules/<pkg>@<version>/<file>`)的组成部分。 */
|
|
84
|
+
version: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 行清单:**cordis Loader 的 entries** 里声明了 `dsh.client` 的那些包——**上游 `client-modules` 的同一份
|
|
89
|
+
* 来源**("装配的真相",任何项目/上游都通用),`disabled` 的行在这里被跳过(profile 的开关就此生效)。
|
|
90
|
+
*
|
|
91
|
+
* 每行给出**包名**与该包 **client 半的源文件**。包的位置用 **entry 自己的 `parent.tree.ctx.baseUrl`**
|
|
92
|
+
* 解析(上游 `resolveSource`/`resolveMeta` 用的就是它)——从进程的 cwd 解析不到这些包:pnpm 的严格布局
|
|
93
|
+
* 下它们只在各自消费者的 node_modules 里,profile 的运行时目录又另有一套。
|
|
94
|
+
*
|
|
95
|
+
* **判据是清单里的 `dsh.client` 声明**(与上游 `client-modules` 同源):光有 `src/client/` 目录不算行——
|
|
96
|
+
* 上游有若干包只是"宿主插件 + 一份 client 半"(`@deepseek-ai/dsh-session-title` 那种),它们由真正的行在
|
|
97
|
+
* 自己的 client 半里 import,不该出现在装配清单里;混进来就会多装配一次(`apply` 里那些"同 namespace 的
|
|
98
|
+
* locale 已注册"的报错就是这么来的),也会在入口打出一串 `skip (no apply/default)`。
|
|
99
|
+
*/
|
|
100
|
+
async function clientRowsOf(loader: { entries(): Generator<LoaderEntry> }): Promise<ClientRow[]> {
|
|
101
|
+
const found = new Map<string, ClientRow>();
|
|
102
|
+
// `entries()` 是生成器(cordis 的 Loader 就是这么给的),直接迭代即可。
|
|
103
|
+
for (const entry of loader.entries()) {
|
|
104
|
+
if (entry.disabled === true || entry.fiber === undefined) continue;
|
|
105
|
+
const name = entry.options?.name;
|
|
106
|
+
if (typeof name !== "string" || name === "") continue;
|
|
107
|
+
// 虚拟入口(`cordis:include`、`<插件>:<组>`)不是包名。
|
|
108
|
+
if (name.includes(":")) continue;
|
|
109
|
+
if (found.has(name)) continue;
|
|
110
|
+
const baseUrl = entry.parent?.tree?.ctx?.baseUrl;
|
|
111
|
+
if (baseUrl === undefined) continue;
|
|
112
|
+
const resolved = await packageRootOf(baseUrl, name);
|
|
113
|
+
if (resolved === undefined) continue;
|
|
114
|
+
const manifest = await manifestOf(resolved.root);
|
|
115
|
+
const declared = (manifest as { dsh?: { client?: unknown } } | undefined)?.dsh?.client;
|
|
116
|
+
if (manifest === undefined || declared === undefined) continue;
|
|
117
|
+
const clientPath = await clientHalfOf(resolved.root);
|
|
118
|
+
if (clientPath === undefined) continue;
|
|
119
|
+
// 本地文件一律写成 `@/{filepath}`(对外只有两种形态:`@/…` 本地文件、`@client-modules/…` 联邦产物)。
|
|
120
|
+
// 入口是**源码文件路径**:vite 按它解析、按需转换(改着的包逐文件走,HMR 照旧);而
|
|
121
|
+
// "这次不改的包"(`dsh.dev.exclude`)会进 `optimizeDeps.include`——vite 把该文件收编成依赖,
|
|
122
|
+
// 引用被重写成它的 deps 产物(一次构建、共享 chunk、落盘缓存、按内容 hash 失效)。
|
|
123
|
+
found.set(name, {
|
|
124
|
+
id: name,
|
|
125
|
+
clientPath: `@/${relative(await resolutionRoot(), clientPath)
|
|
126
|
+
.split(sep)
|
|
127
|
+
.join("/")}`,
|
|
128
|
+
version: manifest.version,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return [...found.values()];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 打包器指纹的输入集:**只与包有关、与出口无关**(缓存按包聚合,rev 跟着出口变就等于永不命中)。 */
|
|
135
|
+
function watchOf(root: string | undefined, entry: string): string[] {
|
|
136
|
+
if (root === undefined) return [entry];
|
|
137
|
+
return root.includes("node_modules") ? [join(root, "package.json")] : [join(root, "src")];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function apply(ctx: Context): void {
|
|
141
|
+
// 细节诊断(逐包、逐请求)默认静默,`DSH_TRACE=1` 时走 logger(见 `trace.ts`)。
|
|
142
|
+
const trace = traceOf(ctx, "federation");
|
|
143
|
+
const webServer = ctx.get("webServer") as RouteHost | undefined;
|
|
144
|
+
// 本包的位置(`src/` 或打包后的 `dist/`):`app-boot` 与仓库根都从它推。
|
|
145
|
+
//
|
|
146
|
+
// **不用 `process.cwd()`**:桌面形态的宿主进程 cwd 是 profile 目录(`apps/<profile>`),从那里推出来的
|
|
147
|
+
// 仓库根、app-boot、缓存目录全是错的(web 形态下 cwd 恰好是仓库根,所以这条一直没暴露)。
|
|
148
|
+
const appRoot = appBootDir();
|
|
149
|
+
// 日志落在数据根下(`$DSH_HOME/logs/…`):`apply` 是同步的,所以这里先起一个 promise,用时再等。
|
|
150
|
+
const logFileOf = dataDir().then((dir) => join(dir, "logs", "dev-host.log"));
|
|
151
|
+
// 与上面同一个事实的同步镜像:`onExit` 回调不能 await,只能读已经落定的那一份。
|
|
152
|
+
let resolvedLogFile: string | undefined;
|
|
153
|
+
void logFileOf.then((file) => {
|
|
154
|
+
resolvedLogFile = file;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// **宿主进程的异常要留下痕迹**:dev 形态下 vite 跑在同一个进程里(它的原生绑定或某个未捕获异常崩过一次,
|
|
158
|
+
// 外部只看到连接被拒,无从溯源)。落一份日志到数据根的 `logs/dev-host.log`,退出时在 stderr 指路。
|
|
159
|
+
const appendLog = async (text: string): Promise<void> => {
|
|
160
|
+
try {
|
|
161
|
+
const logFile = await logFileOf;
|
|
162
|
+
await mkdir(dirname(logFile), { recursive: true });
|
|
163
|
+
await appendFile(logFile, `${new Date().toISOString()} ${text}\n`, "utf8");
|
|
164
|
+
} catch {
|
|
165
|
+
// 记不下来就算了:这里已在异常路径上,不该再抛。
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
ctx.effect(() => {
|
|
169
|
+
const onException = (error: Error): void => {
|
|
170
|
+
void appendLog(`[uncaughtException] ${error.stack ?? error.message}`);
|
|
171
|
+
};
|
|
172
|
+
const onRejection = (reason: unknown): void => {
|
|
173
|
+
void appendLog(
|
|
174
|
+
`[unhandledRejection] ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`,
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
const onExit = (code: number): void => {
|
|
178
|
+
// 退出回调是同步的(不能 await):日志路径在退出前早已解析好,直接取缓存值。
|
|
179
|
+
const shown = resolvedLogFile ?? "(尚未就绪)";
|
|
180
|
+
ctx.logger.info(`dev 宿主退出:code=${String(code)},异常日志见 ${shown}\n`);
|
|
181
|
+
};
|
|
182
|
+
process.on("uncaughtException", onException);
|
|
183
|
+
process.on("unhandledRejection", onRejection);
|
|
184
|
+
process.on("exit", onExit);
|
|
185
|
+
return () => {
|
|
186
|
+
process.off("uncaughtException", onException);
|
|
187
|
+
process.off("unhandledRejection", onRejection);
|
|
188
|
+
process.off("exit", onExit);
|
|
189
|
+
};
|
|
190
|
+
}, "client-modules: 宿主异常日志");
|
|
191
|
+
// 行清单:自己扫(上游 `client-modules` 在 dev patch 里禁用了)。懒建一次——`apply` 不是 async。
|
|
192
|
+
let rowsCache: ClientRow[] | undefined;
|
|
193
|
+
// `ctx.loader` 是 cordis 的服务(属性访问,别用 `ctx.get`——那给的是追踪代理);上游 `hmr` 也是这么用的。
|
|
194
|
+
const loader = ctx.loader;
|
|
195
|
+
let excludedCache: RegExp[] | undefined;
|
|
196
|
+
const excludedOf = async (): Promise<RegExp[]> =>
|
|
197
|
+
(excludedCache ??= await devExcluded(await profileDir()));
|
|
198
|
+
const sourceRowsOf = async (): Promise<ClientRow[]> => {
|
|
199
|
+
rowsCache ??= loader === undefined ? [] : await clientRowsOf(loader);
|
|
200
|
+
return rowsCache;
|
|
201
|
+
};
|
|
202
|
+
// **非开发模块**(`dsh.dev.exclude` 点名)的行给**包名**而不是源码路径:走哪条链(聚合产物 / 源码逐文件)
|
|
203
|
+
// 由解析规则在那一次 resolve 里判(能解析到 + 被点名 → 联邦),地址也由同一份出口表(`urlOf`)算——
|
|
204
|
+
// 在这里写死 URL 就等于把"地址长什么样"抄成两份。没被点名的行仍是源码路径(改它要 HMR)。
|
|
205
|
+
const rows = async (): Promise<ClientRow[]> => await sourceRowsOf();
|
|
206
|
+
if (webServer === undefined) throw new Error("client-modules: webServer is a required service");
|
|
207
|
+
|
|
208
|
+
// 包名 → 包根:交给 node 的解析(`createRequire(from).resolve`),不自己扫目录。
|
|
209
|
+
// 基准是**发起引用的那个文件**——node 的规则就是这样,pnpm 的 node_modules 也按依赖关系摆好了。
|
|
210
|
+
// 没有 importer 的场合(我们自己生成的入口),退到模块表里任一 client 行当基准(它们都在有依赖的包里)。
|
|
211
|
+
let fallbackBase: string | undefined;
|
|
212
|
+
// 只按**包名**解析(不带子路径):`resolve('pkg/subpath')` 要求清单有对应的 `exports` 键,而很多包的
|
|
213
|
+
// 子路径是要另外解的(`oniguruma-parser/parser` 这种)。包根拿到之后,子路径再按 `exports` 落文件。
|
|
214
|
+
const rootOf = async (name: string, from?: string): Promise<string | undefined> => {
|
|
215
|
+
const bases = from === undefined ? [] : [from];
|
|
216
|
+
// 解析基准:profile 的 `package.json`——pnpm 从这里能解析到工作区里的包
|
|
217
|
+
// (我们自己生成的入口没有 importer,得有个能站住的基准)。
|
|
218
|
+
fallbackBase ??= await profileManifestPath();
|
|
219
|
+
if (fallbackBase !== undefined) bases.push(fallbackBase);
|
|
220
|
+
// 本包自己的清单也是一个基准:这条链的运行期依赖声明在它里面——典型是 rolldown 注入装饰器 helper 用的
|
|
221
|
+
// `@oxc-project/runtime`,pnpm 的严格布局下从 profile 出发解析不到,只有从声明它的包出发才找得到。
|
|
222
|
+
bases.push(packageManifestPath());
|
|
223
|
+
for (const base of bases) {
|
|
224
|
+
const found = await packageRootOf(base, name);
|
|
225
|
+
if (found !== undefined) return found.root;
|
|
226
|
+
}
|
|
227
|
+
// 兜底:pnpm 的虚拟 store。按出口请求时**没有 importer**(URL 里只有包名),"从谁出发解析"这个信息
|
|
228
|
+
// 已经丢了——嵌套依赖(`oniguruma-parser` 之于 `oniguruma-to-es`)从模块表首行那类基准解析不到,
|
|
229
|
+
// 但 store 里装过就一定能找到。
|
|
230
|
+
return undefined;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** 兜底:vite 不认的路径直接 404(dev 形态下页面与资源全归它)。 */ const fallback = async (
|
|
234
|
+
_req: IncomingMessage,
|
|
235
|
+
res: ServerResponse,
|
|
236
|
+
): Promise<void> => {
|
|
237
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
238
|
+
res.end("client-modules: 没有这条路由\n");
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// vite dev server(懒建):客户端资源全权交给它——依赖预打包、CJS→ESM、具名导出、外置 require、共享
|
|
242
|
+
// chunk、HMR 都是它现成的能力;我们只提供"哪些包是行、源码入口在哪"与页面骨架。
|
|
243
|
+
let viteHandoff: Awaited<ReturnType<typeof startViteDev>> | undefined;
|
|
244
|
+
let viteStarting: Promise<void> | undefined;
|
|
245
|
+
/** 地址只印一次(vite 就绪那一刻,跟它自己的 ready 提示一个意思)。 */
|
|
246
|
+
let announced = false;
|
|
247
|
+
const ensureVite = async (): Promise<void> => {
|
|
248
|
+
if (viteHandoff !== undefined) return;
|
|
249
|
+
viteStarting ??= (async () => {
|
|
250
|
+
const repoRoot = await resolutionRoot();
|
|
251
|
+
const started = await startViteDev(ctx, {
|
|
252
|
+
repoRoot,
|
|
253
|
+
base: PLUGIN_ROUTE,
|
|
254
|
+
// 行清单:包名 → 入口。**被 `dsh.dev.exclude` 点名的行给联邦地址**(带 loader 给的版本):它是我们自己
|
|
255
|
+
// 路由服务的产物(按包预编译 + 落盘缓存),插件对 `@client-modules/` 开头的直接当外部地址留着;
|
|
256
|
+
// 而**裸包名**会被 vite 的 import 分析按 `package.json` 去解析——行清单是虚拟模块,那些上游 workspace
|
|
257
|
+
// 内部包不在工作区解析面上,于是 `Cannot find package … imported from …/package.json`。
|
|
258
|
+
// 没被点名的行仍给源码路径(改它要 HMR)。
|
|
259
|
+
rows: {
|
|
260
|
+
rows: async () => {
|
|
261
|
+
const patterns = await excludedOf();
|
|
262
|
+
const all = await rows();
|
|
263
|
+
return new Map(
|
|
264
|
+
all.map((row) => [
|
|
265
|
+
row.id,
|
|
266
|
+
isAggregated(row.id, patterns)
|
|
267
|
+
? `${CLIENT_MODULES_PREFIX}/${encodePackageName(row.id)}@${row.version}/client`
|
|
268
|
+
: row.clientPath,
|
|
269
|
+
]),
|
|
270
|
+
);
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
// `/plugins/<pkg>/<version>/<export>` → 源码文件(对外形态 → vite 认的路径)。
|
|
274
|
+
resolvePluginUrl: (url: string) => resolvePluginUrl(url),
|
|
275
|
+
depsUrl: (specifier: string, importer?: string) => depsUrlOfSpecifier(specifier, importer),
|
|
276
|
+
// 同源的 http server:web 形态是宿主自己的端口;桌面形态是宿主**进程内**的回环端点
|
|
277
|
+
// (`desktopUpgradeServer`)——HMR 的 WebSocket 就挂它上面,页面侧由垫片重写到它。
|
|
278
|
+
httpServer:
|
|
279
|
+
(
|
|
280
|
+
webServer as {
|
|
281
|
+
server?: Parameters<typeof startViteDev>[1]["httpServer"];
|
|
282
|
+
desktopUpgradeServer?: Parameters<typeof startViteDev>[1]["httpServer"];
|
|
283
|
+
}
|
|
284
|
+
).server ??
|
|
285
|
+
(
|
|
286
|
+
webServer as {
|
|
287
|
+
desktopUpgradeServer?: Parameters<typeof startViteDev>[1]["httpServer"];
|
|
288
|
+
}
|
|
289
|
+
).desktopUpgradeServer,
|
|
290
|
+
});
|
|
291
|
+
viteHandoff = started;
|
|
292
|
+
})();
|
|
293
|
+
await viteStarting;
|
|
294
|
+
if (!announced) {
|
|
295
|
+
announced = true;
|
|
296
|
+
// 端口是**宿主自己 listen 的那个**(缺省 `0` = 由系统分配),不是 `PORT` 环境变量——它只在"显式指定"
|
|
297
|
+
// 时才有值,跟着它打印会在随机端口下报出一个并不存在的 3080。
|
|
298
|
+
const port = (webServer as { port?: number }).port;
|
|
299
|
+
if (port !== undefined) {
|
|
300
|
+
process.stdout.write(`\n ➜ Local: http://127.0.0.1:${String(port)}/\n\n`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** **行**的包名集合(loader 给的 `dsh.client` + client 半):包级引用的出口按它分。 */
|
|
306
|
+
const clientRowIds = async (): Promise<ReadonlySet<string>> =>
|
|
307
|
+
new Set((await rows()).map((row) => row.id));
|
|
308
|
+
|
|
309
|
+
/** 解析根那份 tsconfig 的路径(不存在时返回 undefined,交给 rolldown 默认)。 */
|
|
310
|
+
const tsconfigOf = async (): Promise<string | undefined> => {
|
|
311
|
+
const candidate = join(await resolutionRoot(), "tsconfig.json");
|
|
312
|
+
return (await readFile(candidate, "utf8").then(
|
|
313
|
+
() => true,
|
|
314
|
+
() => false,
|
|
315
|
+
))
|
|
316
|
+
? candidate
|
|
317
|
+
: undefined;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 对外 URL → **源文件**。`exports` 优先,**没有该键**时退回物理路径(上游多数包没有 `./client` 出口,
|
|
322
|
+
* 它们的 client 半就是 `src/client/index.ts`)——中间件的改写与插件的 `resolveId` 共用这一份。
|
|
323
|
+
*/
|
|
324
|
+
const resolvePluginUrl = async (url: string): Promise<string | undefined> => {
|
|
325
|
+
const address = parsePluginAddress(url);
|
|
326
|
+
if (address === undefined) return undefined;
|
|
327
|
+
const root = await rootOf(address.name);
|
|
328
|
+
if (root === undefined) return undefined;
|
|
329
|
+
const manifest = await manifestOf(root);
|
|
330
|
+
const subpath = address.exportName === "." ? undefined : address.exportName.slice(2);
|
|
331
|
+
if (subpath !== undefined) {
|
|
332
|
+
const byExports = await exportTargetOf(root, subpath);
|
|
333
|
+
if (byExports !== undefined) return byExports;
|
|
334
|
+
// 物理回退:上游多数包没有 `./client` 出口,它们的 client 半就是 `src/client/index.ts`。
|
|
335
|
+
const pkg = { name: address.name, version: manifest?.version ?? "", root };
|
|
336
|
+
return (
|
|
337
|
+
resolveInPackage(pkg, join("src", subpath, "index.ts")) ??
|
|
338
|
+
resolveInPackage(pkg, join(subpath, "index.ts")) ??
|
|
339
|
+
resolveInPackage(pkg, subpath)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return await exportTargetOf(root, undefined);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const serve = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
346
|
+
if (req.method !== "GET" && req.method !== "HEAD") return fallback(req, res);
|
|
347
|
+
// `/plugins/*` 下的请求交给 vite。**不做任何 URL 改写**:包名、路径与 client 半的入口都由 vite 自己
|
|
348
|
+
// resolve(它认 node 规则、workspace 软链与 pnpm 布局),我们只提供行清单与页面。
|
|
349
|
+
try {
|
|
350
|
+
await ensureVite();
|
|
351
|
+
} catch (error) {
|
|
352
|
+
ctx.logger.error(error);
|
|
353
|
+
}
|
|
354
|
+
if (viteHandoff?.serve(req, res) === true) return;
|
|
355
|
+
// 其余交回上游的兜底。
|
|
356
|
+
return fallback(req, res);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// 聚合与三方的缓存都在 `node_modules/` 下(与 vite 的 `.vite` 并列,跨进程共用)。
|
|
360
|
+
// 一个缓存目录:三方的与我们的**没有本质区别**(同一条链、同一种地址)。
|
|
361
|
+
// 缓存与 alias 都要仓库根,但 `apply` 是同步的:惰性算一次。
|
|
362
|
+
let cacheRootCache: string | undefined;
|
|
363
|
+
const cacheRootOf = async (): Promise<string> => (cacheRootCache ??= await cacheDir());
|
|
364
|
+
const federationAliasOf = async (): Promise<ReturnType<typeof clientModulesAlias>> =>
|
|
365
|
+
clientModulesAlias({
|
|
366
|
+
repoRoot: await resolutionRoot(),
|
|
367
|
+
// 联邦构建走 rolldown:替身必须是 CJS 形态,否则未知具名导入在构建期就是 `MISSING_EXPORT`。
|
|
368
|
+
nodeStub: join(appRoot, "node-stub.cjs"),
|
|
369
|
+
});
|
|
370
|
+
// 外置说明符要写成"联邦地址",而 `paths` 回调不能 await:先把要用的两张表算出来。
|
|
371
|
+
// 表一:包名 → 它外置时该指的**出口**——`client` 就是清单里的一个普通 export(有 `./client` 用它,
|
|
372
|
+
// 否则根出口)。这不是"身份判断",所以 `immer` 这类三方不会因为它被特殊对待。
|
|
373
|
+
// 两张表**懒建一次**:它们要扫工作区(几百个清单)与 pnpm store,而联邦路由是**每个请求**都要它们
|
|
374
|
+
// ——每次重算等于把首屏时间乘以产物数(几百次全仓扫描)。dev 里包的版本与出口不会中途变。
|
|
375
|
+
/**
|
|
376
|
+
* dev 侧源码里的**裸说明符** → 联邦地址;`undefined` 表示"交给 vite 走源码"。
|
|
377
|
+
*
|
|
378
|
+
* 判据与产物侧一致:仓库内的**开发模块**(没被 `dsh.dev.exclude` 点名)逐文件走 vite(改它要 HMR);
|
|
379
|
+
* 其余——三方与"非开发模块"——都由联邦链按包产出(同一条链、同一种地址)。放给 vite 的 `optimizeDeps`
|
|
380
|
+
* 会多出第二条链:浏览器里同时有 `.vite/deps/react.js` 与我们的 `@client-modules/react@…/index.js`,
|
|
381
|
+
* 两个 react 实例,hook 分发立刻报 `Invalid hook call`。
|
|
382
|
+
*/
|
|
383
|
+
const depsUrlOfSpecifier = async (
|
|
384
|
+
specifier: string,
|
|
385
|
+
importer?: string,
|
|
386
|
+
): Promise<string | undefined> => {
|
|
387
|
+
// `@/…` 是本地文件(alias 管),不是包——`packageNameOf` 会把它切成 `@/vendor` 这种假包名。
|
|
388
|
+
if (specifier.startsWith("@/")) return undefined;
|
|
389
|
+
// `zlib` / `stream` 这类**裸名的 node 内置**不是包:接管成联邦地址只会换来一串 404。留给 alias
|
|
390
|
+
// 的替身(名单同一份)。
|
|
391
|
+
if (NODE_BUILTIN.test(specifier)) return undefined;
|
|
392
|
+
const bare = packageNameOf(specifier) || specifier;
|
|
393
|
+
const root = await rootOf(bare);
|
|
394
|
+
const devModule =
|
|
395
|
+
root !== undefined &&
|
|
396
|
+
!root.includes("node_modules") &&
|
|
397
|
+
!isAggregated(bare, await excludedOf());
|
|
398
|
+
if (devModule) return undefined;
|
|
399
|
+
// 版本**从引用方解析**(`findPackageJSON` 按 node 的解析、与布局无关):与产物侧同一条规则
|
|
400
|
+
// (`resolvableVersionsOf`),两边写出的地址才对应得上。
|
|
401
|
+
// `importer` 可能**不是文件路径**:虚拟模块(`\0dsh-client-rows`)、vite 的 `/@id/…`、或根本没给。
|
|
402
|
+
// 拿它们当 base 解析会失败(`pathToFileURL("\0…")` 直接抛),于是这条说明符落到 vite 手里变成
|
|
403
|
+
// `Cannot find package`——行清单的虚拟模块就是这么把整条链带崩的。那种情况退到**解析根的清单**。
|
|
404
|
+
const base =
|
|
405
|
+
importer === undefined || !isAbsolute(importer)
|
|
406
|
+
? await resolutionManifestPath()
|
|
407
|
+
: pathToFileURL(importer).href;
|
|
408
|
+
const found = findPackageJSON(bare, base);
|
|
409
|
+
const version =
|
|
410
|
+
found === undefined
|
|
411
|
+
? undefined
|
|
412
|
+
: (JSON.parse(await readFile(found, "utf8").catch(() => "{}")) as { version?: string })
|
|
413
|
+
.version;
|
|
414
|
+
return urlOf(specifier, {
|
|
415
|
+
...(version === undefined ? {} : { versions: new Map([[bare, version]]) }),
|
|
416
|
+
clientRows: await clientRowIds(),
|
|
417
|
+
});
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* `@client-modules/<pkg>@<version>/<file>`:**联邦式**的统一寻址。
|
|
422
|
+
*
|
|
423
|
+
* 三方(react)与我们的包在这件事上没有区别——都是"带版本、可独立寻址的一份产物"。落在哪个缓存里
|
|
424
|
+
* 由"包是不是我们这个工作区的"决定:是 → 按包聚合(整包一次 vite build、产物按出口分开);
|
|
425
|
+
* 不是 → 三方的预打包。两条链用的是同一份配置。
|
|
426
|
+
*/
|
|
427
|
+
const serveFederation = async (
|
|
428
|
+
url: string,
|
|
429
|
+
req: IncomingMessage,
|
|
430
|
+
res: ServerResponse,
|
|
431
|
+
): Promise<boolean> => {
|
|
432
|
+
const cacheRoot = await cacheRootOf();
|
|
433
|
+
const federationAlias = await federationAliasOf();
|
|
434
|
+
// `?v=` 是**构建世代**(见 `urlOf`):它在地址里只是让"同一份产物的 URL 稳定且可辨识",
|
|
435
|
+
// 路由不据此改变响应(产物一律协商缓存)。
|
|
436
|
+
const rest = url.slice(`${CLIENT_MODULES_PREFIX}/`.length).split("?")[0] ?? "";
|
|
437
|
+
const slash = rest.indexOf("/");
|
|
438
|
+
trace("rest %s slash %s", rest, slash);
|
|
439
|
+
if (slash <= 0) return false;
|
|
440
|
+
const pkgVersion = rest.slice(0, slash);
|
|
441
|
+
const file = rest.slice(slash + 1);
|
|
442
|
+
// scope 的 `@` 在前、版本分隔的在后:从**最后一个** `@` 切。
|
|
443
|
+
const at = pkgVersion.lastIndexOf("@");
|
|
444
|
+
if (at <= 0) return false;
|
|
445
|
+
const name = decodePackageName(pkgVersion.slice(0, at));
|
|
446
|
+
const urlVersion = pkgVersion.slice(at + 1);
|
|
447
|
+
// 入口:我们的包按 `exports` 找 client 半;三方按 node 的解析找。之后**同一条链**。
|
|
448
|
+
// 一视同仁:包根可能在仓库里(工作区/vendor),也可能在 node_modules(三方)——对取入口这件事
|
|
449
|
+
// 没有区别。出口名来自 URL,入口一律从清单的 `exports` 取。
|
|
450
|
+
const root = await rootOf(name);
|
|
451
|
+
// 版本以**包清单**为准:URL 里那个是外置时按版本表写下的,而多版本共存时表只能猜(`katex@0.16.47`
|
|
452
|
+
// 的 URL 解析到的可能是 0.18.10)——缓存键跟着清单走,键与内容才对得上。
|
|
453
|
+
const version =
|
|
454
|
+
(root === undefined ? undefined : (await manifestOf(root))?.version) ?? urlVersion;
|
|
455
|
+
const key = `${name.replaceAll("/", "+")}@${version}`;
|
|
456
|
+
// **产出的整文件**(资产 / 拆出来的块):产物里只留 URL,文件本身按清单(`.files.json`)取——清单是
|
|
457
|
+
// "这个包产出里有什么"的唯一真相,也顺手挡住了凭 URL 读任意文件。
|
|
458
|
+
//
|
|
459
|
+
// 给它们**自己的前缀**(`assets/`、`chunks/`)而不与出口共用一段:出口名要回源码里找目标,而块和资产
|
|
460
|
+
// 都没有源码对应(`src/chunk-abc.js` 不存在),混在同一段里服务端只能把它当出口,落空后回个空模块
|
|
461
|
+
// (浏览器那边就是 "does not provide an export named …")。
|
|
462
|
+
if (file.startsWith("assets/") || file.startsWith("chunks/")) {
|
|
463
|
+
const manifest = JSON.parse(
|
|
464
|
+
await readFile(join(cacheRoot, key, ".files.json"), "utf8").catch(() => "{}"),
|
|
465
|
+
) as {
|
|
466
|
+
files?: Record<string, string>;
|
|
467
|
+
};
|
|
468
|
+
const target = manifest.files?.[file];
|
|
469
|
+
if (target === undefined) return false;
|
|
470
|
+
const bytes = await readFile(join(cacheRoot, target)).catch(() => undefined);
|
|
471
|
+
if (bytes === undefined) return false;
|
|
472
|
+
const extension = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
473
|
+
res.writeHead(200, {
|
|
474
|
+
"content-type":
|
|
475
|
+
extension === "js"
|
|
476
|
+
? "text/javascript; charset=utf-8"
|
|
477
|
+
: (ASSET_MIME[extension] ?? "application/octet-stream"),
|
|
478
|
+
// 资产与块的文件名里就带内容 hash:内容与 URL 一一对应,可以长缓存。
|
|
479
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
480
|
+
});
|
|
481
|
+
res.end(req.method === "HEAD" ? undefined : bytes);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
const exportName = exportNameOfFileName(file);
|
|
485
|
+
const entry =
|
|
486
|
+
root === undefined
|
|
487
|
+
? (await dependencyEntry(await resolutionManifestPath(), name).catch(() => undefined))
|
|
488
|
+
?.entry
|
|
489
|
+
: ((await exportTargetOf(root, exportName === "index" ? undefined : exportName)) ??
|
|
490
|
+
// 清单没覆盖这个子路径时按物理路径找(没有 `exports` 的老包:`picomatch/posix` 就是 `posix.js`)。
|
|
491
|
+
(await fileInPackage(root, exportName)));
|
|
492
|
+
trace(`[fed] ${name}@${version}/${exportName} root=${String(root)} entry=${String(entry)}\n`);
|
|
493
|
+
if (entry === undefined) {
|
|
494
|
+
// **如实报错**。走到这里只剩两类:仓库内的包这一侧没有实现(纯 host 包:只有 `./types` 出口、
|
|
495
|
+
// `src/` 下全是宿主代码),或说明符既不是内置、也不在闭包里(这台机器上没有的可选依赖)。
|
|
496
|
+
//
|
|
497
|
+
// 以前这两种都回一个空模块("让它照样是导入了、什么都没导出")——那是**神坑**:浏览器只报一句
|
|
498
|
+
// 没头没尾的 `does not provide an export named …`,谁也追不到这里;而页面空白的原因看起来像
|
|
499
|
+
// 上游产物坏了。现在如实 404:谁引了不存在的东西,一眼就看见。可选依赖那条:产物里的 `require`
|
|
500
|
+
// 已被转成模块图里的 ESM import,一个 404 会让整条图断掉、入口一行都不执行——那也正是"缺什么"的
|
|
501
|
+
// 正确表现(该装它、或该在我们的包里声明它),而不是静默降级。
|
|
502
|
+
trace(
|
|
503
|
+
`[federation] ${name}@${version}/${exportName}: unresolved-entry root=${String(root)}\n`,
|
|
504
|
+
);
|
|
505
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
506
|
+
res.end(
|
|
507
|
+
req.method === "HEAD" ? undefined : `unresolved entry: ${name}@${version}/${exportName}\n`,
|
|
508
|
+
);
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
const bundled = await bundlePackage({
|
|
512
|
+
cacheRoot,
|
|
513
|
+
repoRoot: await resolutionRoot(),
|
|
514
|
+
// 预编译的诊断输出走这条链的 logger(细节级别:默认静默,要排查时把级别调低)。
|
|
515
|
+
log: (message) => trace(message),
|
|
516
|
+
alias: federationAlias,
|
|
517
|
+
name,
|
|
518
|
+
version,
|
|
519
|
+
// 外置名单与地址里的版本都**从本包解析**(`findPackageJSON` 按 node 的解析、与布局无关)。
|
|
520
|
+
...(root === undefined ? {} : { manifestOf: join(root, "package.json") }),
|
|
521
|
+
clientRows: await clientRowIds(),
|
|
522
|
+
input: { [exportName]: entry },
|
|
523
|
+
// 指纹的输入必须**与出口无关**(缓存是按包聚合的,rev 随出口变就等于永不命中):仓库内的包看它的
|
|
524
|
+
// `src`(源码改了就重打);`node_modules` 里的包看它的清单——那边的目录名与布局都不由我们定,
|
|
525
|
+
// 拿目录去算只会算到空(曾经所有三方包的指纹都是同一个空目录的哈希)。
|
|
526
|
+
watch: watchOf(root ?? (await owningPackageOf(entry))?.root, entry),
|
|
527
|
+
// 编译设置:解析根那份 tsconfig(有就用;没有交给 rolldown 默认)。
|
|
528
|
+
...(await tsconfigOf().then((path) => (path === undefined ? {} : { tsconfig: path }))),
|
|
529
|
+
}).catch((error: unknown) => {
|
|
530
|
+
ctx.logger.warn(`${name}: bundle-failed ${String(error)}\n`);
|
|
531
|
+
return undefined;
|
|
532
|
+
});
|
|
533
|
+
if (bundled === undefined) return false;
|
|
534
|
+
trace(
|
|
535
|
+
`[fed-tail] ${name}: files=${[...bundled.files.keys()].join(",")} exportName=${exportName}\n`,
|
|
536
|
+
);
|
|
537
|
+
// 清单的键是**文件名**(出口产物 `<出口名>.js`,拆出的块 `chunks/…`):按 `exportName` 去查会落空
|
|
538
|
+
// (`remote` 的产物叫 `remote.js`),于是明明产出过也回退到 `client.js`——看起来像"每次都在实时打包"。
|
|
539
|
+
const target =
|
|
540
|
+
bundled.files.get(`${exportName}.js`) ??
|
|
541
|
+
bundled.files.get(exportName) ??
|
|
542
|
+
bundled.files.get("client.js") ??
|
|
543
|
+
bundled.files.get("index.js");
|
|
544
|
+
trace(
|
|
545
|
+
`${name}: target=${String(target)} 绝对=${String(target === undefined ? undefined : join(cacheRoot, target))}\n`,
|
|
546
|
+
);
|
|
547
|
+
if (target === undefined) {
|
|
548
|
+
ctx.logger.warn(
|
|
549
|
+
`${name}: missing-export export=${exportName} have=${[...bundled.files.keys()].join(",")}\n`,
|
|
550
|
+
);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
const code = await readFile(join(cacheRoot, target), "utf8").catch((error: unknown) => {
|
|
554
|
+
ctx.logger.warn(`${name}: unreadable file=${join(cacheRoot, target)} ${String(error)}\n`);
|
|
555
|
+
return undefined;
|
|
556
|
+
});
|
|
557
|
+
// 空产物是**合法的**:纯类型 / 空的 client 半编译出来就是空 JS,不是失败。
|
|
558
|
+
if (code === undefined) return false;
|
|
559
|
+
// 产物缓存策略**按"这份产物还会不会变"分**:
|
|
560
|
+
// - 三方包(`node_modules` 里的)在 dev 期间不会变,地址里又有它的版本 + 构建世代,可以长缓存;
|
|
561
|
+
// - 仓库内的包(`dsh.dev.exclude` 点名的那些)随时可能被改 → 协商缓存(`no-cache` + `ETag`,
|
|
562
|
+
// 重复请求走 304)。这就是 vite 对"预打包依赖"与"源码模块"的分法。
|
|
563
|
+
const cacheControl =
|
|
564
|
+
root !== undefined && !root.includes("node_modules")
|
|
565
|
+
? "no-cache"
|
|
566
|
+
: "public, max-age=31536000, immutable";
|
|
567
|
+
// **不发 304**:这边的响应最终要由上游包成 Fetch `Response`,而它不接受 304
|
|
568
|
+
// (`Invalid response status code 304`)——请求会以异常告终。产物本来就不大,直接 200 全量。
|
|
569
|
+
const etag = `"${bundled.rev}"`;
|
|
570
|
+
res.writeHead(200, {
|
|
571
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
572
|
+
"cache-control": cacheControl,
|
|
573
|
+
etag,
|
|
574
|
+
});
|
|
575
|
+
res.end(req.method === "HEAD" ? undefined : code);
|
|
576
|
+
return true;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const serveAny = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
580
|
+
// 桌面形态的请求经自定义协议(`dsh-…://app/…`)进来,`req.url` 是**完整 URL** 而不是路径——先归一,
|
|
581
|
+
// 否则下面所有按路径判断的规则(`@` 前缀、页面、联邦地址)都会落空,页面直接 404。
|
|
582
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(req.url ?? "")) {
|
|
583
|
+
const absolute = new URL(req.url ?? "");
|
|
584
|
+
req.url = `${absolute.pathname}${absolute.search}`;
|
|
585
|
+
}
|
|
586
|
+
// **拒的是"仓库路径",不是"非 `@` 前缀"**:vite 的 root 就是 `app-boot`,页面自己的资源在这个 root
|
|
587
|
+
// 下是**root 相对**的 URL(`/index.ts`、`/base.css`、将来的 `/assets/…`),逐个列举只会漏——今天就
|
|
588
|
+
// 漏了 `/base.css`(vite 解析出来就是它,被白名单挡在门外 → 入口整条没跑起来,页面空)。
|
|
589
|
+
//
|
|
590
|
+
// 真正该拦的是那些"没被解析规则换掉"的漏网请求:裸的 `/packages/…`、`/vendor/…`、`/apps/…`、
|
|
591
|
+
// `/node_modules/…`——它们是仓库里的路径,在打包形态下根本不存在,放过去只会让人在几百条请求里猜。
|
|
592
|
+
const requestPath = (req.url ?? "").split("?")[0] ?? "";
|
|
593
|
+
const path = requestPath;
|
|
594
|
+
const repoPath = /^\/(?:packages|vendor|apps|devpackages|node_modules)(?:\/|$)/u;
|
|
595
|
+
const known = !repoPath.test(path);
|
|
596
|
+
// 浏览器自己的探测(Chrome DevTools 的 `.well-known`):给个 204,别让它出现在失败列表里。
|
|
597
|
+
if (path.startsWith("/.well-known/")) {
|
|
598
|
+
res.writeHead(204);
|
|
599
|
+
res.end();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!known) {
|
|
603
|
+
trace("%s referer %s", req.url ?? "", req.headers.referer ?? "-");
|
|
604
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
605
|
+
res.end(
|
|
606
|
+
"client-modules: 这是仓库里的路径,打包形态下不存在(页面资源在 app-boot 下、包走 exports)\n",
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
trace(`[serveAny] ${req.url ?? ""}
|
|
611
|
+
`);
|
|
612
|
+
// 页面由**我们自己**服务:读 `app-boot/index.html`,注入 vite 的客户端(HMR),入口脚本仍走 vite
|
|
613
|
+
// (它要转译 `index.ts`、解析 `dsh-client-rows` 虚拟模块)。
|
|
614
|
+
//
|
|
615
|
+
// 为什么不交给 vite 的 indexHtml 中间件:桌面形态的请求经自定义协议(`dsh-…://app/`)进来,那条
|
|
616
|
+
// 中间件不认(返回 404),而两端要的页面是同一份。
|
|
617
|
+
if (requestPath === "/" || requestPath === "/index.html") {
|
|
618
|
+
const html = await readFile(join(appRoot, "index.html"), "utf8").catch(() => undefined);
|
|
619
|
+
if (html === undefined) {
|
|
620
|
+
ctx.logger.warn(`[serveAny] 页面骨架读不到:${join(appRoot, "index.html")}\n`);
|
|
621
|
+
return fallback(req, res);
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
await ensureVite();
|
|
625
|
+
} catch (error) {
|
|
626
|
+
ctx.logger.error(error);
|
|
627
|
+
}
|
|
628
|
+
// HMR 客户端只在那条通道真的挂着时才注入(无端口形态没有同源的 http server,注了只会报
|
|
629
|
+
// `WebSocket closed without opened.`)。
|
|
630
|
+
const injected =
|
|
631
|
+
viteHandoff?.hmr === true
|
|
632
|
+
? html.replace(
|
|
633
|
+
"</head>",
|
|
634
|
+
` <script type="module" src="/@vite/client"></script>\n </head>`,
|
|
635
|
+
)
|
|
636
|
+
: html;
|
|
637
|
+
res.writeHead(200, {
|
|
638
|
+
"content-type": "text/html; charset=utf-8",
|
|
639
|
+
"cache-control": "no-cache",
|
|
640
|
+
});
|
|
641
|
+
res.end(req.method === "HEAD" ? undefined : injected);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const requestUrl = req.url ?? "";
|
|
645
|
+
if (requestUrl.split("?")[0]?.startsWith(`${CLIENT_MODULES_PREFIX}/`) === true) {
|
|
646
|
+
const handled = await serveFederation(requestUrl, req, res);
|
|
647
|
+
trace("%s handled %s status %s", requestUrl, handled, res.statusCode);
|
|
648
|
+
if (handled) return;
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
await ensureVite();
|
|
652
|
+
} catch (error) {
|
|
653
|
+
ctx.logger.error(error);
|
|
654
|
+
}
|
|
655
|
+
// 先交给 vite(页面、入口、HMR 通道都是它的);它不认的再回上游兜底。
|
|
656
|
+
//
|
|
657
|
+
// 结果留一行(与联邦那条 `[fed-exit]` 对称):入口 `index.ts` 转译失败时,浏览器那边只报一句
|
|
658
|
+
// 没头没尾的模块错误,而这里能直接看到是哪个 URL、什么状态。
|
|
659
|
+
//
|
|
660
|
+
// **`@` 前缀的路径不兜给页面**:那是这条链的模块 URL 空间,没解析出来就是解析规则漏了一段——当场 404
|
|
661
|
+
// 并把 URL 与 referer 打出来,别让它静默变成一份 HTML(那样浏览器只会报"模块语法错误")。
|
|
662
|
+
if (viteHandoff !== undefined && viteHandoff.serve(req, res)) {
|
|
663
|
+
trace("%s status %s", path, res.statusCode);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (path.startsWith("/@")) {
|
|
667
|
+
ctx.logger.warn(`${req.url ?? ""} referer=${req.headers.referer ?? "-"}\n`);
|
|
668
|
+
}
|
|
669
|
+
return fallback(req, res);
|
|
670
|
+
};
|
|
671
|
+
// 用与上游同一个 API 占**兜底位**(`registerFallback`):页面、入口、依赖预打包产物与 HMR 通道都是
|
|
672
|
+
// vite 的;上游 `frontend-static` 那一行(挂在 `web-runtime` 上)在 dev patch 里已停用。
|
|
673
|
+
ctx.effect(() => webServer.registerFallback(serveAny), "client-modules: vite 兜底");
|
|
674
|
+
// **懒建**(第一条请求时起):桌面的宿主会在启动过程中把"同源 http server"(进程内回环端点)挂到
|
|
675
|
+
// webServer 上,而这条链要拿它当 HMR 的落脚点——预热跑在这之前就会拿不到它,HMR 从此挂不上去。
|
|
676
|
+
// vite 的 HMR WebSocket:上游对**未注册**的升级请求会直接 destroy socket(vite 就握不上手),所以在这
|
|
677
|
+
// 里给它一条同名路由——handler 什么都不做,只让 socket 活着,vite 自己挂在 `upgrade` 上的处理器完成握手。
|
|
678
|
+
ctx.effect(
|
|
679
|
+
() =>
|
|
680
|
+
webServer.registerUpgrade({
|
|
681
|
+
path: HMR_PATH,
|
|
682
|
+
handler: () => {
|
|
683
|
+
/* vite 的 HMR 通道自己处理这次升级 */
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
"client-modules: vite hmr upgrade",
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
ctx.effect(
|
|
690
|
+
// `prefix /plugins`:对外形态(`/plugins/<pkg>/<version>/<export>`)之外的资源都由 vite 的兜底路由
|
|
691
|
+
// 处理,这里只留一条出口给「按包寻址」的请求。
|
|
692
|
+
() => webServer.register({ kind: "prefix", path: PLUGIN_ROUTE, handler: serve }),
|
|
693
|
+
"client-modules: /plugins route",
|
|
694
|
+
);
|
|
695
|
+
}
|