@longzai-intelligence-vite/plugins 0.0.0-stage → 0.0.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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * 产物版本清单写入 vite 插件
3
+ */
4
+
5
+ import type { Plugin } from 'vite';
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import * as z from 'zod';
11
+
12
+ /**
13
+ * 包定义 schema,`name` 与 `version` 作为产物版本清单的真源
14
+ */
15
+ const PkgSchema = z.object({
16
+ /**
17
+ * 包名
18
+ */
19
+ name: z.string(),
20
+
21
+ /**
22
+ * 版本号
23
+ */
24
+ version: z.string(),
25
+ });
26
+
27
+ /**
28
+ * 产物版本清单形态,字段与升级域解析服务的 zod 校验同构
29
+ */
30
+ export type DistManifest = {
31
+ /**
32
+ * 包名
33
+ */
34
+ name: string;
35
+
36
+ /**
37
+ * 版本号
38
+ */
39
+ version: string;
40
+
41
+ /**
42
+ * 构建时刻的 ISO 时间戳
43
+ */
44
+ builtAt: string;
45
+
46
+ /**
47
+ * 构建时所在提交的短哈希,非 git 环境省略该键
48
+ */
49
+ gitCommit?: string;
50
+ };
51
+
52
+ /**
53
+ * 清单组装入参形态
54
+ */
55
+ export type ComposeDistManifestInput = {
56
+ /**
57
+ * 包定义文件路径,缺省取进程工作目录下 `package.json`
58
+ *
59
+ * @default `path.join(process.cwd(), 'package.json')`
60
+ */
61
+ pkgPath?: string;
62
+
63
+ /**
64
+ * 提交短哈希推导函数,测试可注入桩替身
65
+ *
66
+ * @default `resolveGitCommit`
67
+ */
68
+ resolveCommit?: () => string;
69
+ };
70
+
71
+ /**
72
+ * 插件选项形态
73
+ */
74
+ export type DistManifestPluginOptions = {
75
+ /**
76
+ * 包定义文件路径,缺省取插件工厂调用时进程工作目录下 `package.json`
77
+ *
78
+ * @default `path.join(process.cwd(), 'package.json')`
79
+ */
80
+ pkgPath?: string;
81
+
82
+ /**
83
+ * 写入产物目录的清单文件名
84
+ *
85
+ * @default `manifest.json`
86
+ */
87
+ manifestFileName?: string;
88
+ };
89
+
90
+ /**
91
+ * 推导当前提交短哈希
92
+ *
93
+ * @param cwd - git 仓库工作目录,缺省取进程工作目录
94
+ * @returns 七位短哈希,非 git 环境或执行失败时为空文本
95
+ */
96
+ export function resolveGitCommit(cwd: string = process.cwd()): string {
97
+ try {
98
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
99
+ cwd,
100
+ encoding: 'utf-8',
101
+ }).trim();
102
+ } catch {
103
+ return '';
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 组装产物版本清单
109
+ *
110
+ * @remarks
111
+ * 字段与写盘形态对齐发布脚本的清单约定,桌面宿主的升级域解析服务按产物目录内清单文件展示当前版本与构建时间,
112
+ * 治理沿革自 git-reviewer 仓 issue 0048,统一方案正本见本仓 `docs/ledgers/lzi-vite-adoption/`
113
+ * @param input - 清单组装入参,路径与哈希推导均可注入
114
+ * @returns 清单对象
115
+ */
116
+ export function composeDistManifest(input: ComposeDistManifestInput = {}): DistManifest {
117
+ /**
118
+ * 包定义文件路径
119
+ */
120
+ const pkgPath = input.pkgPath ?? path.join(process.cwd(), 'package.json');
121
+
122
+ /**
123
+ * 本包定义,`name` 与 `version` 取自真源文件
124
+ */
125
+ const pkg = PkgSchema.parse(JSON.parse(readFileSync(pkgPath, 'utf-8')));
126
+
127
+ /**
128
+ * 提交短哈希推导函数
129
+ */
130
+ const resolveCommit = input.resolveCommit ?? resolveGitCommit;
131
+
132
+ /**
133
+ * 清单内容
134
+ */
135
+ const manifest: DistManifest = {
136
+ name: pkg.name,
137
+ version: pkg.version,
138
+ builtAt: new Date().toISOString(),
139
+ };
140
+
141
+ /**
142
+ * 当前提交短哈希
143
+ */
144
+ const gitCommit = resolveCommit();
145
+
146
+ if (gitCommit !== '') {
147
+ manifest.gitCommit = gitCommit;
148
+ }
149
+
150
+ return manifest;
151
+ }
152
+
153
+ /**
154
+ * 写盘入参形态
155
+ */
156
+ export type WriteDistManifestInput = {
157
+ /**
158
+ * 产物目录绝对路径
159
+ */
160
+ outDir: string;
161
+
162
+ /**
163
+ * 包定义文件路径
164
+ *
165
+ * @default `path.join(process.cwd(), 'package.json')`
166
+ */
167
+ pkgPath?: string;
168
+
169
+ /**
170
+ * 清单文件名
171
+ *
172
+ * @default `manifest.json`
173
+ */
174
+ manifestFileName?: string;
175
+ };
176
+
177
+ /**
178
+ * 向产物目录写入版本清单
179
+ *
180
+ * @remarks
181
+ * 插件 `closeBundle` 钩子的写盘正体,独立导出以便消费方与测试直接复用
182
+ * @param input - 写盘入参
183
+ */
184
+ export function writeDistManifest(input: WriteDistManifestInput): void {
185
+ /**
186
+ * 清单文件名
187
+ */
188
+ const manifestFileName = input.manifestFileName ?? 'manifest.json';
189
+
190
+ writeFileSync(
191
+ path.join(input.outDir, manifestFileName),
192
+ `${JSON.stringify(composeDistManifest({ pkgPath: input.pkgPath }), null, 2)}\n`,
193
+ 'utf-8',
194
+ );
195
+ }
196
+
197
+ /**
198
+ * 产物版本清单写入插件工厂
199
+ *
200
+ * @remarks
201
+ * 构建收尾把清单写入产物目录,开发邻接解析层与打包基线均从该清单取版本与构建时间,
202
+ * 目录缺清单时界面将显示版本未知,产物目录经 `configResolved` 取解析后 `outDir` 以尊重命令行覆写
203
+ * @param options - 插件选项,包定义路径与清单文件名可覆写
204
+ * @returns vite 插件实例
205
+ */
206
+ export function distManifestPlugin(options: DistManifestPluginOptions = {}): Plugin {
207
+ /**
208
+ * 包定义文件路径
209
+ */
210
+ const pkgPath = options.pkgPath ?? path.join(process.cwd(), 'package.json');
211
+
212
+ /**
213
+ * 清单文件名
214
+ */
215
+ const manifestFileName = options.manifestFileName ?? 'manifest.json';
216
+
217
+ /**
218
+ * 包定义在工厂期完成解析,配置装载期即暴露包定义缺失或形态错误
219
+ */
220
+ PkgSchema.parse(JSON.parse(readFileSync(pkgPath, 'utf-8')));
221
+
222
+ /**
223
+ * 解析后的产物目录,绝对路径
224
+ */
225
+ let resolvedOutDir = path.resolve(process.cwd(), 'dist');
226
+
227
+ return {
228
+ name: 'longzai-intelligence-dist-manifest',
229
+ apply: 'build',
230
+
231
+ configResolved(config) {
232
+ resolvedOutDir = config.build.outDir;
233
+ },
234
+
235
+ closeBundle() {
236
+ writeDistManifest({ outDir: resolvedOutDir, pkgPath, manifestFileName });
237
+ },
238
+ };
239
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Vite 统一插件库入口
3
+ *
4
+ * 承载产物版本清单写入插件、端口发现插件与代理目标解析工具,
5
+ * 统一方案正本见本仓 `docs/ledgers/lzi-vite-adoption/`
6
+ */
7
+ export {
8
+ composeDistManifest,
9
+ distManifestPlugin,
10
+ resolveGitCommit,
11
+ writeDistManifest,
12
+ } from './dist-manifest.plugin.js';
13
+
14
+ export type {
15
+ ComposeDistManifestInput,
16
+ DistManifest,
17
+ DistManifestPluginOptions,
18
+ WriteDistManifestInput,
19
+ } from './dist-manifest.plugin.js';
20
+
21
+ export { portDiscovery } from './port-discovery.plugin.js';
22
+
23
+ export type { PortDiscoveryOptions } from './port-discovery.plugin.js';
24
+
25
+ export { resolveProxyTarget } from './proxy-target.utils.js';
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Vite 端口发现插件
3
+ *
4
+ * 在 vite dev server 启动时自动从注册表发现后端服务端口,并将前端自身端口写入注册表,
5
+ * 自 dev-tools 仓 `packages/vite-plugin` 承接迁入,统一方案正本见本仓 `docs/ledgers/lzi-vite-adoption/`
6
+ */
7
+
8
+ import type { PortRegistryOptions, ServicePortInfo } from '@longzai-intelligence-port/registry';
9
+ import type { Plugin, ViteDevServer } from 'vite';
10
+
11
+ import { createPortRegistry } from '@longzai-intelligence-port/registry';
12
+
13
+ /**
14
+ * 插件配置选项
15
+ */
16
+ export type PortDiscoveryOptions = {
17
+ /**
18
+ * 要发现的后端服务名称,如 `data-server`
19
+ */
20
+ serviceName?: string;
21
+
22
+ /**
23
+ * 前端自身注册为的服务名称,如 `data-web`
24
+ */
25
+ registerAs?: string;
26
+
27
+ /**
28
+ * 等待后端服务的超时时间,单位毫秒
29
+ *
30
+ * @default 30000
31
+ */
32
+ timeout?: number;
33
+
34
+ /**
35
+ * 轮询间隔,单位毫秒
36
+ *
37
+ * @default 1000
38
+ */
39
+ pollInterval?: number;
40
+
41
+ /**
42
+ * 注册表配置选项
43
+ */
44
+ registryOptions?: PortRegistryOptions;
45
+ };
46
+
47
+ /**
48
+ * 默认超时时间,单位毫秒
49
+ */
50
+ const DEFAULT_TIMEOUT = 30000;
51
+
52
+ /**
53
+ * 默认轮询间隔,单位毫秒
54
+ */
55
+ const DEFAULT_POLL_INTERVAL = 1000;
56
+
57
+ /**
58
+ * 从 vite dev server 获取实际监听端口
59
+ *
60
+ * @param server - vite dev server 实例
61
+ * @returns 实际监听端口号
62
+ */
63
+ const getServerPort = (server: ViteDevServer): number => {
64
+ /**
65
+ * httpServer 地址信息
66
+ */
67
+ const address = server.httpServer?.address();
68
+ if (address && typeof address === 'object') {
69
+ return address.port;
70
+ }
71
+ return server.config.server.port ?? 5173;
72
+ };
73
+
74
+ /**
75
+ * 从 vite dev server 获取实际监听主机
76
+ *
77
+ * @param server - vite dev server 实例
78
+ * @returns 实际监听主机地址
79
+ */
80
+ const getServerHost = (server: ViteDevServer): string => {
81
+ /**
82
+ * httpServer 地址信息
83
+ */
84
+ const address = server.httpServer?.address();
85
+ if (address && typeof address === 'object') {
86
+ return address.address || 'localhost';
87
+ }
88
+ return 'localhost';
89
+ };
90
+
91
+ /**
92
+ * 判断未知值是否为代理配置形态,结构守卫替代断言
93
+ *
94
+ * @param value - vite `server.config.proxy` 条目,类型面不透明
95
+ * @returns 是则收窄为可读取服务标识与目标地址的记录
96
+ */
97
+ function isProxyLike(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null;
99
+ }
100
+
101
+ /**
102
+ * 判断代理条目是否需要更新目标,按自定义标头或未绑定端口特征识别
103
+ *
104
+ * @param proxyItem - vite proxy 配置条目,经结构守卫收窄
105
+ * @param serviceName - 后端服务名
106
+ * @returns 是否应更新
107
+ */
108
+ const shouldUpdateProxy = (proxyItem: unknown, serviceName: string): boolean => {
109
+ /**
110
+ * 结构守卫后的代理配置对象
111
+ */
112
+ if (!isProxyLike(proxyItem)) {
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * 检查自定义标头中是否包含服务标识
118
+ */
119
+ if (proxyItem['x-service'] === serviceName) {
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * 检查代理目标是否为未绑定端口形态
125
+ */
126
+ if (typeof proxyItem.target === 'string') {
127
+ return proxyItem.target.includes('localhost:0') || proxyItem.target.includes('127.0.0.1:0');
128
+ }
129
+ return false;
130
+ };
131
+
132
+ /**
133
+ * 更新代理配置中的目标地址
134
+ *
135
+ * @remarks
136
+ * 遍历 `server.proxy` 中的全部代理项,将匹配后端服务特征的 `target` 替换为新地址
137
+ * @param server - vite dev server 实例
138
+ * @param serviceName - 后端服务名称
139
+ * @param newTarget - 新的代理目标地址
140
+ */
141
+ const updateProxyTarget = (server: ViteDevServer, serviceName: string, newTarget: string): void => {
142
+ /**
143
+ * 代理配置
144
+ */
145
+ const proxyConfig = server.config.server.proxy;
146
+ if (!proxyConfig) return;
147
+
148
+ for (const [, proxyItem] of Object.entries(proxyConfig)) {
149
+ if (typeof proxyItem === 'object' && proxyItem !== null && 'target' in proxyItem) {
150
+ /**
151
+ * 当前代理目标
152
+ */
153
+ const currentTarget = String(proxyItem.target);
154
+ if (currentTarget.includes(serviceName) || shouldUpdateProxy(proxyItem, serviceName)) {
155
+ proxyItem.target = newTarget;
156
+
157
+ /**
158
+ * WebSocket 代理同步更新
159
+ */
160
+ if (proxyItem.ws) {
161
+ proxyItem.target = newTarget;
162
+ }
163
+ server.config.logger.info(`[端口发现] 代理目标已更新: ${serviceName} -> ${newTarget}`);
164
+ }
165
+ }
166
+ }
167
+ };
168
+
169
+ /**
170
+ * 轮询等待后端服务端口
171
+ *
172
+ * @remarks
173
+ * 每隔 `pollInterval` 毫秒检查一次注册表,超时后输出警告但不阻止启动
174
+ * @param serviceName - 后端服务名称
175
+ * @param registry - 端口注册表实例
176
+ * @param timeout - 超时时间,单位毫秒
177
+ * @param pollInterval - 轮询间隔,单位毫秒
178
+ * @returns 服务端口信息或 null
179
+ */
180
+ const pollForService = async (
181
+ serviceName: string,
182
+ registry: ReturnType<typeof createPortRegistry>,
183
+ timeout: number,
184
+ pollInterval: number,
185
+ ): Promise<ServicePortInfo | null> => {
186
+ /**
187
+ * 开始时间
188
+ */
189
+ const startTime = Date.now();
190
+
191
+ while (Date.now() - startTime < timeout) {
192
+ /**
193
+ * 尝试解析服务
194
+ */
195
+ const info = await registry.resolve(serviceName);
196
+ if (info) {
197
+ return info;
198
+ }
199
+
200
+ /**
201
+ * 等待下一次轮询
202
+ */
203
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
204
+ }
205
+
206
+ return null;
207
+ };
208
+
209
+ /**
210
+ * 创建 vite 端口发现插件
211
+ *
212
+ * @remarks
213
+ * 在 dev server 启动时自动从注册表发现后端服务端口,并将前端自身端口写入注册表,
214
+ * 后端端口变化经注册表 `watch` 自动同步代理目标
215
+ * @param options - 插件配置选项
216
+ * @returns vite 插件实例
217
+ */
218
+ const portDiscovery = (options?: PortDiscoveryOptions): Plugin => {
219
+ /**
220
+ * 要发现的后端服务名称
221
+ */
222
+ const serviceName = options?.serviceName;
223
+
224
+ /**
225
+ * 前端自身注册为的服务名称
226
+ */
227
+ const registerAs = options?.registerAs;
228
+
229
+ /**
230
+ * 超时时间
231
+ */
232
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
233
+
234
+ /**
235
+ * 轮询间隔
236
+ */
237
+ const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL;
238
+
239
+ /**
240
+ * 注册表配置选项
241
+ */
242
+ const registryOptions = options?.registryOptions;
243
+
244
+ /**
245
+ * 端口注册表实例
246
+ */
247
+ let registry: ReturnType<typeof createPortRegistry> | null = null;
248
+
249
+ /**
250
+ * watch 取消监听函数
251
+ */
252
+ let unwatch: (() => void) | null = null;
253
+
254
+ return {
255
+ name: 'longzai-intelligence-port-discovery',
256
+
257
+ apply: 'serve',
258
+
259
+ configureServer: async (server: ViteDevServer) => {
260
+ registry = createPortRegistry(registryOptions);
261
+
262
+ /**
263
+ * 获取 vite dev server 的实际端口和主机
264
+ */
265
+ const port = getServerPort(server);
266
+
267
+ /**
268
+ * 获取 vite dev server 的实际主机地址
269
+ */
270
+ const host = getServerHost(server);
271
+
272
+ /**
273
+ * 如果配置了 `registerAs`,将前端端口注册到端口注册表
274
+ */
275
+ if (registerAs) {
276
+ await registry.register(registerAs, {
277
+ port,
278
+ host: host === '::' ? 'localhost' : host,
279
+ pid: process.pid,
280
+ startedAt: Date.now(),
281
+ });
282
+ server.config.logger.info(
283
+ `[端口发现] 前端服务已注册: ${registerAs} -> http://${host}:${port}`,
284
+ );
285
+ }
286
+
287
+ /**
288
+ * 如果配置了 `serviceName`,从端口注册表轮询等待后端端口
289
+ */
290
+ if (serviceName) {
291
+ server.config.logger.info(`[端口发现] 正在等待后端服务: ${serviceName}...`);
292
+
293
+ /**
294
+ * 轮询等待后端服务
295
+ */
296
+ const info = await pollForService(serviceName, registry, timeout, pollInterval);
297
+
298
+ if (info) {
299
+ /**
300
+ * 后端服务主机地址
301
+ */
302
+ const backendHost = info.host === '0.0.0.0' ? 'localhost' : info.host;
303
+
304
+ /**
305
+ * 后端服务目标地址
306
+ */
307
+ const newTarget = `http://${backendHost}:${info.port}`;
308
+ server.config.logger.info(`[端口发现] 后端服务已发现: ${serviceName} -> ${newTarget}`);
309
+ updateProxyTarget(server, serviceName, newTarget);
310
+ } else {
311
+ server.config.logger.warn(
312
+ `[端口发现] 等待后端服务超时: ${serviceName},已等待 ${timeout}ms,代理目标可能不正确`,
313
+ );
314
+ }
315
+
316
+ /**
317
+ * 设置 watch 监听后端端口变化,自动更新代理目标
318
+ */
319
+ unwatch = registry.watch(serviceName, (updatedInfo) => {
320
+ if (updatedInfo) {
321
+ /**
322
+ * 更新后的主机地址
323
+ */
324
+ const updatedHost = updatedInfo.host === '0.0.0.0' ? 'localhost' : updatedInfo.host;
325
+
326
+ /**
327
+ * 更新后的目标地址
328
+ */
329
+ const updatedTarget = `http://${updatedHost}:${updatedInfo.port}`;
330
+ server.config.logger.info(
331
+ `[端口发现] 后端服务端口变化: ${serviceName} -> ${updatedTarget}`,
332
+ );
333
+ updateProxyTarget(server, serviceName, updatedTarget);
334
+ } else {
335
+ server.config.logger.warn(`[端口发现] 后端服务已下线: ${serviceName}`);
336
+ }
337
+ });
338
+ }
339
+
340
+ /**
341
+ * 进程退出时自动注销前端端口
342
+ */
343
+ if (registerAs) {
344
+ /**
345
+ * 闭包外快照,消除闭包内非空断言
346
+ */
347
+ const httpServer = server.httpServer;
348
+
349
+ /**
350
+ * 注册表快照,闭包外非空收敛
351
+ */
352
+ const activeRegistry = registry;
353
+
354
+ /**
355
+ * 原始 close 方法绑定
356
+ */
357
+ const originalClose = httpServer?.close.bind(httpServer);
358
+ if (originalClose && httpServer && activeRegistry) {
359
+ httpServer.close = function (cb?: (err?: Error) => void) {
360
+ activeRegistry.deregister(registerAs).catch(() => {});
361
+
362
+ /**
363
+ * vite `httpServer.close` 重载返回面在闭包包装处不可统一,
364
+ * 以宽类型透传原始返回值,运行时行为不变,承接自 dev-tools 既有形态
365
+ */
366
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @longzai-intelligence-oxlint/typescript-eslint-plugin/consistent-type-assertions
367
+ return originalClose(cb) as any;
368
+ };
369
+ }
370
+ }
371
+ },
372
+
373
+ closeBundle: async () => {
374
+ /**
375
+ * 取消 watch 监听
376
+ */
377
+ if (unwatch) {
378
+ unwatch();
379
+ unwatch = null;
380
+ }
381
+
382
+ /**
383
+ * 注销前端端口
384
+ */
385
+ if (registry && registerAs) {
386
+ await registry.deregister(registerAs).catch(() => {});
387
+ }
388
+ },
389
+ };
390
+ };
391
+
392
+ export { portDiscovery };