@neat.is/core 0.3.8 → 0.4.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/dist/{chunk-LQ3JFBTX.js → chunk-CB2UK4EH.js} +139 -41
- package/dist/chunk-CB2UK4EH.js.map +1 -0
- package/dist/{chunk-V4TU7OKZ.js → chunk-D5PIJFBE.js} +2 -2
- package/dist/{chunk-7TYESDAI.js → chunk-KYRIQIPG.js} +48 -11
- package/dist/chunk-KYRIQIPG.js.map +1 -0
- package/dist/{chunk-CZ3T6TE2.js → chunk-NTQHMXWE.js} +239 -71
- package/dist/chunk-NTQHMXWE.js.map +1 -0
- package/dist/cli.cjs +1209 -176
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +4 -1
- package/dist/cli.d.ts +4 -1
- package/dist/cli.js +940 -99
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +418 -122
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +4 -4
- package/dist/neatd.cjs +427 -131
- package/dist/neatd.cjs.map +1 -1
- package/dist/neatd.js +3 -3
- package/dist/{otel-grpc-S3AENOZ6.js → otel-grpc-QTX2YQJZ.js} +3 -3
- package/dist/server.cjs +401 -203
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +5 -4
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-7TYESDAI.js.map +0 -1
- package/dist/chunk-CZ3T6TE2.js.map +0 -1
- package/dist/chunk-LQ3JFBTX.js.map +0 -1
- /package/dist/{chunk-V4TU7OKZ.js.map → chunk-D5PIJFBE.js.map} +0 -0
- /package/dist/{otel-grpc-S3AENOZ6.js.map → otel-grpc-QTX2YQJZ.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -55,14 +55,19 @@ function mountBearerAuth(app, opts) {
|
|
|
55
55
|
if (opts.trustProxy) return;
|
|
56
56
|
const expected = Buffer.from(opts.token, "utf8");
|
|
57
57
|
const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
|
|
58
|
+
const publicRead = opts.publicRead === true;
|
|
58
59
|
app.addHook("preHandler", (req, reply, done) => {
|
|
59
|
-
const
|
|
60
|
+
const path38 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
|
|
60
61
|
for (const suffix of suffixes) {
|
|
61
|
-
if (
|
|
62
|
+
if (path38 === suffix || path38.endsWith(suffix)) {
|
|
62
63
|
done();
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {
|
|
68
|
+
done();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
66
71
|
const header = req.headers.authorization;
|
|
67
72
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) {
|
|
68
73
|
void reply.code(401).send({ error: "unauthorized" });
|
|
@@ -76,16 +81,21 @@ function mountBearerAuth(app, opts) {
|
|
|
76
81
|
done();
|
|
77
82
|
});
|
|
78
83
|
}
|
|
84
|
+
function parseBoolEnv(v) {
|
|
85
|
+
if (!v) return false;
|
|
86
|
+
return v === "true" || v === "1";
|
|
87
|
+
}
|
|
79
88
|
function readAuthEnv(env = process.env) {
|
|
80
89
|
const t = env.NEAT_AUTH_TOKEN;
|
|
81
90
|
const ot = env.NEAT_OTEL_TOKEN;
|
|
82
91
|
return {
|
|
83
92
|
authToken: t && t.length > 0 ? t : void 0,
|
|
84
93
|
otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
|
|
85
|
-
trustProxy: env.NEAT_AUTH_PROXY === "true"
|
|
94
|
+
trustProxy: env.NEAT_AUTH_PROXY === "true",
|
|
95
|
+
publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
|
|
86
96
|
};
|
|
87
97
|
}
|
|
88
|
-
var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
|
|
98
|
+
var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
|
|
89
99
|
var init_auth = __esm({
|
|
90
100
|
"src/auth.ts"() {
|
|
91
101
|
"use strict";
|
|
@@ -105,7 +115,13 @@ var init_auth = __esm({
|
|
|
105
115
|
this.name = "BindAuthorityError";
|
|
106
116
|
}
|
|
107
117
|
};
|
|
108
|
-
|
|
118
|
+
PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
119
|
+
DEFAULT_UNAUTH_SUFFIXES = [
|
|
120
|
+
"/health",
|
|
121
|
+
"/healthz",
|
|
122
|
+
"/readyz",
|
|
123
|
+
"/api/config"
|
|
124
|
+
];
|
|
109
125
|
}
|
|
110
126
|
});
|
|
111
127
|
|
|
@@ -308,6 +324,15 @@ function isoFromUnixNano(nanos) {
|
|
|
308
324
|
return void 0;
|
|
309
325
|
}
|
|
310
326
|
}
|
|
327
|
+
function pickEnv(spanAttrs, resourceAttrs) {
|
|
328
|
+
for (const attrs of [spanAttrs, resourceAttrs]) {
|
|
329
|
+
for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
|
|
330
|
+
const v = attrs[key];
|
|
331
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return ENV_FALLBACK;
|
|
335
|
+
}
|
|
311
336
|
function parseOtlpRequest(body) {
|
|
312
337
|
const out = [];
|
|
313
338
|
for (const rs of body.resourceSpans ?? []) {
|
|
@@ -327,6 +352,7 @@ function parseOtlpRequest(body) {
|
|
|
327
352
|
endTimeUnixNano: span.endTimeUnixNano ?? "0",
|
|
328
353
|
startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
|
|
329
354
|
durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
|
|
355
|
+
env: pickEnv(attrs, resourceAttrs),
|
|
330
356
|
attributes: attrs,
|
|
331
357
|
dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
|
|
332
358
|
dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
|
|
@@ -473,7 +499,7 @@ function logSpanHandler(span) {
|
|
|
473
499
|
`otel: ${span.service} ${span.name} parent=${parent} status=${status2}${db}`
|
|
474
500
|
);
|
|
475
501
|
}
|
|
476
|
-
var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
|
|
502
|
+
var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
|
|
477
503
|
var init_otel = __esm({
|
|
478
504
|
"src/otel.ts"() {
|
|
479
505
|
"use strict";
|
|
@@ -483,6 +509,9 @@ var init_otel = __esm({
|
|
|
483
509
|
import_fastify2 = __toESM(require("fastify"), 1);
|
|
484
510
|
import_protobufjs = __toESM(require("protobufjs"), 1);
|
|
485
511
|
init_auth();
|
|
512
|
+
ENV_ATTR_CANONICAL = "deployment.environment.name";
|
|
513
|
+
ENV_ATTR_COMPAT = "deployment.environment";
|
|
514
|
+
ENV_FALLBACK = "unknown";
|
|
486
515
|
exportTraceServiceRequestType = null;
|
|
487
516
|
exportTraceServiceResponseType = null;
|
|
488
517
|
cachedProtobufResponseBody = null;
|
|
@@ -994,19 +1023,19 @@ function confidenceFromMix(edges, now = Date.now()) {
|
|
|
994
1023
|
function longestIncomingWalk(graph, start, maxDepth) {
|
|
995
1024
|
let best = { path: [start], edges: [] };
|
|
996
1025
|
const visited = /* @__PURE__ */ new Set([start]);
|
|
997
|
-
function step(node,
|
|
998
|
-
if (
|
|
999
|
-
best = { path: [...
|
|
1026
|
+
function step(node, path38, edges) {
|
|
1027
|
+
if (path38.length > best.path.length) {
|
|
1028
|
+
best = { path: [...path38], edges: [...edges] };
|
|
1000
1029
|
}
|
|
1001
|
-
if (
|
|
1030
|
+
if (path38.length - 1 >= maxDepth) return;
|
|
1002
1031
|
const incoming = bestEdgeBySource(graph, graph.inboundEdges(node));
|
|
1003
1032
|
for (const [srcId, edge] of incoming) {
|
|
1004
1033
|
if (visited.has(srcId)) continue;
|
|
1005
1034
|
visited.add(srcId);
|
|
1006
|
-
|
|
1035
|
+
path38.push(srcId);
|
|
1007
1036
|
edges.push(edge);
|
|
1008
|
-
step(srcId,
|
|
1009
|
-
|
|
1037
|
+
step(srcId, path38, edges);
|
|
1038
|
+
path38.pop();
|
|
1010
1039
|
edges.pop();
|
|
1011
1040
|
visited.delete(srcId);
|
|
1012
1041
|
}
|
|
@@ -1579,52 +1608,64 @@ function cacheSpanService(span, now) {
|
|
|
1579
1608
|
if (!span.traceId || !span.spanId) return;
|
|
1580
1609
|
const key = parentSpanKey(span.traceId, span.spanId);
|
|
1581
1610
|
parentSpanCache.delete(key);
|
|
1582
|
-
parentSpanCache.set(key, {
|
|
1611
|
+
parentSpanCache.set(key, {
|
|
1612
|
+
service: span.service,
|
|
1613
|
+
env: span.env ?? "unknown",
|
|
1614
|
+
expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
|
|
1615
|
+
});
|
|
1583
1616
|
while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
|
|
1584
1617
|
const oldest = parentSpanCache.keys().next().value;
|
|
1585
1618
|
if (!oldest) break;
|
|
1586
1619
|
parentSpanCache.delete(oldest);
|
|
1587
1620
|
}
|
|
1588
1621
|
}
|
|
1589
|
-
function
|
|
1622
|
+
function lookupParentSpan(traceId, parentSpanId, now) {
|
|
1590
1623
|
const entry = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
|
|
1591
1624
|
if (!entry) return null;
|
|
1592
1625
|
if (entry.expiresAt <= now) {
|
|
1593
1626
|
parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
|
|
1594
1627
|
return null;
|
|
1595
1628
|
}
|
|
1596
|
-
return entry.service;
|
|
1629
|
+
return { service: entry.service, env: entry.env };
|
|
1597
1630
|
}
|
|
1598
|
-
function resolveServiceId(graph, host) {
|
|
1599
|
-
const
|
|
1600
|
-
if (graph.hasNode(
|
|
1601
|
-
|
|
1631
|
+
function resolveServiceId(graph, host, env) {
|
|
1632
|
+
const envTagged = (0, import_types3.serviceId)(host, env);
|
|
1633
|
+
if (graph.hasNode(envTagged)) return envTagged;
|
|
1634
|
+
const envLess = (0, import_types3.serviceId)(host);
|
|
1635
|
+
if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
|
|
1636
|
+
let sameEnv = null;
|
|
1637
|
+
let envLessMatch = null;
|
|
1638
|
+
let anyMatch = null;
|
|
1602
1639
|
graph.forEachNode((id, attrs) => {
|
|
1603
|
-
if (
|
|
1640
|
+
if (sameEnv) return;
|
|
1604
1641
|
const a = attrs;
|
|
1605
1642
|
if (a.type !== import_types3.NodeType.ServiceNode) return;
|
|
1606
|
-
|
|
1607
|
-
|
|
1643
|
+
const matchesByName = a.name === host;
|
|
1644
|
+
const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
|
|
1645
|
+
if (!matchesByName && !matchesByAlias) return;
|
|
1646
|
+
const nodeEnv = a.env ?? "unknown";
|
|
1647
|
+
if (nodeEnv === env) {
|
|
1648
|
+
sameEnv = id;
|
|
1608
1649
|
return;
|
|
1609
1650
|
}
|
|
1610
|
-
if (
|
|
1611
|
-
|
|
1612
|
-
}
|
|
1651
|
+
if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
|
|
1652
|
+
else if (!anyMatch) anyMatch = id;
|
|
1613
1653
|
});
|
|
1614
|
-
return
|
|
1654
|
+
return sameEnv ?? envLessMatch ?? anyMatch;
|
|
1615
1655
|
}
|
|
1616
1656
|
function frontierIdFor(host) {
|
|
1617
1657
|
return (0, import_types3.frontierId)(host);
|
|
1618
1658
|
}
|
|
1619
|
-
function ensureServiceNode(graph, serviceName) {
|
|
1620
|
-
const id = (0, import_types3.serviceId)(serviceName);
|
|
1659
|
+
function ensureServiceNode(graph, serviceName, env) {
|
|
1660
|
+
const id = (0, import_types3.serviceId)(serviceName, env);
|
|
1621
1661
|
if (graph.hasNode(id)) return id;
|
|
1622
1662
|
const node = {
|
|
1623
1663
|
id,
|
|
1624
1664
|
type: import_types3.NodeType.ServiceNode,
|
|
1625
1665
|
name: serviceName,
|
|
1626
1666
|
language: "unknown",
|
|
1627
|
-
discoveredVia: "otel"
|
|
1667
|
+
discoveredVia: "otel",
|
|
1668
|
+
...env !== "unknown" ? { env } : {}
|
|
1628
1669
|
};
|
|
1629
1670
|
graph.addNode(id, node);
|
|
1630
1671
|
return id;
|
|
@@ -1770,7 +1811,7 @@ function buildErrorEventForReceiver(span) {
|
|
|
1770
1811
|
...span.exception?.type ? { exceptionType: span.exception.type } : {},
|
|
1771
1812
|
...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
|
|
1772
1813
|
...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
|
|
1773
|
-
affectedNode: (0, import_types3.serviceId)(span.service)
|
|
1814
|
+
affectedNode: (0, import_types3.serviceId)(span.service, span.env)
|
|
1774
1815
|
};
|
|
1775
1816
|
}
|
|
1776
1817
|
function makeErrorSpanWriter(errorsPath) {
|
|
@@ -1784,7 +1825,8 @@ function makeErrorSpanWriter(errorsPath) {
|
|
|
1784
1825
|
async function handleSpan(ctx, span) {
|
|
1785
1826
|
const ts = span.startTimeIso ?? nowIso(ctx);
|
|
1786
1827
|
const nowMs = ctx.now ? ctx.now() : Date.now();
|
|
1787
|
-
const
|
|
1828
|
+
const env = span.env ?? "unknown";
|
|
1829
|
+
const sourceId = ensureServiceNode(ctx.graph, span.service, env);
|
|
1788
1830
|
const isError = span.statusCode === 2;
|
|
1789
1831
|
cacheSpanService(span, nowMs);
|
|
1790
1832
|
let affectedNode = sourceId;
|
|
@@ -1807,7 +1849,7 @@ async function handleSpan(ctx, span) {
|
|
|
1807
1849
|
const host = pickAddress(span);
|
|
1808
1850
|
let resolvedViaAddress = false;
|
|
1809
1851
|
if (host && host !== span.service) {
|
|
1810
|
-
const targetId = resolveServiceId(ctx.graph, host);
|
|
1852
|
+
const targetId = resolveServiceId(ctx.graph, host, env);
|
|
1811
1853
|
if (targetId && targetId !== sourceId) {
|
|
1812
1854
|
upsertObservedEdge(
|
|
1813
1855
|
ctx.graph,
|
|
@@ -1834,9 +1876,9 @@ async function handleSpan(ctx, span) {
|
|
|
1834
1876
|
}
|
|
1835
1877
|
}
|
|
1836
1878
|
if (!resolvedViaAddress && span.parentSpanId) {
|
|
1837
|
-
const
|
|
1838
|
-
if (
|
|
1839
|
-
const parentId = ensureServiceNode(ctx.graph,
|
|
1879
|
+
const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
|
|
1880
|
+
if (parent && parent.service !== span.service) {
|
|
1881
|
+
const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
|
|
1840
1882
|
upsertObservedEdge(
|
|
1841
1883
|
ctx.graph,
|
|
1842
1884
|
import_types3.EdgeType.CALLS,
|
|
@@ -2032,6 +2074,28 @@ async function readErrorEvents(errorsPath) {
|
|
|
2032
2074
|
throw err;
|
|
2033
2075
|
}
|
|
2034
2076
|
}
|
|
2077
|
+
function mergeSnapshot(graph, snapshot) {
|
|
2078
|
+
const exported = snapshot.graph;
|
|
2079
|
+
let nodesAdded = 0;
|
|
2080
|
+
let edgesAdded = 0;
|
|
2081
|
+
for (const node of exported.nodes ?? []) {
|
|
2082
|
+
if (graph.hasNode(node.key)) continue;
|
|
2083
|
+
if (!node.attributes) continue;
|
|
2084
|
+
graph.addNode(node.key, node.attributes);
|
|
2085
|
+
nodesAdded++;
|
|
2086
|
+
}
|
|
2087
|
+
for (const edge of exported.edges ?? []) {
|
|
2088
|
+
const attrs = edge.attributes;
|
|
2089
|
+
if (!attrs) continue;
|
|
2090
|
+
const id = edge.key ?? attrs.id;
|
|
2091
|
+
if (!id) continue;
|
|
2092
|
+
if (graph.hasEdge(id)) continue;
|
|
2093
|
+
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
|
|
2094
|
+
graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
|
|
2095
|
+
edgesAdded++;
|
|
2096
|
+
}
|
|
2097
|
+
return { nodesAdded, edgesAdded };
|
|
2098
|
+
}
|
|
2035
2099
|
|
|
2036
2100
|
// src/extract/services.ts
|
|
2037
2101
|
init_cjs_shims();
|
|
@@ -2055,8 +2119,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
|
2055
2119
|
".turbo",
|
|
2056
2120
|
"dist",
|
|
2057
2121
|
"build",
|
|
2058
|
-
".next"
|
|
2122
|
+
".next",
|
|
2123
|
+
// Python virtualenv shapes (issue #344). Walking into a venv pulls in the
|
|
2124
|
+
// entire CPython stdlib + every installed package as if it were first-party
|
|
2125
|
+
// service code — 20k+ files, none of which the user wrote. The shape names
|
|
2126
|
+
// cover the three common venv tools (`venv` / `python -m venv`,
|
|
2127
|
+
// `virtualenv`) and the tox + PEP 582 conventions.
|
|
2128
|
+
".venv",
|
|
2129
|
+
"venv",
|
|
2130
|
+
"__pypackages__",
|
|
2131
|
+
".tox",
|
|
2132
|
+
// `site-packages` shows up nested inside venvs that don't carry one of the
|
|
2133
|
+
// outer names (system Python on macOS, in-place pyenv layouts). Listing it
|
|
2134
|
+
// here means we stop the walk at the boundary even when the wrapper dir
|
|
2135
|
+
// wasn't recognisable.
|
|
2136
|
+
"site-packages"
|
|
2059
2137
|
]);
|
|
2138
|
+
var PYVENV_MARKER_CACHE = /* @__PURE__ */ new Map();
|
|
2139
|
+
async function isPythonVenvDir(dir) {
|
|
2140
|
+
const cached = PYVENV_MARKER_CACHE.get(dir);
|
|
2141
|
+
if (cached !== void 0) return cached;
|
|
2142
|
+
try {
|
|
2143
|
+
const stat = await import_node_fs4.promises.stat(import_node_path4.default.join(dir, "pyvenv.cfg"));
|
|
2144
|
+
const ok = stat.isFile();
|
|
2145
|
+
PYVENV_MARKER_CACHE.set(dir, ok);
|
|
2146
|
+
return ok;
|
|
2147
|
+
} catch {
|
|
2148
|
+
PYVENV_MARKER_CACHE.set(dir, false);
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2060
2152
|
function isConfigFile(name) {
|
|
2061
2153
|
const ext = import_node_path4.default.extname(name);
|
|
2062
2154
|
if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
|
|
@@ -2393,6 +2485,7 @@ async function walkDirs(start, scanPath, options, visit) {
|
|
|
2393
2485
|
const rel = import_node_path8.default.relative(scanPath, child).split(import_node_path8.default.sep).join("/");
|
|
2394
2486
|
if (rel && options.ig.ignores(rel + "/")) continue;
|
|
2395
2487
|
}
|
|
2488
|
+
if (await isPythonVenvDir(child)) continue;
|
|
2396
2489
|
await visit(child);
|
|
2397
2490
|
await recurse(child, depth + 1);
|
|
2398
2491
|
}
|
|
@@ -2428,6 +2521,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
|
|
|
2428
2521
|
}
|
|
2429
2522
|
return [...found];
|
|
2430
2523
|
}
|
|
2524
|
+
function detectJsFramework(pkg) {
|
|
2525
|
+
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2526
|
+
if (deps["next"] !== void 0) return "next";
|
|
2527
|
+
if (deps["remix"] !== void 0) return "remix";
|
|
2528
|
+
for (const k of Object.keys(deps)) {
|
|
2529
|
+
if (k.startsWith("@remix-run/")) return "remix";
|
|
2530
|
+
}
|
|
2531
|
+
if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
|
|
2532
|
+
if (deps["nuxt"] !== void 0) return "nuxt";
|
|
2533
|
+
if (deps["astro"] !== void 0) return "astro";
|
|
2534
|
+
return void 0;
|
|
2535
|
+
}
|
|
2431
2536
|
async function discoverNodeService(scanPath, dir) {
|
|
2432
2537
|
const pkgPath = import_node_path8.default.join(dir, "package.json");
|
|
2433
2538
|
if (!await exists(pkgPath)) return null;
|
|
@@ -2439,6 +2544,7 @@ async function discoverNodeService(scanPath, dir) {
|
|
|
2439
2544
|
return null;
|
|
2440
2545
|
}
|
|
2441
2546
|
if (!pkg.name) return null;
|
|
2547
|
+
const framework = detectJsFramework(pkg);
|
|
2442
2548
|
const node = {
|
|
2443
2549
|
id: (0, import_types5.serviceId)(pkg.name),
|
|
2444
2550
|
type: import_types5.NodeType.ServiceNode,
|
|
@@ -2447,7 +2553,8 @@ async function discoverNodeService(scanPath, dir) {
|
|
|
2447
2553
|
version: pkg.version,
|
|
2448
2554
|
dependencies: pkg.dependencies ?? {},
|
|
2449
2555
|
repoPath: import_node_path8.default.relative(scanPath, dir),
|
|
2450
|
-
...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
|
|
2556
|
+
...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
|
|
2557
|
+
...framework ? { framework } : {}
|
|
2451
2558
|
};
|
|
2452
2559
|
return { pkg, dir, node };
|
|
2453
2560
|
}
|
|
@@ -2657,7 +2764,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
|
|
|
2657
2764
|
for (const entry of entries) {
|
|
2658
2765
|
if (entry.isDirectory()) {
|
|
2659
2766
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
2660
|
-
|
|
2767
|
+
const child = import_node_path9.default.join(start, entry.name);
|
|
2768
|
+
if (await isPythonVenvDir(child)) continue;
|
|
2769
|
+
out.push(...await walkYamlFiles(child, depth + 1, max));
|
|
2661
2770
|
} else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path9.default.extname(entry.name))) {
|
|
2662
2771
|
out.push(import_node_path9.default.join(start, entry.name));
|
|
2663
2772
|
}
|
|
@@ -3339,7 +3448,9 @@ async function walkConfigFiles(dir) {
|
|
|
3339
3448
|
for (const entry of entries) {
|
|
3340
3449
|
const full = import_node_path18.default.join(current, entry.name);
|
|
3341
3450
|
if (entry.isDirectory()) {
|
|
3342
|
-
if (
|
|
3451
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
3452
|
+
if (await isPythonVenvDir(full)) continue;
|
|
3453
|
+
await walk(full);
|
|
3343
3454
|
} else if (entry.isFile() && isConfigFile(entry.name).match) {
|
|
3344
3455
|
out.push(full);
|
|
3345
3456
|
}
|
|
@@ -3407,7 +3518,9 @@ async function walkSourceFiles(dir) {
|
|
|
3407
3518
|
for (const entry of entries) {
|
|
3408
3519
|
const full = import_node_path19.default.join(current, entry.name);
|
|
3409
3520
|
if (entry.isDirectory()) {
|
|
3410
|
-
if (
|
|
3521
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
3522
|
+
if (await isPythonVenvDir(full)) continue;
|
|
3523
|
+
await walk(full);
|
|
3411
3524
|
} else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path19.default.extname(entry.name))) {
|
|
3412
3525
|
out.push(full);
|
|
3413
3526
|
}
|
|
@@ -4043,7 +4156,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
|
|
|
4043
4156
|
for (const entry of entries) {
|
|
4044
4157
|
if (entry.isDirectory()) {
|
|
4045
4158
|
if (IGNORED_DIRS.has(entry.name) || entry.name === ".terraform") continue;
|
|
4046
|
-
|
|
4159
|
+
const child = import_node_path27.default.join(start, entry.name);
|
|
4160
|
+
if (await isPythonVenvDir(child)) continue;
|
|
4161
|
+
out.push(...await walkTfFiles(child, depth + 1, max));
|
|
4047
4162
|
} else if (entry.isFile() && entry.name.endsWith(".tf")) {
|
|
4048
4163
|
out.push(import_node_path27.default.join(start, entry.name));
|
|
4049
4164
|
}
|
|
@@ -4091,7 +4206,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
|
|
|
4091
4206
|
for (const entry of entries) {
|
|
4092
4207
|
if (entry.isDirectory()) {
|
|
4093
4208
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
4094
|
-
|
|
4209
|
+
const child = import_node_path28.default.join(start, entry.name);
|
|
4210
|
+
if (await isPythonVenvDir(child)) continue;
|
|
4211
|
+
out.push(...await walkYamlFiles2(child, depth + 1, max));
|
|
4095
4212
|
} else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path28.default.extname(entry.name))) {
|
|
4096
4213
|
out.push(import_node_path28.default.join(start, entry.name));
|
|
4097
4214
|
}
|
|
@@ -4230,7 +4347,7 @@ init_cjs_shims();
|
|
|
4230
4347
|
var import_node_fs18 = require("fs");
|
|
4231
4348
|
var import_node_path31 = __toESM(require("path"), 1);
|
|
4232
4349
|
var import_types19 = require("@neat.is/types");
|
|
4233
|
-
var SCHEMA_VERSION =
|
|
4350
|
+
var SCHEMA_VERSION = 4;
|
|
4234
4351
|
function migrateV1ToV2(payload) {
|
|
4235
4352
|
const nodes = payload.graph.nodes;
|
|
4236
4353
|
if (Array.isArray(nodes)) {
|
|
@@ -4242,6 +4359,9 @@ function migrateV1ToV2(payload) {
|
|
|
4242
4359
|
}
|
|
4243
4360
|
return { ...payload, schemaVersion: 2 };
|
|
4244
4361
|
}
|
|
4362
|
+
function migrateV3ToV4(payload) {
|
|
4363
|
+
return { ...payload, schemaVersion: 4 };
|
|
4364
|
+
}
|
|
4245
4365
|
function migrateV2ToV3(payload) {
|
|
4246
4366
|
const edges = payload.graph.edges;
|
|
4247
4367
|
if (Array.isArray(edges)) {
|
|
@@ -4290,6 +4410,9 @@ async function loadGraphFromDisk(graph, outPath) {
|
|
|
4290
4410
|
if (payload.schemaVersion === 2) {
|
|
4291
4411
|
payload = migrateV2ToV3(payload);
|
|
4292
4412
|
}
|
|
4413
|
+
if (payload.schemaVersion === 3) {
|
|
4414
|
+
payload = migrateV3ToV4(payload);
|
|
4415
|
+
}
|
|
4293
4416
|
if (payload.schemaVersion !== SCHEMA_VERSION) {
|
|
4294
4417
|
throw new Error(
|
|
4295
4418
|
`persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
|
|
@@ -4944,10 +5067,19 @@ function projectFromReq(req) {
|
|
|
4944
5067
|
const params = req.params;
|
|
4945
5068
|
return params.project ?? DEFAULT_PROJECT;
|
|
4946
5069
|
}
|
|
4947
|
-
function resolveProject(registry, req, reply) {
|
|
5070
|
+
function resolveProject(registry, req, reply, bootstrap) {
|
|
4948
5071
|
const name = projectFromReq(req);
|
|
4949
5072
|
const ctx = registry.get(name);
|
|
4950
5073
|
if (!ctx) {
|
|
5074
|
+
const phase = bootstrap?.status(name);
|
|
5075
|
+
if (phase === "bootstrapping") {
|
|
5076
|
+
void reply.code(503).send({ ready: false, project: name, status: "bootstrapping" });
|
|
5077
|
+
return null;
|
|
5078
|
+
}
|
|
5079
|
+
if (phase === "broken") {
|
|
5080
|
+
void reply.code(503).send({ ready: false, project: name, status: "broken" });
|
|
5081
|
+
return null;
|
|
5082
|
+
}
|
|
4951
5083
|
void reply.code(404).send({ error: "project not found", project: name });
|
|
4952
5084
|
return null;
|
|
4953
5085
|
}
|
|
@@ -4977,36 +5109,38 @@ function buildLegacyRegistry(opts) {
|
|
|
4977
5109
|
function registerRoutes(scope, ctx) {
|
|
4978
5110
|
const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
|
|
4979
5111
|
scope.get("/events", (req, reply) => {
|
|
4980
|
-
const proj = resolveProject(registry, req, reply);
|
|
5112
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4981
5113
|
if (!proj) return;
|
|
4982
5114
|
handleSse(req, reply, { project: proj.name });
|
|
4983
5115
|
});
|
|
4984
|
-
scope
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5116
|
+
if (ctx.scope === "project") {
|
|
5117
|
+
scope.get("/health", async (req, reply) => {
|
|
5118
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5119
|
+
if (!proj) return;
|
|
5120
|
+
const uptimeMs = Date.now() - startedAt;
|
|
5121
|
+
return {
|
|
5122
|
+
ok: true,
|
|
5123
|
+
project: proj.name,
|
|
5124
|
+
uptimeMs,
|
|
5125
|
+
// Legacy fields kept additively. The web shell's StatusBar reads
|
|
5126
|
+
// nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
|
|
5127
|
+
// the canonical triple and lets the extras pass through.
|
|
5128
|
+
uptime: Math.floor(uptimeMs / 1e3),
|
|
5129
|
+
nodeCount: proj.graph.order,
|
|
5130
|
+
edgeCount: proj.graph.size,
|
|
5131
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
5132
|
+
};
|
|
5133
|
+
});
|
|
5134
|
+
}
|
|
5001
5135
|
scope.get("/graph", async (req, reply) => {
|
|
5002
|
-
const proj = resolveProject(registry, req, reply);
|
|
5136
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5003
5137
|
if (!proj) return;
|
|
5004
5138
|
return serializeGraph(proj.graph);
|
|
5005
5139
|
});
|
|
5006
5140
|
scope.get(
|
|
5007
5141
|
"/graph/node/:id",
|
|
5008
5142
|
async (req, reply) => {
|
|
5009
|
-
const proj = resolveProject(registry, req, reply);
|
|
5143
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5010
5144
|
if (!proj) return;
|
|
5011
5145
|
const { id } = req.params;
|
|
5012
5146
|
if (!proj.graph.hasNode(id)) {
|
|
@@ -5018,7 +5152,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5018
5152
|
scope.get(
|
|
5019
5153
|
"/graph/edges/:id",
|
|
5020
5154
|
async (req, reply) => {
|
|
5021
|
-
const proj = resolveProject(registry, req, reply);
|
|
5155
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5022
5156
|
if (!proj) return;
|
|
5023
5157
|
const { id } = req.params;
|
|
5024
5158
|
if (!proj.graph.hasNode(id)) {
|
|
@@ -5030,7 +5164,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5030
5164
|
}
|
|
5031
5165
|
);
|
|
5032
5166
|
scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
|
|
5033
|
-
const proj = resolveProject(registry, req, reply);
|
|
5167
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5034
5168
|
if (!proj) return;
|
|
5035
5169
|
const { nodeId } = req.params;
|
|
5036
5170
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -5045,7 +5179,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5045
5179
|
return getTransitiveDependencies(proj.graph, nodeId, depth);
|
|
5046
5180
|
});
|
|
5047
5181
|
scope.get("/graph/divergences", async (req, reply) => {
|
|
5048
|
-
const proj = resolveProject(registry, req, reply);
|
|
5182
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5049
5183
|
if (!proj) return;
|
|
5050
5184
|
let typeFilter;
|
|
5051
5185
|
if (req.query.type) {
|
|
@@ -5080,7 +5214,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5080
5214
|
});
|
|
5081
5215
|
});
|
|
5082
5216
|
scope.get("/incidents", async (req, reply) => {
|
|
5083
|
-
const proj = resolveProject(registry, req, reply);
|
|
5217
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5084
5218
|
if (!proj) return;
|
|
5085
5219
|
const epath = errorsPathFor(proj);
|
|
5086
5220
|
if (!epath) return { count: 0, total: 0, events: [] };
|
|
@@ -5092,7 +5226,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5092
5226
|
return { count: sliced.length, total, events: sliced };
|
|
5093
5227
|
});
|
|
5094
5228
|
scope.get("/stale-events", async (req, reply) => {
|
|
5095
|
-
const proj = resolveProject(registry, req, reply);
|
|
5229
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5096
5230
|
if (!proj) return;
|
|
5097
5231
|
const spath = staleEventsPathFor(proj);
|
|
5098
5232
|
if (!spath) return { count: 0, total: 0, events: [] };
|
|
@@ -5107,7 +5241,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5107
5241
|
scope.get(
|
|
5108
5242
|
"/incidents/:nodeId",
|
|
5109
5243
|
async (req, reply) => {
|
|
5110
|
-
const proj = resolveProject(registry, req, reply);
|
|
5244
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5111
5245
|
if (!proj) return;
|
|
5112
5246
|
const { nodeId } = req.params;
|
|
5113
5247
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -5123,7 +5257,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5123
5257
|
}
|
|
5124
5258
|
);
|
|
5125
5259
|
scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
|
|
5126
|
-
const proj = resolveProject(registry, req, reply);
|
|
5260
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5127
5261
|
if (!proj) return;
|
|
5128
5262
|
const { nodeId } = req.params;
|
|
5129
5263
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -5143,7 +5277,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5143
5277
|
return result;
|
|
5144
5278
|
});
|
|
5145
5279
|
scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
|
|
5146
|
-
const proj = resolveProject(registry, req, reply);
|
|
5280
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5147
5281
|
if (!proj) return;
|
|
5148
5282
|
const { nodeId } = req.params;
|
|
5149
5283
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -5156,7 +5290,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5156
5290
|
return getBlastRadius(proj.graph, nodeId, depth);
|
|
5157
5291
|
});
|
|
5158
5292
|
scope.get("/search", async (req, reply) => {
|
|
5159
|
-
const proj = resolveProject(registry, req, reply);
|
|
5293
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5160
5294
|
if (!proj) return;
|
|
5161
5295
|
const raw = (req.query.q ?? "").trim();
|
|
5162
5296
|
if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
|
|
@@ -5187,7 +5321,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5187
5321
|
scope.get(
|
|
5188
5322
|
"/graph/diff",
|
|
5189
5323
|
async (req, reply) => {
|
|
5190
|
-
const proj = resolveProject(registry, req, reply);
|
|
5324
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5191
5325
|
if (!proj) return;
|
|
5192
5326
|
const against = req.query.against;
|
|
5193
5327
|
if (!against) {
|
|
@@ -5201,8 +5335,37 @@ function registerRoutes(scope, ctx) {
|
|
|
5201
5335
|
}
|
|
5202
5336
|
}
|
|
5203
5337
|
);
|
|
5338
|
+
scope.post("/snapshot", async (req, reply) => {
|
|
5339
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5340
|
+
if (!proj) return;
|
|
5341
|
+
const body = req.body;
|
|
5342
|
+
if (!body || typeof body !== "object" || !body.snapshot) {
|
|
5343
|
+
return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
|
|
5344
|
+
}
|
|
5345
|
+
const snap = body.snapshot;
|
|
5346
|
+
if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
|
|
5347
|
+
return reply.code(400).send({
|
|
5348
|
+
error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
|
|
5349
|
+
});
|
|
5350
|
+
}
|
|
5351
|
+
try {
|
|
5352
|
+
const result = mergeSnapshot(proj.graph, snap);
|
|
5353
|
+
return {
|
|
5354
|
+
project: proj.name,
|
|
5355
|
+
nodesAdded: result.nodesAdded,
|
|
5356
|
+
edgesAdded: result.edgesAdded,
|
|
5357
|
+
nodeCount: proj.graph.order,
|
|
5358
|
+
edgeCount: proj.graph.size
|
|
5359
|
+
};
|
|
5360
|
+
} catch (err) {
|
|
5361
|
+
return reply.code(400).send({
|
|
5362
|
+
error: "snapshot merge failed",
|
|
5363
|
+
details: err.message
|
|
5364
|
+
});
|
|
5365
|
+
}
|
|
5366
|
+
});
|
|
5204
5367
|
scope.post("/graph/scan", async (req, reply) => {
|
|
5205
|
-
const proj = resolveProject(registry, req, reply);
|
|
5368
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5206
5369
|
if (!proj) return;
|
|
5207
5370
|
if (!proj.scanPath) {
|
|
5208
5371
|
return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
|
|
@@ -5218,7 +5381,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5218
5381
|
};
|
|
5219
5382
|
});
|
|
5220
5383
|
scope.get("/policies", async (req, reply) => {
|
|
5221
|
-
const proj = resolveProject(registry, req, reply);
|
|
5384
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5222
5385
|
if (!proj) return;
|
|
5223
5386
|
const policyPath = ctx.policyFilePathFor(proj);
|
|
5224
5387
|
if (!policyPath) {
|
|
@@ -5235,7 +5398,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5235
5398
|
}
|
|
5236
5399
|
});
|
|
5237
5400
|
scope.get("/policies/violations", async (req, reply) => {
|
|
5238
|
-
const proj = resolveProject(registry, req, reply);
|
|
5401
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5239
5402
|
if (!proj) return;
|
|
5240
5403
|
const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
|
|
5241
5404
|
let violations = await log.readAll();
|
|
@@ -5255,7 +5418,7 @@ function registerRoutes(scope, ctx) {
|
|
|
5255
5418
|
return { violations };
|
|
5256
5419
|
});
|
|
5257
5420
|
scope.post("/policies/check", async (req, reply) => {
|
|
5258
|
-
const proj = resolveProject(registry, req, reply);
|
|
5421
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5259
5422
|
if (!proj) return;
|
|
5260
5423
|
const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req.body ?? {});
|
|
5261
5424
|
if (!parsed.success) {
|
|
@@ -5294,7 +5457,15 @@ function registerRoutes(scope, ctx) {
|
|
|
5294
5457
|
async function buildApi(opts) {
|
|
5295
5458
|
const app = (0, import_fastify.default)({ logger: false });
|
|
5296
5459
|
await app.register(import_cors.default, { origin: true });
|
|
5297
|
-
mountBearerAuth(app, {
|
|
5460
|
+
mountBearerAuth(app, {
|
|
5461
|
+
token: opts.authToken,
|
|
5462
|
+
trustProxy: opts.trustProxy,
|
|
5463
|
+
publicRead: opts.publicRead
|
|
5464
|
+
});
|
|
5465
|
+
app.get("/api/config", async () => ({
|
|
5466
|
+
publicRead: opts.publicRead === true,
|
|
5467
|
+
authProxy: opts.trustProxy === true
|
|
5468
|
+
}));
|
|
5298
5469
|
const startedAt = opts.startedAt ?? Date.now();
|
|
5299
5470
|
const registry = buildLegacyRegistry(opts);
|
|
5300
5471
|
const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
|
|
@@ -5318,10 +5489,36 @@ async function buildApi(opts) {
|
|
|
5318
5489
|
const routeCtx = {
|
|
5319
5490
|
registry,
|
|
5320
5491
|
startedAt,
|
|
5492
|
+
scope: "root",
|
|
5321
5493
|
errorsPathFor,
|
|
5322
5494
|
staleEventsPathFor,
|
|
5323
|
-
policyFilePathFor
|
|
5495
|
+
policyFilePathFor,
|
|
5496
|
+
bootstrap: opts.bootstrap
|
|
5324
5497
|
};
|
|
5498
|
+
app.get("/health", async () => {
|
|
5499
|
+
const uptimeMs = Date.now() - startedAt;
|
|
5500
|
+
const bootstrapList = opts.bootstrap?.list() ?? [];
|
|
5501
|
+
const byName = new Map(bootstrapList.map((p) => [p.name, p]));
|
|
5502
|
+
const names = /* @__PURE__ */ new Set([
|
|
5503
|
+
...registry.list(),
|
|
5504
|
+
...bootstrapList.map((p) => p.name)
|
|
5505
|
+
]);
|
|
5506
|
+
const projects = [...names].sort().map((name) => {
|
|
5507
|
+
const proj = registry.get(name);
|
|
5508
|
+
const tracked = byName.get(name);
|
|
5509
|
+
return {
|
|
5510
|
+
name,
|
|
5511
|
+
nodeCount: proj?.graph.order ?? 0,
|
|
5512
|
+
edgeCount: proj?.graph.size ?? 0,
|
|
5513
|
+
...tracked ? { status: tracked.status, elapsedMs: tracked.elapsedMs } : {}
|
|
5514
|
+
};
|
|
5515
|
+
});
|
|
5516
|
+
return {
|
|
5517
|
+
ok: true,
|
|
5518
|
+
uptimeMs,
|
|
5519
|
+
projects
|
|
5520
|
+
};
|
|
5521
|
+
});
|
|
5325
5522
|
app.get("/projects", async (_req, reply) => {
|
|
5326
5523
|
try {
|
|
5327
5524
|
return await listProjects();
|
|
@@ -5349,7 +5546,7 @@ async function buildApi(opts) {
|
|
|
5349
5546
|
registerRoutes(app, routeCtx);
|
|
5350
5547
|
await app.register(
|
|
5351
5548
|
async (scope) => {
|
|
5352
|
-
registerRoutes(scope, routeCtx);
|
|
5549
|
+
registerRoutes(scope, { ...routeCtx, scope: "project" });
|
|
5353
5550
|
},
|
|
5354
5551
|
{ prefix: "/projects/:project" }
|
|
5355
5552
|
);
|
|
@@ -5362,16 +5559,39 @@ init_otel_grpc();
|
|
|
5362
5559
|
|
|
5363
5560
|
// src/daemon.ts
|
|
5364
5561
|
init_cjs_shims();
|
|
5365
|
-
var
|
|
5366
|
-
var
|
|
5562
|
+
var import_node_fs22 = require("fs");
|
|
5563
|
+
var import_node_path37 = __toESM(require("path"), 1);
|
|
5367
5564
|
init_otel();
|
|
5368
5565
|
init_auth();
|
|
5566
|
+
|
|
5567
|
+
// src/unrouted.ts
|
|
5568
|
+
init_cjs_shims();
|
|
5569
|
+
var import_node_fs21 = require("fs");
|
|
5570
|
+
var import_node_path36 = __toESM(require("path"), 1);
|
|
5571
|
+
function buildUnroutedSpanRecord(serviceName, traceId, now = /* @__PURE__ */ new Date()) {
|
|
5572
|
+
return {
|
|
5573
|
+
timestamp: now.toISOString(),
|
|
5574
|
+
reason: "no-project-match",
|
|
5575
|
+
service_name: serviceName ?? null,
|
|
5576
|
+
traceId: traceId ?? null
|
|
5577
|
+
};
|
|
5578
|
+
}
|
|
5579
|
+
async function appendUnroutedSpan(neatHome2, record) {
|
|
5580
|
+
const target = import_node_path36.default.join(neatHome2, "errors.ndjson");
|
|
5581
|
+
await import_node_fs21.promises.mkdir(neatHome2, { recursive: true });
|
|
5582
|
+
await import_node_fs21.promises.appendFile(target, JSON.stringify(record) + "\n", "utf8");
|
|
5583
|
+
}
|
|
5584
|
+
function unroutedErrorsPath(neatHome2) {
|
|
5585
|
+
return import_node_path36.default.join(neatHome2, "errors.ndjson");
|
|
5586
|
+
}
|
|
5587
|
+
|
|
5588
|
+
// src/daemon.ts
|
|
5369
5589
|
function neatHomeFor(opts) {
|
|
5370
|
-
if (opts.neatHome && opts.neatHome.length > 0) return
|
|
5590
|
+
if (opts.neatHome && opts.neatHome.length > 0) return import_node_path37.default.resolve(opts.neatHome);
|
|
5371
5591
|
const env = process.env.NEAT_HOME;
|
|
5372
|
-
if (env && env.length > 0) return
|
|
5592
|
+
if (env && env.length > 0) return import_node_path37.default.resolve(env);
|
|
5373
5593
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
5374
|
-
return
|
|
5594
|
+
return import_node_path37.default.join(home, ".neat");
|
|
5375
5595
|
}
|
|
5376
5596
|
function routeSpanToProject(serviceName, projects) {
|
|
5377
5597
|
if (!serviceName) return DEFAULT_PROJECT;
|
|
@@ -5406,9 +5626,9 @@ function isTokenContained(needle, haystack) {
|
|
|
5406
5626
|
return tokens.includes(needle);
|
|
5407
5627
|
}
|
|
5408
5628
|
async function bootstrapProject(entry) {
|
|
5409
|
-
const paths = pathsForProject(entry.name,
|
|
5629
|
+
const paths = pathsForProject(entry.name, import_node_path37.default.join(entry.path, "neat-out"));
|
|
5410
5630
|
try {
|
|
5411
|
-
const stat = await
|
|
5631
|
+
const stat = await import_node_fs22.promises.stat(entry.path);
|
|
5412
5632
|
if (!stat.isDirectory()) {
|
|
5413
5633
|
throw new Error(`registered path ${entry.path} is not a directory`);
|
|
5414
5634
|
}
|
|
@@ -5463,27 +5683,30 @@ function resolveOtlpPort(opts) {
|
|
|
5463
5683
|
}
|
|
5464
5684
|
return 4318;
|
|
5465
5685
|
}
|
|
5466
|
-
function resolveHost(opts) {
|
|
5686
|
+
function resolveHost(opts, authTokenSet) {
|
|
5467
5687
|
if (opts.host && opts.host.length > 0) return opts.host;
|
|
5468
5688
|
const env = process.env.HOST;
|
|
5469
5689
|
if (env && env.length > 0) return env;
|
|
5690
|
+
if (!authTokenSet) return "127.0.0.1";
|
|
5470
5691
|
return "0.0.0.0";
|
|
5471
5692
|
}
|
|
5472
5693
|
async function startDaemon(opts = {}) {
|
|
5473
5694
|
const home = neatHomeFor(opts);
|
|
5474
5695
|
const regPath = registryPath();
|
|
5475
5696
|
try {
|
|
5476
|
-
await
|
|
5697
|
+
await import_node_fs22.promises.access(regPath);
|
|
5477
5698
|
} catch {
|
|
5478
5699
|
throw new Error(
|
|
5479
5700
|
`neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
|
|
5480
5701
|
);
|
|
5481
5702
|
}
|
|
5482
|
-
const pidPath =
|
|
5703
|
+
const pidPath = import_node_path37.default.join(home, "neatd.pid");
|
|
5483
5704
|
await writeAtomically(pidPath, `${process.pid}
|
|
5484
5705
|
`);
|
|
5485
5706
|
const slots = /* @__PURE__ */ new Map();
|
|
5486
5707
|
const registry = new Projects();
|
|
5708
|
+
const bootstrapStatus = /* @__PURE__ */ new Map();
|
|
5709
|
+
const bootstrapStartedAt = /* @__PURE__ */ new Map();
|
|
5487
5710
|
const DROP_WARN_INTERVAL_MS = 6e4;
|
|
5488
5711
|
const lastDropWarnAt = /* @__PURE__ */ new Map();
|
|
5489
5712
|
function warnDroppedSpan(project, reason) {
|
|
@@ -5495,6 +5718,22 @@ async function startDaemon(opts = {}) {
|
|
|
5495
5718
|
`[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
|
|
5496
5719
|
);
|
|
5497
5720
|
}
|
|
5721
|
+
const unroutedPath = unroutedErrorsPath(home);
|
|
5722
|
+
const lastUnroutedWarnAt = /* @__PURE__ */ new Map();
|
|
5723
|
+
async function recordUnroutedSpan(serviceName, traceId) {
|
|
5724
|
+
const key = serviceName ?? "<missing>";
|
|
5725
|
+
const now = Date.now();
|
|
5726
|
+
try {
|
|
5727
|
+
await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)));
|
|
5728
|
+
} catch {
|
|
5729
|
+
}
|
|
5730
|
+
const prev = lastUnroutedWarnAt.get(key) ?? 0;
|
|
5731
|
+
if (now - prev < DROP_WARN_INTERVAL_MS) return;
|
|
5732
|
+
lastUnroutedWarnAt.set(key, now);
|
|
5733
|
+
console.warn(
|
|
5734
|
+
`[neatd] dropping span \u2014 service.name "${key}" matches no registered project and no \`default\` project exists. See ${unroutedPath}.`
|
|
5735
|
+
);
|
|
5736
|
+
}
|
|
5498
5737
|
function upsertRegistryFromSlot(slot) {
|
|
5499
5738
|
if (slot.status !== "active") return;
|
|
5500
5739
|
registry.set(slot.entry.name, {
|
|
@@ -5523,34 +5762,43 @@ async function startDaemon(opts = {}) {
|
|
|
5523
5762
|
return slots.get(entry.name);
|
|
5524
5763
|
}
|
|
5525
5764
|
}
|
|
5765
|
+
async function bootstrapOne(entry) {
|
|
5766
|
+
bootstrapStatus.set(entry.name, "bootstrapping");
|
|
5767
|
+
bootstrapStartedAt.set(entry.name, Date.now());
|
|
5768
|
+
try {
|
|
5769
|
+
const slot = await bootstrapProject(entry);
|
|
5770
|
+
slots.set(entry.name, slot);
|
|
5771
|
+
upsertRegistryFromSlot(slot);
|
|
5772
|
+
bootstrapStatus.set(entry.name, slot.status === "broken" ? "broken" : "active");
|
|
5773
|
+
if (slot.status === "broken") {
|
|
5774
|
+
console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
|
|
5775
|
+
} else {
|
|
5776
|
+
console.log(`neatd: project "${entry.name}" active (${entry.path})`);
|
|
5777
|
+
}
|
|
5778
|
+
} catch (err) {
|
|
5779
|
+
bootstrapStatus.set(entry.name, "broken");
|
|
5780
|
+
console.warn(
|
|
5781
|
+
`neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
|
|
5782
|
+
);
|
|
5783
|
+
await setStatus(entry.name, "broken").catch(() => {
|
|
5784
|
+
});
|
|
5785
|
+
}
|
|
5786
|
+
}
|
|
5526
5787
|
async function loadAll() {
|
|
5527
5788
|
const projects = await listProjects();
|
|
5528
5789
|
const seen = /* @__PURE__ */ new Set();
|
|
5790
|
+
const pending = [];
|
|
5529
5791
|
for (const entry of projects) {
|
|
5530
5792
|
seen.add(entry.name);
|
|
5531
5793
|
const existing = slots.get(entry.name);
|
|
5532
5794
|
if (existing) {
|
|
5533
5795
|
if (existing.status === "broken") {
|
|
5534
|
-
|
|
5796
|
+
pending.push(tryRecoverSlot(entry).then(() => {
|
|
5797
|
+
}));
|
|
5535
5798
|
}
|
|
5536
5799
|
continue;
|
|
5537
5800
|
}
|
|
5538
|
-
|
|
5539
|
-
const slot = await bootstrapProject(entry);
|
|
5540
|
-
slots.set(entry.name, slot);
|
|
5541
|
-
upsertRegistryFromSlot(slot);
|
|
5542
|
-
if (slot.status === "broken") {
|
|
5543
|
-
console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
|
|
5544
|
-
} else {
|
|
5545
|
-
console.log(`neatd: project "${entry.name}" active (${entry.path})`);
|
|
5546
|
-
}
|
|
5547
|
-
} catch (err) {
|
|
5548
|
-
console.warn(
|
|
5549
|
-
`neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
|
|
5550
|
-
);
|
|
5551
|
-
await setStatus(entry.name, "broken").catch(() => {
|
|
5552
|
-
});
|
|
5553
|
-
}
|
|
5801
|
+
pending.push(bootstrapOne(entry));
|
|
5554
5802
|
}
|
|
5555
5803
|
for (const [name, slot] of [...slots.entries()]) {
|
|
5556
5804
|
if (seen.has(name)) continue;
|
|
@@ -5559,26 +5807,45 @@ async function startDaemon(opts = {}) {
|
|
|
5559
5807
|
} catch {
|
|
5560
5808
|
}
|
|
5561
5809
|
slots.delete(name);
|
|
5810
|
+
bootstrapStatus.delete(name);
|
|
5811
|
+
bootstrapStartedAt.delete(name);
|
|
5562
5812
|
console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
|
|
5563
5813
|
}
|
|
5814
|
+
await Promise.allSettled(pending);
|
|
5815
|
+
}
|
|
5816
|
+
const initialEntries = await listProjects().catch(() => []);
|
|
5817
|
+
for (const entry of initialEntries) {
|
|
5818
|
+
bootstrapStatus.set(entry.name, "bootstrapping");
|
|
5819
|
+
bootstrapStartedAt.set(entry.name, Date.now());
|
|
5564
5820
|
}
|
|
5565
|
-
await loadAll();
|
|
5566
5821
|
const bind = opts.bindListeners !== false;
|
|
5567
5822
|
let restApp = null;
|
|
5568
5823
|
let otlpApp = null;
|
|
5569
5824
|
let restAddress = "";
|
|
5570
5825
|
let otlpAddress = "";
|
|
5571
5826
|
if (bind) {
|
|
5572
|
-
const
|
|
5827
|
+
const auth = readAuthEnv();
|
|
5828
|
+
const host = resolveHost(opts, Boolean(auth.authToken));
|
|
5573
5829
|
const restPort = resolveRestPort(opts);
|
|
5574
5830
|
const otlpPort = resolveOtlpPort(opts);
|
|
5575
|
-
const auth = readAuthEnv();
|
|
5576
5831
|
assertBindAuthority(host, auth.authToken);
|
|
5577
5832
|
try {
|
|
5578
5833
|
restApp = await buildApi({
|
|
5579
5834
|
projects: registry,
|
|
5580
5835
|
authToken: auth.authToken,
|
|
5581
|
-
trustProxy: auth.trustProxy
|
|
5836
|
+
trustProxy: auth.trustProxy,
|
|
5837
|
+
publicRead: auth.publicRead,
|
|
5838
|
+
bootstrap: {
|
|
5839
|
+
status: (name) => bootstrapStatus.get(name),
|
|
5840
|
+
list: () => {
|
|
5841
|
+
const now = Date.now();
|
|
5842
|
+
return [...bootstrapStatus.entries()].map(([name, status2]) => ({
|
|
5843
|
+
name,
|
|
5844
|
+
status: status2,
|
|
5845
|
+
elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
|
|
5846
|
+
}));
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5582
5849
|
});
|
|
5583
5850
|
restAddress = await restApp.listen({ port: restPort, host });
|
|
5584
5851
|
console.log(`neatd: REST listening on ${restAddress}`);
|
|
@@ -5591,17 +5858,20 @@ async function startDaemon(opts = {}) {
|
|
|
5591
5858
|
}
|
|
5592
5859
|
if (restApp) await restApp.close().catch(() => {
|
|
5593
5860
|
});
|
|
5594
|
-
await
|
|
5861
|
+
await import_node_fs22.promises.unlink(pidPath).catch(() => {
|
|
5595
5862
|
});
|
|
5596
5863
|
throw new Error(
|
|
5597
5864
|
`neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
|
|
5598
5865
|
);
|
|
5599
5866
|
}
|
|
5600
|
-
async function resolveTargetSlot(serviceName) {
|
|
5867
|
+
async function resolveTargetSlot(serviceName, traceId) {
|
|
5601
5868
|
const liveEntries = await listProjects().catch(() => []);
|
|
5602
5869
|
const target = routeSpanToProject(serviceName, liveEntries);
|
|
5603
5870
|
let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
|
|
5604
|
-
if (!slot)
|
|
5871
|
+
if (!slot) {
|
|
5872
|
+
await recordUnroutedSpan(serviceName, traceId);
|
|
5873
|
+
return null;
|
|
5874
|
+
}
|
|
5605
5875
|
if (slot.status === "broken") {
|
|
5606
5876
|
const entry = liveEntries.find((e) => e.name === slot.entry.name);
|
|
5607
5877
|
if (entry) {
|
|
@@ -5619,7 +5889,7 @@ async function startDaemon(opts = {}) {
|
|
|
5619
5889
|
authToken: auth.otelToken,
|
|
5620
5890
|
trustProxy: auth.trustProxy,
|
|
5621
5891
|
onSpan: async (span) => {
|
|
5622
|
-
const slot = await resolveTargetSlot(span.service);
|
|
5892
|
+
const slot = await resolveTargetSlot(span.service, span.traceId);
|
|
5623
5893
|
if (!slot) return;
|
|
5624
5894
|
await handleSpan(
|
|
5625
5895
|
{
|
|
@@ -5633,7 +5903,7 @@ async function startDaemon(opts = {}) {
|
|
|
5633
5903
|
);
|
|
5634
5904
|
},
|
|
5635
5905
|
onErrorSpanSync: async (span) => {
|
|
5636
|
-
const slot = await resolveTargetSlot(span.service);
|
|
5906
|
+
const slot = await resolveTargetSlot(span.service, span.traceId);
|
|
5637
5907
|
if (!slot) return;
|
|
5638
5908
|
await makeErrorSpanWriter(slot.paths.errorsPath)(span);
|
|
5639
5909
|
}
|
|
@@ -5651,14 +5921,17 @@ async function startDaemon(opts = {}) {
|
|
|
5651
5921
|
});
|
|
5652
5922
|
if (otlpApp) await otlpApp.close().catch(() => {
|
|
5653
5923
|
});
|
|
5654
|
-
await
|
|
5924
|
+
await import_node_fs22.promises.unlink(pidPath).catch(() => {
|
|
5655
5925
|
});
|
|
5656
5926
|
throw new Error(
|
|
5657
5927
|
`neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
|
|
5658
5928
|
);
|
|
5659
5929
|
}
|
|
5660
5930
|
}
|
|
5661
|
-
|
|
5931
|
+
const initialBootstrap = loadAll().catch((err) => {
|
|
5932
|
+
console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
|
|
5933
|
+
});
|
|
5934
|
+
let reloading = initialBootstrap;
|
|
5662
5935
|
const reload = async () => {
|
|
5663
5936
|
if (reloading) return reloading;
|
|
5664
5937
|
reloading = (async () => {
|
|
@@ -5670,6 +5943,20 @@ async function startDaemon(opts = {}) {
|
|
|
5670
5943
|
})();
|
|
5671
5944
|
return reloading;
|
|
5672
5945
|
};
|
|
5946
|
+
void initialBootstrap.finally(() => {
|
|
5947
|
+
if (reloading === initialBootstrap) reloading = null;
|
|
5948
|
+
});
|
|
5949
|
+
const tracker = {
|
|
5950
|
+
status: (name) => bootstrapStatus.get(name),
|
|
5951
|
+
list: () => {
|
|
5952
|
+
const now = Date.now();
|
|
5953
|
+
return [...bootstrapStatus.entries()].map(([name, status2]) => ({
|
|
5954
|
+
name,
|
|
5955
|
+
status: status2,
|
|
5956
|
+
elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
|
|
5957
|
+
}));
|
|
5958
|
+
}
|
|
5959
|
+
};
|
|
5673
5960
|
const sighupHandler = () => {
|
|
5674
5961
|
void reload().catch((err) => {
|
|
5675
5962
|
console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
|
|
@@ -5691,10 +5978,19 @@ async function startDaemon(opts = {}) {
|
|
|
5691
5978
|
} catch {
|
|
5692
5979
|
}
|
|
5693
5980
|
}
|
|
5694
|
-
await
|
|
5981
|
+
await import_node_fs22.promises.unlink(pidPath).catch(() => {
|
|
5695
5982
|
});
|
|
5696
5983
|
};
|
|
5697
|
-
return {
|
|
5984
|
+
return {
|
|
5985
|
+
slots,
|
|
5986
|
+
reload,
|
|
5987
|
+
stop,
|
|
5988
|
+
pidPath,
|
|
5989
|
+
restAddress,
|
|
5990
|
+
otlpAddress,
|
|
5991
|
+
bootstrap: tracker,
|
|
5992
|
+
initialBootstrap
|
|
5993
|
+
};
|
|
5698
5994
|
}
|
|
5699
5995
|
// Annotate the CommonJS export names for ESM import in node:
|
|
5700
5996
|
0 && (module.exports = {
|