@arcote.tech/arc-map 0.7.27
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/package.json +38 -0
- package/src/app/arc.tsx +92 -0
- package/src/app/contexts.tsx +72 -0
- package/src/app/detail-panel.tsx +164 -0
- package/src/app/index.tsx +107 -0
- package/src/app/layout.ts +350 -0
- package/src/app/map-view.tsx +225 -0
- package/src/app/mdx-view.tsx +74 -0
- package/src/app/nodes.tsx +478 -0
- package/src/app/ref-context.tsx +49 -0
- package/src/app/structure.tsx +95 -0
- package/src/app/subgraph.ts +127 -0
- package/src/app/theme.ts +67 -0
- package/src/app/usecase-layout.ts +249 -0
- package/src/app/usecase-model.ts +346 -0
- package/src/app/usecase-tree.tsx +104 -0
- package/src/app/usecase-view.tsx +173 -0
- package/src/index.ts +18 -0
- package/src/mapper.ts +392 -0
- package/src/module-index.ts +70 -0
- package/src/refs.ts +121 -0
- package/src/scan.ts +393 -0
- package/src/server.ts +262 -0
- package/src/types.ts +154 -0
- package/src/use-cases/discover.ts +109 -0
- package/tests/discover.test.ts +42 -0
- package/tests/fixtures/pkg/detail-page.tsx +11 -0
- package/tests/fixtures/pkg/direct.tsx +7 -0
- package/tests/fixtures/pkg/list-page.tsx +11 -0
- package/tests/fixtures/pkg/routes.ts +6 -0
- package/tests/fixtures/pkg/scope.ts +4 -0
- package/tests/fixtures/uc/pkgA/use-cases/bar.mdx +3 -0
- package/tests/fixtures/uc/pkgA/use-cases/sub/foo.mdx +9 -0
- package/tests/mapper.test.ts +115 -0
- package/tests/refs.test.ts +56 -0
- package/tests/scan.test.ts +71 -0
- package/tests/subgraph.test.ts +54 -0
- package/tests/usecase-model.test.ts +197 -0
- package/tsconfig.json +8 -0
package/src/scan.ts
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TSX scanner — statically maps React `useScope` usages to context elements.
|
|
3
|
+
*
|
|
4
|
+
* Strategy (convention-based, no full type-checker):
|
|
5
|
+
* Phase A — discover scope wrappers across the whole project:
|
|
6
|
+
* `usePlatformScope(ctx, "scope")` and `createScope("scope")` calls,
|
|
7
|
+
* recording the enclosing hook name -> scope.
|
|
8
|
+
* Phase B — per file, resolve scope-bearing variables and accessor variables,
|
|
9
|
+
* then collect `<accessor>.<element>.<method>()` chains in both the
|
|
10
|
+
* inline and via-variable shapes.
|
|
11
|
+
*
|
|
12
|
+
* Joining element name -> map node happens in the mapper; here we only emit
|
|
13
|
+
* `{ file, element, usage, scope, methods }`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { dirname, relative, resolve as resolvePath } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
Node,
|
|
19
|
+
Project,
|
|
20
|
+
SyntaxKind,
|
|
21
|
+
type CallExpression,
|
|
22
|
+
type SourceFile,
|
|
23
|
+
} from "ts-morph";
|
|
24
|
+
import type { MapComponent, MapComponentEdge } from "./types";
|
|
25
|
+
import { normalizePagePath } from "./refs";
|
|
26
|
+
|
|
27
|
+
export interface ScanInput {
|
|
28
|
+
/** Absolute glob(s) or file paths of TSX sources to scan. */
|
|
29
|
+
globs: string[];
|
|
30
|
+
/** Workspace root, used to relativize component ids. */
|
|
31
|
+
workspaceRoot: string;
|
|
32
|
+
/** Maps an absolute file path to its owning workspace package name. */
|
|
33
|
+
packageOf?: (absFile: string) => string | undefined;
|
|
34
|
+
/** Optional tsconfig for module resolution (not required for the AST pass). */
|
|
35
|
+
tsConfigFilePath?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ScanResult {
|
|
39
|
+
components: MapComponent[];
|
|
40
|
+
componentEdges: MapComponentEdge[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Usage = "query" | "mutation";
|
|
44
|
+
|
|
45
|
+
const SCOPE_FACTORY = "usePlatformScope";
|
|
46
|
+
const SCOPE_CREATE = "createScope";
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// AST helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function callName(call: CallExpression): string | undefined {
|
|
53
|
+
const expr = call.getExpression();
|
|
54
|
+
if (Node.isIdentifier(expr)) return expr.getText();
|
|
55
|
+
if (Node.isPropertyAccessExpression(expr)) return expr.getName();
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stringArg(call: CallExpression, index: number): string | undefined {
|
|
60
|
+
const arg = call.getArguments()[index];
|
|
61
|
+
if (arg && Node.isStringLiteral(arg)) return arg.getLiteralValue();
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Name of the function/variable that lexically encloses a node. */
|
|
66
|
+
function enclosingHookName(node: Node): string | undefined {
|
|
67
|
+
let cur: Node | undefined = node;
|
|
68
|
+
while (cur) {
|
|
69
|
+
if (Node.isFunctionDeclaration(cur)) return cur.getName();
|
|
70
|
+
if (Node.isVariableDeclaration(cur)) return cur.getName();
|
|
71
|
+
cur = cur.getParent();
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Phase A — project-wide scope wrapper discovery
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** hook name (e.g. `useContentScope`) -> scope name (e.g. `workspace`). */
|
|
81
|
+
function discoverScopeWrappers(sourceFiles: SourceFile[]): Map<string, string> {
|
|
82
|
+
const wrappers = new Map<string, string>();
|
|
83
|
+
for (const sf of sourceFiles) {
|
|
84
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
85
|
+
const name = callName(call);
|
|
86
|
+
let scope: string | undefined;
|
|
87
|
+
if (name === SCOPE_FACTORY) scope = stringArg(call, 1);
|
|
88
|
+
else if (name === SCOPE_CREATE) scope = stringArg(call, 0);
|
|
89
|
+
if (!scope) continue;
|
|
90
|
+
const hook = enclosingHookName(call);
|
|
91
|
+
if (hook) wrappers.set(hook, scope);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return wrappers;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Phase B — per-file resolution
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
interface FileScopeState {
|
|
102
|
+
/** variable name -> scope name (e.g. `scope` -> `workspace`). */
|
|
103
|
+
scopeVars: Map<string, string | undefined>;
|
|
104
|
+
/** accessor variable -> { usage, scope } (e.g. `commands` -> mutation). */
|
|
105
|
+
accessorVars: Map<string, { usage: Usage; scope?: string }>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve the scope name of an expression that yields a scope object. */
|
|
109
|
+
function resolveScopeExpr(
|
|
110
|
+
expr: Node,
|
|
111
|
+
wrappers: Map<string, string>,
|
|
112
|
+
scopeVars: Map<string, string | undefined>,
|
|
113
|
+
): string | undefined {
|
|
114
|
+
if (Node.isIdentifier(expr)) {
|
|
115
|
+
if (scopeVars.has(expr.getText())) return scopeVars.get(expr.getText());
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (Node.isCallExpression(expr)) {
|
|
119
|
+
const name = callName(expr);
|
|
120
|
+
if (name === SCOPE_FACTORY) return stringArg(expr, 1);
|
|
121
|
+
if (name && wrappers.has(name)) return wrappers.get(name);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** If `call` is `<scopeExpr>.useQuery()/.useMutation()`, return its usage + scope. */
|
|
127
|
+
function asAccessorCall(
|
|
128
|
+
call: CallExpression,
|
|
129
|
+
wrappers: Map<string, string>,
|
|
130
|
+
scopeVars: Map<string, string | undefined>,
|
|
131
|
+
): { usage: Usage; scope?: string } | undefined {
|
|
132
|
+
const expr = call.getExpression();
|
|
133
|
+
if (!Node.isPropertyAccessExpression(expr)) return undefined;
|
|
134
|
+
const m = expr.getName();
|
|
135
|
+
if (m !== "useQuery" && m !== "useMutation") return undefined;
|
|
136
|
+
const usage: Usage = m === "useQuery" ? "query" : "mutation";
|
|
137
|
+
return { usage, scope: resolveScopeExpr(expr.getExpression(), wrappers, scopeVars) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildFileState(
|
|
141
|
+
sf: SourceFile,
|
|
142
|
+
wrappers: Map<string, string>,
|
|
143
|
+
): FileScopeState {
|
|
144
|
+
const scopeVars = new Map<string, string | undefined>();
|
|
145
|
+
const accessorVars = new Map<string, { usage: Usage; scope?: string }>();
|
|
146
|
+
|
|
147
|
+
for (const decl of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
148
|
+
const init = decl.getInitializer();
|
|
149
|
+
if (!init) continue;
|
|
150
|
+
const nameNode = decl.getNameNode();
|
|
151
|
+
|
|
152
|
+
// const scope = useXxxScope() | const scope = usePlatformScope(ctx, "user")
|
|
153
|
+
if (Node.isIdentifier(nameNode) && Node.isCallExpression(init)) {
|
|
154
|
+
const name = callName(init);
|
|
155
|
+
if (name === SCOPE_FACTORY) {
|
|
156
|
+
scopeVars.set(nameNode.getText(), stringArg(init, 1));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (name && wrappers.has(name)) {
|
|
160
|
+
scopeVars.set(nameNode.getText(), wrappers.get(name));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// const commands = scope.useMutation() | const q = scope.useQuery()
|
|
166
|
+
if (Node.isCallExpression(init)) {
|
|
167
|
+
const accessor = asAccessorCall(init, wrappers, scopeVars);
|
|
168
|
+
if (accessor && Node.isIdentifier(nameNode)) {
|
|
169
|
+
accessorVars.set(nameNode.getText(), accessor);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { scopeVars, accessorVars };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Usage collection
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
interface RawUsage {
|
|
182
|
+
element: string;
|
|
183
|
+
method?: string;
|
|
184
|
+
usage: Usage;
|
|
185
|
+
scope?: string;
|
|
186
|
+
line: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Given the expression node that yields the accessor (the object before
|
|
191
|
+
* `.<element>`), return the usage + scope, or undefined if it isn't an accessor.
|
|
192
|
+
*/
|
|
193
|
+
function resolveAccessor(
|
|
194
|
+
obj: Node,
|
|
195
|
+
state: FileScopeState,
|
|
196
|
+
wrappers: Map<string, string>,
|
|
197
|
+
): { usage: Usage; scope?: string } | undefined {
|
|
198
|
+
// Inline: (scope.useQuery()).contentTopic
|
|
199
|
+
if (Node.isCallExpression(obj)) {
|
|
200
|
+
return asAccessorCall(obj, wrappers, state.scopeVars);
|
|
201
|
+
}
|
|
202
|
+
// Via variable: commands.creatorWorkspaces
|
|
203
|
+
if (Node.isIdentifier(obj)) {
|
|
204
|
+
return state.accessorVars.get(obj.getText());
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function collectUsages(
|
|
210
|
+
sf: SourceFile,
|
|
211
|
+
state: FileScopeState,
|
|
212
|
+
wrappers: Map<string, string>,
|
|
213
|
+
): RawUsage[] {
|
|
214
|
+
const usages: RawUsage[] = [];
|
|
215
|
+
|
|
216
|
+
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
217
|
+
// pa = <obj>.<element>
|
|
218
|
+
const obj = pa.getExpression();
|
|
219
|
+
const elementName = pa.getName();
|
|
220
|
+
if (elementName === "useQuery" || elementName === "useMutation") continue;
|
|
221
|
+
|
|
222
|
+
const accessor = resolveAccessor(obj, state, wrappers);
|
|
223
|
+
if (!accessor) continue;
|
|
224
|
+
|
|
225
|
+
// Optional method: <obj>.<element>.<method>(...)
|
|
226
|
+
let method: string | undefined;
|
|
227
|
+
const parent = pa.getParent();
|
|
228
|
+
if (parent && Node.isPropertyAccessExpression(parent)) {
|
|
229
|
+
method = parent.getName();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
usages.push({
|
|
233
|
+
element: elementName,
|
|
234
|
+
method,
|
|
235
|
+
usage: accessor.usage,
|
|
236
|
+
scope: accessor.scope,
|
|
237
|
+
line: pa.getStartLineNumber(),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return usages;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Page() ↔ component-file linking
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/** Resolve the import specifier that backs a component identifier — both the
|
|
249
|
+
* `const X = lazy(() => import("./x"))` and `import X from "./x"` shapes. */
|
|
250
|
+
function importSpecifierForIdent(sf: SourceFile, name: string): string | undefined {
|
|
251
|
+
const decl = sf.getVariableDeclaration(name);
|
|
252
|
+
const init = decl?.getInitializer();
|
|
253
|
+
if (init) {
|
|
254
|
+
for (const call of init.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
255
|
+
if (call.getExpression().getKind() === SyntaxKind.ImportKeyword) {
|
|
256
|
+
const a = call.getArguments()[0];
|
|
257
|
+
if (a && Node.isStringLiteral(a)) return a.getLiteralValue();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
262
|
+
if (imp.getDefaultImport()?.getText() === name)
|
|
263
|
+
return imp.getModuleSpecifierValue();
|
|
264
|
+
if (imp.getNamedImports().some((ni) => ni.getName() === name))
|
|
265
|
+
return imp.getModuleSpecifierValue();
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Match a relative import specifier (resolved against the declaring file) to a
|
|
271
|
+
* scanned component id, trying common extensions / index files. */
|
|
272
|
+
function matchComponentId(
|
|
273
|
+
specifier: string,
|
|
274
|
+
declFileAbs: string,
|
|
275
|
+
workspaceRoot: string,
|
|
276
|
+
componentIds: Set<string>,
|
|
277
|
+
): string | undefined {
|
|
278
|
+
if (!specifier.startsWith(".")) return undefined;
|
|
279
|
+
const abs = resolvePath(dirname(declFileAbs), specifier);
|
|
280
|
+
const rel = relative(workspaceRoot, abs).replace(/\\/g, "/");
|
|
281
|
+
for (const c of [rel, `${rel}.tsx`, `${rel}.ts`, `${rel}/index.tsx`, `${rel}/index.ts`]) {
|
|
282
|
+
if (componentIds.has(c)) return c;
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** component-file id -> page route path, for every `page("/path", Comp)` whose
|
|
288
|
+
* component resolves to a scanned file. */
|
|
289
|
+
function discoverPageFiles(
|
|
290
|
+
sourceFiles: SourceFile[],
|
|
291
|
+
workspaceRoot: string,
|
|
292
|
+
componentIds: Set<string>,
|
|
293
|
+
): Map<string, string> {
|
|
294
|
+
const out = new Map<string, string>();
|
|
295
|
+
for (const sf of sourceFiles) {
|
|
296
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
297
|
+
if (callName(call) !== "page") continue;
|
|
298
|
+
const path = stringArg(call, 0);
|
|
299
|
+
const compArg = call.getArguments()[1];
|
|
300
|
+
if (!path || !compArg || !Node.isIdentifier(compArg)) continue;
|
|
301
|
+
const spec = importSpecifierForIdent(sf, compArg.getText());
|
|
302
|
+
if (!spec) continue;
|
|
303
|
+
const compId = matchComponentId(spec, sf.getFilePath(), workspaceRoot, componentIds);
|
|
304
|
+
if (compId) out.set(compId, normalizePagePath(path));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Public API
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
export function scanScopeUsages(input: ScanInput): ScanResult {
|
|
315
|
+
const project = new Project({
|
|
316
|
+
skipAddingFilesFromTsConfig: true,
|
|
317
|
+
skipFileDependencyResolution: true,
|
|
318
|
+
compilerOptions: { allowJs: true, jsx: 4 /* react-jsx */ },
|
|
319
|
+
});
|
|
320
|
+
project.addSourceFilesAtPaths(input.globs);
|
|
321
|
+
const sourceFiles = project.getSourceFiles();
|
|
322
|
+
|
|
323
|
+
const wrappers = discoverScopeWrappers(sourceFiles);
|
|
324
|
+
|
|
325
|
+
const components = new Map<string, MapComponent>();
|
|
326
|
+
// key: file|element|usage|scope -> edge accumulator
|
|
327
|
+
const edges = new Map<string, MapComponentEdge>();
|
|
328
|
+
|
|
329
|
+
for (const sf of sourceFiles) {
|
|
330
|
+
const abs = sf.getFilePath();
|
|
331
|
+
const state = buildFileState(sf, wrappers);
|
|
332
|
+
const usages = collectUsages(sf, state, wrappers);
|
|
333
|
+
if (usages.length === 0) continue;
|
|
334
|
+
|
|
335
|
+
const rel = relative(input.workspaceRoot, abs);
|
|
336
|
+
const compId = rel;
|
|
337
|
+
if (!components.has(compId)) {
|
|
338
|
+
components.set(compId, {
|
|
339
|
+
id: compId,
|
|
340
|
+
file: rel,
|
|
341
|
+
name: baseName(rel),
|
|
342
|
+
package: input.packageOf?.(abs),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const u of usages) {
|
|
347
|
+
const key = `${compId}|${u.element}|${u.usage}|${u.scope ?? ""}`;
|
|
348
|
+
const existing = edges.get(key);
|
|
349
|
+
if (existing) {
|
|
350
|
+
if (u.method && !existing.methods.includes(u.method)) existing.methods.push(u.method);
|
|
351
|
+
if (u.line < (existing.line ?? Infinity)) existing.line = u.line;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
edges.set(key, {
|
|
355
|
+
id: key,
|
|
356
|
+
source: compId,
|
|
357
|
+
target: u.element,
|
|
358
|
+
usage: u.usage,
|
|
359
|
+
scope: u.scope,
|
|
360
|
+
methods: u.method ? [u.method] : [],
|
|
361
|
+
line: u.line,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Link page() registrations: re-key a page component's scope-usage edges onto
|
|
367
|
+
// the page PATH so they attach to the page node (buildPageNodes, id = path) —
|
|
368
|
+
// the screen connects to the elements it uses. The now-orphan file node drops.
|
|
369
|
+
const pageByFile = discoverPageFiles(
|
|
370
|
+
sourceFiles,
|
|
371
|
+
input.workspaceRoot,
|
|
372
|
+
new Set(components.keys()),
|
|
373
|
+
);
|
|
374
|
+
if (pageByFile.size) {
|
|
375
|
+
for (const e of edges.values()) {
|
|
376
|
+
const path = pageByFile.get(e.source);
|
|
377
|
+
if (!path) continue;
|
|
378
|
+
e.source = path;
|
|
379
|
+
e.id = `${path}|${e.target}|${e.usage}|${e.scope ?? ""}`;
|
|
380
|
+
}
|
|
381
|
+
for (const fileId of pageByFile.keys()) components.delete(fileId);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
components: [...components.values()],
|
|
386
|
+
componentEdges: [...edges.values()],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function baseName(rel: string): string {
|
|
391
|
+
const file = rel.split("/").pop() ?? rel;
|
|
392
|
+
return file.replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
393
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arc Context Map dev server.
|
|
3
|
+
*
|
|
4
|
+
* A small standalone Bun.serve that:
|
|
5
|
+
* - bundles the React Flow front once via Bun.build,
|
|
6
|
+
* - serves the shell HTML + bundle,
|
|
7
|
+
* - computes `/api/map` on demand from the live context (+ cached TSX scan),
|
|
8
|
+
* - exposes an SSE stream so the UI auto-refreshes when the host calls
|
|
9
|
+
* {@link MapServer.notifyReload} (wired to platform-dev's file watcher).
|
|
10
|
+
*
|
|
11
|
+
* Decoupled from the CLI: the host injects how to read the live context, the
|
|
12
|
+
* module index, and the scan globs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import type { ArcContextAny } from "@arcote.tech/arc";
|
|
18
|
+
import { buildModuleIndex, buildPageNodes } from "./module-index";
|
|
19
|
+
import { buildMapJson, type ModuleInfo } from "./mapper";
|
|
20
|
+
import { scanScopeUsages, type ScanInput } from "./scan";
|
|
21
|
+
import { discoverUseCases, type UseCasePackage } from "./use-cases/discover";
|
|
22
|
+
import type { MapJson, UseCaseEntry } from "./types";
|
|
23
|
+
|
|
24
|
+
export interface MapServerOptions {
|
|
25
|
+
/** Returns the current live context (recomputed by the host on rebuild). */
|
|
26
|
+
getContext: () => ArcContextAny | null | undefined;
|
|
27
|
+
/** TSX scan configuration (globs + workspace root + package attribution). */
|
|
28
|
+
scan: ScanInput;
|
|
29
|
+
/** Preferred port; auto-increments if busy. Defaults to 5006. */
|
|
30
|
+
port?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optional element->module index provider. Defaults to reading the platform
|
|
33
|
+
* registry via {@link buildModuleIndex}.
|
|
34
|
+
*/
|
|
35
|
+
getModuleIndex?: () => Map<string, ModuleInfo>;
|
|
36
|
+
/** Override the React app entry (absolute path). Mostly for tests. */
|
|
37
|
+
appEntry?: string;
|
|
38
|
+
/** Packages to scan for `use-cases/**\/*.mdx`. Enables the use-case mode. */
|
|
39
|
+
useCasePackages?: UseCasePackage[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MapServer {
|
|
43
|
+
server: ReturnType<typeof Bun.serve>;
|
|
44
|
+
port: number;
|
|
45
|
+
url: string;
|
|
46
|
+
/** Invalidate the scan cache and tell connected UIs to refetch. */
|
|
47
|
+
notifyReload: () => void;
|
|
48
|
+
stop: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_PORT = 5006;
|
|
52
|
+
const MAX_PORT_TRIES = 20;
|
|
53
|
+
|
|
54
|
+
function shellHtml(): string {
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8" />
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
60
|
+
<title>Arc Context Map</title>
|
|
61
|
+
<link rel="stylesheet" href="/app.css" />
|
|
62
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
63
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
|
64
|
+
<style>
|
|
65
|
+
html, body, #root { height: 100%; margin: 0; }
|
|
66
|
+
body {
|
|
67
|
+
color: #2a173f;
|
|
68
|
+
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
69
|
+
background-color: #ffffff;
|
|
70
|
+
background-image: linear-gradient(135deg,#dbeafe 0%,#ffffff 48%,#f3e8ff 100%);
|
|
71
|
+
background-attachment: fixed;
|
|
72
|
+
}
|
|
73
|
+
.react-flow__attribution { display: none; }
|
|
74
|
+
.react-flow__controls-button {
|
|
75
|
+
background: rgba(255,255,255,0.8) !important;
|
|
76
|
+
border-bottom: 1px solid rgba(168,85,247,0.25) !important;
|
|
77
|
+
color: #2a173f !important;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<div id="root"></div>
|
|
83
|
+
<script type="module" src="/app.js"></script>
|
|
84
|
+
</body>
|
|
85
|
+
</html>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function buildApp(appEntry: string): Promise<{ js: string; css: string }> {
|
|
89
|
+
const result = await Bun.build({
|
|
90
|
+
entrypoints: [appEntry],
|
|
91
|
+
target: "browser",
|
|
92
|
+
minify: false,
|
|
93
|
+
define: { "process.env.NODE_ENV": '"development"' },
|
|
94
|
+
});
|
|
95
|
+
if (!result.success) {
|
|
96
|
+
const msg = result.logs.map((l) => String(l)).join("\n");
|
|
97
|
+
throw new Error(`arc-map: failed to build front bundle:\n${msg}`);
|
|
98
|
+
}
|
|
99
|
+
let js = "";
|
|
100
|
+
let css = "";
|
|
101
|
+
for (const out of result.outputs) {
|
|
102
|
+
if (out.path.endsWith(".css")) css += await out.text();
|
|
103
|
+
else if (out.path.endsWith(".js")) js += await out.text();
|
|
104
|
+
}
|
|
105
|
+
return { js, css };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CORS = {
|
|
109
|
+
"Access-Control-Allow-Origin": "*",
|
|
110
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
111
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export async function startMapServer(opts: MapServerOptions): Promise<MapServer> {
|
|
115
|
+
const appEntry =
|
|
116
|
+
opts.appEntry ?? fileURLToPath(new URL("./app/index.tsx", import.meta.url));
|
|
117
|
+
const { js, css } = await buildApp(appEntry);
|
|
118
|
+
|
|
119
|
+
// Cached TSX scan — recomputed lazily, invalidated on notifyReload.
|
|
120
|
+
let scanCache: ReturnType<typeof scanScopeUsages> | null = null;
|
|
121
|
+
const getScan = () => {
|
|
122
|
+
if (!scanCache) scanCache = scanScopeUsages(opts.scan);
|
|
123
|
+
return scanCache;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const buildPayload = (): MapJson => {
|
|
127
|
+
const context = opts.getContext();
|
|
128
|
+
if (!context) {
|
|
129
|
+
return { spaces: [], elements: [], edges: [], components: [], componentEdges: [] };
|
|
130
|
+
}
|
|
131
|
+
const scan = getScan();
|
|
132
|
+
const moduleByElement = (opts.getModuleIndex ?? buildModuleIndex)();
|
|
133
|
+
return buildMapJson({
|
|
134
|
+
context,
|
|
135
|
+
moduleByElement,
|
|
136
|
+
componentEdges: scan.componentEdges,
|
|
137
|
+
components: scan.components,
|
|
138
|
+
pages: buildPageNodes(),
|
|
139
|
+
generatedAt: new Date().toISOString(),
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Cached use-case discovery — invalidated on notifyReload (file changes).
|
|
144
|
+
let useCaseCache: ReturnType<typeof discoverUseCases> | null = null;
|
|
145
|
+
const getUseCases = () => {
|
|
146
|
+
if (!useCaseCache) useCaseCache = discoverUseCases(opts.useCasePackages ?? []);
|
|
147
|
+
return useCaseCache;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const sseClients = new Set<ReadableStreamDefaultController>();
|
|
151
|
+
const notifyReload = () => {
|
|
152
|
+
scanCache = null;
|
|
153
|
+
useCaseCache = null;
|
|
154
|
+
for (const c of sseClients) {
|
|
155
|
+
try {
|
|
156
|
+
c.enqueue("data: reload\n\n");
|
|
157
|
+
} catch {
|
|
158
|
+
sseClients.delete(c);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handler = (req: Request): Response => {
|
|
164
|
+
const url = new URL(req.url);
|
|
165
|
+
if (req.method === "OPTIONS") return new Response(null, { headers: CORS });
|
|
166
|
+
|
|
167
|
+
switch (url.pathname) {
|
|
168
|
+
case "/app.js":
|
|
169
|
+
return new Response(js, {
|
|
170
|
+
headers: { "Content-Type": "text/javascript", "Cache-Control": "no-cache" },
|
|
171
|
+
});
|
|
172
|
+
case "/app.css":
|
|
173
|
+
return new Response(css, {
|
|
174
|
+
headers: { "Content-Type": "text/css", "Cache-Control": "no-cache" },
|
|
175
|
+
});
|
|
176
|
+
case "/api/map": {
|
|
177
|
+
let body: string;
|
|
178
|
+
try {
|
|
179
|
+
body = JSON.stringify(buildPayload());
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return new Response(
|
|
182
|
+
JSON.stringify({ error: (e as Error).message }),
|
|
183
|
+
{ status: 500, headers: { ...CORS, "Content-Type": "application/json" } },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return new Response(body, {
|
|
187
|
+
headers: { ...CORS, "Content-Type": "application/json", "Cache-Control": "no-cache" },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
case "/api/use-cases": {
|
|
191
|
+
const list: UseCaseEntry[] = getUseCases().map(
|
|
192
|
+
({ absPath, ...e }) => e,
|
|
193
|
+
);
|
|
194
|
+
return new Response(JSON.stringify({ useCases: list }), {
|
|
195
|
+
headers: { ...CORS, "Content-Type": "application/json", "Cache-Control": "no-cache" },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
case "/api/use-cases/raw": {
|
|
199
|
+
const id = url.searchParams.get("id");
|
|
200
|
+
const hit = id ? getUseCases().find((u) => u.id === id) : undefined;
|
|
201
|
+
if (!hit) {
|
|
202
|
+
return new Response("Not Found", { status: 404, headers: CORS });
|
|
203
|
+
}
|
|
204
|
+
let text: string;
|
|
205
|
+
try {
|
|
206
|
+
text = readFileSync(hit.absPath, "utf-8");
|
|
207
|
+
} catch (e) {
|
|
208
|
+
return new Response((e as Error).message, { status: 500, headers: CORS });
|
|
209
|
+
}
|
|
210
|
+
return new Response(text, {
|
|
211
|
+
headers: { ...CORS, "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "no-cache" },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
case "/api/reload-stream": {
|
|
215
|
+
const stream = new ReadableStream({
|
|
216
|
+
start(controller) {
|
|
217
|
+
sseClients.add(controller);
|
|
218
|
+
controller.enqueue("data: connected\n\n");
|
|
219
|
+
},
|
|
220
|
+
cancel(controller) {
|
|
221
|
+
sseClients.delete(controller);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
return new Response(stream, {
|
|
225
|
+
headers: {
|
|
226
|
+
...CORS,
|
|
227
|
+
"Content-Type": "text/event-stream",
|
|
228
|
+
"Cache-Control": "no-cache",
|
|
229
|
+
Connection: "keep-alive",
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
return new Response(shellHtml(), {
|
|
235
|
+
headers: { "Content-Type": "text/html" },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Port auto-increment.
|
|
241
|
+
const basePort = opts.port ?? DEFAULT_PORT;
|
|
242
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
243
|
+
let port = basePort;
|
|
244
|
+
for (let i = 0; i < MAX_PORT_TRIES; i++) {
|
|
245
|
+
try {
|
|
246
|
+
server = Bun.serve({ port: basePort + i, fetch: handler });
|
|
247
|
+
port = basePort + i;
|
|
248
|
+
break;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (i === MAX_PORT_TRIES - 1) throw e;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!server) throw new Error("arc-map: could not bind a port");
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
server,
|
|
257
|
+
port,
|
|
258
|
+
url: `http://localhost:${port}`,
|
|
259
|
+
notifyReload,
|
|
260
|
+
stop: () => server!.stop(true),
|
|
261
|
+
};
|
|
262
|
+
}
|