@neat.is/core 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -55,6 +55,7 @@ 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
60
  const path37 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
61
  for (const suffix of suffixes) {
@@ -63,6 +64,10 @@ function mountBearerAuth(app, opts) {
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
- DEFAULT_UNAUTH_SUFFIXES = ["/health", "/healthz", "/readyz"];
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;
@@ -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, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
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 lookupParentSpanService(traceId, parentSpanId, now) {
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 direct = (0, import_types3.serviceId)(host);
1600
- if (graph.hasNode(direct)) return direct;
1601
- let found = null;
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 (found) return;
1640
+ if (sameEnv) return;
1604
1641
  const a = attrs;
1605
1642
  if (a.type !== import_types3.NodeType.ServiceNode) return;
1606
- if (a.name === host) {
1607
- found = id;
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 (a.aliases && a.aliases.includes(host)) {
1611
- found = id;
1612
- }
1651
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1652
+ else if (!anyMatch) anyMatch = id;
1613
1653
  });
1614
- return found;
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 sourceId = ensureServiceNode(ctx.graph, span.service);
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 parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1838
- if (parentService && parentService !== span.service) {
1839
- const parentId = ensureServiceNode(ctx.graph, parentService);
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();
@@ -2428,6 +2492,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2428
2492
  }
2429
2493
  return [...found];
2430
2494
  }
2495
+ function detectJsFramework(pkg) {
2496
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2497
+ if (deps["next"] !== void 0) return "next";
2498
+ if (deps["remix"] !== void 0) return "remix";
2499
+ for (const k of Object.keys(deps)) {
2500
+ if (k.startsWith("@remix-run/")) return "remix";
2501
+ }
2502
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2503
+ if (deps["nuxt"] !== void 0) return "nuxt";
2504
+ if (deps["astro"] !== void 0) return "astro";
2505
+ return void 0;
2506
+ }
2431
2507
  async function discoverNodeService(scanPath, dir) {
2432
2508
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2433
2509
  if (!await exists(pkgPath)) return null;
@@ -2439,6 +2515,7 @@ async function discoverNodeService(scanPath, dir) {
2439
2515
  return null;
2440
2516
  }
2441
2517
  if (!pkg.name) return null;
2518
+ const framework = detectJsFramework(pkg);
2442
2519
  const node = {
2443
2520
  id: (0, import_types5.serviceId)(pkg.name),
2444
2521
  type: import_types5.NodeType.ServiceNode,
@@ -2447,7 +2524,8 @@ async function discoverNodeService(scanPath, dir) {
2447
2524
  version: pkg.version,
2448
2525
  dependencies: pkg.dependencies ?? {},
2449
2526
  repoPath: import_node_path8.default.relative(scanPath, dir),
2450
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2527
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2528
+ ...framework ? { framework } : {}
2451
2529
  };
2452
2530
  return { pkg, dir, node };
2453
2531
  }
@@ -4230,7 +4308,7 @@ init_cjs_shims();
4230
4308
  var import_node_fs18 = require("fs");
4231
4309
  var import_node_path31 = __toESM(require("path"), 1);
4232
4310
  var import_types19 = require("@neat.is/types");
4233
- var SCHEMA_VERSION = 3;
4311
+ var SCHEMA_VERSION = 4;
4234
4312
  function migrateV1ToV2(payload) {
4235
4313
  const nodes = payload.graph.nodes;
4236
4314
  if (Array.isArray(nodes)) {
@@ -4242,6 +4320,9 @@ function migrateV1ToV2(payload) {
4242
4320
  }
4243
4321
  return { ...payload, schemaVersion: 2 };
4244
4322
  }
4323
+ function migrateV3ToV4(payload) {
4324
+ return { ...payload, schemaVersion: 4 };
4325
+ }
4245
4326
  function migrateV2ToV3(payload) {
4246
4327
  const edges = payload.graph.edges;
4247
4328
  if (Array.isArray(edges)) {
@@ -4290,6 +4371,9 @@ async function loadGraphFromDisk(graph, outPath) {
4290
4371
  if (payload.schemaVersion === 2) {
4291
4372
  payload = migrateV2ToV3(payload);
4292
4373
  }
4374
+ if (payload.schemaVersion === 3) {
4375
+ payload = migrateV3ToV4(payload);
4376
+ }
4293
4377
  if (payload.schemaVersion !== SCHEMA_VERSION) {
4294
4378
  throw new Error(
4295
4379
  `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
@@ -5201,6 +5285,35 @@ function registerRoutes(scope, ctx) {
5201
5285
  }
5202
5286
  }
5203
5287
  );
5288
+ scope.post("/snapshot", async (req, reply) => {
5289
+ const proj = resolveProject(registry, req, reply);
5290
+ if (!proj) return;
5291
+ const body = req.body;
5292
+ if (!body || typeof body !== "object" || !body.snapshot) {
5293
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5294
+ }
5295
+ const snap = body.snapshot;
5296
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5297
+ return reply.code(400).send({
5298
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5299
+ });
5300
+ }
5301
+ try {
5302
+ const result = mergeSnapshot(proj.graph, snap);
5303
+ return {
5304
+ project: proj.name,
5305
+ nodesAdded: result.nodesAdded,
5306
+ edgesAdded: result.edgesAdded,
5307
+ nodeCount: proj.graph.order,
5308
+ edgeCount: proj.graph.size
5309
+ };
5310
+ } catch (err) {
5311
+ return reply.code(400).send({
5312
+ error: "snapshot merge failed",
5313
+ details: err.message
5314
+ });
5315
+ }
5316
+ });
5204
5317
  scope.post("/graph/scan", async (req, reply) => {
5205
5318
  const proj = resolveProject(registry, req, reply);
5206
5319
  if (!proj) return;
@@ -5294,7 +5407,15 @@ function registerRoutes(scope, ctx) {
5294
5407
  async function buildApi(opts) {
5295
5408
  const app = (0, import_fastify.default)({ logger: false });
5296
5409
  await app.register(import_cors.default, { origin: true });
5297
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
5410
+ mountBearerAuth(app, {
5411
+ token: opts.authToken,
5412
+ trustProxy: opts.trustProxy,
5413
+ publicRead: opts.publicRead
5414
+ });
5415
+ app.get("/api/config", async () => ({
5416
+ publicRead: opts.publicRead === true,
5417
+ authProxy: opts.trustProxy === true
5418
+ }));
5298
5419
  const startedAt = opts.startedAt ?? Date.now();
5299
5420
  const registry = buildLegacyRegistry(opts);
5300
5421
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5578,7 +5699,8 @@ async function startDaemon(opts = {}) {
5578
5699
  restApp = await buildApi({
5579
5700
  projects: registry,
5580
5701
  authToken: auth.authToken,
5581
- trustProxy: auth.trustProxy
5702
+ trustProxy: auth.trustProxy,
5703
+ publicRead: auth.publicRead
5582
5704
  });
5583
5705
  restAddress = await restApp.listen({ port: restPort, host });
5584
5706
  console.log(`neatd: REST listening on ${restAddress}`);