@harness-fe/unplugin 3.0.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/core.d.ts +19 -0
  4. package/dist/core.js +211 -0
  5. package/dist/esbuild.d.ts +9 -0
  6. package/dist/esbuild.js +9 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.js +20 -0
  9. package/dist/internal/buildIdentity.d.ts +19 -0
  10. package/dist/internal/buildIdentity.js +49 -0
  11. package/dist/internal/log-capture.d.ts +7 -0
  12. package/dist/internal/log-capture.js +22 -0
  13. package/dist/internal/mcp-client.d.ts +11 -0
  14. package/dist/internal/mcp-client.js +165 -0
  15. package/dist/internal/types.d.ts +61 -0
  16. package/dist/internal/types.js +4 -0
  17. package/dist/resolveBuildId.d.ts +32 -0
  18. package/dist/resolveBuildId.js +88 -0
  19. package/dist/resolveProjectId.d.ts +9 -0
  20. package/dist/resolveProjectId.js +44 -0
  21. package/dist/rollup.d.ts +9 -0
  22. package/dist/rollup.js +9 -0
  23. package/dist/rspack.d.ts +9 -0
  24. package/dist/rspack.js +9 -0
  25. package/dist/transform.d.ts +27 -0
  26. package/dist/transform.js +150 -0
  27. package/dist/vite.d.ts +10 -0
  28. package/dist/vite.js +10 -0
  29. package/dist/vue-transform.d.ts +90 -0
  30. package/dist/vue-transform.js +350 -0
  31. package/package.json +75 -0
  32. package/src/core.ts +230 -0
  33. package/src/esbuild.ts +12 -0
  34. package/src/index.ts +34 -0
  35. package/src/internal/buildIdentity.ts +66 -0
  36. package/src/internal/log-capture.ts +26 -0
  37. package/src/internal/mcp-client.ts +181 -0
  38. package/src/internal/types.ts +66 -0
  39. package/src/resolveBuildId.test.ts +63 -0
  40. package/src/resolveBuildId.ts +125 -0
  41. package/src/resolveProjectId.test.ts +99 -0
  42. package/src/resolveProjectId.ts +48 -0
  43. package/src/rollup.ts +12 -0
  44. package/src/rspack.ts +12 -0
  45. package/src/transform.test.ts +89 -0
  46. package/src/transform.ts +188 -0
  47. package/src/vite.ts +13 -0
  48. package/src/vue-transform.test.ts +398 -0
  49. package/src/vue-transform.ts +455 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MorphixAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @harness-fe/unplugin
