@neat.is/core 0.6.4 → 0.7.1
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-7QXN726V.js → chunk-6H757ZNM.js} +2 -2
- package/dist/chunk-MDBE23Y3.js +840 -0
- package/dist/chunk-MDBE23Y3.js.map +1 -0
- package/dist/{chunk-ZLAZ7PLC.js → chunk-P2ZEKJ35.js} +45 -2
- package/dist/chunk-P2ZEKJ35.js.map +1 -0
- package/dist/{chunk-UCDXHLCJ.js → chunk-RR4LWQQB.js} +3929 -677
- package/dist/chunk-RR4LWQQB.js.map +1 -0
- package/dist/cli.cjs +5030 -3535
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +284 -136
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +4554 -3210
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +121 -106
- package/dist/index.d.ts +121 -106
- package/dist/index.js +4 -4
- package/dist/neatd.cjs +4579 -3235
- package/dist/neatd.cjs.map +1 -1
- package/dist/neatd.js +3 -3
- package/dist/{otel-grpc-WXKM7YBW.js → otel-grpc-APVZSB6W.js} +3 -3
- package/dist/server.cjs +4103 -884
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +3 -3
- package/package.json +3 -2
- package/dist/chunk-22X2YM5H.js +0 -2810
- package/dist/chunk-22X2YM5H.js.map +0 -1
- package/dist/chunk-UCDXHLCJ.js.map +0 -1
- package/dist/chunk-ZLAZ7PLC.js.map +0 -1
- /package/dist/{chunk-7QXN726V.js.map → chunk-6H757ZNM.js.map} +0 -0
- /package/dist/{otel-grpc-WXKM7YBW.js.map → otel-grpc-APVZSB6W.js.map} +0 -0
package/dist/chunk-22X2YM5H.js
DELETED
|
@@ -1,2810 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DEFAULT_PROJECT,
|
|
3
|
-
EnvRefUnsetError,
|
|
4
|
-
Projects,
|
|
5
|
-
attachGraphToEventBus,
|
|
6
|
-
buildApi,
|
|
7
|
-
connectorMatchesProject,
|
|
8
|
-
ensureInfraNode,
|
|
9
|
-
ensureObservedFileNode,
|
|
10
|
-
ensureServiceNode,
|
|
11
|
-
extractFromDirectory,
|
|
12
|
-
getGraph,
|
|
13
|
-
handleSpan,
|
|
14
|
-
listProjects,
|
|
15
|
-
loadGraphFromDisk,
|
|
16
|
-
makeErrorSpanWriter,
|
|
17
|
-
normalizePathTemplate,
|
|
18
|
-
pathsForProject,
|
|
19
|
-
pruneRegistry,
|
|
20
|
-
readConnectorsConfig,
|
|
21
|
-
reconcileObservedRelPath,
|
|
22
|
-
recordConnectorPoll,
|
|
23
|
-
registryPath,
|
|
24
|
-
resetGraph,
|
|
25
|
-
resolveCredential,
|
|
26
|
-
sanitizePollError,
|
|
27
|
-
saveGraphToDisk,
|
|
28
|
-
setStatus,
|
|
29
|
-
startPersistLoop,
|
|
30
|
-
startStalenessLoop,
|
|
31
|
-
touchLastSeen,
|
|
32
|
-
upsertObservedEdge,
|
|
33
|
-
writeAtomically
|
|
34
|
-
} from "./chunk-UCDXHLCJ.js";
|
|
35
|
-
import {
|
|
36
|
-
assertBindAuthority,
|
|
37
|
-
buildOtelReceiver,
|
|
38
|
-
listenSteppingOtlp,
|
|
39
|
-
readAuthEnv
|
|
40
|
-
} from "./chunk-ZLAZ7PLC.js";
|
|
41
|
-
|
|
42
|
-
// src/daemon.ts
|
|
43
|
-
import {
|
|
44
|
-
promises as fs2,
|
|
45
|
-
watch,
|
|
46
|
-
renameSync,
|
|
47
|
-
unlinkSync,
|
|
48
|
-
writeFileSync
|
|
49
|
-
} from "fs";
|
|
50
|
-
import path2 from "path";
|
|
51
|
-
import { createRequire } from "module";
|
|
52
|
-
|
|
53
|
-
// src/connectors/index.ts
|
|
54
|
-
import { NodeType, parseFileId, Provenance } from "@neat.is/types";
|
|
55
|
-
var NO_ENV = "unknown";
|
|
56
|
-
function staticCallSiteFor(graph, serviceName, targetNodeId) {
|
|
57
|
-
if (!graph.hasNode(targetNodeId)) return void 0;
|
|
58
|
-
const sites = [];
|
|
59
|
-
for (const edgeId of graph.inboundEdges(targetNodeId)) {
|
|
60
|
-
const edge = graph.getEdgeAttributes(edgeId);
|
|
61
|
-
if (edge.provenance !== Provenance.EXTRACTED) continue;
|
|
62
|
-
const parsed = parseFileId(edge.source);
|
|
63
|
-
if (!parsed || parsed.service !== serviceName || !edge.evidence) continue;
|
|
64
|
-
const site = { relPath: edge.evidence.file };
|
|
65
|
-
if (edge.evidence.line !== void 0) site.line = edge.evidence.line;
|
|
66
|
-
sites.push(site);
|
|
67
|
-
}
|
|
68
|
-
return sites.length === 1 ? sites[0] : void 0;
|
|
69
|
-
}
|
|
70
|
-
function routeCallSiteFor(graph, targetNodeId) {
|
|
71
|
-
if (!graph.hasNode(targetNodeId)) return void 0;
|
|
72
|
-
const attrs = graph.getNodeAttributes(targetNodeId);
|
|
73
|
-
if (attrs.type !== NodeType.RouteNode || !attrs.path) return void 0;
|
|
74
|
-
const site = { relPath: attrs.path };
|
|
75
|
-
if (attrs.line !== void 0) site.line = attrs.line;
|
|
76
|
-
return site;
|
|
77
|
-
}
|
|
78
|
-
async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
|
|
79
|
-
const signals = await connector.poll(ctx);
|
|
80
|
-
let edgesCreated = 0;
|
|
81
|
-
let edgesUpdated = 0;
|
|
82
|
-
let unresolved = 0;
|
|
83
|
-
for (const signal of signals) {
|
|
84
|
-
const resolved = resolveTarget(signal, ctx);
|
|
85
|
-
if (!resolved) {
|
|
86
|
-
unresolved++;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (resolved.ensureInfraNode) {
|
|
90
|
-
const { kind, name, provider } = resolved.ensureInfraNode;
|
|
91
|
-
ensureInfraNode(graph, kind, name, provider);
|
|
92
|
-
}
|
|
93
|
-
const serviceNodeId = ensureServiceNode(graph, resolved.serviceName, NO_ENV);
|
|
94
|
-
const callSite = signal.callSite ? { relPath: signal.callSite.file, line: signal.callSite.line } : routeCallSiteFor(graph, resolved.targetNodeId) ?? staticCallSiteFor(graph, resolved.serviceName, resolved.targetNodeId);
|
|
95
|
-
const sourceId = callSite ? ensureObservedFileNode(graph, resolved.serviceName, serviceNodeId, callSite) : serviceNodeId;
|
|
96
|
-
const evidence = callSite ? {
|
|
97
|
-
file: reconcileObservedRelPath(graph, resolved.serviceName, callSite.relPath),
|
|
98
|
-
line: callSite.line
|
|
99
|
-
} : void 0;
|
|
100
|
-
const calls = Math.trunc(signal.callCount);
|
|
101
|
-
if (!Number.isFinite(calls) || calls < 1) continue;
|
|
102
|
-
const errors = Number.isFinite(signal.errorCount) ? Math.min(Math.max(Math.trunc(signal.errorCount), 0), calls) : 0;
|
|
103
|
-
let created = false;
|
|
104
|
-
let ok = true;
|
|
105
|
-
for (let i = 0; i < calls; i++) {
|
|
106
|
-
const result = upsertObservedEdge(
|
|
107
|
-
graph,
|
|
108
|
-
resolved.edgeType,
|
|
109
|
-
sourceId,
|
|
110
|
-
resolved.targetNodeId,
|
|
111
|
-
signal.lastObservedIso,
|
|
112
|
-
i < errors,
|
|
113
|
-
evidence
|
|
114
|
-
);
|
|
115
|
-
if (!result) {
|
|
116
|
-
ok = false;
|
|
117
|
-
break;
|
|
118
|
-
}
|
|
119
|
-
if (i === 0) created = result.created;
|
|
120
|
-
}
|
|
121
|
-
if (!ok) {
|
|
122
|
-
unresolved++;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (created) edgesCreated++;
|
|
126
|
-
else edgesUpdated++;
|
|
127
|
-
}
|
|
128
|
-
return { signalCount: signals.length, edgesCreated, edgesUpdated, unresolved };
|
|
129
|
-
}
|
|
130
|
-
var DEFAULT_POLL_INTERVAL_MS = 6e4;
|
|
131
|
-
function startConnectorPollLoop(connector, ctx, graph, resolveTarget, options = {}) {
|
|
132
|
-
let stopped = false;
|
|
133
|
-
let since = ctx.since;
|
|
134
|
-
const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
135
|
-
const connectorId = options.connectorId;
|
|
136
|
-
const onError = options.onError ?? ((err) => console.error(`[neatd] connector poll failed (${connector.provider})`, err));
|
|
137
|
-
const tick = () => {
|
|
138
|
-
if (stopped) return;
|
|
139
|
-
void (async () => {
|
|
140
|
-
const tickStartedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
141
|
-
try {
|
|
142
|
-
const result = await runConnectorPoll(connector, { ...ctx, since }, graph, resolveTarget);
|
|
143
|
-
since = tickStartedAt;
|
|
144
|
-
if (connectorId) {
|
|
145
|
-
recordConnectorPoll(connectorId, {
|
|
146
|
-
outcome: "ok",
|
|
147
|
-
at: tickStartedAt,
|
|
148
|
-
signalsLastPoll: result.signalCount
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
} catch (err) {
|
|
152
|
-
onError(err);
|
|
153
|
-
if (connectorId) {
|
|
154
|
-
recordConnectorPoll(connectorId, {
|
|
155
|
-
outcome: "error",
|
|
156
|
-
at: tickStartedAt,
|
|
157
|
-
error: sanitizePollError(err)
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
})();
|
|
162
|
-
};
|
|
163
|
-
tick();
|
|
164
|
-
const interval = setInterval(tick, intervalMs);
|
|
165
|
-
if (typeof interval.unref === "function") interval.unref();
|
|
166
|
-
return () => {
|
|
167
|
-
stopped = true;
|
|
168
|
-
clearInterval(interval);
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// src/connectors/junction.ts
|
|
173
|
-
var buckets = /* @__PURE__ */ new Map();
|
|
174
|
-
function bucketMapKey(provider, accountKey) {
|
|
175
|
-
return `${provider}\0${accountKey}`;
|
|
176
|
-
}
|
|
177
|
-
function getBucket(provider, accountKey, config) {
|
|
178
|
-
const key = bucketMapKey(provider, accountKey);
|
|
179
|
-
const existing = buckets.get(key);
|
|
180
|
-
if (existing && existing.capacity === config.capacity && existing.refillMs === config.refillMs) {
|
|
181
|
-
return existing;
|
|
182
|
-
}
|
|
183
|
-
const fresh = { ...config, tokens: config.capacity, updatedAt: Date.now() };
|
|
184
|
-
buckets.set(key, fresh);
|
|
185
|
-
return fresh;
|
|
186
|
-
}
|
|
187
|
-
function refillBucket(bucket, now) {
|
|
188
|
-
if (now <= bucket.updatedAt) return;
|
|
189
|
-
const elapsed = now - bucket.updatedAt;
|
|
190
|
-
const grant = elapsed / bucket.refillMs;
|
|
191
|
-
if (grant <= 0) return;
|
|
192
|
-
bucket.tokens = Math.min(bucket.capacity, bucket.tokens + grant);
|
|
193
|
-
bucket.updatedAt = now;
|
|
194
|
-
}
|
|
195
|
-
var RateLimitExceededError = class extends Error {
|
|
196
|
-
constructor(provider, accountKey) {
|
|
197
|
-
super(
|
|
198
|
-
`junction: rate limit exceeded for ${provider}:${accountKey} \u2014 waiting for the next token would exceed this call's wall-clock budget`
|
|
199
|
-
);
|
|
200
|
-
this.name = "RateLimitExceededError";
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
function delay(ms) {
|
|
204
|
-
if (ms <= 0) return Promise.resolve();
|
|
205
|
-
return new Promise((resolve) => {
|
|
206
|
-
const timer = setTimeout(resolve, ms);
|
|
207
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
async function acquireToken(provider, accountKey, config, remainingBudgetMs) {
|
|
211
|
-
const bucket = getBucket(provider, accountKey, config);
|
|
212
|
-
refillBucket(bucket, Date.now());
|
|
213
|
-
if (bucket.tokens >= 1) {
|
|
214
|
-
bucket.tokens -= 1;
|
|
215
|
-
return 0;
|
|
216
|
-
}
|
|
217
|
-
const waitMs = Math.ceil((1 - bucket.tokens) * bucket.refillMs);
|
|
218
|
-
if (waitMs > remainingBudgetMs) {
|
|
219
|
-
throw new RateLimitExceededError(provider, accountKey);
|
|
220
|
-
}
|
|
221
|
-
await delay(waitMs);
|
|
222
|
-
refillBucket(bucket, Date.now());
|
|
223
|
-
bucket.tokens = Math.max(0, bucket.tokens - 1);
|
|
224
|
-
return waitMs;
|
|
225
|
-
}
|
|
226
|
-
var JUNCTION_DEFAULT_RATE_LIMITS = {
|
|
227
|
-
// ~300 requests / 5 minutes (ADR-131). Burst capacity holds a third of
|
|
228
|
-
// that ceiling; steady-state refill (1 token / 3s = 20/min = 100/5min)
|
|
229
|
-
// stays well clear of the documented limit even under sustained polling.
|
|
230
|
-
cloudflare: { capacity: 100, refillMs: 3e3 },
|
|
231
|
-
// Placeholder pending a live project confirming the real cap
|
|
232
|
-
// (docs/connectors/railway.md: "does not appear to publish one as of this
|
|
233
|
-
// writing").
|
|
234
|
-
railway: { capacity: 30, refillMs: 1e4 },
|
|
235
|
-
// Placeholder pending a live rate-limit check (docs/connectors/
|
|
236
|
-
// firebase.md: "needs-endpoint-testing against entries.list's live rate
|
|
237
|
-
// limits").
|
|
238
|
-
firebase: { capacity: 30, refillMs: 1e4 },
|
|
239
|
-
// Placeholder pending a live rate-limit check (docs/connectors/supabase.md:
|
|
240
|
-
// "the documented rate limit for this specific endpoint is unconfirmed").
|
|
241
|
-
supabase: { capacity: 30, refillMs: 1e4 },
|
|
242
|
-
// Not a documented API limit at all — a self-imposed ceiling on the raw
|
|
243
|
-
// pg_stat_statements connection (see module header above).
|
|
244
|
-
"supabase-postgres": { capacity: 20, refillMs: 3e3 },
|
|
245
|
-
// Push provider (ADR-146): the Drains REST API is touched only by `neat
|
|
246
|
-
// connector add/remove/test` (provision/deprovision/validate), never a poll
|
|
247
|
-
// loop, so this bucket is exercised a handful of times per command. Kept
|
|
248
|
-
// conservative pending a documented Drains-API rate limit.
|
|
249
|
-
vercel: { capacity: 20, refillMs: 5e3 }
|
|
250
|
-
};
|
|
251
|
-
var JUNCTION_GENERIC_RATE_LIMIT = { capacity: 20, refillMs: 5e3 };
|
|
252
|
-
function defaultRateLimitFor(provider) {
|
|
253
|
-
return JUNCTION_DEFAULT_RATE_LIMITS[provider] ?? JUNCTION_GENERIC_RATE_LIMIT;
|
|
254
|
-
}
|
|
255
|
-
var JUNCTION_DEFAULT_TIMEOUT_MS = 1e4;
|
|
256
|
-
var JUNCTION_DEFAULT_MAX_ATTEMPTS = 3;
|
|
257
|
-
var JUNCTION_DEFAULT_INITIAL_BACKOFF_MS = 200;
|
|
258
|
-
var JUNCTION_DEFAULT_BACKOFF_MULTIPLIER = 4;
|
|
259
|
-
var JUNCTION_DEFAULT_MAX_ELAPSED_MS = 3e4;
|
|
260
|
-
var JUNCTION_DEFAULT_DB_TIMEOUT_MS = 1e4;
|
|
261
|
-
async function backoff(attempt, initialBackoffMs, backoffMultiplier, remainingBudgetMs) {
|
|
262
|
-
const raw = initialBackoffMs * backoffMultiplier ** (attempt - 1);
|
|
263
|
-
const capped = Math.max(0, Math.min(raw, remainingBudgetMs));
|
|
264
|
-
await delay(capped);
|
|
265
|
-
}
|
|
266
|
-
function safeUrlLabel(url) {
|
|
267
|
-
try {
|
|
268
|
-
const u = typeof url === "string" ? new URL(url) : url;
|
|
269
|
-
return `${u.origin}${u.pathname}`;
|
|
270
|
-
} catch {
|
|
271
|
-
return String(url);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
function logOutcome(provider, accountKey, outcome, method, label, attempt, startedAt) {
|
|
275
|
-
const elapsedMs = Date.now() - startedAt;
|
|
276
|
-
const line = `[neat connector] ${provider}:${accountKey} ${method} ${label} \u2014 ${outcome} (attempt ${attempt}, ${elapsedMs}ms)`;
|
|
277
|
-
if (outcome === "success" || outcome === "retried-then-succeeded") {
|
|
278
|
-
console.log(line);
|
|
279
|
-
} else if (outcome === "rate-limited") {
|
|
280
|
-
console.warn(line);
|
|
281
|
-
} else {
|
|
282
|
-
console.error(line);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
function bearerAuthHeader(token) {
|
|
286
|
-
return { Authorization: `Bearer ${token}` };
|
|
287
|
-
}
|
|
288
|
-
async function junctionFetch(url, init = {}, policy) {
|
|
289
|
-
const {
|
|
290
|
-
provider,
|
|
291
|
-
accountKey,
|
|
292
|
-
timeoutMs = JUNCTION_DEFAULT_TIMEOUT_MS,
|
|
293
|
-
maxAttempts = JUNCTION_DEFAULT_MAX_ATTEMPTS,
|
|
294
|
-
maxElapsedMs = JUNCTION_DEFAULT_MAX_ELAPSED_MS,
|
|
295
|
-
initialBackoffMs = JUNCTION_DEFAULT_INITIAL_BACKOFF_MS,
|
|
296
|
-
backoffMultiplier = JUNCTION_DEFAULT_BACKOFF_MULTIPLIER,
|
|
297
|
-
rateLimit = defaultRateLimitFor(provider),
|
|
298
|
-
fetchImpl = fetch
|
|
299
|
-
} = policy;
|
|
300
|
-
const method = (init.method ?? "GET").toUpperCase();
|
|
301
|
-
const label = safeUrlLabel(url);
|
|
302
|
-
const startedAt = Date.now();
|
|
303
|
-
let attempt = 0;
|
|
304
|
-
let sawRetry = false;
|
|
305
|
-
for (; ; ) {
|
|
306
|
-
attempt++;
|
|
307
|
-
const remainingBudget = maxElapsedMs - (Date.now() - startedAt);
|
|
308
|
-
if (remainingBudget <= 0) {
|
|
309
|
-
logOutcome(provider, accountKey, "retried-then-failed", method, label, attempt - 1, startedAt);
|
|
310
|
-
throw new Error(
|
|
311
|
-
`junction: ${provider}:${accountKey} ${method} ${label} exceeded its wall-clock budget (${maxElapsedMs}ms) after ${attempt - 1} attempt(s)`
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
try {
|
|
315
|
-
await acquireToken(provider, accountKey, rateLimit, remainingBudget);
|
|
316
|
-
} catch (err) {
|
|
317
|
-
if (err instanceof RateLimitExceededError) {
|
|
318
|
-
logOutcome(provider, accountKey, "rate-limited", method, label, attempt, startedAt);
|
|
319
|
-
}
|
|
320
|
-
throw err;
|
|
321
|
-
}
|
|
322
|
-
const controller = new AbortController();
|
|
323
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
324
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
325
|
-
try {
|
|
326
|
-
const res = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
327
|
-
clearTimeout(timer);
|
|
328
|
-
if (res.ok || res.status < 500) {
|
|
329
|
-
logOutcome(provider, accountKey, sawRetry ? "retried-then-succeeded" : "success", method, label, attempt, startedAt);
|
|
330
|
-
return res;
|
|
331
|
-
}
|
|
332
|
-
if (attempt >= maxAttempts) {
|
|
333
|
-
logOutcome(provider, accountKey, sawRetry ? "retried-then-failed" : "failed", method, label, attempt, startedAt);
|
|
334
|
-
return res;
|
|
335
|
-
}
|
|
336
|
-
sawRetry = true;
|
|
337
|
-
await backoff(attempt, initialBackoffMs, backoffMultiplier, maxElapsedMs - (Date.now() - startedAt));
|
|
338
|
-
} catch (err) {
|
|
339
|
-
clearTimeout(timer);
|
|
340
|
-
if (attempt >= maxAttempts) {
|
|
341
|
-
logOutcome(provider, accountKey, sawRetry ? "retried-then-failed" : "failed", method, label, attempt, startedAt);
|
|
342
|
-
throw err;
|
|
343
|
-
}
|
|
344
|
-
sawRetry = true;
|
|
345
|
-
await backoff(attempt, initialBackoffMs, backoffMultiplier, maxElapsedMs - (Date.now() - startedAt));
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
var DbJunctionTimeoutError = class extends Error {
|
|
350
|
-
constructor(ms) {
|
|
351
|
-
super(`junction: db query exceeded its ${ms}ms timeout`);
|
|
352
|
-
this.name = "DbJunctionTimeoutError";
|
|
353
|
-
}
|
|
354
|
-
};
|
|
355
|
-
function withTimeout(run, timeoutMs) {
|
|
356
|
-
return new Promise((resolve, reject) => {
|
|
357
|
-
const timer = setTimeout(() => reject(new DbJunctionTimeoutError(timeoutMs)), timeoutMs);
|
|
358
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
359
|
-
run().then(
|
|
360
|
-
(value) => {
|
|
361
|
-
clearTimeout(timer);
|
|
362
|
-
resolve(value);
|
|
363
|
-
},
|
|
364
|
-
(err) => {
|
|
365
|
-
clearTimeout(timer);
|
|
366
|
-
reject(err);
|
|
367
|
-
}
|
|
368
|
-
);
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
var RETRYABLE_NODE_ERROR_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "EAI_AGAIN", "EPIPE"]);
|
|
372
|
-
function isRetryableDbError(err) {
|
|
373
|
-
if (err instanceof DbJunctionTimeoutError) return true;
|
|
374
|
-
const code = err?.code;
|
|
375
|
-
if (typeof code !== "string") return false;
|
|
376
|
-
if (code.startsWith("08") || code === "57P03") return true;
|
|
377
|
-
return RETRYABLE_NODE_ERROR_CODES.has(code);
|
|
378
|
-
}
|
|
379
|
-
async function dbJunction(run, policy) {
|
|
380
|
-
const {
|
|
381
|
-
provider,
|
|
382
|
-
accountKey,
|
|
383
|
-
timeoutMs = JUNCTION_DEFAULT_DB_TIMEOUT_MS,
|
|
384
|
-
maxAttempts = JUNCTION_DEFAULT_MAX_ATTEMPTS,
|
|
385
|
-
maxElapsedMs = JUNCTION_DEFAULT_MAX_ELAPSED_MS,
|
|
386
|
-
initialBackoffMs = JUNCTION_DEFAULT_INITIAL_BACKOFF_MS,
|
|
387
|
-
backoffMultiplier = JUNCTION_DEFAULT_BACKOFF_MULTIPLIER,
|
|
388
|
-
rateLimit = defaultRateLimitFor(provider)
|
|
389
|
-
} = policy;
|
|
390
|
-
const startedAt = Date.now();
|
|
391
|
-
let attempt = 0;
|
|
392
|
-
let sawRetry = false;
|
|
393
|
-
for (; ; ) {
|
|
394
|
-
attempt++;
|
|
395
|
-
const remainingBudget = maxElapsedMs - (Date.now() - startedAt);
|
|
396
|
-
if (remainingBudget <= 0) {
|
|
397
|
-
logOutcome(provider, accountKey, "retried-then-failed", "QUERY", "db", attempt - 1, startedAt);
|
|
398
|
-
throw new Error(
|
|
399
|
-
`junction: ${provider}:${accountKey} db query exceeded its wall-clock budget (${maxElapsedMs}ms) after ${attempt - 1} attempt(s)`
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
try {
|
|
403
|
-
await acquireToken(provider, accountKey, rateLimit, remainingBudget);
|
|
404
|
-
} catch (err) {
|
|
405
|
-
if (err instanceof RateLimitExceededError) {
|
|
406
|
-
logOutcome(provider, accountKey, "rate-limited", "QUERY", "db", attempt, startedAt);
|
|
407
|
-
}
|
|
408
|
-
throw err;
|
|
409
|
-
}
|
|
410
|
-
try {
|
|
411
|
-
const result = await withTimeout(run, timeoutMs);
|
|
412
|
-
logOutcome(provider, accountKey, sawRetry ? "retried-then-succeeded" : "success", "QUERY", "db", attempt, startedAt);
|
|
413
|
-
return result;
|
|
414
|
-
} catch (err) {
|
|
415
|
-
if (!isRetryableDbError(err) || attempt >= maxAttempts) {
|
|
416
|
-
logOutcome(provider, accountKey, sawRetry ? "retried-then-failed" : "failed", "QUERY", "db", attempt, startedAt);
|
|
417
|
-
throw err;
|
|
418
|
-
}
|
|
419
|
-
sawRetry = true;
|
|
420
|
-
await backoff(attempt, initialBackoffMs, backoffMultiplier, maxElapsedMs - (Date.now() - startedAt));
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// src/connectors/supabase/client.ts
|
|
426
|
-
var DEFAULT_SUPABASE_MANAGEMENT_API_URL = "https://api.supabase.com";
|
|
427
|
-
var DEFAULT_LOG_LIMIT = 1e3;
|
|
428
|
-
var SUPABASE_LOG_QUERY_MAX_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
429
|
-
function boundedSupabaseLogWindow(since, now, maxLookbackMs) {
|
|
430
|
-
const window = Math.min(maxLookbackMs, SUPABASE_LOG_QUERY_MAX_WINDOW_MS);
|
|
431
|
-
const floor = new Date(now.getTime() - window);
|
|
432
|
-
const endIso = now.toISOString();
|
|
433
|
-
if (!since) return { startIso: floor.toISOString(), endIso, truncated: false };
|
|
434
|
-
const sinceMs = new Date(since).getTime();
|
|
435
|
-
if (Number.isNaN(sinceMs)) return { startIso: floor.toISOString(), endIso, truncated: false };
|
|
436
|
-
if (sinceMs < floor.getTime()) return { startIso: floor.toISOString(), endIso, truncated: true };
|
|
437
|
-
return { startIso: new Date(sinceMs).toISOString(), endIso, truncated: false };
|
|
438
|
-
}
|
|
439
|
-
function buildEdgeLogsQuery(limit) {
|
|
440
|
-
const safeLimit = Math.max(1, Math.trunc(limit) || DEFAULT_LOG_LIMIT);
|
|
441
|
-
return [
|
|
442
|
-
"select",
|
|
443
|
-
" format_timestamp('%Y-%m-%dT%H:%M:%E6SZ', timestamp) as timestamp,",
|
|
444
|
-
" request.method as method,",
|
|
445
|
-
" request.path as path,",
|
|
446
|
-
" response.status_code as status_code",
|
|
447
|
-
"from edge_logs",
|
|
448
|
-
"cross join unnest(metadata) as metadata",
|
|
449
|
-
"cross join unnest(metadata.request) as request",
|
|
450
|
-
"cross join unnest(metadata.response) as response",
|
|
451
|
-
"where regexp_contains(request.path, '^/rest/v1/')",
|
|
452
|
-
"order by timestamp asc",
|
|
453
|
-
`limit ${safeLimit}`
|
|
454
|
-
].join("\n");
|
|
455
|
-
}
|
|
456
|
-
function logsAllHttpFailureMessage(status) {
|
|
457
|
-
if (status === 401 || status === 403) {
|
|
458
|
-
return `supabase connector: logs.all request rejected (HTTP ${status}). Check the Management API token, its analytics read scope, and --api-project-ref.`;
|
|
459
|
-
}
|
|
460
|
-
if (status === 404) {
|
|
461
|
-
return "supabase connector: logs.all project not found (HTTP 404). Check --api-project-ref.";
|
|
462
|
-
}
|
|
463
|
-
if (status === 429) {
|
|
464
|
-
return "supabase connector: logs.all rate-limited (HTTP 429). The connector will retry on the next poll; reduce poll cadence if this persists.";
|
|
465
|
-
}
|
|
466
|
-
if (status === 400) {
|
|
467
|
-
return "supabase connector: logs.all query rejected (HTTP 400). Provider details redacted; confirm the live log-query dialect before shipping this connector.";
|
|
468
|
-
}
|
|
469
|
-
return `supabase connector: logs.all request failed (HTTP ${status}); provider details redacted.`;
|
|
470
|
-
}
|
|
471
|
-
function providerErrorDetails(error) {
|
|
472
|
-
if (!error || typeof error === "string") return "";
|
|
473
|
-
const parts = [];
|
|
474
|
-
if (typeof error.code === "number") parts.push(`code ${error.code}`);
|
|
475
|
-
if (typeof error.status === "string" && /^[A-Z0-9_.-]+$/.test(error.status)) {
|
|
476
|
-
parts.push(`status ${error.status}`);
|
|
477
|
-
}
|
|
478
|
-
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
479
|
-
}
|
|
480
|
-
async function fetchSupabaseEdgeLogs(config, token, startIso, endIso, fetchImpl = fetch) {
|
|
481
|
-
const baseUrl = config.managementApiUrl ?? DEFAULT_SUPABASE_MANAGEMENT_API_URL;
|
|
482
|
-
const url = new URL(`${baseUrl}/v1/projects/${config.apiProjectRef}/analytics/endpoints/logs.all`);
|
|
483
|
-
url.searchParams.set("sql", buildEdgeLogsQuery(config.logLimit ?? DEFAULT_LOG_LIMIT));
|
|
484
|
-
url.searchParams.set("iso_timestamp_start", startIso);
|
|
485
|
-
url.searchParams.set("iso_timestamp_end", endIso);
|
|
486
|
-
const res = await junctionFetch(
|
|
487
|
-
url,
|
|
488
|
-
{ method: "GET", headers: bearerAuthHeader(token) },
|
|
489
|
-
// accountKey: the Supabase project ref (ADR-131's own worked example) —
|
|
490
|
-
// the Management API's rate limit is enforced per project.
|
|
491
|
-
{ provider: "supabase", accountKey: config.apiProjectRef, fetchImpl }
|
|
492
|
-
);
|
|
493
|
-
if (!res.ok) {
|
|
494
|
-
throw new Error(logsAllHttpFailureMessage(res.status));
|
|
495
|
-
}
|
|
496
|
-
let body;
|
|
497
|
-
try {
|
|
498
|
-
body = await res.json();
|
|
499
|
-
} catch {
|
|
500
|
-
throw new Error("supabase connector: logs.all returned invalid JSON; provider details redacted.");
|
|
501
|
-
}
|
|
502
|
-
if (body.error) {
|
|
503
|
-
throw new Error(
|
|
504
|
-
`supabase connector: logs.all returned a provider error${providerErrorDetails(body.error)}; provider message redacted.`
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
return body.result ?? [];
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// src/connectors/supabase/types.ts
|
|
511
|
-
function readSupabaseCredentials(raw) {
|
|
512
|
-
const managementToken = raw["managementToken"];
|
|
513
|
-
if (typeof managementToken !== "string" || managementToken.length === 0) {
|
|
514
|
-
throw new Error("supabase connector: credentials.managementToken must be a non-empty string");
|
|
515
|
-
}
|
|
516
|
-
const postgresConnectionString = raw["postgresConnectionString"];
|
|
517
|
-
if (postgresConnectionString !== void 0 && (typeof postgresConnectionString !== "string" || postgresConnectionString.length === 0)) {
|
|
518
|
-
throw new Error(
|
|
519
|
-
"supabase connector: credentials.postgresConnectionString must be a non-empty string when present"
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
return {
|
|
523
|
-
managementToken,
|
|
524
|
-
...postgresConnectionString ? { postgresConnectionString } : {}
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
var SUPABASE_TABLE_TARGET_KIND = "supabase-table";
|
|
528
|
-
var SUPABASE_RPC_TARGET_KIND = "supabase-rpc";
|
|
529
|
-
|
|
530
|
-
// src/connectors/supabase/map.ts
|
|
531
|
-
var REST_RPC_PATH_RE = /^\/rest\/v1\/rpc\/([^/?]+)/;
|
|
532
|
-
var REST_TABLE_PATH_RE = /^\/rest\/v1\/([^/?]+)/;
|
|
533
|
-
function targetFromRestPath(path3) {
|
|
534
|
-
const rpcMatch = REST_RPC_PATH_RE.exec(path3);
|
|
535
|
-
if (rpcMatch) return { targetKind: SUPABASE_RPC_TARGET_KIND, name: rpcMatch[1] };
|
|
536
|
-
const tableMatch = REST_TABLE_PATH_RE.exec(path3);
|
|
537
|
-
if (tableMatch) return { targetKind: SUPABASE_TABLE_TARGET_KIND, name: tableMatch[1] };
|
|
538
|
-
return null;
|
|
539
|
-
}
|
|
540
|
-
var ERROR_STATUS_THRESHOLD = 500;
|
|
541
|
-
function mapEdgeLogRowsToSignals(rows) {
|
|
542
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
543
|
-
if (!Array.isArray(rows)) return [];
|
|
544
|
-
for (const row of rows) {
|
|
545
|
-
if (!row || typeof row !== "object") continue;
|
|
546
|
-
if (typeof row.path !== "string" || typeof row.timestamp !== "string") continue;
|
|
547
|
-
const target = targetFromRestPath(row.path);
|
|
548
|
-
if (!target) continue;
|
|
549
|
-
const key = `${target.targetKind}:${target.name}`;
|
|
550
|
-
const isError = row.status_code >= ERROR_STATUS_THRESHOLD;
|
|
551
|
-
const existing = buckets2.get(key);
|
|
552
|
-
if (existing) {
|
|
553
|
-
existing.callCount += 1;
|
|
554
|
-
if (isError) existing.errorCount += 1;
|
|
555
|
-
if (row.timestamp > existing.lastObservedIso) existing.lastObservedIso = row.timestamp;
|
|
556
|
-
} else {
|
|
557
|
-
buckets2.set(key, {
|
|
558
|
-
targetKind: target.targetKind,
|
|
559
|
-
targetName: target.name,
|
|
560
|
-
callCount: 1,
|
|
561
|
-
errorCount: isError ? 1 : 0,
|
|
562
|
-
lastObservedIso: row.timestamp
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
return [...buckets2.values()].map((b) => ({
|
|
567
|
-
targetKind: b.targetKind,
|
|
568
|
-
targetName: b.targetName,
|
|
569
|
-
callCount: b.callCount,
|
|
570
|
-
errorCount: b.errorCount,
|
|
571
|
-
lastObservedIso: b.lastObservedIso
|
|
572
|
-
}));
|
|
573
|
-
}
|
|
574
|
-
var FROM_TABLE_RE = /\bfrom\s+"?(?:[a-z_][a-z0-9_]*"?\.)?"?([a-z_][a-z0-9_]*)"?/i;
|
|
575
|
-
var SYSTEM_SCHEMA_PREFIXES = ["pg_", "information_schema"];
|
|
576
|
-
function tableNameFromQueryText(query) {
|
|
577
|
-
const match = FROM_TABLE_RE.exec(query);
|
|
578
|
-
if (!match) return null;
|
|
579
|
-
const name = match[1];
|
|
580
|
-
const lower = name.toLowerCase();
|
|
581
|
-
if (SYSTEM_SCHEMA_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null;
|
|
582
|
-
return name;
|
|
583
|
-
}
|
|
584
|
-
function diffPgStatStatementsToSignals(rows, previous, nowIso) {
|
|
585
|
-
const signals = [];
|
|
586
|
-
const seen = /* @__PURE__ */ new Set();
|
|
587
|
-
if (!Array.isArray(rows)) return signals;
|
|
588
|
-
for (const row of rows) {
|
|
589
|
-
if (!row || typeof row !== "object" || typeof row.queryid !== "string") continue;
|
|
590
|
-
const calls = Number(row.calls);
|
|
591
|
-
if (!Number.isFinite(calls)) continue;
|
|
592
|
-
seen.add(row.queryid);
|
|
593
|
-
const prior = previous.get(row.queryid);
|
|
594
|
-
previous.set(row.queryid, { calls });
|
|
595
|
-
if (!prior || calls < prior.calls) continue;
|
|
596
|
-
const delta = calls - prior.calls;
|
|
597
|
-
if (delta <= 0) continue;
|
|
598
|
-
const table = tableNameFromQueryText(row.query);
|
|
599
|
-
if (!table) continue;
|
|
600
|
-
signals.push({
|
|
601
|
-
targetKind: SUPABASE_TABLE_TARGET_KIND,
|
|
602
|
-
targetName: table,
|
|
603
|
-
callCount: delta,
|
|
604
|
-
errorCount: 0,
|
|
605
|
-
lastObservedIso: nowIso
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
for (const queryid of [...previous.keys()]) {
|
|
609
|
-
if (!seen.has(queryid)) previous.delete(queryid);
|
|
610
|
-
}
|
|
611
|
-
return signals;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// src/connectors/supabase/postgres-client.ts
|
|
615
|
-
import pg from "pg";
|
|
616
|
-
var { Client } = pg;
|
|
617
|
-
var DEFAULT_STATEMENT_LIMIT = 500;
|
|
618
|
-
var STATEMENTS_QUERY = `
|
|
619
|
-
select queryid, query, calls, total_exec_time, rows
|
|
620
|
-
from pg_stat_statements
|
|
621
|
-
where query ~* '^\\s*select\\b'
|
|
622
|
-
order by calls desc
|
|
623
|
-
limit $1
|
|
624
|
-
`;
|
|
625
|
-
async function fetchPgStatStatements(connectionString, limit = DEFAULT_STATEMENT_LIMIT, accountKey = "unknown", clientFactory = (cs) => new Client({ connectionString: cs })) {
|
|
626
|
-
return dbJunction(
|
|
627
|
-
async () => {
|
|
628
|
-
const client = clientFactory(connectionString);
|
|
629
|
-
await client.connect();
|
|
630
|
-
try {
|
|
631
|
-
await client.query("SET default_transaction_read_only = on");
|
|
632
|
-
const result = await client.query(STATEMENTS_QUERY, [limit]);
|
|
633
|
-
return result.rows;
|
|
634
|
-
} finally {
|
|
635
|
-
await client.end();
|
|
636
|
-
}
|
|
637
|
-
},
|
|
638
|
-
{ provider: "supabase-postgres", accountKey }
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// src/connectors/supabase/resolve.ts
|
|
643
|
-
import { EdgeType, infraId } from "@neat.is/types";
|
|
644
|
-
function createSupabaseResolveTarget(graph, config) {
|
|
645
|
-
return (signal, _ctx) => {
|
|
646
|
-
if (signal.targetKind !== SUPABASE_TABLE_TARGET_KIND && signal.targetKind !== SUPABASE_RPC_TARGET_KIND) {
|
|
647
|
-
return null;
|
|
648
|
-
}
|
|
649
|
-
const subResourceId = infraId(signal.targetKind, `${config.nodeRef}/${signal.targetName}`);
|
|
650
|
-
if (graph.hasNode(subResourceId)) {
|
|
651
|
-
return { targetNodeId: subResourceId, serviceName: config.serviceName, edgeType: EdgeType.CALLS };
|
|
652
|
-
}
|
|
653
|
-
const bareResourceId = infraId(signal.targetKind, signal.targetName);
|
|
654
|
-
if (graph.hasNode(bareResourceId)) {
|
|
655
|
-
return { targetNodeId: bareResourceId, serviceName: config.serviceName, edgeType: EdgeType.CALLS };
|
|
656
|
-
}
|
|
657
|
-
const projectLevelId = infraId("supabase", config.nodeRef);
|
|
658
|
-
if (graph.hasNode(projectLevelId)) {
|
|
659
|
-
return { targetNodeId: projectLevelId, serviceName: config.serviceName, edgeType: EdgeType.CALLS };
|
|
660
|
-
}
|
|
661
|
-
return null;
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// src/connectors/supabase/index.ts
|
|
666
|
-
var DEFAULT_MAX_LOOKBACK_MS = 24 * 60 * 60 * 1e3;
|
|
667
|
-
function errorCode(err) {
|
|
668
|
-
const code = err?.code;
|
|
669
|
-
return typeof code === "string" && code.length > 0 ? code : void 0;
|
|
670
|
-
}
|
|
671
|
-
function describeSupabasePostgresSurfaceFailure(projectRef, err) {
|
|
672
|
-
const code = errorCode(err);
|
|
673
|
-
let reason;
|
|
674
|
-
switch (code) {
|
|
675
|
-
case "42501":
|
|
676
|
-
reason = "permission denied; grant pg_read_all_stats to the configured Postgres role";
|
|
677
|
-
break;
|
|
678
|
-
case "42P01":
|
|
679
|
-
case "42704":
|
|
680
|
-
reason = "pg_stat_statements is not enabled or visible to the configured Postgres role";
|
|
681
|
-
break;
|
|
682
|
-
case "28P01":
|
|
683
|
-
case "28000":
|
|
684
|
-
reason = "Postgres credential rejected";
|
|
685
|
-
break;
|
|
686
|
-
case "3D000":
|
|
687
|
-
reason = "database not found";
|
|
688
|
-
break;
|
|
689
|
-
default: {
|
|
690
|
-
const name = err instanceof Error && err.name ? err.name : "Error";
|
|
691
|
-
reason = code ? `${name} ${code}` : name;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
return `supabase connector: pg_stat_statements surface unavailable for project ${projectRef} (${reason}); continuing with Management API log surface.`;
|
|
695
|
-
}
|
|
696
|
-
var SupabaseConnector = class {
|
|
697
|
-
// `deps.fetchPgStatStatements` defaults to the real Postgres-backed
|
|
698
|
-
// implementation; tests override it to exercise the "both surfaces
|
|
699
|
-
// combine" and "surface 2 only runs when a connection string is present"
|
|
700
|
-
// behavior without a live database — the same dependency-injection seam
|
|
701
|
-
// `fetchImpl` gives cloudflare/client.ts's tests for `fetch`.
|
|
702
|
-
constructor(config, deps = {}) {
|
|
703
|
-
this.config = config;
|
|
704
|
-
this.deps = deps;
|
|
705
|
-
}
|
|
706
|
-
config;
|
|
707
|
-
deps;
|
|
708
|
-
provider = "supabase";
|
|
709
|
-
// pg_stat_statements.calls is cumulative, not per-window (map.ts's
|
|
710
|
-
// diffPgStatStatementsToSignals doc comment) — this Map carries the
|
|
711
|
-
// previous poll's counts across ticks, the same way
|
|
712
|
-
// `startConnectorPollLoop` (connectors/index.ts) carries `since` across
|
|
713
|
-
// ticks for every connector. Lives on the instance, not `ConnectorContext`,
|
|
714
|
-
// because `ConnectorContext` is rebuilt fresh per tick (connectors/index.ts's
|
|
715
|
-
// `{ ...ctx, since }`) while this connector object is the one thing every
|
|
716
|
-
// tick shares.
|
|
717
|
-
statementBaselines = /* @__PURE__ */ new Map();
|
|
718
|
-
async poll(ctx) {
|
|
719
|
-
const creds = readSupabaseCredentials(ctx.credentials);
|
|
720
|
-
const now = /* @__PURE__ */ new Date();
|
|
721
|
-
const maxLookbackMs = this.config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS;
|
|
722
|
-
const { startIso, endIso } = boundedSupabaseLogWindow(ctx.since, now, maxLookbackMs);
|
|
723
|
-
const logRows = await fetchSupabaseEdgeLogs(this.config, creds.managementToken, startIso, endIso);
|
|
724
|
-
const signals = mapEdgeLogRowsToSignals(logRows);
|
|
725
|
-
if (creds.postgresConnectionString) {
|
|
726
|
-
const fetchStatements = this.deps.fetchPgStatStatements ?? fetchPgStatStatements;
|
|
727
|
-
try {
|
|
728
|
-
const statementRows = await fetchStatements(
|
|
729
|
-
creds.postgresConnectionString,
|
|
730
|
-
this.config.statementLimit ?? DEFAULT_STATEMENT_LIMIT,
|
|
731
|
-
this.config.apiProjectRef
|
|
732
|
-
);
|
|
733
|
-
signals.push(...diffPgStatStatementsToSignals(statementRows, this.statementBaselines, now.toISOString()));
|
|
734
|
-
} catch (err) {
|
|
735
|
-
const summary = describeSupabasePostgresSurfaceFailure(this.config.apiProjectRef, err);
|
|
736
|
-
if (this.deps.onPostgresSurfaceError) this.deps.onPostgresSurfaceError(err, summary);
|
|
737
|
-
else console.warn(summary);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
return signals;
|
|
741
|
-
}
|
|
742
|
-
};
|
|
743
|
-
function createSupabaseConnector(graph, config, deps = {}) {
|
|
744
|
-
return {
|
|
745
|
-
connector: new SupabaseConnector(config, deps),
|
|
746
|
-
resolveTarget: createSupabaseResolveTarget(graph, config)
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// src/connectors/railway/index.ts
|
|
751
|
-
import { EdgeType as EdgeType2, NodeType as NodeType2, serviceId } from "@neat.is/types";
|
|
752
|
-
|
|
753
|
-
// src/connectors/railway/client.ts
|
|
754
|
-
var DEFAULT_RAILWAY_API_URL = "https://backboard.railway.com/graphql/v2";
|
|
755
|
-
var DEFAULT_MAX_LOOKBACK_MS2 = 24 * 60 * 60 * 1e3;
|
|
756
|
-
var DEFAULT_LOG_LIMIT2 = 1e3;
|
|
757
|
-
function readRailwayToken(credentials) {
|
|
758
|
-
const token = credentials.token;
|
|
759
|
-
if (typeof token !== "string" || token.length === 0) {
|
|
760
|
-
throw new Error(
|
|
761
|
-
"Railway connector requires ctx.credentials.token (a Project-Access-Token or account Bearer token)"
|
|
762
|
-
);
|
|
763
|
-
}
|
|
764
|
-
return token;
|
|
765
|
-
}
|
|
766
|
-
var RAILWAY_AUTH_STYLES = ["bearer", "project-access-token"];
|
|
767
|
-
function railwayAuthHeader(style, token) {
|
|
768
|
-
return style === "bearer" ? { Authorization: `Bearer ${token}` } : { "Project-Access-Token": token };
|
|
769
|
-
}
|
|
770
|
-
var resolvedRailwayAuthStyle = /* @__PURE__ */ new Map();
|
|
771
|
-
function isRailwayNotAuthorized(err) {
|
|
772
|
-
return err instanceof Error && /not authorized/i.test(err.message);
|
|
773
|
-
}
|
|
774
|
-
async function railwayGraphQLOnce(apiUrl, style, token, query, variables, accountKey, fetchImpl) {
|
|
775
|
-
const res = await junctionFetch(
|
|
776
|
-
apiUrl,
|
|
777
|
-
{
|
|
778
|
-
method: "POST",
|
|
779
|
-
headers: {
|
|
780
|
-
"Content-Type": "application/json",
|
|
781
|
-
...railwayAuthHeader(style, token)
|
|
782
|
-
},
|
|
783
|
-
body: JSON.stringify({ query, variables })
|
|
784
|
-
},
|
|
785
|
-
{ provider: "railway", accountKey, ...fetchImpl ? { fetchImpl } : {} }
|
|
786
|
-
);
|
|
787
|
-
if (!res.ok) {
|
|
788
|
-
throw new Error(`Railway GraphQL request failed: ${res.status} ${res.statusText}`);
|
|
789
|
-
}
|
|
790
|
-
const body = await res.json();
|
|
791
|
-
if (body.errors && body.errors.length > 0) {
|
|
792
|
-
throw new Error(`Railway GraphQL errors: ${body.errors.map((e) => e.message).join("; ")}`);
|
|
793
|
-
}
|
|
794
|
-
if (!body.data) throw new Error("Railway GraphQL response carried no data");
|
|
795
|
-
return body.data;
|
|
796
|
-
}
|
|
797
|
-
async function railwayGraphQL(apiUrl, token, query, variables, accountKey, fetchImpl) {
|
|
798
|
-
const known = resolvedRailwayAuthStyle.get(token);
|
|
799
|
-
const styles = known ? [known] : RAILWAY_AUTH_STYLES;
|
|
800
|
-
let lastNotAuthorized;
|
|
801
|
-
for (let i = 0; i < styles.length; i++) {
|
|
802
|
-
const style = styles[i];
|
|
803
|
-
try {
|
|
804
|
-
const data = await railwayGraphQLOnce(apiUrl, style, token, query, variables, accountKey, fetchImpl);
|
|
805
|
-
resolvedRailwayAuthStyle.set(token, style);
|
|
806
|
-
return data;
|
|
807
|
-
} catch (err) {
|
|
808
|
-
if (isRailwayNotAuthorized(err) && i < styles.length - 1) {
|
|
809
|
-
lastNotAuthorized = err;
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
throw err;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
throw lastNotAuthorized ?? new Error("Railway GraphQL: no auth style resolved");
|
|
816
|
-
}
|
|
817
|
-
var HTTP_LOGS_QUERY = `
|
|
818
|
-
query HttpLogs($deploymentId: String!, $startDate: String, $endDate: String, $limit: Int) {
|
|
819
|
-
httpLogs(deploymentId: $deploymentId, startDate: $startDate, endDate: $endDate, limit: $limit) {
|
|
820
|
-
timestamp
|
|
821
|
-
method
|
|
822
|
-
path
|
|
823
|
-
httpStatus
|
|
824
|
-
totalDuration
|
|
825
|
-
requestId
|
|
826
|
-
deploymentId
|
|
827
|
-
edgeRegion
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
`;
|
|
831
|
-
var NETWORK_FLOW_LOGS_QUERY = `
|
|
832
|
-
query NetworkFlowLogs($environmentId: String!, $serviceId: String) {
|
|
833
|
-
networkFlowLogs(environmentId: $environmentId, serviceId: $serviceId) {
|
|
834
|
-
timestamp: captureStart
|
|
835
|
-
peerServiceId
|
|
836
|
-
peerKind
|
|
837
|
-
direction
|
|
838
|
-
byteCount
|
|
839
|
-
packetCount
|
|
840
|
-
dropCause
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
`;
|
|
844
|
-
var DEPLOYMENTS_QUERY = `
|
|
845
|
-
query LatestDeployment($environmentId: String!, $serviceId: String!) {
|
|
846
|
-
deployments(input: { environmentId: $environmentId, serviceId: $serviceId }, first: 5) {
|
|
847
|
-
edges { node { id status createdAt } }
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
`;
|
|
851
|
-
async function resolveLatestRailwayDeploymentId(config, token, fetchImpl) {
|
|
852
|
-
const data = await railwayGraphQL(
|
|
853
|
-
config.apiUrl ?? DEFAULT_RAILWAY_API_URL,
|
|
854
|
-
token,
|
|
855
|
-
DEPLOYMENTS_QUERY,
|
|
856
|
-
{ environmentId: config.environmentId, serviceId: config.serviceId },
|
|
857
|
-
config.environmentId,
|
|
858
|
-
fetchImpl
|
|
859
|
-
);
|
|
860
|
-
const nodes = data.deployments.edges.map((e) => e.node);
|
|
861
|
-
const newestFirst = [...nodes].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
862
|
-
const success = newestFirst.find((n) => n.status === "SUCCESS");
|
|
863
|
-
return (success ?? newestFirst[0])?.id ?? null;
|
|
864
|
-
}
|
|
865
|
-
async function fetchRailwayHttpLogs(config, token, deploymentId, startDate, endDate) {
|
|
866
|
-
const data = await railwayGraphQL(
|
|
867
|
-
config.apiUrl ?? DEFAULT_RAILWAY_API_URL,
|
|
868
|
-
token,
|
|
869
|
-
HTTP_LOGS_QUERY,
|
|
870
|
-
{ deploymentId, startDate, endDate, limit: config.limit ?? DEFAULT_LOG_LIMIT2 },
|
|
871
|
-
config.environmentId
|
|
872
|
-
);
|
|
873
|
-
return data.httpLogs;
|
|
874
|
-
}
|
|
875
|
-
async function fetchRailwayNetworkFlowLogs(config, token) {
|
|
876
|
-
const data = await railwayGraphQL(
|
|
877
|
-
config.apiUrl ?? DEFAULT_RAILWAY_API_URL,
|
|
878
|
-
token,
|
|
879
|
-
NETWORK_FLOW_LOGS_QUERY,
|
|
880
|
-
{ environmentId: config.environmentId, serviceId: config.serviceId },
|
|
881
|
-
config.environmentId
|
|
882
|
-
);
|
|
883
|
-
return data.networkFlowLogs;
|
|
884
|
-
}
|
|
885
|
-
function boundedRailwayStartDate(since, now, maxLookbackMs) {
|
|
886
|
-
const floor = new Date(now.getTime() - maxLookbackMs);
|
|
887
|
-
if (!since) return floor.toISOString();
|
|
888
|
-
const sinceMs = new Date(since).getTime();
|
|
889
|
-
if (Number.isNaN(sinceMs)) return floor.toISOString();
|
|
890
|
-
return sinceMs < floor.getTime() ? floor.toISOString() : new Date(sinceMs).toISOString();
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// src/connectors/railway/index.ts
|
|
894
|
-
var ROUTE_TARGET_KIND = "route";
|
|
895
|
-
var UNMATCHED_ROUTE_TARGET_KIND = "unmatched-route";
|
|
896
|
-
var PEER_SERVICE_TARGET_KIND = "peer-service";
|
|
897
|
-
function buildRailwayRouteIndex(graph, serviceName) {
|
|
898
|
-
const out = [];
|
|
899
|
-
graph.forEachNode((_id, attrs) => {
|
|
900
|
-
const node = attrs;
|
|
901
|
-
if (node.type !== NodeType2.RouteNode) return;
|
|
902
|
-
const route = attrs;
|
|
903
|
-
if (route.service !== serviceName) return;
|
|
904
|
-
out.push({
|
|
905
|
-
method: route.method.toUpperCase(),
|
|
906
|
-
normalizedPath: normalizePathTemplate(route.pathTemplate),
|
|
907
|
-
routeNodeId: route.id,
|
|
908
|
-
path: route.path,
|
|
909
|
-
line: route.line
|
|
910
|
-
});
|
|
911
|
-
});
|
|
912
|
-
return out;
|
|
913
|
-
}
|
|
914
|
-
function findRailwayRoute(entries, method, normalizedPath) {
|
|
915
|
-
return entries.find(
|
|
916
|
-
(e) => e.normalizedPath === normalizedPath && (e.method === "ALL" || e.method === method)
|
|
917
|
-
);
|
|
918
|
-
}
|
|
919
|
-
function bucketKey(method, normalizedPath) {
|
|
920
|
-
return `${method} ${normalizedPath}`;
|
|
921
|
-
}
|
|
922
|
-
function isHttpErrorStatus(status) {
|
|
923
|
-
return status >= 400;
|
|
924
|
-
}
|
|
925
|
-
function upsertBucket(buckets2, key, isError, timestamp, build) {
|
|
926
|
-
const existing = buckets2.get(key);
|
|
927
|
-
if (existing) {
|
|
928
|
-
existing.callCount += 1;
|
|
929
|
-
if (isError) existing.errorCount += 1;
|
|
930
|
-
if (timestamp > existing.lastObservedIso) existing.lastObservedIso = timestamp;
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
buckets2.set(key, { callCount: 1, errorCount: isError ? 1 : 0, lastObservedIso: timestamp, ...build() });
|
|
934
|
-
}
|
|
935
|
-
function mapRailwayHttpLogsToSignals(entries, routeIndex) {
|
|
936
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
937
|
-
if (!Array.isArray(entries)) return [];
|
|
938
|
-
for (const entry of entries) {
|
|
939
|
-
if (!entry || typeof entry !== "object") continue;
|
|
940
|
-
if (typeof entry.method !== "string" || typeof entry.path !== "string" || typeof entry.timestamp !== "string") {
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
const method = entry.method.toUpperCase();
|
|
944
|
-
const normalizedPath = normalizePathTemplate(entry.path);
|
|
945
|
-
const match = findRailwayRoute(routeIndex, method, normalizedPath);
|
|
946
|
-
const isError = isHttpErrorStatus(entry.httpStatus);
|
|
947
|
-
if (match) {
|
|
948
|
-
upsertBucket(buckets2, `route:${match.routeNodeId}`, isError, entry.timestamp, () => ({
|
|
949
|
-
targetKind: ROUTE_TARGET_KIND,
|
|
950
|
-
targetName: match.routeNodeId,
|
|
951
|
-
// RouteNode.line is optional in the schema (packages/types/src/
|
|
952
|
-
// nodes.ts) even though routes.ts always sets it today — skip the
|
|
953
|
-
// callSite rather than fabricate a line when it's ever absent
|
|
954
|
-
// (file-awareness.md §6).
|
|
955
|
-
...match.line !== void 0 ? { callSite: { file: match.path, line: match.line } } : {}
|
|
956
|
-
}));
|
|
957
|
-
} else {
|
|
958
|
-
upsertBucket(
|
|
959
|
-
buckets2,
|
|
960
|
-
`unmatched:${bucketKey(method, normalizedPath)}`,
|
|
961
|
-
isError,
|
|
962
|
-
entry.timestamp,
|
|
963
|
-
() => ({
|
|
964
|
-
targetKind: UNMATCHED_ROUTE_TARGET_KIND,
|
|
965
|
-
targetName: bucketKey(method, normalizedPath)
|
|
966
|
-
})
|
|
967
|
-
);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
return [...buckets2.values()].map((b) => ({
|
|
971
|
-
targetKind: b.targetKind,
|
|
972
|
-
targetName: b.targetName,
|
|
973
|
-
callCount: b.callCount,
|
|
974
|
-
errorCount: b.errorCount,
|
|
975
|
-
lastObservedIso: b.lastObservedIso,
|
|
976
|
-
...b.callSite ? { callSite: b.callSite } : {}
|
|
977
|
-
}));
|
|
978
|
-
}
|
|
979
|
-
function mapRailwayNetworkFlowLogsToSignals(entries) {
|
|
980
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
981
|
-
if (!Array.isArray(entries)) return [];
|
|
982
|
-
for (const entry of entries) {
|
|
983
|
-
if (!entry || typeof entry !== "object") continue;
|
|
984
|
-
if (typeof entry.peerServiceId !== "string" || entry.peerServiceId.length === 0) continue;
|
|
985
|
-
if (typeof entry.timestamp !== "string") continue;
|
|
986
|
-
const isError = entry.dropCause !== null && entry.dropCause !== "";
|
|
987
|
-
upsertBucket(buckets2, entry.peerServiceId, isError, entry.timestamp, () => ({
|
|
988
|
-
targetKind: PEER_SERVICE_TARGET_KIND,
|
|
989
|
-
targetName: entry.peerServiceId
|
|
990
|
-
}));
|
|
991
|
-
}
|
|
992
|
-
return [...buckets2.values()].map((b) => ({
|
|
993
|
-
targetKind: b.targetKind,
|
|
994
|
-
targetName: b.targetName,
|
|
995
|
-
callCount: b.callCount,
|
|
996
|
-
errorCount: b.errorCount,
|
|
997
|
-
lastObservedIso: b.lastObservedIso
|
|
998
|
-
}));
|
|
999
|
-
}
|
|
1000
|
-
function createRailwayResolveTarget(config) {
|
|
1001
|
-
return (signal) => {
|
|
1002
|
-
const serviceName = config.serviceNameById[config.serviceId];
|
|
1003
|
-
if (!serviceName) return null;
|
|
1004
|
-
if (signal.targetKind === ROUTE_TARGET_KIND) {
|
|
1005
|
-
return { targetNodeId: signal.targetName, serviceName, edgeType: EdgeType2.CALLS };
|
|
1006
|
-
}
|
|
1007
|
-
if (signal.targetKind === PEER_SERVICE_TARGET_KIND) {
|
|
1008
|
-
const peerName = config.serviceNameById[signal.targetName];
|
|
1009
|
-
if (!peerName) return null;
|
|
1010
|
-
return { targetNodeId: serviceId(peerName), serviceName, edgeType: EdgeType2.CONNECTS_TO };
|
|
1011
|
-
}
|
|
1012
|
-
return null;
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
function createRailwayConnector(graph, config) {
|
|
1016
|
-
return {
|
|
1017
|
-
provider: "railway",
|
|
1018
|
-
async poll(ctx) {
|
|
1019
|
-
const token = readRailwayToken(ctx.credentials);
|
|
1020
|
-
const now = /* @__PURE__ */ new Date();
|
|
1021
|
-
const maxLookbackMs = config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS2;
|
|
1022
|
-
const startDate = boundedRailwayStartDate(ctx.since, now, maxLookbackMs);
|
|
1023
|
-
const endDate = now.toISOString();
|
|
1024
|
-
const deploymentId = await resolveLatestRailwayDeploymentId(config, token);
|
|
1025
|
-
const [httpLogsResult, flowLogsResult] = await Promise.allSettled([
|
|
1026
|
-
deploymentId ? fetchRailwayHttpLogs(config, token, deploymentId, startDate, endDate) : Promise.resolve([]),
|
|
1027
|
-
fetchRailwayNetworkFlowLogs(config, token)
|
|
1028
|
-
]);
|
|
1029
|
-
if (httpLogsResult.status === "rejected") {
|
|
1030
|
-
console.error("[neat connector] railway httpLogs poll failed", httpLogsResult.reason);
|
|
1031
|
-
}
|
|
1032
|
-
if (flowLogsResult.status === "rejected") {
|
|
1033
|
-
console.error("[neat connector] railway networkFlowLogs poll failed", flowLogsResult.reason);
|
|
1034
|
-
}
|
|
1035
|
-
const httpLogs = httpLogsResult.status === "fulfilled" ? httpLogsResult.value : [];
|
|
1036
|
-
const flowLogs = flowLogsResult.status === "fulfilled" ? flowLogsResult.value : [];
|
|
1037
|
-
const serviceName = config.serviceNameById[config.serviceId];
|
|
1038
|
-
const routeIndex = serviceName ? buildRailwayRouteIndex(graph, serviceName) : [];
|
|
1039
|
-
return [
|
|
1040
|
-
...mapRailwayHttpLogsToSignals(httpLogs, routeIndex),
|
|
1041
|
-
...mapRailwayNetworkFlowLogsToSignals(flowLogs)
|
|
1042
|
-
];
|
|
1043
|
-
}
|
|
1044
|
-
};
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// src/connectors/firebase/logging-api.ts
|
|
1048
|
-
function readFirebaseCredentials(raw) {
|
|
1049
|
-
const projectId = raw["projectId"];
|
|
1050
|
-
const accessToken = raw["accessToken"];
|
|
1051
|
-
if (typeof projectId !== "string" || projectId.length === 0) {
|
|
1052
|
-
throw new Error("firebase connector: credentials.projectId must be a non-empty string");
|
|
1053
|
-
}
|
|
1054
|
-
if (typeof accessToken !== "string" || accessToken.length === 0) {
|
|
1055
|
-
throw new Error("firebase connector: credentials.accessToken must be a non-empty string");
|
|
1056
|
-
}
|
|
1057
|
-
return { projectId, accessToken };
|
|
1058
|
-
}
|
|
1059
|
-
var RESOURCE_TYPES = [
|
|
1060
|
-
"cloud_function",
|
|
1061
|
-
"cloud_run_revision",
|
|
1062
|
-
"firebase_domain"
|
|
1063
|
-
];
|
|
1064
|
-
function isFirebaseResourceType(value) {
|
|
1065
|
-
return RESOURCE_TYPES.includes(value);
|
|
1066
|
-
}
|
|
1067
|
-
function buildEntriesFilter(sinceIso) {
|
|
1068
|
-
return [
|
|
1069
|
-
'resource.type = ("cloud_function" OR "cloud_run_revision" OR "firebase_domain")',
|
|
1070
|
-
"httpRequest:*",
|
|
1071
|
-
`timestamp >= "${sinceIso}"`
|
|
1072
|
-
].join(" AND ");
|
|
1073
|
-
}
|
|
1074
|
-
var DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1e3;
|
|
1075
|
-
var ENTRIES_LIST_URL = "https://logging.googleapis.com/v2/entries:list";
|
|
1076
|
-
var PAGE_SIZE = 1e3;
|
|
1077
|
-
var MAX_PAGES = 20;
|
|
1078
|
-
async function fetchHttpRequestLogEntries(creds, sinceIso) {
|
|
1079
|
-
const filter = buildEntriesFilter(sinceIso);
|
|
1080
|
-
const out = [];
|
|
1081
|
-
let pageToken;
|
|
1082
|
-
for (let page = 0; page < MAX_PAGES; page++) {
|
|
1083
|
-
const body = {
|
|
1084
|
-
resourceNames: [`projects/${creds.projectId}`],
|
|
1085
|
-
filter,
|
|
1086
|
-
orderBy: "timestamp asc",
|
|
1087
|
-
pageSize: PAGE_SIZE,
|
|
1088
|
-
...pageToken ? { pageToken } : {}
|
|
1089
|
-
};
|
|
1090
|
-
const res = await junctionFetch(
|
|
1091
|
-
ENTRIES_LIST_URL,
|
|
1092
|
-
{
|
|
1093
|
-
method: "POST",
|
|
1094
|
-
headers: {
|
|
1095
|
-
...bearerAuthHeader(creds.accessToken),
|
|
1096
|
-
"Content-Type": "application/json"
|
|
1097
|
-
},
|
|
1098
|
-
body: JSON.stringify(body)
|
|
1099
|
-
},
|
|
1100
|
-
// accountKey: the GCP project id (ADR-131's own worked example for
|
|
1101
|
-
// Firebase) — one customer's Cloud Logging quota is scoped per GCP
|
|
1102
|
-
// project, not per Firebase site/function.
|
|
1103
|
-
{ provider: "firebase", accountKey: creds.projectId }
|
|
1104
|
-
);
|
|
1105
|
-
if (!res.ok) {
|
|
1106
|
-
throw new Error(`Cloud Logging entries.list failed: ${res.status} ${res.statusText}`);
|
|
1107
|
-
}
|
|
1108
|
-
const json = await res.json();
|
|
1109
|
-
if (Array.isArray(json.entries)) out.push(...json.entries);
|
|
1110
|
-
if (!json.nextPageToken) break;
|
|
1111
|
-
pageToken = json.nextPageToken;
|
|
1112
|
-
}
|
|
1113
|
-
return out;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
// src/connectors/firebase/map.ts
|
|
1117
|
-
var FIELD_SEP = "\0";
|
|
1118
|
-
function packFirebaseTargetName(identity) {
|
|
1119
|
-
return [identity.resourceName, identity.method, identity.path].join(FIELD_SEP);
|
|
1120
|
-
}
|
|
1121
|
-
function parseFirebaseTargetName(targetName) {
|
|
1122
|
-
const firstSep = targetName.indexOf(FIELD_SEP);
|
|
1123
|
-
if (firstSep === -1) return null;
|
|
1124
|
-
const resourceName = targetName.slice(0, firstSep);
|
|
1125
|
-
const rest = targetName.slice(firstSep + 1);
|
|
1126
|
-
const secondSep = rest.indexOf(FIELD_SEP);
|
|
1127
|
-
if (secondSep === -1) return null;
|
|
1128
|
-
const method = rest.slice(0, secondSep);
|
|
1129
|
-
const path3 = rest.slice(secondSep + 1);
|
|
1130
|
-
if (!resourceName || !method || !path3) return null;
|
|
1131
|
-
return { resourceName, method, path: path3 };
|
|
1132
|
-
}
|
|
1133
|
-
function resourceNameFor(type, labels) {
|
|
1134
|
-
if (!labels) return null;
|
|
1135
|
-
switch (type) {
|
|
1136
|
-
case "cloud_function":
|
|
1137
|
-
return labels["function_name"] ?? null;
|
|
1138
|
-
case "cloud_run_revision":
|
|
1139
|
-
return labels["service_name"] ?? null;
|
|
1140
|
-
case "firebase_domain":
|
|
1141
|
-
return labels["site_name"] ?? null;
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
function pathFromRequestUrl(requestUrl) {
|
|
1145
|
-
if (typeof requestUrl !== "string" || requestUrl.length === 0) return null;
|
|
1146
|
-
if (requestUrl.startsWith("/")) {
|
|
1147
|
-
const withoutQuery = requestUrl.split("?")[0];
|
|
1148
|
-
return withoutQuery && withoutQuery.length > 0 ? withoutQuery : "/";
|
|
1149
|
-
}
|
|
1150
|
-
try {
|
|
1151
|
-
const candidate = requestUrl.startsWith("//") ? `https:${requestUrl}` : requestUrl;
|
|
1152
|
-
const parsed = new URL(candidate);
|
|
1153
|
-
return parsed.pathname || "/";
|
|
1154
|
-
} catch {
|
|
1155
|
-
return null;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
var ERROR_STATUS_THRESHOLD2 = 500;
|
|
1159
|
-
function mapLogEntryToSignal(entry) {
|
|
1160
|
-
if (!entry || typeof entry !== "object") return null;
|
|
1161
|
-
const resourceType = entry.resource?.type;
|
|
1162
|
-
if (!resourceType || !isFirebaseResourceType(resourceType)) return null;
|
|
1163
|
-
const resourceName = resourceNameFor(resourceType, entry.resource?.labels);
|
|
1164
|
-
if (!resourceName) return null;
|
|
1165
|
-
const req = entry.httpRequest;
|
|
1166
|
-
if (!req) return null;
|
|
1167
|
-
if (typeof req.requestMethod !== "string" || req.requestMethod.length === 0) return null;
|
|
1168
|
-
const method = req.requestMethod.toUpperCase();
|
|
1169
|
-
const path3 = pathFromRequestUrl(req.requestUrl);
|
|
1170
|
-
if (path3 === null) return null;
|
|
1171
|
-
const timestamp = entry.timestamp;
|
|
1172
|
-
if (typeof timestamp !== "string" || timestamp.length === 0) return null;
|
|
1173
|
-
const isError = typeof req.status === "number" && req.status >= ERROR_STATUS_THRESHOLD2;
|
|
1174
|
-
return {
|
|
1175
|
-
targetKind: resourceType,
|
|
1176
|
-
targetName: packFirebaseTargetName({ resourceName, method, path: path3 }),
|
|
1177
|
-
callCount: 1,
|
|
1178
|
-
errorCount: isError ? 1 : 0,
|
|
1179
|
-
lastObservedIso: timestamp
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
|
-
function mapLogEntriesToSignals(entries) {
|
|
1183
|
-
const out = [];
|
|
1184
|
-
for (const entry of entries) {
|
|
1185
|
-
const signal = mapLogEntryToSignal(entry);
|
|
1186
|
-
if (signal) out.push(signal);
|
|
1187
|
-
}
|
|
1188
|
-
return out;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// src/connectors/firebase/resolve.ts
|
|
1192
|
-
import { NodeType as NodeType3, EdgeType as EdgeType3 } from "@neat.is/types";
|
|
1193
|
-
function neatServiceNameFor(resourceType, resourceName, serviceMap) {
|
|
1194
|
-
switch (resourceType) {
|
|
1195
|
-
case "cloud_function":
|
|
1196
|
-
return serviceMap.functions?.[resourceName] ?? null;
|
|
1197
|
-
case "cloud_run_revision":
|
|
1198
|
-
return serviceMap.cloudRun?.[resourceName] ?? null;
|
|
1199
|
-
case "firebase_domain":
|
|
1200
|
-
return serviceMap.hosting?.[resourceName] ?? null;
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
function routeEntriesFor(graph, serviceName) {
|
|
1204
|
-
const entries = [];
|
|
1205
|
-
graph.forEachNode((_id, attrs) => {
|
|
1206
|
-
const node = attrs;
|
|
1207
|
-
if (node.type !== NodeType3.RouteNode) return;
|
|
1208
|
-
const route = attrs;
|
|
1209
|
-
if (route.service !== serviceName) return;
|
|
1210
|
-
entries.push({
|
|
1211
|
-
method: route.method.toUpperCase(),
|
|
1212
|
-
normalizedPath: normalizePathTemplate(route.pathTemplate),
|
|
1213
|
-
routeNodeId: route.id
|
|
1214
|
-
});
|
|
1215
|
-
});
|
|
1216
|
-
return entries;
|
|
1217
|
-
}
|
|
1218
|
-
function findRoute(entries, method, normalizedPath) {
|
|
1219
|
-
return entries.find(
|
|
1220
|
-
(e) => e.normalizedPath === normalizedPath && (e.method === "ALL" || e.method === method)
|
|
1221
|
-
);
|
|
1222
|
-
}
|
|
1223
|
-
function createFirebaseResolveTarget(graph, serviceMap) {
|
|
1224
|
-
return (signal, _ctx) => {
|
|
1225
|
-
const resourceType = signal.targetKind;
|
|
1226
|
-
if (resourceType !== "cloud_function" && resourceType !== "cloud_run_revision" && resourceType !== "firebase_domain") {
|
|
1227
|
-
return null;
|
|
1228
|
-
}
|
|
1229
|
-
const identity = parseFirebaseTargetName(signal.targetName);
|
|
1230
|
-
if (!identity) return null;
|
|
1231
|
-
const serviceName = neatServiceNameFor(resourceType, identity.resourceName, serviceMap);
|
|
1232
|
-
if (!serviceName) return null;
|
|
1233
|
-
const normalizedPath = normalizePathTemplate(identity.path);
|
|
1234
|
-
const match = findRoute(routeEntriesFor(graph, serviceName), identity.method, normalizedPath);
|
|
1235
|
-
if (!match) return null;
|
|
1236
|
-
return {
|
|
1237
|
-
targetNodeId: match.routeNodeId,
|
|
1238
|
-
serviceName,
|
|
1239
|
-
edgeType: EdgeType3.CALLS
|
|
1240
|
-
};
|
|
1241
|
-
};
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// src/connectors/firebase/index.ts
|
|
1245
|
-
var FirebaseConnector = class {
|
|
1246
|
-
provider = "firebase";
|
|
1247
|
-
async poll(ctx) {
|
|
1248
|
-
const creds = readFirebaseCredentials(ctx.credentials);
|
|
1249
|
-
const sinceIso = ctx.since ?? new Date(Date.now() - DEFAULT_LOOKBACK_MS).toISOString();
|
|
1250
|
-
const entries = await fetchHttpRequestLogEntries(creds, sinceIso);
|
|
1251
|
-
return mapLogEntriesToSignals(entries);
|
|
1252
|
-
}
|
|
1253
|
-
};
|
|
1254
|
-
function createFirebaseConnector(graph, serviceMap) {
|
|
1255
|
-
return {
|
|
1256
|
-
connector: new FirebaseConnector(),
|
|
1257
|
-
resolveTarget: createFirebaseResolveTarget(graph, serviceMap)
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// src/connectors/cloudflare/connector.ts
|
|
1262
|
-
import { EdgeType as EdgeType4, NodeType as NodeType4, fileId, infraId as infraId2 } from "@neat.is/types";
|
|
1263
|
-
|
|
1264
|
-
// src/connectors/cloudflare/client.ts
|
|
1265
|
-
import { randomUUID } from "crypto";
|
|
1266
|
-
var DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4";
|
|
1267
|
-
var DEFAULT_EVENT_LIMIT = 1e3;
|
|
1268
|
-
async function queryWorkerInvocations(ctx, config, window, fetchImpl = fetch) {
|
|
1269
|
-
const token = ctx.credentials.apiToken;
|
|
1270
|
-
if (typeof token !== "string" || token.length === 0) {
|
|
1271
|
-
throw new Error("cloudflare connector: ctx.credentials.apiToken must be a non-empty string");
|
|
1272
|
-
}
|
|
1273
|
-
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
1274
|
-
const url = `${baseUrl}/accounts/${config.accountId}/workers/observability/telemetry/query`;
|
|
1275
|
-
const body = {
|
|
1276
|
-
// Cloudflare's schema requires an identifier per query even for an
|
|
1277
|
-
// ad-hoc, unsaved one — a fresh id per tick, never reused.
|
|
1278
|
-
queryId: `neat-connector-${randomUUID()}`,
|
|
1279
|
-
timeframe: { from: window.fromMs, to: window.toMs },
|
|
1280
|
-
view: "events",
|
|
1281
|
-
limit: config.eventLimit ?? DEFAULT_EVENT_LIMIT,
|
|
1282
|
-
// The inline query definition. WITHOUT `parameters`, the API treats
|
|
1283
|
-
// `queryId` as a reference to a previously *saved* query and answers 400
|
|
1284
|
-
// "Query not found" (confirmed against the live endpoint) — omitting it was
|
|
1285
|
-
// the reason the connector never worked against real Cloudflare. Naming the
|
|
1286
|
-
// `cloudflare-workers` dataset runs the invocation-telemetry query ad-hoc.
|
|
1287
|
-
parameters: { datasets: ["cloudflare-workers"] },
|
|
1288
|
-
// Execute without persisting — this is a read, not a saved query
|
|
1289
|
-
// (connectors.md §2's "never writes on the read path" applies to
|
|
1290
|
-
// Cloudflare's own query-history state too).
|
|
1291
|
-
dry: true
|
|
1292
|
-
};
|
|
1293
|
-
const res = await junctionFetch(
|
|
1294
|
-
url,
|
|
1295
|
-
{
|
|
1296
|
-
method: "POST",
|
|
1297
|
-
headers: {
|
|
1298
|
-
"Content-Type": "application/json",
|
|
1299
|
-
...bearerAuthHeader(token)
|
|
1300
|
-
},
|
|
1301
|
-
body: JSON.stringify(body)
|
|
1302
|
-
},
|
|
1303
|
-
// accountKey: the Cloudflare account id (ADR-131's own worked example) —
|
|
1304
|
-
// the Telemetry Query API's ~300/5min limit is enforced per account.
|
|
1305
|
-
{ provider: "cloudflare", accountKey: config.accountId, fetchImpl }
|
|
1306
|
-
);
|
|
1307
|
-
if (!res.ok) {
|
|
1308
|
-
throw new Error(
|
|
1309
|
-
`cloudflare connector: telemetry query failed (${res.status} ${res.statusText})`
|
|
1310
|
-
);
|
|
1311
|
-
}
|
|
1312
|
-
const payload = await res.json();
|
|
1313
|
-
if (!payload.success) {
|
|
1314
|
-
const message = payload.errors?.map((e) => e.message).join("; ") || "unknown error";
|
|
1315
|
-
throw new Error(`cloudflare connector: telemetry query returned an error (${message})`);
|
|
1316
|
-
}
|
|
1317
|
-
const events = payload.result?.events?.events;
|
|
1318
|
-
if (!Array.isArray(events)) {
|
|
1319
|
-
console.warn(
|
|
1320
|
-
"[neat connector] cloudflare: telemetry query returned success:true but no result.events.events array \u2014 the response shape may have changed; treating as zero events this tick"
|
|
1321
|
-
);
|
|
1322
|
-
return [];
|
|
1323
|
-
}
|
|
1324
|
-
return events;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
// src/connectors/cloudflare/types.ts
|
|
1328
|
-
var CLOUDFLARE_TARGET_KIND = "cloudflare-worker-invocation";
|
|
1329
|
-
|
|
1330
|
-
// src/connectors/cloudflare/map.ts
|
|
1331
|
-
var HTTP_METHODS = /* @__PURE__ */ new Set([
|
|
1332
|
-
"GET",
|
|
1333
|
-
"POST",
|
|
1334
|
-
"PUT",
|
|
1335
|
-
"PATCH",
|
|
1336
|
-
"DELETE",
|
|
1337
|
-
"HEAD",
|
|
1338
|
-
"OPTIONS",
|
|
1339
|
-
"TRACE",
|
|
1340
|
-
"CONNECT"
|
|
1341
|
-
]);
|
|
1342
|
-
var LEADING_TOKEN_RE = /^(\S+)\s+\S/;
|
|
1343
|
-
function parseHttpMethodFromTrigger(trigger) {
|
|
1344
|
-
if (typeof trigger !== "string") return null;
|
|
1345
|
-
const match = LEADING_TOKEN_RE.exec(trigger.trim());
|
|
1346
|
-
const token = match?.[1];
|
|
1347
|
-
if (!token) return null;
|
|
1348
|
-
const method = token.toUpperCase();
|
|
1349
|
-
return HTTP_METHODS.has(method) ? method : null;
|
|
1350
|
-
}
|
|
1351
|
-
function parsePathFromTrigger(trigger) {
|
|
1352
|
-
const trimmed = trigger.trim();
|
|
1353
|
-
const spaceIdx = trimmed.indexOf(" ");
|
|
1354
|
-
if (spaceIdx === -1) return void 0;
|
|
1355
|
-
const rest = trimmed.slice(spaceIdx + 1).trim();
|
|
1356
|
-
return rest.length > 0 ? rest : void 0;
|
|
1357
|
-
}
|
|
1358
|
-
var ERROR_STATUS_THRESHOLD3 = 500;
|
|
1359
|
-
function mapEventToSignal(event) {
|
|
1360
|
-
if (!event || typeof event !== "object") return null;
|
|
1361
|
-
const metadata = event.$metadata;
|
|
1362
|
-
const workers = event.$workers;
|
|
1363
|
-
const method = parseHttpMethodFromTrigger(metadata?.trigger);
|
|
1364
|
-
if (!method) return null;
|
|
1365
|
-
const scriptName = workers?.scriptName ?? metadata?.service;
|
|
1366
|
-
if (typeof scriptName !== "string" || scriptName.length === 0) return null;
|
|
1367
|
-
const timestampMs = event.timestamp ?? metadata?.startTime;
|
|
1368
|
-
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null;
|
|
1369
|
-
const observedAt = new Date(timestampMs);
|
|
1370
|
-
if (Number.isNaN(observedAt.getTime())) return null;
|
|
1371
|
-
const statusCode = metadata?.statusCode;
|
|
1372
|
-
const isError = typeof statusCode === "number" && statusCode >= ERROR_STATUS_THRESHOLD3;
|
|
1373
|
-
const path3 = metadata?.trigger ? parsePathFromTrigger(metadata.trigger) : void 0;
|
|
1374
|
-
return {
|
|
1375
|
-
targetKind: CLOUDFLARE_TARGET_KIND,
|
|
1376
|
-
targetName: scriptName,
|
|
1377
|
-
callCount: 1,
|
|
1378
|
-
errorCount: isError ? 1 : 0,
|
|
1379
|
-
lastObservedIso: observedAt.toISOString(),
|
|
1380
|
-
method,
|
|
1381
|
-
...path3 ? { path: path3 } : {},
|
|
1382
|
-
...typeof statusCode === "number" ? { statusCode } : {},
|
|
1383
|
-
...typeof metadata?.duration === "number" ? { duration: metadata.duration } : {}
|
|
1384
|
-
};
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
// src/connectors/cloudflare/connector.ts
|
|
1388
|
-
var DEFAULT_MAX_LOOKBACK_MS3 = 60 * 60 * 1e3;
|
|
1389
|
-
function resolveFromMs(since, maxLookbackMs) {
|
|
1390
|
-
const cap = maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS3;
|
|
1391
|
-
const now = Date.now();
|
|
1392
|
-
const floor = now - cap;
|
|
1393
|
-
if (!since) return floor;
|
|
1394
|
-
const parsed = Date.parse(since);
|
|
1395
|
-
if (Number.isNaN(parsed)) return floor;
|
|
1396
|
-
return Math.max(parsed, floor);
|
|
1397
|
-
}
|
|
1398
|
-
var CloudflareConnector = class {
|
|
1399
|
-
constructor(config) {
|
|
1400
|
-
this.config = config;
|
|
1401
|
-
}
|
|
1402
|
-
config;
|
|
1403
|
-
provider = "cloudflare";
|
|
1404
|
-
async poll(ctx) {
|
|
1405
|
-
const toMs = Date.now();
|
|
1406
|
-
const fromMs = resolveFromMs(ctx.since, this.config.maxLookbackMs);
|
|
1407
|
-
const events = await queryWorkerInvocations(ctx, this.config, { fromMs, toMs });
|
|
1408
|
-
const signals = [];
|
|
1409
|
-
for (const event of events) {
|
|
1410
|
-
const signal = mapEventToSignal(event);
|
|
1411
|
-
if (signal) signals.push(signal);
|
|
1412
|
-
}
|
|
1413
|
-
return signals;
|
|
1414
|
-
}
|
|
1415
|
-
};
|
|
1416
|
-
function findTaggedWorkerFileNode(graph, workerName) {
|
|
1417
|
-
let found = null;
|
|
1418
|
-
graph.forEachNode((id, attrs) => {
|
|
1419
|
-
if (found) return;
|
|
1420
|
-
const a = attrs;
|
|
1421
|
-
if (a.type === NodeType4.FileNode && a.platform === "cloudflare" && a.platformName === workerName) {
|
|
1422
|
-
found = id;
|
|
1423
|
-
}
|
|
1424
|
-
});
|
|
1425
|
-
return found;
|
|
1426
|
-
}
|
|
1427
|
-
function findMatchingRouteNode(graph, serviceName, method, path3) {
|
|
1428
|
-
const normalizedPath = normalizePathTemplate(path3);
|
|
1429
|
-
let found = null;
|
|
1430
|
-
graph.forEachNode((id, attrs) => {
|
|
1431
|
-
if (found) return;
|
|
1432
|
-
const a = attrs;
|
|
1433
|
-
if (a.type !== NodeType4.RouteNode || a.service !== serviceName) return;
|
|
1434
|
-
if (!a.pathTemplate || normalizePathTemplate(a.pathTemplate) !== normalizedPath) return;
|
|
1435
|
-
const routeMethod = (a.method ?? "").toUpperCase();
|
|
1436
|
-
if (routeMethod !== "ALL" && routeMethod !== method) return;
|
|
1437
|
-
found = id;
|
|
1438
|
-
});
|
|
1439
|
-
return found;
|
|
1440
|
-
}
|
|
1441
|
-
function createCloudflareResolveTarget(config, graph) {
|
|
1442
|
-
return (signal) => {
|
|
1443
|
-
if (signal.targetKind !== CLOUDFLARE_TARGET_KIND) return null;
|
|
1444
|
-
const scriptName = signal.targetName;
|
|
1445
|
-
const { method, path: path3 } = signal;
|
|
1446
|
-
const resolveRouteGrain = (serviceName, wholeFileId) => {
|
|
1447
|
-
if (!method || !path3) return wholeFileId;
|
|
1448
|
-
return findMatchingRouteNode(graph, serviceName, method, path3) ?? wholeFileId;
|
|
1449
|
-
};
|
|
1450
|
-
const mapping = config.workers?.[scriptName];
|
|
1451
|
-
if (mapping) {
|
|
1452
|
-
const wholeFileId = fileId(mapping.service, mapping.entryFile);
|
|
1453
|
-
return {
|
|
1454
|
-
targetNodeId: resolveRouteGrain(mapping.service, wholeFileId),
|
|
1455
|
-
serviceName: mapping.service,
|
|
1456
|
-
edgeType: EdgeType4.CALLS
|
|
1457
|
-
};
|
|
1458
|
-
}
|
|
1459
|
-
const taggedFileId = findTaggedWorkerFileNode(graph, scriptName);
|
|
1460
|
-
if (taggedFileId) {
|
|
1461
|
-
const fileNode = graph.getNodeAttributes(taggedFileId);
|
|
1462
|
-
return {
|
|
1463
|
-
targetNodeId: resolveRouteGrain(fileNode.service, taggedFileId),
|
|
1464
|
-
serviceName: fileNode.service,
|
|
1465
|
-
edgeType: EdgeType4.CALLS
|
|
1466
|
-
};
|
|
1467
|
-
}
|
|
1468
|
-
return {
|
|
1469
|
-
targetNodeId: infraId2("cloudflare-worker", scriptName),
|
|
1470
|
-
serviceName: scriptName,
|
|
1471
|
-
edgeType: EdgeType4.CALLS,
|
|
1472
|
-
ensureInfraNode: { kind: "cloudflare-worker", name: scriptName, provider: "cloudflare" }
|
|
1473
|
-
};
|
|
1474
|
-
};
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// src/connectors/vercel/client.ts
|
|
1478
|
-
var DEFAULT_API_BASE_URL = "https://api.vercel.com";
|
|
1479
|
-
var DEFAULT_DRAIN_NAME = "neat-otlp";
|
|
1480
|
-
var TRACE_SCHEMAS = { trace: { version: "v1" } };
|
|
1481
|
-
function apiBase(config) {
|
|
1482
|
-
return config.apiBaseUrl ?? DEFAULT_API_BASE_URL;
|
|
1483
|
-
}
|
|
1484
|
-
function teamQuery(config) {
|
|
1485
|
-
return `?teamId=${encodeURIComponent(config.teamId)}`;
|
|
1486
|
-
}
|
|
1487
|
-
function drainDelivery(config, otelToken) {
|
|
1488
|
-
return {
|
|
1489
|
-
type: "http",
|
|
1490
|
-
endpoint: config.endpoint,
|
|
1491
|
-
encoding: "json",
|
|
1492
|
-
headers: bearerAuthHeader(otelToken),
|
|
1493
|
-
...config.secret ? { secret: config.secret } : {}
|
|
1494
|
-
};
|
|
1495
|
-
}
|
|
1496
|
-
async function describeError(res) {
|
|
1497
|
-
try {
|
|
1498
|
-
const data = await res.json();
|
|
1499
|
-
const message = data?.error?.message;
|
|
1500
|
-
return typeof message === "string" && message.length > 0 ? ` \u2014 ${message}` : "";
|
|
1501
|
-
} catch {
|
|
1502
|
-
return "";
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
async function createVercelDrain(config, credentials, fetchImpl = fetch) {
|
|
1506
|
-
const projectIds = config.projectIds ?? [];
|
|
1507
|
-
const body = {
|
|
1508
|
-
name: config.drainName ?? DEFAULT_DRAIN_NAME,
|
|
1509
|
-
projects: projectIds.length > 0 ? "some" : "all",
|
|
1510
|
-
...projectIds.length > 0 ? { projectIds } : {},
|
|
1511
|
-
schemas: TRACE_SCHEMAS,
|
|
1512
|
-
delivery: drainDelivery(config, credentials.otelToken),
|
|
1513
|
-
source: { kind: "self-served" }
|
|
1514
|
-
};
|
|
1515
|
-
const res = await junctionFetch(
|
|
1516
|
-
`${apiBase(config)}/v1/drains${teamQuery(config)}`,
|
|
1517
|
-
{
|
|
1518
|
-
method: "POST",
|
|
1519
|
-
headers: { "Content-Type": "application/json", ...bearerAuthHeader(credentials.token) },
|
|
1520
|
-
body: JSON.stringify(body)
|
|
1521
|
-
},
|
|
1522
|
-
{ provider: "vercel", accountKey: config.teamId, fetchImpl }
|
|
1523
|
-
);
|
|
1524
|
-
if (!res.ok) {
|
|
1525
|
-
throw new Error(
|
|
1526
|
-
`vercel connector: create drain failed (${res.status} ${res.statusText}${await describeError(res)})`
|
|
1527
|
-
);
|
|
1528
|
-
}
|
|
1529
|
-
const payload = await res.json().catch(() => null);
|
|
1530
|
-
if (!payload || typeof payload.id !== "string" || payload.id.length === 0) {
|
|
1531
|
-
throw new Error(
|
|
1532
|
-
"vercel connector: create drain returned no drain id \u2014 the Drains API response shape may have changed"
|
|
1533
|
-
);
|
|
1534
|
-
}
|
|
1535
|
-
return {
|
|
1536
|
-
id: payload.id,
|
|
1537
|
-
...payload.status ? { status: payload.status } : {},
|
|
1538
|
-
...payload.disabledReason ? { disabledReason: payload.disabledReason } : {}
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
async function deleteVercelDrain(config, drainId, credentials, fetchImpl = fetch) {
|
|
1542
|
-
const res = await junctionFetch(
|
|
1543
|
-
`${apiBase(config)}/v1/drains/${encodeURIComponent(drainId)}${teamQuery(config)}`,
|
|
1544
|
-
{ method: "DELETE", headers: { ...bearerAuthHeader(credentials.token) } },
|
|
1545
|
-
{ provider: "vercel", accountKey: config.teamId, fetchImpl }
|
|
1546
|
-
);
|
|
1547
|
-
if (res.ok || res.status === 404) return;
|
|
1548
|
-
throw new Error(
|
|
1549
|
-
`vercel connector: delete drain failed (${res.status} ${res.statusText}${await describeError(res)})`
|
|
1550
|
-
);
|
|
1551
|
-
}
|
|
1552
|
-
async function testVercelDrainDelivery(config, credentials, fetchImpl = fetch) {
|
|
1553
|
-
const res = await junctionFetch(
|
|
1554
|
-
`${apiBase(config)}/v1/drains/test${teamQuery(config)}`,
|
|
1555
|
-
{
|
|
1556
|
-
method: "POST",
|
|
1557
|
-
headers: { "Content-Type": "application/json", ...bearerAuthHeader(credentials.token) },
|
|
1558
|
-
body: JSON.stringify({ schemas: TRACE_SCHEMAS, delivery: drainDelivery(config, credentials.otelToken) })
|
|
1559
|
-
},
|
|
1560
|
-
{ provider: "vercel", accountKey: config.teamId, fetchImpl }
|
|
1561
|
-
);
|
|
1562
|
-
if (res.status === 401 || res.status === 403) {
|
|
1563
|
-
return { status: "failure", error: `vercel rejected the API token (HTTP ${res.status})` };
|
|
1564
|
-
}
|
|
1565
|
-
if (!res.ok) {
|
|
1566
|
-
return {
|
|
1567
|
-
status: "failure",
|
|
1568
|
-
error: `vercel drain validation failed (${res.status} ${res.statusText}${await describeError(res)})`
|
|
1569
|
-
};
|
|
1570
|
-
}
|
|
1571
|
-
const payload = await res.json().catch(() => null);
|
|
1572
|
-
return {
|
|
1573
|
-
...payload?.status ? { status: payload.status } : {},
|
|
1574
|
-
...payload?.error ? { error: payload.error } : {},
|
|
1575
|
-
...payload?.endpoint ? { endpoint: payload.endpoint } : {}
|
|
1576
|
-
};
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// src/connectors/registry.ts
|
|
1580
|
-
var CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
|
|
1581
|
-
async function authProbe(input) {
|
|
1582
|
-
const { provider, accountKey, url, token, init, fetchImpl } = input;
|
|
1583
|
-
try {
|
|
1584
|
-
const res = await junctionFetch(
|
|
1585
|
-
url,
|
|
1586
|
-
{
|
|
1587
|
-
...init ?? {},
|
|
1588
|
-
headers: { ...bearerAuthHeader(token), ...init?.headers ?? {} }
|
|
1589
|
-
},
|
|
1590
|
-
{ provider, accountKey, ...fetchImpl ? { fetchImpl } : {} }
|
|
1591
|
-
);
|
|
1592
|
-
if (res.ok) return { ok: true };
|
|
1593
|
-
if (res.status === 401 || res.status === 403) {
|
|
1594
|
-
return { ok: false, reason: `${provider} rejected the credential (HTTP ${res.status})` };
|
|
1595
|
-
}
|
|
1596
|
-
return {
|
|
1597
|
-
ok: false,
|
|
1598
|
-
reason: `${provider} auth check returned HTTP ${res.status} ${res.statusText} \u2014 could not confirm the credential`
|
|
1599
|
-
};
|
|
1600
|
-
} catch (err) {
|
|
1601
|
-
return {
|
|
1602
|
-
ok: false,
|
|
1603
|
-
reason: `${provider} auth check could not reach the provider: ${err.message}`
|
|
1604
|
-
};
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
var PROVIDER_DISPATCH = {
|
|
1608
|
-
supabase: {
|
|
1609
|
-
provider: "supabase",
|
|
1610
|
-
primaryCredentialKey: "managementToken",
|
|
1611
|
-
requiredCredentialFields: ["managementToken"],
|
|
1612
|
-
requiredOptionFields: ["apiProjectRef", "nodeRef", "serviceName"],
|
|
1613
|
-
build(graph, options) {
|
|
1614
|
-
return createSupabaseConnector(graph, options);
|
|
1615
|
-
},
|
|
1616
|
-
// GET /v1/projects — the Management API's own auth-gated list endpoint, the
|
|
1617
|
-
// cheapest confirmation the management token is live (the same surface
|
|
1618
|
-
// client.ts polls, minus the heavy log query).
|
|
1619
|
-
validate({ credentials, options, fetchImpl }) {
|
|
1620
|
-
const cfg = options;
|
|
1621
|
-
const baseUrl = cfg.managementApiUrl ?? DEFAULT_SUPABASE_MANAGEMENT_API_URL;
|
|
1622
|
-
return authProbe({
|
|
1623
|
-
provider: "supabase",
|
|
1624
|
-
accountKey: cfg.apiProjectRef ?? "validate",
|
|
1625
|
-
url: `${baseUrl}/v1/projects`,
|
|
1626
|
-
token: String(credentials.managementToken ?? ""),
|
|
1627
|
-
...fetchImpl ? { fetchImpl } : {}
|
|
1628
|
-
});
|
|
1629
|
-
}
|
|
1630
|
-
},
|
|
1631
|
-
railway: {
|
|
1632
|
-
provider: "railway",
|
|
1633
|
-
primaryCredentialKey: "token",
|
|
1634
|
-
requiredCredentialFields: ["token"],
|
|
1635
|
-
requiredOptionFields: ["environmentId", "serviceId", "serviceNameById"],
|
|
1636
|
-
build(graph, options) {
|
|
1637
|
-
const config = options;
|
|
1638
|
-
return {
|
|
1639
|
-
connector: createRailwayConnector(graph, config),
|
|
1640
|
-
resolveTarget: createRailwayResolveTarget(config)
|
|
1641
|
-
};
|
|
1642
|
-
},
|
|
1643
|
-
// Runs the same `deployments` lookup the poller itself needs
|
|
1644
|
-
// (railway/client.ts's resolveLatestRailwayDeploymentId) rather than a
|
|
1645
|
-
// trivial `{ __typename }` probe. That trivial form is a false positive
|
|
1646
|
-
// for this provider: `Authorization: Bearer <token>` authenticates at
|
|
1647
|
-
// Railway's HTTP gateway (any well-formed token gets a 2xx on a query
|
|
1648
|
-
// that touches no real data) but is not authorized for the connector's
|
|
1649
|
-
// actual queries, which come back as an HTTP-200 response carrying a
|
|
1650
|
-
// GraphQL-level "Not Authorized" error — invisible to authProbe's
|
|
1651
|
-
// status-code-only check. Probing with the real lookup, through the same
|
|
1652
|
-
// Project-Access-Token header client.ts sends, catches both failure
|
|
1653
|
-
// modes: an HTTP-level rejection (thrown as a fetch/status error) and a
|
|
1654
|
-
// GraphQL-level one (thrown by railwayGraphQL's own body.errors check).
|
|
1655
|
-
async validate({ credentials, options, fetchImpl }) {
|
|
1656
|
-
const cfg = options;
|
|
1657
|
-
if (!cfg.environmentId || !cfg.serviceId) {
|
|
1658
|
-
return { ok: false, reason: "railway: environmentId and serviceId are required to validate" };
|
|
1659
|
-
}
|
|
1660
|
-
const config = {
|
|
1661
|
-
environmentId: cfg.environmentId,
|
|
1662
|
-
serviceId: cfg.serviceId,
|
|
1663
|
-
serviceNameById: cfg.serviceNameById ?? {},
|
|
1664
|
-
...cfg.apiUrl ? { apiUrl: cfg.apiUrl } : {}
|
|
1665
|
-
};
|
|
1666
|
-
try {
|
|
1667
|
-
await resolveLatestRailwayDeploymentId(config, String(credentials.token ?? ""), fetchImpl);
|
|
1668
|
-
return { ok: true };
|
|
1669
|
-
} catch (err) {
|
|
1670
|
-
return { ok: false, reason: `railway auth check failed: ${err.message}` };
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
},
|
|
1674
|
-
firebase: {
|
|
1675
|
-
provider: "firebase",
|
|
1676
|
-
// Firebase reads both projectId and accessToken from the credential; the
|
|
1677
|
-
// single-string form maps to the secret, and the required-fields check
|
|
1678
|
-
// below catches a projectId that was never supplied.
|
|
1679
|
-
primaryCredentialKey: "accessToken",
|
|
1680
|
-
requiredCredentialFields: ["projectId", "accessToken"],
|
|
1681
|
-
requiredOptionFields: [],
|
|
1682
|
-
build(graph, options) {
|
|
1683
|
-
return createFirebaseConnector(graph, options);
|
|
1684
|
-
},
|
|
1685
|
-
// GET the project's Cloud Logging log-name list (pageSize 1) — within the
|
|
1686
|
-
// same `roles/logging.viewer` grant the connector polls under, and the
|
|
1687
|
-
// lightest call that still fails 401/403 on a bad or wrong-scoped token.
|
|
1688
|
-
validate({ credentials, fetchImpl }) {
|
|
1689
|
-
const projectId = String(credentials.projectId ?? "");
|
|
1690
|
-
return authProbe({
|
|
1691
|
-
provider: "firebase",
|
|
1692
|
-
accountKey: projectId || "validate",
|
|
1693
|
-
url: `https://logging.googleapis.com/v2/projects/${projectId}/logs?pageSize=1`,
|
|
1694
|
-
token: String(credentials.accessToken ?? ""),
|
|
1695
|
-
...fetchImpl ? { fetchImpl } : {}
|
|
1696
|
-
});
|
|
1697
|
-
}
|
|
1698
|
-
},
|
|
1699
|
-
cloudflare: {
|
|
1700
|
-
provider: "cloudflare",
|
|
1701
|
-
primaryCredentialKey: "apiToken",
|
|
1702
|
-
requiredCredentialFields: ["apiToken"],
|
|
1703
|
-
// `workers` dropped as a required field (ADR-133) — the mapping is now
|
|
1704
|
-
// derived from the extracted graph's platform tag; an `options.workers`
|
|
1705
|
-
// entry still works as an explicit override
|
|
1706
|
-
// (CloudflareConnectorConfig.workers).
|
|
1707
|
-
requiredOptionFields: ["accountId"],
|
|
1708
|
-
build(graph, options) {
|
|
1709
|
-
const config = options;
|
|
1710
|
-
return {
|
|
1711
|
-
connector: new CloudflareConnector(config),
|
|
1712
|
-
resolveTarget: createCloudflareResolveTarget(config, graph)
|
|
1713
|
-
};
|
|
1714
|
-
},
|
|
1715
|
-
// GET /accounts/{accountId}/tokens/verify — the *account-scoped* token-verify
|
|
1716
|
-
// endpoint. A Workers connector token is scoped to the account, and the
|
|
1717
|
-
// user-level `GET /user/tokens/verify` returns 401 "Invalid API Token" for
|
|
1718
|
-
// such a token even though it authenticates fine against the account's own
|
|
1719
|
-
// resources (confirmed live). Probing the account-scoped verify endpoint —
|
|
1720
|
-
// `accountId` is already required for this provider — returns 200
|
|
1721
|
-
// `{status:"active"}` for a working token and 401 for a bad one, so a valid
|
|
1722
|
-
// Workers token is no longer falsely rejected at `neat connector add`.
|
|
1723
|
-
validate({ credentials, options, fetchImpl }) {
|
|
1724
|
-
const cfg = options;
|
|
1725
|
-
const baseUrl = cfg.baseUrl ?? CLOUDFLARE_API_BASE_URL;
|
|
1726
|
-
return authProbe({
|
|
1727
|
-
provider: "cloudflare",
|
|
1728
|
-
accountKey: cfg.accountId ?? "validate",
|
|
1729
|
-
url: `${baseUrl}/accounts/${cfg.accountId ?? ""}/tokens/verify`,
|
|
1730
|
-
token: String(credentials.apiToken ?? ""),
|
|
1731
|
-
...fetchImpl ? { fetchImpl } : {}
|
|
1732
|
-
});
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
};
|
|
1736
|
-
function vercelCredsFrom(credentials) {
|
|
1737
|
-
return { token: String(credentials.token ?? ""), otelToken: String(credentials.otelToken ?? "") };
|
|
1738
|
-
}
|
|
1739
|
-
function vercelConfigFromOptions(options) {
|
|
1740
|
-
const raw = options.projectIds;
|
|
1741
|
-
let projectIds;
|
|
1742
|
-
if (Array.isArray(raw)) projectIds = raw.filter((p) => typeof p === "string");
|
|
1743
|
-
else if (typeof raw === "string" && raw.trim().length > 0) {
|
|
1744
|
-
projectIds = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1745
|
-
}
|
|
1746
|
-
return {
|
|
1747
|
-
teamId: String(options.teamId ?? ""),
|
|
1748
|
-
endpoint: String(options.endpoint ?? ""),
|
|
1749
|
-
...projectIds && projectIds.length > 0 ? { projectIds } : {},
|
|
1750
|
-
...typeof options.drainId === "string" ? { drainId: options.drainId } : {},
|
|
1751
|
-
...typeof options.drainName === "string" ? { drainName: options.drainName } : {},
|
|
1752
|
-
...typeof options.apiBaseUrl === "string" ? { apiBaseUrl: options.apiBaseUrl } : {},
|
|
1753
|
-
...typeof options.secret === "string" ? { secret: options.secret } : {}
|
|
1754
|
-
};
|
|
1755
|
-
}
|
|
1756
|
-
var PUSH_PROVIDER_DISPATCH = {
|
|
1757
|
-
vercel: {
|
|
1758
|
-
provider: "vercel",
|
|
1759
|
-
// The Vercel access token is the "primary" secret a single `--token`
|
|
1760
|
-
// populates; `otelToken` (the daemon's OTLP bearer) is the second field.
|
|
1761
|
-
primaryCredentialKey: "token",
|
|
1762
|
-
requiredCredentialFields: ["token", "otelToken"],
|
|
1763
|
-
// teamId scopes every Drains call; endpoint is where the drain delivers.
|
|
1764
|
-
// projectIds is optional (absent → the drain covers the whole team).
|
|
1765
|
-
requiredOptionFields: ["teamId", "endpoint"],
|
|
1766
|
-
// POST /v1/drains/test — authenticates the token and pings the endpoint
|
|
1767
|
-
// with a sample event, so `success` means the credential is live *and* the
|
|
1768
|
-
// daemon's OTLP endpoint is reachable and accepted the drain's bearer.
|
|
1769
|
-
async validate({ credentials, options, fetchImpl }) {
|
|
1770
|
-
const result = await testVercelDrainDelivery(
|
|
1771
|
-
vercelConfigFromOptions(options),
|
|
1772
|
-
vercelCredsFrom(credentials),
|
|
1773
|
-
fetchImpl
|
|
1774
|
-
);
|
|
1775
|
-
if (result.status === "success") return { ok: true };
|
|
1776
|
-
return {
|
|
1777
|
-
ok: false,
|
|
1778
|
-
reason: result.error ?? `vercel drain delivery test returned "${result.status ?? "no status"}"`
|
|
1779
|
-
};
|
|
1780
|
-
},
|
|
1781
|
-
// POST /v1/drains — creates the trace drain, returns its id to store in
|
|
1782
|
-
// `options.drainId`. A created-but-not-enabled drain is surfaced as a note,
|
|
1783
|
-
// not a failure (the entry still points at a real drain).
|
|
1784
|
-
async provision({ credentials, options, fetchImpl }) {
|
|
1785
|
-
try {
|
|
1786
|
-
const created = await createVercelDrain(
|
|
1787
|
-
vercelConfigFromOptions(options),
|
|
1788
|
-
vercelCredsFrom(credentials),
|
|
1789
|
-
fetchImpl
|
|
1790
|
-
);
|
|
1791
|
-
const note = created.status && created.status !== "enabled" ? `the drain was created but its status is "${created.status}"${created.disabledReason ? ` (${created.disabledReason})` : ""} \u2014 check the Vercel dashboard` : void 0;
|
|
1792
|
-
return { ok: true, options: { drainId: created.id }, ...note ? { note } : {} };
|
|
1793
|
-
} catch (err) {
|
|
1794
|
-
return { ok: false, reason: err.message };
|
|
1795
|
-
}
|
|
1796
|
-
},
|
|
1797
|
-
// DELETE /v1/drains/{id} — idempotent (deleteVercelDrain treats 404 as
|
|
1798
|
-
// success). No recorded drainId → nothing to delete, still a success.
|
|
1799
|
-
async deprovision({ credentials, options, fetchImpl }) {
|
|
1800
|
-
const drainId = typeof options.drainId === "string" ? options.drainId : "";
|
|
1801
|
-
if (!drainId) {
|
|
1802
|
-
return { ok: true, note: "no drain id was recorded \u2014 nothing to delete on the Vercel side" };
|
|
1803
|
-
}
|
|
1804
|
-
try {
|
|
1805
|
-
await deleteVercelDrain(
|
|
1806
|
-
vercelConfigFromOptions(options),
|
|
1807
|
-
drainId,
|
|
1808
|
-
vercelCredsFrom(credentials),
|
|
1809
|
-
fetchImpl
|
|
1810
|
-
);
|
|
1811
|
-
return { ok: true };
|
|
1812
|
-
} catch (err) {
|
|
1813
|
-
return { ok: false, reason: err.message };
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
};
|
|
1818
|
-
function isPushProvider(provider) {
|
|
1819
|
-
return provider in PUSH_PROVIDER_DISPATCH;
|
|
1820
|
-
}
|
|
1821
|
-
function getProviderFieldSchema(provider) {
|
|
1822
|
-
return PROVIDER_DISPATCH[provider] ?? PUSH_PROVIDER_DISPATCH[provider];
|
|
1823
|
-
}
|
|
1824
|
-
function knownProviderNames() {
|
|
1825
|
-
return [...Object.keys(PROVIDER_DISPATCH), ...Object.keys(PUSH_PROVIDER_DISPATCH)].sort();
|
|
1826
|
-
}
|
|
1827
|
-
function resolveEntryCredentials(dispatch, entry, env) {
|
|
1828
|
-
let credentials;
|
|
1829
|
-
try {
|
|
1830
|
-
const resolved = resolveCredential(entry.credential, env);
|
|
1831
|
-
credentials = resolved.kind === "single" ? { [dispatch.primaryCredentialKey]: resolved.value } : { ...resolved.fields };
|
|
1832
|
-
} catch (err) {
|
|
1833
|
-
if (err instanceof EnvRefUnsetError) return { ok: false, kind: "unset-env", reason: err.message };
|
|
1834
|
-
return { ok: false, kind: "error", reason: err.message };
|
|
1835
|
-
}
|
|
1836
|
-
const missingCreds = dispatch.requiredCredentialFields.filter((k) => !credentials[k]);
|
|
1837
|
-
if (missingCreds.length > 0) {
|
|
1838
|
-
return {
|
|
1839
|
-
ok: false,
|
|
1840
|
-
kind: "missing-field",
|
|
1841
|
-
reason: `credential missing required field(s): ${missingCreds.join(", ")}`
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1844
|
-
return { ok: true, credentials };
|
|
1845
|
-
}
|
|
1846
|
-
function buildRegistration(entry, graph, env = process.env) {
|
|
1847
|
-
const dispatch = PROVIDER_DISPATCH[entry.provider];
|
|
1848
|
-
if (!dispatch) {
|
|
1849
|
-
if (isPushProvider(entry.provider)) {
|
|
1850
|
-
return {
|
|
1851
|
-
ok: false,
|
|
1852
|
-
push: true,
|
|
1853
|
-
reason: `push provider "${entry.provider}" ingests via the OTLP receiver \u2014 nothing to poll`
|
|
1854
|
-
};
|
|
1855
|
-
}
|
|
1856
|
-
return { ok: false, reason: `unknown provider "${entry.provider}"` };
|
|
1857
|
-
}
|
|
1858
|
-
const creds = resolveEntryCredentials(dispatch, entry, env);
|
|
1859
|
-
if (!creds.ok) return { ok: false, reason: creds.reason };
|
|
1860
|
-
const credentials = creds.credentials;
|
|
1861
|
-
const options = entry.options ?? {};
|
|
1862
|
-
const missingOpts = dispatch.requiredOptionFields.filter((k) => !(k in options));
|
|
1863
|
-
if (missingOpts.length > 0) {
|
|
1864
|
-
return {
|
|
1865
|
-
ok: false,
|
|
1866
|
-
reason: `options missing required field(s): ${missingOpts.join(", ")}`
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
let built;
|
|
1870
|
-
try {
|
|
1871
|
-
built = dispatch.build(graph, options);
|
|
1872
|
-
} catch (err) {
|
|
1873
|
-
return { ok: false, reason: err.message };
|
|
1874
|
-
}
|
|
1875
|
-
const intervalMs = typeof options.intervalMs === "number" ? options.intervalMs : void 0;
|
|
1876
|
-
return {
|
|
1877
|
-
ok: true,
|
|
1878
|
-
registration: {
|
|
1879
|
-
// Carry the entry id so the daemon can key this connector's poll-status
|
|
1880
|
-
// records to it (ADR-136).
|
|
1881
|
-
id: entry.id,
|
|
1882
|
-
connector: built.connector,
|
|
1883
|
-
credentials,
|
|
1884
|
-
resolveTarget: built.resolveTarget,
|
|
1885
|
-
...intervalMs !== void 0 ? { intervalMs } : {}
|
|
1886
|
-
}
|
|
1887
|
-
};
|
|
1888
|
-
}
|
|
1889
|
-
async function validateConnectorEntry(entry, env = process.env, fetchImpl) {
|
|
1890
|
-
const dispatch = PROVIDER_DISPATCH[entry.provider] ?? PUSH_PROVIDER_DISPATCH[entry.provider];
|
|
1891
|
-
if (!dispatch) {
|
|
1892
|
-
return { status: "unknown-provider", reason: `unknown provider "${entry.provider}"` };
|
|
1893
|
-
}
|
|
1894
|
-
const creds = resolveEntryCredentials(dispatch, entry, env);
|
|
1895
|
-
if (!creds.ok) {
|
|
1896
|
-
if (creds.kind === "unset-env") return { status: "unset-env", reason: creds.reason };
|
|
1897
|
-
return { status: "missing-field", reason: creds.reason };
|
|
1898
|
-
}
|
|
1899
|
-
const options = entry.options ?? {};
|
|
1900
|
-
const missingOpts = dispatch.requiredOptionFields.filter((k) => !(k in options));
|
|
1901
|
-
if (missingOpts.length > 0) {
|
|
1902
|
-
return {
|
|
1903
|
-
status: "missing-field",
|
|
1904
|
-
reason: `options missing required field(s): ${missingOpts.join(", ")}`
|
|
1905
|
-
};
|
|
1906
|
-
}
|
|
1907
|
-
const result = await dispatch.validate({
|
|
1908
|
-
credentials: creds.credentials,
|
|
1909
|
-
options,
|
|
1910
|
-
...fetchImpl ? { fetchImpl } : {}
|
|
1911
|
-
});
|
|
1912
|
-
return result.ok ? { status: "ok" } : { status: "rejected", reason: result.reason };
|
|
1913
|
-
}
|
|
1914
|
-
async function loadConnectorRegistrations(input) {
|
|
1915
|
-
const { project, graph, home, env = process.env, onSkip } = input;
|
|
1916
|
-
let connectors;
|
|
1917
|
-
try {
|
|
1918
|
-
connectors = (await readConnectorsConfig(home)).connectors;
|
|
1919
|
-
} catch (err) {
|
|
1920
|
-
onSkip?.(
|
|
1921
|
-
{ id: "(file)", provider: "(all)", credential: "" },
|
|
1922
|
-
`connectors.json unreadable \u2014 ${err.message}`
|
|
1923
|
-
);
|
|
1924
|
-
return [];
|
|
1925
|
-
}
|
|
1926
|
-
const registrations = [];
|
|
1927
|
-
for (const entry of connectors) {
|
|
1928
|
-
if (!connectorMatchesProject(entry, project)) continue;
|
|
1929
|
-
const result = buildRegistration(entry, graph, env);
|
|
1930
|
-
if (result.ok) registrations.push(result.registration);
|
|
1931
|
-
else if (!result.push) onSkip?.(entry, result.reason);
|
|
1932
|
-
}
|
|
1933
|
-
return registrations;
|
|
1934
|
-
}
|
|
1935
|
-
async function startConnectorPolling(input) {
|
|
1936
|
-
const fileConnectors = input.home ? await loadConnectorRegistrations({
|
|
1937
|
-
project: input.project,
|
|
1938
|
-
graph: input.graph,
|
|
1939
|
-
home: input.home,
|
|
1940
|
-
...input.onSkip ? { onSkip: input.onSkip } : {}
|
|
1941
|
-
}) : [];
|
|
1942
|
-
const all = [...input.extra ?? [], ...fileConnectors];
|
|
1943
|
-
const stopFns = all.map(
|
|
1944
|
-
(registration) => startConnectorPollLoop(
|
|
1945
|
-
registration.connector,
|
|
1946
|
-
{ projectDir: input.projectDir, credentials: registration.credentials },
|
|
1947
|
-
input.graph,
|
|
1948
|
-
registration.resolveTarget,
|
|
1949
|
-
{ intervalMs: registration.intervalMs, connectorId: registration.id }
|
|
1950
|
-
)
|
|
1951
|
-
);
|
|
1952
|
-
return () => {
|
|
1953
|
-
for (const stop of stopFns) stop();
|
|
1954
|
-
};
|
|
1955
|
-
}
|
|
1956
|
-
function resolvePushEntry(entry, env) {
|
|
1957
|
-
const dispatch = PUSH_PROVIDER_DISPATCH[entry.provider];
|
|
1958
|
-
if (!dispatch) {
|
|
1959
|
-
return PROVIDER_DISPATCH[entry.provider] ? {
|
|
1960
|
-
ok: false,
|
|
1961
|
-
outcome: {
|
|
1962
|
-
status: "not-push",
|
|
1963
|
-
reason: `provider "${entry.provider}" is polled, not provisioned \u2014 there is no drain to manage`
|
|
1964
|
-
}
|
|
1965
|
-
} : { ok: false, outcome: { status: "unknown-provider", reason: `unknown provider "${entry.provider}"` } };
|
|
1966
|
-
}
|
|
1967
|
-
const creds = resolveEntryCredentials(dispatch, entry, env);
|
|
1968
|
-
if (!creds.ok) {
|
|
1969
|
-
const status = creds.kind === "unset-env" ? "unset-env" : creds.kind === "missing-field" ? "missing-field" : "failed";
|
|
1970
|
-
return { ok: false, outcome: { status, reason: creds.reason } };
|
|
1971
|
-
}
|
|
1972
|
-
const options = entry.options ?? {};
|
|
1973
|
-
const missingOpts = dispatch.requiredOptionFields.filter((k) => !(k in options));
|
|
1974
|
-
if (missingOpts.length > 0) {
|
|
1975
|
-
return {
|
|
1976
|
-
ok: false,
|
|
1977
|
-
outcome: { status: "missing-field", reason: `options missing required field(s): ${missingOpts.join(", ")}` }
|
|
1978
|
-
};
|
|
1979
|
-
}
|
|
1980
|
-
return { ok: true, dispatch, credentials: creds.credentials, options };
|
|
1981
|
-
}
|
|
1982
|
-
async function provisionConnector(entry, env = process.env, fetchImpl) {
|
|
1983
|
-
const resolved = resolvePushEntry(entry, env);
|
|
1984
|
-
if (!resolved.ok) return resolved.outcome;
|
|
1985
|
-
const result = await resolved.dispatch.provision({
|
|
1986
|
-
credentials: resolved.credentials,
|
|
1987
|
-
options: resolved.options,
|
|
1988
|
-
...fetchImpl ? { fetchImpl } : {}
|
|
1989
|
-
});
|
|
1990
|
-
if (!result.ok) return { status: "failed", reason: result.reason };
|
|
1991
|
-
return { status: "ok", ...result.options ? { options: result.options } : {}, ...result.note ? { note: result.note } : {} };
|
|
1992
|
-
}
|
|
1993
|
-
async function deprovisionConnector(entry, env = process.env, fetchImpl) {
|
|
1994
|
-
const resolved = resolvePushEntry(entry, env);
|
|
1995
|
-
if (!resolved.ok) return resolved.outcome;
|
|
1996
|
-
const result = await resolved.dispatch.deprovision({
|
|
1997
|
-
credentials: resolved.credentials,
|
|
1998
|
-
options: resolved.options,
|
|
1999
|
-
...fetchImpl ? { fetchImpl } : {}
|
|
2000
|
-
});
|
|
2001
|
-
if (!result.ok) return { status: "failed", reason: result.reason };
|
|
2002
|
-
return { status: "ok", ...result.note ? { note: result.note } : {} };
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
// src/unrouted.ts
|
|
2006
|
-
import { promises as fs } from "fs";
|
|
2007
|
-
import path from "path";
|
|
2008
|
-
function buildUnroutedSpanRecord(serviceName, traceId, now = /* @__PURE__ */ new Date()) {
|
|
2009
|
-
return {
|
|
2010
|
-
timestamp: now.toISOString(),
|
|
2011
|
-
reason: "no-project-match",
|
|
2012
|
-
service_name: serviceName ?? null,
|
|
2013
|
-
traceId: traceId ?? null
|
|
2014
|
-
};
|
|
2015
|
-
}
|
|
2016
|
-
async function appendUnroutedSpan(neatHome, record) {
|
|
2017
|
-
const target = path.join(neatHome, "errors.ndjson");
|
|
2018
|
-
await fs.mkdir(neatHome, { recursive: true });
|
|
2019
|
-
await fs.appendFile(target, JSON.stringify(record) + "\n", "utf8");
|
|
2020
|
-
}
|
|
2021
|
-
function unroutedErrorsPath(neatHome) {
|
|
2022
|
-
return path.join(neatHome, "errors.ndjson");
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
// src/daemon.ts
|
|
2026
|
-
import { NodeType as NodeType5 } from "@neat.is/types";
|
|
2027
|
-
function daemonJsonPath(scanPath) {
|
|
2028
|
-
return path2.join(scanPath, "neat-out", "daemon.json");
|
|
2029
|
-
}
|
|
2030
|
-
function daemonsDiscoveryDir(home) {
|
|
2031
|
-
const base = home && home.length > 0 ? home : neatHomeFromEnv();
|
|
2032
|
-
return path2.join(base, "daemons");
|
|
2033
|
-
}
|
|
2034
|
-
function daemonDiscoveryPath(project, home) {
|
|
2035
|
-
return path2.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`);
|
|
2036
|
-
}
|
|
2037
|
-
function sanitizeDiscoveryName(project) {
|
|
2038
|
-
return project.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
2039
|
-
}
|
|
2040
|
-
function neatHomeFromEnv() {
|
|
2041
|
-
const env = process.env.NEAT_HOME;
|
|
2042
|
-
if (env && env.length > 0) return path2.resolve(env);
|
|
2043
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
2044
|
-
return path2.join(home, ".neat");
|
|
2045
|
-
}
|
|
2046
|
-
async function readDaemonRecord(scanPath) {
|
|
2047
|
-
try {
|
|
2048
|
-
const raw = await fs2.readFile(daemonJsonPath(scanPath), "utf8");
|
|
2049
|
-
const parsed = JSON.parse(raw);
|
|
2050
|
-
if (typeof parsed.project === "string" && parsed.ports && typeof parsed.ports.rest === "number" && typeof parsed.ports.otlp === "number" && typeof parsed.ports.web === "number") {
|
|
2051
|
-
return parsed;
|
|
2052
|
-
}
|
|
2053
|
-
return null;
|
|
2054
|
-
} catch {
|
|
2055
|
-
return null;
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
function resolveNeatVersion() {
|
|
2059
|
-
if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
|
|
2060
|
-
return process.env.NEAT_LOCAL_VERSION;
|
|
2061
|
-
}
|
|
2062
|
-
try {
|
|
2063
|
-
const req = createRequire(import.meta.url);
|
|
2064
|
-
const pkg = req("../package.json");
|
|
2065
|
-
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
2066
|
-
} catch {
|
|
2067
|
-
return "0.0.0";
|
|
2068
|
-
}
|
|
2069
|
-
}
|
|
2070
|
-
async function writeDaemonRecord(record, home) {
|
|
2071
|
-
const body = JSON.stringify(record, null, 2) + "\n";
|
|
2072
|
-
await writeAtomically(daemonJsonPath(record.projectPath), body);
|
|
2073
|
-
try {
|
|
2074
|
-
await writeAtomically(daemonDiscoveryPath(record.project, home), body);
|
|
2075
|
-
} catch (err) {
|
|
2076
|
-
console.warn(
|
|
2077
|
-
`neatd: could not write discovery copy for "${record.project}" \u2014 ${err.message}`
|
|
2078
|
-
);
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
async function clearDaemonRecord(record, home) {
|
|
2082
|
-
try {
|
|
2083
|
-
const stopped = { ...record, status: "stopped" };
|
|
2084
|
-
await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + "\n");
|
|
2085
|
-
} catch {
|
|
2086
|
-
}
|
|
2087
|
-
try {
|
|
2088
|
-
await fs2.unlink(daemonDiscoveryPath(record.project, home));
|
|
2089
|
-
} catch {
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
function reconcileDaemonRecordSync(record, home) {
|
|
2093
|
-
try {
|
|
2094
|
-
const stopped = { ...record, status: "stopped" };
|
|
2095
|
-
const target = daemonJsonPath(record.projectPath);
|
|
2096
|
-
const tmp = `${target}.${process.pid}.tmp`;
|
|
2097
|
-
writeFileSync(tmp, JSON.stringify(stopped, null, 2) + "\n");
|
|
2098
|
-
renameSync(tmp, target);
|
|
2099
|
-
} catch {
|
|
2100
|
-
}
|
|
2101
|
-
try {
|
|
2102
|
-
unlinkSync(daemonDiscoveryPath(record.project, home));
|
|
2103
|
-
} catch {
|
|
2104
|
-
}
|
|
2105
|
-
}
|
|
2106
|
-
function teardownSlot(slot) {
|
|
2107
|
-
try {
|
|
2108
|
-
slot.stopPersist();
|
|
2109
|
-
} catch {
|
|
2110
|
-
}
|
|
2111
|
-
try {
|
|
2112
|
-
slot.stopStaleness();
|
|
2113
|
-
} catch {
|
|
2114
|
-
}
|
|
2115
|
-
try {
|
|
2116
|
-
slot.stopConnectors();
|
|
2117
|
-
} catch {
|
|
2118
|
-
}
|
|
2119
|
-
try {
|
|
2120
|
-
slot.detachEvents();
|
|
2121
|
-
} catch {
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
function neatHomeFor(opts) {
|
|
2125
|
-
if (opts.neatHome && opts.neatHome.length > 0) return path2.resolve(opts.neatHome);
|
|
2126
|
-
const env = process.env.NEAT_HOME;
|
|
2127
|
-
if (env && env.length > 0) return path2.resolve(env);
|
|
2128
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
2129
|
-
return path2.join(home, ".neat");
|
|
2130
|
-
}
|
|
2131
|
-
function routeSpanToProject(serviceName, projects) {
|
|
2132
|
-
if (!serviceName) return DEFAULT_PROJECT;
|
|
2133
|
-
for (const entry of projects) {
|
|
2134
|
-
if (entry.status === "paused") continue;
|
|
2135
|
-
if (entry.name.toLowerCase() === serviceName.toLowerCase()) return entry.name;
|
|
2136
|
-
}
|
|
2137
|
-
const candidates = [];
|
|
2138
|
-
for (const entry of projects) {
|
|
2139
|
-
if (entry.status === "paused") continue;
|
|
2140
|
-
if (isTokenPrefix(entry.name, serviceName)) candidates.push(entry);
|
|
2141
|
-
}
|
|
2142
|
-
if (candidates.length > 0) {
|
|
2143
|
-
candidates.sort((a, b) => b.name.length - a.name.length);
|
|
2144
|
-
return candidates[0].name;
|
|
2145
|
-
}
|
|
2146
|
-
for (const entry of projects) {
|
|
2147
|
-
if (entry.status === "paused") continue;
|
|
2148
|
-
if (isTokenContained(entry.name, serviceName)) return entry.name;
|
|
2149
|
-
}
|
|
2150
|
-
return DEFAULT_PROJECT;
|
|
2151
|
-
}
|
|
2152
|
-
function isTokenPrefix(prefix, full) {
|
|
2153
|
-
const p = prefix.toLowerCase();
|
|
2154
|
-
const f = full.toLowerCase();
|
|
2155
|
-
if (p.length >= f.length) return false;
|
|
2156
|
-
if (!f.startsWith(p)) return false;
|
|
2157
|
-
const sep = f.charAt(p.length);
|
|
2158
|
-
return sep === "-" || sep === "_";
|
|
2159
|
-
}
|
|
2160
|
-
function isTokenContained(needle, haystack) {
|
|
2161
|
-
const n = needle.toLowerCase();
|
|
2162
|
-
const h = haystack.toLowerCase();
|
|
2163
|
-
if (!h.includes(n)) return false;
|
|
2164
|
-
const tokens = h.split(/[-_]/);
|
|
2165
|
-
return tokens.includes(n);
|
|
2166
|
-
}
|
|
2167
|
-
function serviceNameMatchesProject(serviceName, project) {
|
|
2168
|
-
if (serviceName.toLowerCase() === project.toLowerCase()) return true;
|
|
2169
|
-
if (isTokenPrefix(project, serviceName)) return true;
|
|
2170
|
-
if (isTokenContained(project, serviceName)) return true;
|
|
2171
|
-
return false;
|
|
2172
|
-
}
|
|
2173
|
-
function spanBelongsToSingleProject(graph, project, serviceName) {
|
|
2174
|
-
if (!serviceName) return true;
|
|
2175
|
-
if (serviceNameMatchesProject(serviceName, project)) return true;
|
|
2176
|
-
return graph.someNode(
|
|
2177
|
-
(_id, attrs) => attrs.type === NodeType5.ServiceNode && attrs.name === serviceName
|
|
2178
|
-
);
|
|
2179
|
-
}
|
|
2180
|
-
async function bootstrapProject(entry, connectors = [], neatHome) {
|
|
2181
|
-
const paths = pathsForProject(entry.name, path2.join(entry.path, "neat-out"));
|
|
2182
|
-
try {
|
|
2183
|
-
const stat = await fs2.stat(entry.path);
|
|
2184
|
-
if (!stat.isDirectory()) {
|
|
2185
|
-
throw new Error(`registered path ${entry.path} is not a directory`);
|
|
2186
|
-
}
|
|
2187
|
-
} catch (err) {
|
|
2188
|
-
await setStatus(entry.name, "broken").catch(() => {
|
|
2189
|
-
});
|
|
2190
|
-
return {
|
|
2191
|
-
entry,
|
|
2192
|
-
// Empty graph is fine — `slots` keeps the entry visible in `status`
|
|
2193
|
-
// output; nothing routes to it because it's not 'active'.
|
|
2194
|
-
graph: getGraph(`__broken__:${entry.name}`),
|
|
2195
|
-
outPath: "",
|
|
2196
|
-
paths,
|
|
2197
|
-
stopPersist: () => {
|
|
2198
|
-
},
|
|
2199
|
-
stopStaleness: () => {
|
|
2200
|
-
},
|
|
2201
|
-
stopConnectors: () => {
|
|
2202
|
-
},
|
|
2203
|
-
detachEvents: () => {
|
|
2204
|
-
},
|
|
2205
|
-
status: "broken",
|
|
2206
|
-
errorReason: err.message
|
|
2207
|
-
};
|
|
2208
|
-
}
|
|
2209
|
-
resetGraph(entry.name);
|
|
2210
|
-
const graph = getGraph(entry.name);
|
|
2211
|
-
const outPath = paths.snapshotPath;
|
|
2212
|
-
await loadGraphFromDisk(graph, outPath);
|
|
2213
|
-
const detachEvents = attachGraphToEventBus(graph, { project: entry.name });
|
|
2214
|
-
try {
|
|
2215
|
-
await extractFromDirectory(graph, entry.path);
|
|
2216
|
-
const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
|
|
2217
|
-
const stopStaleness = startStalenessLoop(graph, {
|
|
2218
|
-
staleEventsPath: paths.staleEventsPath,
|
|
2219
|
-
project: entry.name
|
|
2220
|
-
});
|
|
2221
|
-
const stopConnectors = await startConnectorPolling({
|
|
2222
|
-
project: entry.name,
|
|
2223
|
-
graph,
|
|
2224
|
-
projectDir: entry.path,
|
|
2225
|
-
...neatHome ? { home: neatHome } : {},
|
|
2226
|
-
extra: connectors,
|
|
2227
|
-
onSkip: (skipped, reason) => console.warn(
|
|
2228
|
-
`neatd: connector "${skipped.id}" (${skipped.provider}) skipped for project "${entry.name}" \u2014 ${reason}`
|
|
2229
|
-
)
|
|
2230
|
-
});
|
|
2231
|
-
await touchLastSeen(entry.name).catch(() => {
|
|
2232
|
-
});
|
|
2233
|
-
return {
|
|
2234
|
-
entry,
|
|
2235
|
-
graph,
|
|
2236
|
-
outPath,
|
|
2237
|
-
paths,
|
|
2238
|
-
stopPersist,
|
|
2239
|
-
stopStaleness,
|
|
2240
|
-
stopConnectors,
|
|
2241
|
-
detachEvents,
|
|
2242
|
-
status: "active"
|
|
2243
|
-
};
|
|
2244
|
-
} catch (err) {
|
|
2245
|
-
detachEvents();
|
|
2246
|
-
throw err;
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
function resolveRestPort(opts) {
|
|
2250
|
-
if (typeof opts.restPort === "number") return opts.restPort;
|
|
2251
|
-
const env = process.env.PORT;
|
|
2252
|
-
if (env && env.length > 0) {
|
|
2253
|
-
const n = Number.parseInt(env, 10);
|
|
2254
|
-
if (Number.isFinite(n)) return n;
|
|
2255
|
-
}
|
|
2256
|
-
return 8080;
|
|
2257
|
-
}
|
|
2258
|
-
function resolveOtlpPort(opts) {
|
|
2259
|
-
if (typeof opts.otlpPort === "number") return opts.otlpPort;
|
|
2260
|
-
const env = process.env.OTEL_PORT;
|
|
2261
|
-
if (env && env.length > 0) {
|
|
2262
|
-
const n = Number.parseInt(env, 10);
|
|
2263
|
-
if (Number.isFinite(n)) return n;
|
|
2264
|
-
}
|
|
2265
|
-
return 4318;
|
|
2266
|
-
}
|
|
2267
|
-
function resolveWebPort() {
|
|
2268
|
-
const env = process.env.NEAT_WEB_PORT;
|
|
2269
|
-
if (env && env.length > 0) {
|
|
2270
|
-
const n = Number.parseInt(env, 10);
|
|
2271
|
-
if (Number.isFinite(n)) return n;
|
|
2272
|
-
}
|
|
2273
|
-
return 6328;
|
|
2274
|
-
}
|
|
2275
|
-
function portFromListenAddress(address, fallback) {
|
|
2276
|
-
try {
|
|
2277
|
-
const port = new URL(address).port;
|
|
2278
|
-
const n = Number.parseInt(port, 10);
|
|
2279
|
-
if (Number.isFinite(n) && n > 0) return n;
|
|
2280
|
-
} catch {
|
|
2281
|
-
}
|
|
2282
|
-
return fallback;
|
|
2283
|
-
}
|
|
2284
|
-
function resolveHost(opts, authTokenSet) {
|
|
2285
|
-
if (opts.host && opts.host.length > 0) return opts.host;
|
|
2286
|
-
const env = process.env.HOST;
|
|
2287
|
-
if (env && env.length > 0) return env;
|
|
2288
|
-
if (!authTokenSet) return "127.0.0.1";
|
|
2289
|
-
return "0.0.0.0";
|
|
2290
|
-
}
|
|
2291
|
-
async function startDaemon(opts = {}) {
|
|
2292
|
-
const home = neatHomeFor(opts);
|
|
2293
|
-
const regPath = registryPath();
|
|
2294
|
-
const projectArg = typeof opts.project === "string" && opts.project.length > 0 ? opts.project : process.env.NEAT_PROJECT && process.env.NEAT_PROJECT.length > 0 ? process.env.NEAT_PROJECT : null;
|
|
2295
|
-
const projectPathArg = opts.projectPath && opts.projectPath.length > 0 ? opts.projectPath : process.env.NEAT_PROJECT_PATH && process.env.NEAT_PROJECT_PATH.length > 0 ? process.env.NEAT_PROJECT_PATH : null;
|
|
2296
|
-
const singleProject = projectArg;
|
|
2297
|
-
const singleProjectPath = singleProject && projectPathArg ? path2.resolve(projectPathArg) : null;
|
|
2298
|
-
if (singleProject && !singleProjectPath) {
|
|
2299
|
-
throw new Error(
|
|
2300
|
-
`neatd: project "${singleProject}" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`
|
|
2301
|
-
);
|
|
2302
|
-
}
|
|
2303
|
-
if (!singleProject) {
|
|
2304
|
-
try {
|
|
2305
|
-
await fs2.access(regPath);
|
|
2306
|
-
} catch {
|
|
2307
|
-
throw new Error(
|
|
2308
|
-
`neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
|
|
2309
|
-
);
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
const pidPath = path2.join(home, "neatd.pid");
|
|
2313
|
-
await writeAtomically(pidPath, `${process.pid}
|
|
2314
|
-
`);
|
|
2315
|
-
const slots = /* @__PURE__ */ new Map();
|
|
2316
|
-
const registry = new Projects();
|
|
2317
|
-
const bootstrapStatus = /* @__PURE__ */ new Map();
|
|
2318
|
-
const bootstrapStartedAt = /* @__PURE__ */ new Map();
|
|
2319
|
-
const DROP_WARN_INTERVAL_MS = 6e4;
|
|
2320
|
-
const lastDropWarnAt = /* @__PURE__ */ new Map();
|
|
2321
|
-
function warnDroppedSpan(project, reason) {
|
|
2322
|
-
const now = Date.now();
|
|
2323
|
-
const prev = lastDropWarnAt.get(project) ?? 0;
|
|
2324
|
-
if (now - prev < DROP_WARN_INTERVAL_MS) return;
|
|
2325
|
-
lastDropWarnAt.set(project, now);
|
|
2326
|
-
console.warn(
|
|
2327
|
-
`[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
|
|
2328
|
-
);
|
|
2329
|
-
}
|
|
2330
|
-
const unroutedPath = unroutedErrorsPath(home);
|
|
2331
|
-
const lastUnroutedWarnAt = /* @__PURE__ */ new Map();
|
|
2332
|
-
async function recordUnroutedSpan(serviceName, traceId) {
|
|
2333
|
-
const key = serviceName ?? "<missing>";
|
|
2334
|
-
const now = Date.now();
|
|
2335
|
-
try {
|
|
2336
|
-
await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)));
|
|
2337
|
-
} catch {
|
|
2338
|
-
}
|
|
2339
|
-
const prev = lastUnroutedWarnAt.get(key) ?? 0;
|
|
2340
|
-
if (now - prev < DROP_WARN_INTERVAL_MS) return;
|
|
2341
|
-
lastUnroutedWarnAt.set(key, now);
|
|
2342
|
-
console.warn(
|
|
2343
|
-
`[neatd] dropping span \u2014 service.name "${key}" matches no registered project and no \`default\` project exists. See ${unroutedPath}.`
|
|
2344
|
-
);
|
|
2345
|
-
}
|
|
2346
|
-
function upsertRegistryFromSlot(slot) {
|
|
2347
|
-
if (slot.status !== "active") return;
|
|
2348
|
-
registry.set(slot.entry.name, {
|
|
2349
|
-
scanPath: slot.entry.path,
|
|
2350
|
-
paths: slot.paths,
|
|
2351
|
-
graph: slot.graph
|
|
2352
|
-
});
|
|
2353
|
-
}
|
|
2354
|
-
async function tryRecoverSlot(entry) {
|
|
2355
|
-
try {
|
|
2356
|
-
const fresh = await bootstrapProject(entry, opts.connectors ?? [], home);
|
|
2357
|
-
const prior = slots.get(entry.name);
|
|
2358
|
-
if (prior) teardownSlot(prior);
|
|
2359
|
-
slots.set(entry.name, fresh);
|
|
2360
|
-
upsertRegistryFromSlot(fresh);
|
|
2361
|
-
if (fresh.status === "active") {
|
|
2362
|
-
await setStatus(entry.name, "active").catch(() => {
|
|
2363
|
-
});
|
|
2364
|
-
console.log(
|
|
2365
|
-
`neatd: project "${entry.name}" recovered from broken \u2014 active`
|
|
2366
|
-
);
|
|
2367
|
-
}
|
|
2368
|
-
return fresh;
|
|
2369
|
-
} catch (err) {
|
|
2370
|
-
console.warn(
|
|
2371
|
-
`neatd: project "${entry.name}" still broken after recovery attempt \u2014 ${err.message}`
|
|
2372
|
-
);
|
|
2373
|
-
return slots.get(entry.name);
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
async function bootstrapOne(entry) {
|
|
2377
|
-
bootstrapStatus.set(entry.name, "bootstrapping");
|
|
2378
|
-
bootstrapStartedAt.set(entry.name, Date.now());
|
|
2379
|
-
try {
|
|
2380
|
-
const slot = await bootstrapProject(entry, opts.connectors ?? [], home);
|
|
2381
|
-
const prior = slots.get(entry.name);
|
|
2382
|
-
if (prior) teardownSlot(prior);
|
|
2383
|
-
slots.set(entry.name, slot);
|
|
2384
|
-
upsertRegistryFromSlot(slot);
|
|
2385
|
-
bootstrapStatus.set(entry.name, slot.status === "broken" ? "broken" : "active");
|
|
2386
|
-
if (slot.status === "broken") {
|
|
2387
|
-
console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
|
|
2388
|
-
} else {
|
|
2389
|
-
console.log(`neatd: project "${entry.name}" active (${entry.path})`);
|
|
2390
|
-
}
|
|
2391
|
-
} catch (err) {
|
|
2392
|
-
bootstrapStatus.set(entry.name, "broken");
|
|
2393
|
-
console.warn(
|
|
2394
|
-
`neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
|
|
2395
|
-
);
|
|
2396
|
-
await setStatus(entry.name, "broken").catch(() => {
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
async function enumerateProjects() {
|
|
2401
|
-
if (singleProject && singleProjectPath) {
|
|
2402
|
-
return [
|
|
2403
|
-
{
|
|
2404
|
-
name: singleProject,
|
|
2405
|
-
path: singleProjectPath,
|
|
2406
|
-
registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2407
|
-
languages: [],
|
|
2408
|
-
status: "active"
|
|
2409
|
-
}
|
|
2410
|
-
];
|
|
2411
|
-
}
|
|
2412
|
-
return listProjects();
|
|
2413
|
-
}
|
|
2414
|
-
async function loadAll() {
|
|
2415
|
-
if (!singleProject) {
|
|
2416
|
-
try {
|
|
2417
|
-
const pruned = await pruneRegistry();
|
|
2418
|
-
for (const entry of pruned) {
|
|
2419
|
-
console.log(
|
|
2420
|
-
`neatd: pruned project "${entry.name}" \u2014 registered path ${entry.path} is gone`
|
|
2421
|
-
);
|
|
2422
|
-
slots.delete(entry.name);
|
|
2423
|
-
bootstrapStatus.delete(entry.name);
|
|
2424
|
-
bootstrapStartedAt.delete(entry.name);
|
|
2425
|
-
}
|
|
2426
|
-
} catch (err) {
|
|
2427
|
-
console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
|
|
2428
|
-
}
|
|
2429
|
-
}
|
|
2430
|
-
const projects = await enumerateProjects();
|
|
2431
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2432
|
-
const pending = [];
|
|
2433
|
-
for (const entry of projects) {
|
|
2434
|
-
seen.add(entry.name);
|
|
2435
|
-
const existing = slots.get(entry.name);
|
|
2436
|
-
if (existing) {
|
|
2437
|
-
if (existing.status === "broken") {
|
|
2438
|
-
pending.push(tryRecoverSlot(entry).then(() => {
|
|
2439
|
-
}));
|
|
2440
|
-
}
|
|
2441
|
-
continue;
|
|
2442
|
-
}
|
|
2443
|
-
pending.push(bootstrapOne(entry));
|
|
2444
|
-
}
|
|
2445
|
-
for (const [name, slot] of [...slots.entries()]) {
|
|
2446
|
-
if (seen.has(name)) continue;
|
|
2447
|
-
teardownSlot(slot);
|
|
2448
|
-
slots.delete(name);
|
|
2449
|
-
bootstrapStatus.delete(name);
|
|
2450
|
-
bootstrapStartedAt.delete(name);
|
|
2451
|
-
console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
|
|
2452
|
-
}
|
|
2453
|
-
await Promise.allSettled(pending);
|
|
2454
|
-
}
|
|
2455
|
-
const initialEntries = await enumerateProjects().catch(() => []);
|
|
2456
|
-
for (const entry of initialEntries) {
|
|
2457
|
-
bootstrapStatus.set(entry.name, "bootstrapping");
|
|
2458
|
-
bootstrapStartedAt.set(entry.name, Date.now());
|
|
2459
|
-
}
|
|
2460
|
-
const bind = opts.bindListeners !== false;
|
|
2461
|
-
let restApp = null;
|
|
2462
|
-
let otlpApp = null;
|
|
2463
|
-
let restAddress = "";
|
|
2464
|
-
let otlpAddress = "";
|
|
2465
|
-
let daemonRecord = null;
|
|
2466
|
-
if (bind) {
|
|
2467
|
-
const auth = readAuthEnv();
|
|
2468
|
-
const host = resolveHost(opts, Boolean(auth.authToken));
|
|
2469
|
-
const restPort = resolveRestPort(opts);
|
|
2470
|
-
const otlpPort = resolveOtlpPort(opts);
|
|
2471
|
-
assertBindAuthority(host, auth.authToken);
|
|
2472
|
-
try {
|
|
2473
|
-
restApp = await buildApi({
|
|
2474
|
-
projects: registry,
|
|
2475
|
-
authToken: auth.authToken,
|
|
2476
|
-
trustProxy: auth.trustProxy,
|
|
2477
|
-
publicRead: auth.publicRead,
|
|
2478
|
-
bootstrap: {
|
|
2479
|
-
status: (name) => bootstrapStatus.get(name),
|
|
2480
|
-
list: () => {
|
|
2481
|
-
const now = Date.now();
|
|
2482
|
-
return [...bootstrapStatus.entries()].map(([name, status]) => ({
|
|
2483
|
-
name,
|
|
2484
|
-
status,
|
|
2485
|
-
elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
|
|
2486
|
-
}));
|
|
2487
|
-
}
|
|
2488
|
-
},
|
|
2489
|
-
// ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST
|
|
2490
|
-
// surface reflects "the daemon is the project": `GET /projects` reports
|
|
2491
|
-
// only this project (the dashboard pins to it), and the daemon-wide
|
|
2492
|
-
// `/health` carries it at the top level for the spawn-reuse identity
|
|
2493
|
-
// check. Absent for the legacy multi-project daemon.
|
|
2494
|
-
singleProject: singleProject && singleProjectPath ? { name: singleProject, path: singleProjectPath } : void 0,
|
|
2495
|
-
// ADR-136 — the connector-status endpoint reads ~/.neat/connectors.json
|
|
2496
|
-
// through the same resolved home the slot bootstrap read it from, so a
|
|
2497
|
-
// daemon given an explicit NEAT_HOME serves status for the same file it
|
|
2498
|
-
// polls.
|
|
2499
|
-
connectorsHome: home
|
|
2500
|
-
});
|
|
2501
|
-
restAddress = await restApp.listen({ port: restPort, host });
|
|
2502
|
-
console.log(
|
|
2503
|
-
`neatd: REST listening on http://${host}:${portFromListenAddress(restAddress, restPort)}`
|
|
2504
|
-
);
|
|
2505
|
-
} catch (err) {
|
|
2506
|
-
for (const slot of slots.values()) {
|
|
2507
|
-
teardownSlot(slot);
|
|
2508
|
-
}
|
|
2509
|
-
if (restApp) await restApp.close().catch(() => {
|
|
2510
|
-
});
|
|
2511
|
-
await fs2.unlink(pidPath).catch(() => {
|
|
2512
|
-
});
|
|
2513
|
-
throw new Error(
|
|
2514
|
-
`neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
|
|
2515
|
-
);
|
|
2516
|
-
}
|
|
2517
|
-
async function resolveTargetSlot(serviceName, traceId) {
|
|
2518
|
-
if (singleProject) {
|
|
2519
|
-
let slot2 = slots.get(singleProject);
|
|
2520
|
-
if (!slot2) {
|
|
2521
|
-
slot2 = await tryRecoverSlot({
|
|
2522
|
-
name: singleProject,
|
|
2523
|
-
path: singleProjectPath,
|
|
2524
|
-
registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2525
|
-
languages: [],
|
|
2526
|
-
status: "active"
|
|
2527
|
-
});
|
|
2528
|
-
} else if (slot2.status === "broken") {
|
|
2529
|
-
slot2 = await tryRecoverSlot(slot2.entry);
|
|
2530
|
-
}
|
|
2531
|
-
if (!slot2 || slot2.status !== "active") {
|
|
2532
|
-
warnDroppedSpan(singleProject, slot2?.errorReason ?? "unknown");
|
|
2533
|
-
return null;
|
|
2534
|
-
}
|
|
2535
|
-
if (!spanBelongsToSingleProject(slot2.graph, singleProject, serviceName)) {
|
|
2536
|
-
await recordUnroutedSpan(serviceName, traceId);
|
|
2537
|
-
return null;
|
|
2538
|
-
}
|
|
2539
|
-
return slot2;
|
|
2540
|
-
}
|
|
2541
|
-
const liveEntries = await listProjects().catch(() => []);
|
|
2542
|
-
const target = routeSpanToProject(serviceName, liveEntries);
|
|
2543
|
-
let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
|
|
2544
|
-
if (!slot) {
|
|
2545
|
-
await recordUnroutedSpan(serviceName, traceId);
|
|
2546
|
-
return null;
|
|
2547
|
-
}
|
|
2548
|
-
if (slot.status === "broken") {
|
|
2549
|
-
const entry = liveEntries.find((e) => e.name === slot.entry.name);
|
|
2550
|
-
if (entry) {
|
|
2551
|
-
slot = await tryRecoverSlot(entry);
|
|
2552
|
-
}
|
|
2553
|
-
if (slot.status !== "active") {
|
|
2554
|
-
warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
|
|
2555
|
-
return null;
|
|
2556
|
-
}
|
|
2557
|
-
}
|
|
2558
|
-
return slot.status === "active" ? slot : null;
|
|
2559
|
-
}
|
|
2560
|
-
async function resolveSlotByName(project, serviceName, traceId) {
|
|
2561
|
-
const liveEntries = await listProjects().catch(() => []);
|
|
2562
|
-
let slot = slots.get(project);
|
|
2563
|
-
if (!slot) {
|
|
2564
|
-
await recordUnroutedSpan(serviceName, traceId);
|
|
2565
|
-
return null;
|
|
2566
|
-
}
|
|
2567
|
-
if (slot.status === "broken") {
|
|
2568
|
-
const entry = liveEntries.find((e) => e.name === slot.entry.name);
|
|
2569
|
-
if (entry) {
|
|
2570
|
-
slot = await tryRecoverSlot(entry);
|
|
2571
|
-
}
|
|
2572
|
-
if (slot.status !== "active") {
|
|
2573
|
-
warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
|
|
2574
|
-
return null;
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
return slot.status === "active" ? slot : null;
|
|
2578
|
-
}
|
|
2579
|
-
try {
|
|
2580
|
-
otlpApp = await buildOtelReceiver({
|
|
2581
|
-
authToken: auth.otelToken,
|
|
2582
|
-
trustProxy: auth.trustProxy,
|
|
2583
|
-
onSpan: async (span) => {
|
|
2584
|
-
const slot = await resolveTargetSlot(span.service, span.traceId);
|
|
2585
|
-
if (!slot) return;
|
|
2586
|
-
await handleSpan(
|
|
2587
|
-
{
|
|
2588
|
-
graph: slot.graph,
|
|
2589
|
-
errorsPath: slot.paths.errorsPath,
|
|
2590
|
-
scanPath: slot.entry.path,
|
|
2591
|
-
project: slot.entry.name,
|
|
2592
|
-
// Receiver already wrote the error event synchronously below.
|
|
2593
|
-
writeErrorEventInline: false
|
|
2594
|
-
},
|
|
2595
|
-
span
|
|
2596
|
-
);
|
|
2597
|
-
},
|
|
2598
|
-
onErrorSpanSync: async (span) => {
|
|
2599
|
-
const slot = await resolveTargetSlot(span.service, span.traceId);
|
|
2600
|
-
if (!slot) return;
|
|
2601
|
-
await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span);
|
|
2602
|
-
},
|
|
2603
|
-
// Project-scoped route (issue #367) — the URL already named the
|
|
2604
|
-
// project. Resolution is a direct slot lookup; service.name resolves
|
|
2605
|
-
// the ServiceNode inside the slot's graph instead of which project
|
|
2606
|
-
// owns the span.
|
|
2607
|
-
onProjectSpan: async (project, span) => {
|
|
2608
|
-
const slot = await resolveSlotByName(project, span.service, span.traceId);
|
|
2609
|
-
if (!slot) return;
|
|
2610
|
-
await handleSpan(
|
|
2611
|
-
{
|
|
2612
|
-
graph: slot.graph,
|
|
2613
|
-
errorsPath: slot.paths.errorsPath,
|
|
2614
|
-
scanPath: slot.entry.path,
|
|
2615
|
-
project: slot.entry.name,
|
|
2616
|
-
writeErrorEventInline: false
|
|
2617
|
-
},
|
|
2618
|
-
span
|
|
2619
|
-
);
|
|
2620
|
-
},
|
|
2621
|
-
onProjectErrorSpanSync: async (project, span) => {
|
|
2622
|
-
const slot = await resolveSlotByName(project, span.service, span.traceId);
|
|
2623
|
-
if (!slot) return;
|
|
2624
|
-
await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span);
|
|
2625
|
-
},
|
|
2626
|
-
// #881 — 404 a project-scoped POST for a project this daemon doesn't
|
|
2627
|
-
// host, rather than accepting it and dropping the batch. `slots` covers
|
|
2628
|
-
// active/recovering projects, `bootstrapStatus` the ones still
|
|
2629
|
-
// extracting; a foreign or wrong-cased project name matches neither.
|
|
2630
|
-
isProjectRegistered: (project) => slots.has(project) || bootstrapStatus.has(project)
|
|
2631
|
-
});
|
|
2632
|
-
otlpAddress = await listenSteppingOtlp(otlpApp, otlpPort, host);
|
|
2633
|
-
console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`);
|
|
2634
|
-
} catch (err) {
|
|
2635
|
-
for (const slot of slots.values()) {
|
|
2636
|
-
teardownSlot(slot);
|
|
2637
|
-
}
|
|
2638
|
-
if (restApp) await restApp.close().catch(() => {
|
|
2639
|
-
});
|
|
2640
|
-
if (otlpApp) await otlpApp.close().catch(() => {
|
|
2641
|
-
});
|
|
2642
|
-
await fs2.unlink(pidPath).catch(() => {
|
|
2643
|
-
});
|
|
2644
|
-
throw new Error(
|
|
2645
|
-
`neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
|
|
2646
|
-
);
|
|
2647
|
-
}
|
|
2648
|
-
if (singleProject && singleProjectPath) {
|
|
2649
|
-
const ports = {
|
|
2650
|
-
rest: portFromListenAddress(restAddress, restPort),
|
|
2651
|
-
otlp: portFromListenAddress(otlpAddress, otlpPort),
|
|
2652
|
-
// The daemon doesn't bind the web port itself (neatd spawns the web
|
|
2653
|
-
// child); it records the allocated value passed through so the
|
|
2654
|
-
// dashboard and `neat ps` agree on where to look.
|
|
2655
|
-
web: typeof opts.webPort === "number" ? opts.webPort : resolveWebPort()
|
|
2656
|
-
};
|
|
2657
|
-
daemonRecord = {
|
|
2658
|
-
project: singleProject,
|
|
2659
|
-
projectPath: singleProjectPath,
|
|
2660
|
-
pid: process.pid,
|
|
2661
|
-
status: "running",
|
|
2662
|
-
ports,
|
|
2663
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2664
|
-
neatVersion: resolveNeatVersion()
|
|
2665
|
-
};
|
|
2666
|
-
try {
|
|
2667
|
-
await writeDaemonRecord(daemonRecord, home);
|
|
2668
|
-
console.log(
|
|
2669
|
-
`neatd: project "${singleProject}" \u2192 REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`
|
|
2670
|
-
);
|
|
2671
|
-
} catch (err) {
|
|
2672
|
-
for (const slot of slots.values()) teardownSlot(slot);
|
|
2673
|
-
if (restApp) await restApp.close().catch(() => {
|
|
2674
|
-
});
|
|
2675
|
-
if (otlpApp) await otlpApp.close().catch(() => {
|
|
2676
|
-
});
|
|
2677
|
-
await fs2.unlink(pidPath).catch(() => {
|
|
2678
|
-
});
|
|
2679
|
-
throw new Error(
|
|
2680
|
-
`neatd: failed to write daemon.json for "${singleProject}" \u2014 ${err.message}`
|
|
2681
|
-
);
|
|
2682
|
-
}
|
|
2683
|
-
}
|
|
2684
|
-
}
|
|
2685
|
-
const initialBootstrap = loadAll().catch((err) => {
|
|
2686
|
-
console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
|
|
2687
|
-
});
|
|
2688
|
-
let reloading = initialBootstrap;
|
|
2689
|
-
const reload = async () => {
|
|
2690
|
-
if (reloading) return reloading;
|
|
2691
|
-
reloading = (async () => {
|
|
2692
|
-
try {
|
|
2693
|
-
await loadAll();
|
|
2694
|
-
} finally {
|
|
2695
|
-
reloading = null;
|
|
2696
|
-
}
|
|
2697
|
-
})();
|
|
2698
|
-
return reloading;
|
|
2699
|
-
};
|
|
2700
|
-
void initialBootstrap.finally(() => {
|
|
2701
|
-
if (reloading === initialBootstrap) reloading = null;
|
|
2702
|
-
});
|
|
2703
|
-
const tracker = {
|
|
2704
|
-
status: (name) => bootstrapStatus.get(name),
|
|
2705
|
-
list: () => {
|
|
2706
|
-
const now = Date.now();
|
|
2707
|
-
return [...bootstrapStatus.entries()].map(([name, status]) => ({
|
|
2708
|
-
name,
|
|
2709
|
-
status,
|
|
2710
|
-
elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
|
|
2711
|
-
}));
|
|
2712
|
-
}
|
|
2713
|
-
};
|
|
2714
|
-
const sighupHandler = () => {
|
|
2715
|
-
void reload().catch((err) => {
|
|
2716
|
-
console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
|
|
2717
|
-
});
|
|
2718
|
-
};
|
|
2719
|
-
process.on("SIGHUP", sighupHandler);
|
|
2720
|
-
const REGISTRY_RELOAD_DEBOUNCE_MS = 500;
|
|
2721
|
-
let registryWatcher = null;
|
|
2722
|
-
let reloadTimer = null;
|
|
2723
|
-
if (!singleProject) try {
|
|
2724
|
-
const regDir = path2.dirname(regPath);
|
|
2725
|
-
const regBase = path2.basename(regPath);
|
|
2726
|
-
registryWatcher = watch(regDir, (_eventType, filename) => {
|
|
2727
|
-
if (filename !== null && filename !== regBase) return;
|
|
2728
|
-
if (reloadTimer) clearTimeout(reloadTimer);
|
|
2729
|
-
reloadTimer = setTimeout(() => {
|
|
2730
|
-
reloadTimer = null;
|
|
2731
|
-
void reload().catch((err) => {
|
|
2732
|
-
console.warn(
|
|
2733
|
-
`neatd: registry-watch reload failed \u2014 ${err.message}`
|
|
2734
|
-
);
|
|
2735
|
-
});
|
|
2736
|
-
}, REGISTRY_RELOAD_DEBOUNCE_MS);
|
|
2737
|
-
});
|
|
2738
|
-
} catch (err) {
|
|
2739
|
-
console.warn(
|
|
2740
|
-
`neatd: failed to watch registry at ${regPath} \u2014 ${err.message}. Run \`neatd reload\` (or send SIGHUP) after registering new projects.`
|
|
2741
|
-
);
|
|
2742
|
-
}
|
|
2743
|
-
let stopped = false;
|
|
2744
|
-
const stop = async () => {
|
|
2745
|
-
if (stopped) return;
|
|
2746
|
-
stopped = true;
|
|
2747
|
-
process.off("SIGHUP", sighupHandler);
|
|
2748
|
-
if (reloadTimer) {
|
|
2749
|
-
clearTimeout(reloadTimer);
|
|
2750
|
-
reloadTimer = null;
|
|
2751
|
-
}
|
|
2752
|
-
if (registryWatcher) {
|
|
2753
|
-
try {
|
|
2754
|
-
registryWatcher.close();
|
|
2755
|
-
} catch {
|
|
2756
|
-
}
|
|
2757
|
-
registryWatcher = null;
|
|
2758
|
-
}
|
|
2759
|
-
if (otlpApp) await otlpApp.close().catch(() => {
|
|
2760
|
-
});
|
|
2761
|
-
if (restApp) await restApp.close().catch(() => {
|
|
2762
|
-
});
|
|
2763
|
-
for (const slot of slots.values()) {
|
|
2764
|
-
if (slot.status === "active" && slot.outPath) {
|
|
2765
|
-
await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
}
|
|
2769
|
-
for (const slot of slots.values()) {
|
|
2770
|
-
teardownSlot(slot);
|
|
2771
|
-
}
|
|
2772
|
-
if (daemonRecord) {
|
|
2773
|
-
await clearDaemonRecord(daemonRecord, home);
|
|
2774
|
-
}
|
|
2775
|
-
await fs2.unlink(pidPath).catch(() => {
|
|
2776
|
-
});
|
|
2777
|
-
};
|
|
2778
|
-
return {
|
|
2779
|
-
slots,
|
|
2780
|
-
reload,
|
|
2781
|
-
stop,
|
|
2782
|
-
pidPath,
|
|
2783
|
-
restAddress,
|
|
2784
|
-
otlpAddress,
|
|
2785
|
-
bootstrap: tracker,
|
|
2786
|
-
initialBootstrap,
|
|
2787
|
-
daemonRecord,
|
|
2788
|
-
neatHome: home
|
|
2789
|
-
};
|
|
2790
|
-
}
|
|
2791
|
-
|
|
2792
|
-
export {
|
|
2793
|
-
isPushProvider,
|
|
2794
|
-
getProviderFieldSchema,
|
|
2795
|
-
knownProviderNames,
|
|
2796
|
-
validateConnectorEntry,
|
|
2797
|
-
startConnectorPolling,
|
|
2798
|
-
provisionConnector,
|
|
2799
|
-
deprovisionConnector,
|
|
2800
|
-
readDaemonRecord,
|
|
2801
|
-
resolveNeatVersion,
|
|
2802
|
-
writeDaemonRecord,
|
|
2803
|
-
clearDaemonRecord,
|
|
2804
|
-
reconcileDaemonRecordSync,
|
|
2805
|
-
routeSpanToProject,
|
|
2806
|
-
portFromListenAddress,
|
|
2807
|
-
resolveHost,
|
|
2808
|
-
startDaemon
|
|
2809
|
-
};
|
|
2810
|
-
//# sourceMappingURL=chunk-22X2YM5H.js.map
|