@forgeax/engine-vite-plugin-rhi-debug 0.1.2
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 +202 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/atomic-tape-write.unit.test.d.ts +2 -0
- package/dist/__tests__/atomic-tape-write.unit.test.d.ts.map +1 -0
- package/dist/__tests__/define.test.d.ts +2 -0
- package/dist/__tests__/define.test.d.ts.map +1 -0
- package/dist/__tests__/provider-cardinality.unit.test.d.ts +2 -0
- package/dist/__tests__/provider-cardinality.unit.test.d.ts.map +1 -0
- package/dist/__tests__/raw-tape-provider.integration.test.d.ts +2 -0
- package/dist/__tests__/raw-tape-provider.integration.test.d.ts.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +170 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +64 -0
- package/src/__tests__/atomic-tape-write.unit.test.ts +57 -0
- package/src/__tests__/define.test.ts +107 -0
- package/src/__tests__/provider-cardinality.unit.test.ts +27 -0
- package/src/__tests__/raw-tape-provider.integration.test.ts +86 -0
- package/src/index.ts +252 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// @forgeax/engine-vite-plugin-rhi-debug -- serve-only raw tape transport.
|
|
2
|
+
//
|
|
3
|
+
// The plugin is a leaf: it validates one encoded v7 tape with the core decoder,
|
|
4
|
+
// persists one `.rhitape` file, and exposes no replay or report owner.
|
|
5
|
+
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { decodeTape } from '@forgeax/engine-rhi-debug';
|
|
10
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
11
|
+
|
|
12
|
+
export const RAW_TAPE_ROUTE = '/__forgeax-debug/tape' as const;
|
|
13
|
+
export const RHITAPE_MIME = 'application/x-forgeax-rhitape' as const;
|
|
14
|
+
|
|
15
|
+
const DEFINE_KEY = 'import.meta.env.FORGEAX_ENGINE_RHI_DEBUG';
|
|
16
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
17
|
+
|
|
18
|
+
export interface CaptureProvider {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CaptureProviderError =
|
|
23
|
+
| { readonly code: 'capture-target-unavailable'; readonly providerIds: readonly string[] }
|
|
24
|
+
| { readonly code: 'capture-target-ambiguous'; readonly providerIds: readonly string[] };
|
|
25
|
+
|
|
26
|
+
export type ViteProviderError =
|
|
27
|
+
| CaptureProviderError
|
|
28
|
+
| {
|
|
29
|
+
readonly code:
|
|
30
|
+
| 'capture-run-id-invalid'
|
|
31
|
+
| 'capture-mime-invalid'
|
|
32
|
+
| 'capture-tape-invalid'
|
|
33
|
+
| 'capture-artifact-write-failed';
|
|
34
|
+
readonly hint: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface RawTapeUpload {
|
|
38
|
+
readonly runId: string;
|
|
39
|
+
readonly contentType: string;
|
|
40
|
+
readonly bytes: Uint8Array;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RawTapeArtifactRef {
|
|
44
|
+
readonly kind: 'rhi-tape';
|
|
45
|
+
readonly digest: string;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RawTapeProviderOptions {
|
|
50
|
+
readonly rootDir: string;
|
|
51
|
+
readonly writeFile?: (path: string, bytes: Uint8Array) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RawTapeProvider {
|
|
55
|
+
accept(upload: RawTapeUpload): Promise<ProviderResult<RawTapeArtifactRef, ViteProviderError>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type ProviderResult<T, E> =
|
|
59
|
+
| { readonly ok: true; readonly value: T }
|
|
60
|
+
| { readonly ok: false; readonly error: E };
|
|
61
|
+
|
|
62
|
+
export function selectCaptureProvider(
|
|
63
|
+
providers: readonly CaptureProvider[],
|
|
64
|
+
): ProviderResult<CaptureProvider, CaptureProviderError> {
|
|
65
|
+
const providerIds = providers.map((provider) => provider.id);
|
|
66
|
+
if (providers.length === 0) {
|
|
67
|
+
return { ok: false, error: { code: 'capture-target-unavailable', providerIds } };
|
|
68
|
+
}
|
|
69
|
+
if (providers.length > 1) {
|
|
70
|
+
return { ok: false, error: { code: 'capture-target-ambiguous', providerIds } };
|
|
71
|
+
}
|
|
72
|
+
const provider = providers[0];
|
|
73
|
+
if (provider === undefined) {
|
|
74
|
+
return { ok: false, error: { code: 'capture-target-unavailable', providerIds } };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, value: provider };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createRawTapeProvider(options: RawTapeProviderOptions): RawTapeProvider {
|
|
80
|
+
const rootDir = resolve(options.rootDir);
|
|
81
|
+
const write =
|
|
82
|
+
options.writeFile ??
|
|
83
|
+
(async (path: string, bytes: Uint8Array) => {
|
|
84
|
+
await writeFile(path, bytes);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async accept(upload) {
|
|
89
|
+
if (!RUN_ID_PATTERN.test(upload.runId)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: {
|
|
93
|
+
code: 'capture-run-id-invalid',
|
|
94
|
+
hint: 'runId must contain only ASCII letters, numbers, underscore, or hyphen',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (upload.contentType !== RHITAPE_MIME) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: {
|
|
102
|
+
code: 'capture-mime-invalid',
|
|
103
|
+
hint: `content-type must be exactly ${RHITAPE_MIME}`,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const decoded = decodeTape(upload.bytes);
|
|
109
|
+
if (!decoded.ok) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: {
|
|
113
|
+
code: 'capture-tape-invalid',
|
|
114
|
+
hint: decoded.error.hint,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const digest = `sha256:${createHash('sha256').update(upload.bytes).digest('hex')}`;
|
|
120
|
+
const debugDir = join(rootDir, '.forgeax-debug');
|
|
121
|
+
const outDir = join(debugDir, upload.runId);
|
|
122
|
+
const finalPath = join(outDir, 'frame.rhitape');
|
|
123
|
+
const tempDir = join(debugDir, `.rhitape-${upload.runId}-${randomUUID()}`);
|
|
124
|
+
const debugDirExisted = await pathExists(debugDir);
|
|
125
|
+
const outDirExisted = await pathExists(outDir);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await mkdir(tempDir, { recursive: true });
|
|
129
|
+
await write(join(tempDir, 'frame.rhitape'), upload.bytes);
|
|
130
|
+
await mkdir(outDir, { recursive: true });
|
|
131
|
+
await rename(join(tempDir, 'frame.rhitape'), finalPath);
|
|
132
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
133
|
+
} catch {
|
|
134
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
135
|
+
if (!outDirExisted) await rm(outDir, { recursive: true, force: true });
|
|
136
|
+
if (!debugDirExisted) await rm(debugDir, { recursive: true, force: true });
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: {
|
|
140
|
+
code: 'capture-artifact-write-failed',
|
|
141
|
+
hint: 'the raw tape could not be written atomically; inspect the dev-server filesystem and retry',
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: true, value: { kind: 'rhi-tape', digest, path: finalPath } };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface RhiDebugPluginOptions {
|
|
152
|
+
readonly rootDir?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface MiddlewareRequest extends AsyncIterable<Uint8Array> {
|
|
156
|
+
readonly method?: string;
|
|
157
|
+
readonly url?: string;
|
|
158
|
+
readonly headers?: Readonly<Record<string, string | string[] | undefined>>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface MiddlewareResponse {
|
|
162
|
+
statusCode: number;
|
|
163
|
+
setHeader(name: string, value: string): void;
|
|
164
|
+
end(chunk?: string | Uint8Array): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sendJson(res: MiddlewareResponse, status: number, payload: unknown): void {
|
|
168
|
+
res.statusCode = status;
|
|
169
|
+
res.setHeader('Content-Type', 'application/json');
|
|
170
|
+
res.end(JSON.stringify(payload));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readRawBody(req: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
174
|
+
const chunks: Uint8Array[] = [];
|
|
175
|
+
let size = 0;
|
|
176
|
+
for await (const chunk of req) {
|
|
177
|
+
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
178
|
+
chunks.push(bytes);
|
|
179
|
+
size += bytes.byteLength;
|
|
180
|
+
}
|
|
181
|
+
const output = new Uint8Array(size);
|
|
182
|
+
let offset = 0;
|
|
183
|
+
for (const chunk of chunks) {
|
|
184
|
+
output.set(chunk, offset);
|
|
185
|
+
offset += chunk.byteLength;
|
|
186
|
+
}
|
|
187
|
+
return output;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function contentType(req: MiddlewareRequest): string {
|
|
191
|
+
const value = req.headers?.['content-type'];
|
|
192
|
+
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
196
|
+
try {
|
|
197
|
+
await access(path);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function vitePluginRhiDebug(options: RhiDebugPluginOptions = {}): Plugin {
|
|
205
|
+
return {
|
|
206
|
+
name: 'forgeax:rhi-debug',
|
|
207
|
+
|
|
208
|
+
config(_config, env) {
|
|
209
|
+
return {
|
|
210
|
+
define: { [DEFINE_KEY]: JSON.stringify(env.command === 'serve' ? '1' : '0') },
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
configureServer(server: ViteDevServer) {
|
|
215
|
+
const provider = createRawTapeProvider({ rootDir: options.rootDir ?? process.cwd() });
|
|
216
|
+
server.middlewares.use(async (request, response, next) => {
|
|
217
|
+
const req = request as MiddlewareRequest;
|
|
218
|
+
const res = response as unknown as MiddlewareResponse;
|
|
219
|
+
const url = new URL(req.url ?? '', 'http://localhost');
|
|
220
|
+
if (url.pathname !== RAW_TAPE_ROUTE) {
|
|
221
|
+
next();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (req.method !== 'POST') {
|
|
225
|
+
res.setHeader('Allow', 'POST');
|
|
226
|
+
sendJson(res, 405, {
|
|
227
|
+
error: 'method-not-allowed',
|
|
228
|
+
hint: `use POST ${RAW_TAPE_ROUTE}?runId=<id> with raw ${RHITAPE_MIME} bytes`,
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const runId = url.searchParams.get('runId') ?? '';
|
|
234
|
+
const bytes = await readRawBody(req);
|
|
235
|
+
const result = await provider.accept({ runId, contentType: contentType(req), bytes });
|
|
236
|
+
if (!result.ok) {
|
|
237
|
+
sendJson(
|
|
238
|
+
res,
|
|
239
|
+
result.error.code === 'capture-artifact-write-failed' ? 500 : 400,
|
|
240
|
+
result.error,
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
res.statusCode = 200;
|
|
245
|
+
res.setHeader('Content-Type', 'application/json');
|
|
246
|
+
res.end(JSON.stringify(result.value));
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default vitePluginRhiDebug;
|