2
+
3
+ > Unified build plugin core for [Harness-FE](https://github.com/Morphicai/harness-fe). Powers the Vite, Rspack, esbuild, and Rollup adapters.
4
+
5
+ You normally do **not** install this directly — install the bundler-specific package instead:
6
+
7
+ - [`@harness-fe/vite`](https://www.npmjs.com/package/@harness-fe/vite)
8
+ - [`@harness-fe/webpack`](https://www.npmjs.com/package/@harness-fe/webpack) — **native** webpack plugin (not a unplugin adapter). Required for thread-loader compatibility.
9
+
10
+ ## Install (advanced)
11
+
12
+ ```bash
13
+ pnpm add -D @harness-fe/unplugin
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { harnessFE } from '@harness-fe/unplugin/vite';
20
+ // or '/rspack' '/esbuild' '/rollup'
21
+ ```
22
+
23
+ **Webpack users**: install `@harness-fe/webpack` instead. The `./webpack` subpath export has been removed because unplugin's webpack adapter serializes the plugin instance into loader options, which breaks `thread-loader` (the plugin holds a `compiler` reference and JSON.stringify trips on `Compiler.root`). See the changeset for details.
24
+
25
+ For custom integrations, import the raw factory:
26
+
27
+ ```ts
28
+ import { unplugin, unpluginFactory } from '@harness-fe/unplugin';
29
+ ```
30
+
31
+ ## Public API
32
+
33
+ - `unplugin` — pre-built unplugin instance (call `.vite()` / `.rspack()` / etc.)
34
+ - `unpluginFactory` — raw factory for fully custom adapters
35
+ - `transformJsx` — JSX/TSX source transform with location attribute injection
36
+ - `transformVueSFC` / `transformVueTemplate` — Vue SFC + template transforms
37
+ - `createMcpClient`, `installNodeLogCapture`, `createBuildIdentity`, `appendTokenQuery` — internal building blocks used by `@harness-fe/webpack` to assemble a native plugin without going through unplugin's webpack adapter
38
+
39
+ ## Docs
40
+
41
+ - [Root README](https://github.com/Morphicai/harness-fe#readme)
42
+ - [Architecture](https://github.com/Morphicai/harness-fe/blob/main/ARCHITECTURE.md)
43
+
44
+ ## License
45
+
46
+ MIT
package/dist/core.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Core unplugin definition for Harness-FE.
3
+ *
4
+ * Handles:
5
+ * 1. Source-aware JSX / Vue transform (data-morphix-loc / data-morphix-comp)
6
+ * 2. WebSocket connection to MCP server (hello handshake, command handling)
7
+ * 3. HTML injection of runtime client + config (Vite)
8
+ * 4. HMR event forwarding (Vite)
9
+ *
10
+ * Webpack users should use `@harness-fe/webpack` (a native webpack plugin)
11
+ * instead — the unplugin webpack adapter is incompatible with thread-loader
12
+ * because it serializes the plugin instance (and its compiler reference) via
13
+ * loader options.
14
+ */
15
+ import { type UnpluginFactory } from 'unplugin';
16
+ import type { HarnessFEOptions } from './internal/types.js';
17
+ export type { HarnessFEOptions };
18
+ export declare const unpluginFactory: UnpluginFactory<HarnessFEOptions | undefined>;
19
+ export declare const unplugin: import("unplugin").UnpluginInstance<HarnessFEOptions | undefined, boolean>;
package/dist/core.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Core unplugin definition for Harness-FE.
3
+ *
4
+ * Handles:
5
+ * 1. Source-aware JSX / Vue transform (data-morphix-loc / data-morphix-comp)
6
+ * 2. WebSocket connection to MCP server (hello handshake, command handling)
7
+ * 3. HTML injection of runtime client + config (Vite)
8
+ * 4. HMR event forwarding (Vite)
9
+ *
10
+ * Webpack users should use `@harness-fe/webpack` (a native webpack plugin)
11
+ * instead — the unplugin webpack adapter is incompatible with thread-loader
12
+ * because it serializes the plugin instance (and its compiler reference) via
13
+ * loader options.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+ import { relative } from 'node:path';
18
+ import { createUnplugin } from 'unplugin';
19
+ import { DEFAULT_WS_PORT } from '@harness-fe/protocol';
20
+ import { transformJsx } from './transform.js';
21
+ import { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, createVueTransformStats, formatVueTransformReport, } from './vue-transform.js';
22
+ import { resolveProjectId } from './resolveProjectId.js';
23
+ import { createMcpClient } from './internal/mcp-client.js';
24
+ import { installNodeLogCapture } from './internal/log-capture.js';
25
+ import { appendTokenQuery, createBuildIdentity } from './internal/buildIdentity.js';
26
+ const require = createRequire(import.meta.url);
27
+ /**
28
+ * Virtual module ID used to inject the runtime client into the dev page.
29
+ */
30
+ const VIRTUAL_RUNTIME_ID = 'virtual:harness-fe/runtime';
31
+ const RESOLVED_VIRTUAL_RUNTIME_ID = '\0' + VIRTUAL_RUNTIME_ID;
32
+ export const unpluginFactory = (options = {}) => {
33
+ let projectId = options.projectId ?? 'unknown-project';
34
+ const baseMcpUrl = options.mcpUrl ?? process.env.HARNESS_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
35
+ const token = options.token ?? process.env.HARNESS_FE_TOKEN;
36
+ const mcpUrl = appendTokenQuery(baseMcpUrl, token);
37
+ let projectRoot = process.cwd();
38
+ let peerRole = 'vite-plugin';
39
+ const componentMap = new Map();
40
+ let logCaptureCleanup;
41
+ let mcpClient;
42
+ // Vue 2 hardening — safeMode on by default, dry-run gated by env so
43
+ // legacy projects can collect a coverage report before flipping on.
44
+ const dryRun = process.env.HARNESS_FE_DRY_RUN === '1';
45
+ const vueStats = createVueTransformStats();
46
+ const vueOptions = {
47
+ safeMode: options.safeMode !== false,
48
+ dryRun,
49
+ stats: vueStats,
50
+ };
51
+ let dumpReportInstalled = false;
52
+ function ensureExitReport() {
53
+ if (dumpReportInstalled)
54
+ return;
55
+ dumpReportInstalled = true;
56
+ const dump = () => {
57
+ if (vueStats.filesAttempted === 0)
58
+ return;
59
+ process.stderr.write(formatVueTransformReport(vueStats) + '\n');
60
+ };
61
+ process.once('exit', dump);
62
+ process.once('SIGINT', () => { dump(); process.exit(0); });
63
+ process.once('SIGTERM', () => { dump(); process.exit(0); });
64
+ }
65
+ if (dryRun)
66
+ ensureExitReport();
67
+ const identity = createBuildIdentity({
68
+ userBuildId: options.buildId,
69
+ userDisplayName: options.displayName,
70
+ });
71
+ function buildMcpContext() {
72
+ return {
73
+ get projectId() { return projectId; },
74
+ get mcpUrl() { return mcpUrl; },
75
+ get token() { return token; },
76
+ get peerRole() { return peerRole; },
77
+ get parentProjectId() { return options.parentProjectId; },
78
+ get projectRoot() { return projectRoot; },
79
+ get componentMap() { return componentMap; },
80
+ getBuildId: () => identity.getBuildId(projectRoot),
81
+ getDisplayName: () => identity.getDisplayName(projectRoot),
82
+ };
83
+ }
84
+ function ensureMcpClient() {
85
+ if (!mcpClient)
86
+ mcpClient = createMcpClient(buildMcpContext());
87
+ return mcpClient;
88
+ }
89
+ // Expose for advanced usage (e.g. tests or downstream plugins inspecting state).
90
+ const ctx = {
91
+ get projectId() { return projectId; },
92
+ get mcpUrl() { return mcpUrl; },
93
+ get componentMap() { return componentMap; },
94
+ get isActive() { return mcpClient?.isActive ?? false; },
95
+ };
96
+ return {
97
+ name: 'harness-fe',
98
+ enforce: 'pre',
99
+ async buildStart() {
100
+ if (options.disabled)
101
+ return;
102
+ projectId = await resolveProjectId(projectRoot, options.projectId);
103
+ },
104
+ transformInclude(id) {
105
+ if (options.disabled)
106
+ return false;
107
+ // Accept query-string variants so we can intercept vue-loader's
108
+ // virtual sub-modules (`App.vue?vue&type=template…`).
109
+ if (!/\.([jt]sx|vue)($|\?)/.test(id))
110
+ return false;
111
+ if (id.includes('/node_modules/') || id.includes('\\node_modules\\'))
112
+ return false;
113
+ return true;
114
+ },
115
+ transform(code, id) {
116
+ if (options.disabled)
117
+ return null;
118
+ const queryIdx = id.indexOf('?');
119
+ const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx);
120
+ const query = queryIdx === -1 ? '' : id.slice(queryIdx);
121
+ const rel = relative(projectRoot, filePath);
122
+ // Vue template virtual sub-module.
123
+ if (filePath.endsWith('.vue') && /[?&]vue\b/.test(query) && /[?&]type=template\b/.test(query)) {
124
+ let componentName;
125
+ let lineOffset = 0;
126
+ try {
127
+ const sfcSource = readFileSync(filePath, 'utf-8');
128
+ componentName = resolveVueComponentName(sfcSource, rel);
129
+ lineOffset = getTemplateLineOffset(sfcSource, rel);
130
+ }
131
+ catch {
132
+ /* fall through with no offset / no name */
133
+ }
134
+ const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset, vueOptions);
135
+ if (!out)
136
+ return null;
137
+ return { code: out.code, map: out.map };
138
+ }
139
+ // Plain .vue request: full SFC transform.
140
+ if (filePath.endsWith('.vue') && !query) {
141
+ const out = transformVueSFC(code, rel, componentMap, vueOptions);
142
+ if (!out)
143
+ return null;
144
+ return { code: out.code, map: out.map };
145
+ }
146
+ // Skip every other .vue sub-module (script / style).
147
+ if (filePath.endsWith('.vue'))
148
+ return null;
149
+ const out = transformJsx(code, rel, componentMap);
150
+ if (!out)
151
+ return null;
152
+ return { code: out.code, map: out.map };
153
+ },
154
+ // Vite-specific hooks
155
+ vite: {
156
+ async configResolved(config) {
157
+ if (options.disabled)
158
+ return;
159
+ projectRoot = config.root ?? process.cwd();
160
+ projectId = await resolveProjectId(projectRoot, options.projectId);
161
+ },
162
+ configureServer(server) {
163
+ if (options.disabled)
164
+ return;
165
+ const client = ensureMcpClient();
166
+ client.connect();
167
+ logCaptureCleanup = installNodeLogCapture((name, payload) => client.emitEvent(name, payload));
168
+ server.httpServer?.once('close', () => {
169
+ logCaptureCleanup?.();
170
+ logCaptureCleanup = undefined;
171
+ client.disconnect();
172
+ });
173
+ },
174
+ resolveId(id) {
175
+ if (id === VIRTUAL_RUNTIME_ID)
176
+ return RESOLVED_VIRTUAL_RUNTIME_ID;
177
+ return undefined;
178
+ },
179
+ load(id) {
180
+ if (id !== RESOLVED_VIRTUAL_RUNTIME_ID)
181
+ return undefined;
182
+ const runtimeEntry = require.resolve('@harness-fe/runtime');
183
+ return `export * from ${JSON.stringify(runtimeEntry)};\nimport ${JSON.stringify(runtimeEntry)};`;
184
+ },
185
+ transformIndexHtml: {
186
+ order: 'pre',
187
+ handler(html) {
188
+ if (options.disabled)
189
+ return html;
190
+ const injection = `<!-- @harness-fe injected (dev only) -->
191
+ <script>
192
+ window.__HARNESS_FE__ = ${JSON.stringify({ projectId, mcpUrl, buildId: identity.getBuildId(projectRoot), parentProjectId: options.parentProjectId, displayName: identity.getDisplayName(projectRoot) })};
193
+ </script>
194
+ <script type="module">import '${VIRTUAL_RUNTIME_ID}';</script>`;
195
+ return html.replace(/<\/head>/i, `${injection}\n</head>`);
196
+ },
197
+ },
198
+ handleHotUpdate(hmrCtx) {
199
+ mcpClient?.emitEvent('hmr', {
200
+ file: hmrCtx.file,
201
+ type: hmrCtx.modules?.length ? 'update' : 'reload',
202
+ moduleCount: hmrCtx.modules?.length ?? 0,
203
+ });
204
+ return hmrCtx.modules;
205
+ },
206
+ },
207
+ // Expose context for advanced usage
208
+ _ctx: ctx,
209
+ };
210
+ };
211
+ export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * esbuild-specific export.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/esbuild'
6
+ */
7
+ export type { HarnessFEOptions } from './core.js';
8
+ export declare const harnessFE: (options?: import("./core.js").HarnessFEOptions | undefined) => import("unplugin").EsbuildPlugin;
9
+ export default harnessFE;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * esbuild-specific export.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/esbuild'
6
+ */
7
+ import { unplugin } from './core.js';
8
+ export const harnessFE = unplugin.esbuild;
9
+ export default harnessFE;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @harness-fe/unplugin — unified build plugin.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/vite'
6
+ * import { harnessFE } from '@harness-fe/unplugin/rspack'
7
+ *
8
+ * Webpack: use @harness-fe/webpack (a native plugin — see that package).
9
+ *
10
+ * Or import the raw unplugin for custom integrations:
11
+ * import { unplugin, unpluginFactory } from '@harness-fe/unplugin'
12
+ */
13
+ export { unplugin, unpluginFactory, type HarnessFEOptions } from './core.js';
14
+ export { transformJsx, type ComponentMap, type ComponentLocation, type TransformResult } from './transform.js';
15
+ export { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, createVueTransformStats, formatVueTransformReport, type VueTransformOptions, type VueTransformResult, type VueTransformStats, } from './vue-transform.js';
16
+ export { createMcpClient } from './internal/mcp-client.js';
17
+ export { installNodeLogCapture } from './internal/log-capture.js';
18
+ export { createBuildIdentity, appendTokenQuery } from './internal/buildIdentity.js';
19
+ export type { McpClient, McpClientContext, PeerRole } from './internal/types.js';
20
+ export type { BuildIdentity, BuildIdentityOptions } from './internal/buildIdentity.js';
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @harness-fe/unplugin — unified build plugin.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/vite'
6
+ * import { harnessFE } from '@harness-fe/unplugin/rspack'
7
+ *
8
+ * Webpack: use @harness-fe/webpack (a native plugin — see that package).
9
+ *
10
+ * Or import the raw unplugin for custom integrations:
11
+ * import { unplugin, unpluginFactory } from '@harness-fe/unplugin'
12
+ */
13
+ export { unplugin, unpluginFactory } from './core.js';
14
+ export { transformJsx } from './transform.js';
15
+ export { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, createVueTransformStats, formatVueTransformReport, } from './vue-transform.js';
16
+ // Internal building blocks — re-exported for downstream native plugins (e.g.
17
+ // @harness-fe/webpack) that cannot use the unplugin adapter directly.
18
+ export { createMcpClient } from './internal/mcp-client.js';
19
+ export { installNodeLogCapture } from './internal/log-capture.js';
20
+ export { createBuildIdentity, appendTokenQuery } from './internal/buildIdentity.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Lazy resolvers for build-time identity metadata (buildId, displayName).
3
+ *
4
+ * Both are deferred until first read because the host bundler may not have
5
+ * resolved `projectRoot` at plugin instantiation time.
6
+ */
7
+ export interface BuildIdentityOptions {
8
+ userBuildId?: string;
9
+ userDisplayName?: string;
10
+ /** Captured once so dev-mode fallback ids stay stable across re-reads. */
11
+ startTs?: number;
12
+ }
13
+ export interface BuildIdentity {
14
+ getBuildId(root: string): string;
15
+ getDisplayName(root: string): string | undefined;
16
+ }
17
+ export declare function createBuildIdentity(opts?: BuildIdentityOptions): BuildIdentity;
18
+ /** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */
19
+ export declare function appendTokenQuery(url: string, token: string | undefined): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Lazy resolvers for build-time identity metadata (buildId, displayName).
3
+ *
4
+ * Both are deferred until first read because the host bundler may not have
5
+ * resolved `projectRoot` at plugin instantiation time.
6
+ */
7
+ import { createRequire } from 'node:module';
8
+ import { resolveBuildId } from '../resolveBuildId.js';
9
+ const require = createRequire(import.meta.url);
10
+ export function createBuildIdentity(opts = {}) {
11
+ const startTs = opts.startTs ?? Date.now();
12
+ let resolvedBuild;
13
+ let resolvedDisplayName = opts.userDisplayName;
14
+ let displayNameResolved = opts.userDisplayName !== undefined;
15
+ return {
16
+ getBuildId(root) {
17
+ if (resolvedBuild)
18
+ return resolvedBuild.buildId;
19
+ resolvedBuild = resolveBuildId({
20
+ userConfig: opts.userBuildId,
21
+ root,
22
+ startTs,
23
+ });
24
+ return resolvedBuild.buildId;
25
+ },
26
+ getDisplayName(root) {
27
+ if (displayNameResolved)
28
+ return resolvedDisplayName;
29
+ displayNameResolved = true;
30
+ try {
31
+ const pkg = JSON.parse(require('node:fs').readFileSync(require('node:path').join(root, 'package.json'), 'utf-8'));
32
+ resolvedDisplayName = pkg.name;
33
+ }
34
+ catch {
35
+ resolvedDisplayName = undefined;
36
+ }
37
+ return resolvedDisplayName;
38
+ },
39
+ };
40
+ }
41
+ /** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */
42
+ export function appendTokenQuery(url, token) {
43
+ if (!token)
44
+ return url;
45
+ if (/[?&]token=/.test(url))
46
+ return url;
47
+ const sep = url.includes('?') ? '&' : '?';
48
+ return `${url}${sep}token=${encodeURIComponent(token)}`;
49
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Intercepts `process.stdout.write` and `process.stderr.write` to emit
3
+ * `'node:log'` / `'node:err'` events to the MCP server.
4
+ *
5
+ * Returns a cleanup function that restores the original write methods.
6
+ */
7
+ export declare function installNodeLogCapture(emitEvent: (name: string, payload: unknown) => void): () => void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Intercepts `process.stdout.write` and `process.stderr.write` to emit
3
+ * `'node:log'` / `'node:err'` events to the MCP server.
4
+ *
5
+ * Returns a cleanup function that restores the original write methods.
6
+ */
7
+ export function installNodeLogCapture(emitEvent) {
8
+ const origOut = process.stdout.write.bind(process.stdout);
9
+ const origErr = process.stderr.write.bind(process.stderr);
10
+ process.stdout.write = (chunk, ...args) => {
11
+ emitEvent('node:log', { text: String(chunk) });
12
+ return origOut(chunk, ...args);
13
+ };
14
+ process.stderr.write = (chunk, ...args) => {
15
+ emitEvent('node:err', { text: String(chunk) });
16
+ return origErr(chunk, ...args);
17
+ };
18
+ return () => {
19
+ process.stdout.write = origOut;
20
+ process.stderr.write = origErr;
21
+ };
22
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * MCP WebSocket client + command dispatch loop, factored out of the unplugin
3
+ * core so the native webpack plugin can reuse it without depending on the
4
+ * unplugin webpack adapter (which carries circular references via the plugin
5
+ * instance and breaks thread-loader serialization).
6
+ *
7
+ * The componentMap is supplied by the caller — vite/rspack pass their shared
8
+ * map; the webpack plugin passes its shared-state map keyed by pluginId.
9
+ */
10
+ import type { McpClient, McpClientContext } from './types.js';
11
+ export declare function createMcpClient(ctx: McpClientContext): McpClient;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * MCP WebSocket client + command dispatch loop, factored out of the unplugin
3
+ * core so the native webpack plugin can reuse it without depending on the
4
+ * unplugin webpack adapter (which carries circular references via the plugin
5
+ * instance and breaks thread-loader serialization).
6
+ *
7
+ * The componentMap is supplied by the caller — vite/rspack pass their shared
8
+ * map; the webpack plugin passes its shared-state map keyed by pluginId.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { WebSocket } from 'ws';
13
+ import { COMMAND, frameSchema, } from '@harness-fe/protocol';
14
+ function newId() {
15
+ const g = globalThis;
16
+ return g.crypto?.randomUUID ? g.crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
17
+ }
18
+ export function createMcpClient(ctx) {
19
+ let ws;
20
+ let isActive = false;
21
+ function send(frame) {
22
+ if (!ws || ws.readyState !== WebSocket.OPEN)
23
+ return;
24
+ try {
25
+ ws.send(JSON.stringify(frame));
26
+ }
27
+ catch {
28
+ /* swallow */
29
+ }
30
+ }
31
+ async function runCommand(command, args) {
32
+ switch (command) {
33
+ case COMMAND.PROJECT_SOURCE: {
34
+ const a = args;
35
+ let file = a.file;
36
+ if (!file && a.component) {
37
+ const locs = ctx.componentMap.get(a.component);
38
+ if (!locs?.length) {
39
+ throw new Error(`project.source: component "${a.component}" not found in the scan`);
40
+ }
41
+ file = locs[0].file;
42
+ }
43
+ if (!file) {
44
+ throw new Error('project.source: pass either `file` or `component`');
45
+ }
46
+ const abs = resolve(ctx.projectRoot, file);
47
+ if (!abs.startsWith(ctx.projectRoot)) {
48
+ throw new Error(`project.source: refusing to read outside project root: ${file}`);
49
+ }
50
+ const content = readFileSync(abs, 'utf-8');
51
+ return { file, content };
52
+ }
53
+ case COMMAND.PROJECT_WHERE_IS: {
54
+ const a = args;
55
+ const locs = ctx.componentMap.get(a.component);
56
+ if (!locs?.length) {
57
+ throw new Error(`project.where_is: component "${a.component}" not found`);
58
+ }
59
+ return { component: a.component, locations: locs };
60
+ }
61
+ case COMMAND.PROJECT_MODULE_GRAPH: {
62
+ const components = {};
63
+ for (const [name, locs] of ctx.componentMap.entries()) {
64
+ components[name] = locs;
65
+ }
66
+ return {
67
+ components,
68
+ totalFiles: new Set([...ctx.componentMap.values()].flat().map((l) => l.file)).size,
69
+ };
70
+ }
71
+ default:
72
+ throw new Error(`harness-fe: unhandled command "${command}"`);
73
+ }
74
+ }
75
+ async function handleCommand(frame) {
76
+ let response;
77
+ try {
78
+ const result = await runCommand(frame.command, frame.args);
79
+ response = { type: 'response', id: frame.id, ok: true, result };
80
+ }
81
+ catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ response = { type: 'response', id: frame.id, ok: false, error: { message } };
84
+ }
85
+ send(response);
86
+ }
87
+ function connect() {
88
+ if (isActive)
89
+ return;
90
+ isActive = true;
91
+ connectInternal();
92
+ }
93
+ function connectInternal() {
94
+ try {
95
+ const headers = {};
96
+ if (ctx.token)
97
+ headers.authorization = `Bearer ${ctx.token}`;
98
+ ws = new WebSocket(ctx.mcpUrl, { headers });
99
+ ws.on('open', () => {
100
+ const hello = {
101
+ type: 'hello',
102
+ id: newId(),
103
+ role: ctx.peerRole,
104
+ projectId: ctx.projectId,
105
+ parentProjectId: ctx.parentProjectId,
106
+ displayName: ctx.getDisplayName(),
107
+ buildId: ctx.getBuildId(),
108
+ };
109
+ send(hello);
110
+ });
111
+ ws.on('message', (raw) => {
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(raw.toString());
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ const result = frameSchema.safeParse(parsed);
120
+ if (!result.success)
121
+ return;
122
+ const frame = result.data;
123
+ if (frame.type === 'command')
124
+ void handleCommand(frame);
125
+ });
126
+ ws.on('error', () => {
127
+ // Server may not be running — best-effort metadata only.
128
+ });
129
+ ws.on('close', () => {
130
+ setTimeout(() => {
131
+ if (isActive)
132
+ connectInternal();
133
+ }, 2000);
134
+ });
135
+ }
136
+ catch {
137
+ /* swallow */
138
+ }
139
+ }
140
+ function disconnect() {
141
+ isActive = false;
142
+ ws?.close();
143
+ ws = undefined;
144
+ }
145
+ function emitEvent(name, payload) {
146
+ const event = {
147
+ type: 'event',
148
+ id: newId(),
149
+ projectId: ctx.projectId,
150
+ buildId: ctx.getBuildId(),
151
+ name,
152
+ ts: Date.now(),
153
+ payload,
154
+ };
155
+ send(event);
156
+ }
157
+ return {
158
+ connect,
159
+ disconnect,
160
+ emitEvent,
161
+ get isActive() {
162
+ return isActive;
163
+ },
164
+ };
165
+ }