@pdpp/local-collector 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/local-collector/bin/pdpp-local-collector.js +347 -0
- package/dist/local-collector/src/errors.d.ts +12 -0
- package/dist/local-collector/src/errors.js +20 -0
- package/dist/local-collector/src/runner.d.ts +16 -0
- package/dist/local-collector/src/runner.js +59 -0
- package/dist/polyfill-connectors/connectors/claude_code/index.js +806 -0
- package/dist/polyfill-connectors/connectors/claude_code/parsers.js +224 -0
- package/dist/polyfill-connectors/connectors/claude_code/schemas.js +120 -0
- package/dist/polyfill-connectors/connectors/claude_code/types.js +1 -0
- package/dist/polyfill-connectors/connectors/codex/index.js +880 -0
- package/dist/polyfill-connectors/connectors/codex/parsers.js +159 -0
- package/dist/polyfill-connectors/connectors/codex/schemas.js +118 -0
- package/dist/polyfill-connectors/connectors/codex/types.js +1 -0
- package/dist/polyfill-connectors/src/auth.js +76 -0
- package/dist/polyfill-connectors/src/browser-handoff.js +197 -0
- package/dist/polyfill-connectors/src/collector-protocol.d.ts +2 -0
- package/dist/polyfill-connectors/src/collector-protocol.js +2 -0
- package/dist/polyfill-connectors/src/collector-runner.d.ts +139 -0
- package/dist/polyfill-connectors/src/collector-runner.js +1084 -0
- package/dist/polyfill-connectors/src/connector-runtime-protocol.d.ts +191 -0
- package/dist/polyfill-connectors/src/connector-runtime-protocol.js +1 -0
- package/dist/polyfill-connectors/src/connector-runtime.js +879 -0
- package/dist/polyfill-connectors/src/fixture-capture.js +237 -0
- package/dist/polyfill-connectors/src/is-main-module.d.ts +1 -0
- package/dist/polyfill-connectors/src/is-main-module.js +17 -0
- package/dist/polyfill-connectors/src/local-device-client.d.ts +126 -0
- package/dist/polyfill-connectors/src/local-device-client.js +132 -0
- package/dist/polyfill-connectors/src/local-device-envelope.d.ts +26 -0
- package/dist/polyfill-connectors/src/local-device-envelope.js +43 -0
- package/dist/polyfill-connectors/src/local-device-outbox.d.ts +115 -0
- package/dist/polyfill-connectors/src/local-device-outbox.js +509 -0
- package/dist/polyfill-connectors/src/local-device-queue.d.ts +34 -0
- package/dist/polyfill-connectors/src/local-device-queue.js +133 -0
- package/dist/polyfill-connectors/src/local-source-inventory.js +119 -0
- package/dist/polyfill-connectors/src/pdpp-safe-text.js +13 -0
- package/dist/polyfill-connectors/src/runner/index.d.ts +11 -0
- package/dist/polyfill-connectors/src/runner/index.js +10 -0
- package/dist/polyfill-connectors/src/runtime-capabilities.d.ts +40 -0
- package/dist/polyfill-connectors/src/runtime-capabilities.js +59 -0
- package/dist/polyfill-connectors/src/safe-emit.d.ts +3 -0
- package/dist/polyfill-connectors/src/safe-emit.js +30 -0
- package/dist/polyfill-connectors/src/safe-text-preview.js +156 -0
- package/dist/polyfill-connectors/src/schema-registry.js +17 -0
- package/dist/polyfill-connectors/src/scope-filters.d.ts +38 -0
- package/dist/polyfill-connectors/src/scope-filters.js +80 -0
- package/dist/polyfill-connectors/src/shutdown-hook.js +51 -0
- package/dist/polyfill-connectors/src/streaming-target-registration.js +161 -0
- package/package.json +63 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
5
|
+
const ARIA_SNAPSHOT_TIMEOUT_MS = 2000;
|
|
6
|
+
const LOCATOR_PROBE_TIMEOUT_MS = 1000;
|
|
7
|
+
const LOCATOR_PROBE_ARIA_DEPTH = 2;
|
|
8
|
+
const safeLabel = (s) => String(s)
|
|
9
|
+
.replace(/[^A-Za-z0-9_.-]/g, "_")
|
|
10
|
+
.slice(0, 120);
|
|
11
|
+
function requireProbeMethod(page, key) {
|
|
12
|
+
const method = page[key];
|
|
13
|
+
if (typeof method !== "function") {
|
|
14
|
+
throw new Error(`locator probe page is missing ${String(key)}`);
|
|
15
|
+
}
|
|
16
|
+
return method;
|
|
17
|
+
}
|
|
18
|
+
function locatorForProbe(page, probe) {
|
|
19
|
+
switch (probe.kind) {
|
|
20
|
+
case "css":
|
|
21
|
+
return page.locator(probe.selector);
|
|
22
|
+
case "label":
|
|
23
|
+
return requireProbeMethod(page, "getByLabel")(probe.text, probe.exact === undefined ? undefined : { exact: probe.exact });
|
|
24
|
+
case "placeholder":
|
|
25
|
+
return requireProbeMethod(page, "getByPlaceholder")(probe.text, probe.exact === undefined ? undefined : { exact: probe.exact });
|
|
26
|
+
case "role": {
|
|
27
|
+
const name = probe.namePattern ? new RegExp(probe.namePattern, probe.nameFlags ?? "i") : probe.name;
|
|
28
|
+
return requireProbeMethod(page, "getByRole")(probe.role, {
|
|
29
|
+
...(probe.exact === undefined ? {} : { exact: probe.exact }),
|
|
30
|
+
...(name === undefined ? {} : { name }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
case "text":
|
|
34
|
+
return requireProbeMethod(page, "getByText")(probe.text, probe.exact === undefined ? undefined : { exact: probe.exact });
|
|
35
|
+
default:
|
|
36
|
+
throw new Error(`unsupported locator probe kind: ${probe.kind ?? "unknown"}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function probeForReport(probe) {
|
|
40
|
+
const { description: _description, id: _id, kind: _kind, ...rest } = probe;
|
|
41
|
+
return rest;
|
|
42
|
+
}
|
|
43
|
+
async function captureDomHtml(page, baseDir, label, safe) {
|
|
44
|
+
try {
|
|
45
|
+
const html = await page.content();
|
|
46
|
+
writeFileSync(join(baseDir, "dom", `${safe}.html`), html);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
process.stderr.write(`[capture] dom write failed for ${label}: ${message}\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function capturePageMetadata(page, baseDir, label, safe) {
|
|
54
|
+
try {
|
|
55
|
+
const title = await page.title().catch(() => "");
|
|
56
|
+
writeFileSync(join(baseDir, "pages", `${safe}.json`), JSON.stringify({
|
|
57
|
+
captured_at: new Date().toISOString(),
|
|
58
|
+
label,
|
|
59
|
+
title,
|
|
60
|
+
url: page.url(),
|
|
61
|
+
}, null, 2));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
65
|
+
process.stderr.write(`[capture] page metadata write failed for ${label}: ${message}\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function captureAriaSnapshot(page, baseDir, label, safe) {
|
|
69
|
+
try {
|
|
70
|
+
const ariaSnapshot = await page.ariaSnapshot({
|
|
71
|
+
depth: Number(process.env.PDPP_CAPTURE_ARIA_DEPTH ?? 8),
|
|
72
|
+
mode: "ai",
|
|
73
|
+
timeout: ARIA_SNAPSHOT_TIMEOUT_MS,
|
|
74
|
+
});
|
|
75
|
+
writeFileSync(join(baseDir, "aria", `${safe}.aria.yml`), ariaSnapshot);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
process.stderr.write(`[capture] aria snapshot failed for ${label}: ${message}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function captureScreenshot(page, baseDir, label, safe) {
|
|
83
|
+
try {
|
|
84
|
+
const screenshot = await page.screenshot({ fullPage: false, type: "png" });
|
|
85
|
+
writeFileSync(join(baseDir, "screenshots", `${safe}.png`), screenshot);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
89
|
+
process.stderr.write(`[capture] screenshot write failed for ${label}: ${message}\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function runLocatorProbe(page, probe) {
|
|
93
|
+
const result = {
|
|
94
|
+
id: probe.id,
|
|
95
|
+
kind: probe.kind,
|
|
96
|
+
probe: probeForReport(probe),
|
|
97
|
+
};
|
|
98
|
+
if (probe.description !== undefined) {
|
|
99
|
+
result.description = probe.description;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const locator = locatorForProbe(page, probe);
|
|
103
|
+
result.count = await locator.count();
|
|
104
|
+
if (result.count > 0) {
|
|
105
|
+
const first = locator.first();
|
|
106
|
+
result.visible = await first.isVisible();
|
|
107
|
+
result.enabled = await first.isEnabled({ timeout: LOCATOR_PROBE_TIMEOUT_MS }).catch(() => false);
|
|
108
|
+
const ariaSnapshot = await first
|
|
109
|
+
.ariaSnapshot({
|
|
110
|
+
depth: LOCATOR_PROBE_ARIA_DEPTH,
|
|
111
|
+
mode: "ai",
|
|
112
|
+
timeout: LOCATOR_PROBE_TIMEOUT_MS,
|
|
113
|
+
})
|
|
114
|
+
.catch(() => undefined);
|
|
115
|
+
if (ariaSnapshot !== undefined) {
|
|
116
|
+
result.ariaSnapshot = ariaSnapshot;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
result.error = err instanceof Error ? err.message : String(err);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
async function writeLocatorProbeReport(page, baseDir, label, safe, results) {
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(join(baseDir, "locators", `${safe}.json`), JSON.stringify({
|
|
128
|
+
captured_at: new Date().toISOString(),
|
|
129
|
+
label,
|
|
130
|
+
probes: results,
|
|
131
|
+
title: await page.title().catch(() => ""),
|
|
132
|
+
url: page.url(),
|
|
133
|
+
}, null, 2));
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
137
|
+
process.stderr.write(`[capture] locator probe write failed for ${label}: ${message}\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function createCaptureSession(connectorName) {
|
|
141
|
+
const alwaysRetain = process.env.PDPP_CAPTURE_FIXTURES === "1";
|
|
142
|
+
const onFailureOnly = process.env.PDPP_CAPTURE_ON_FAILURE === "1";
|
|
143
|
+
if (!(alwaysRetain || onFailureOnly)) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const keepOnSuccess = alwaysRetain;
|
|
147
|
+
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
148
|
+
const baseDir = join(PACKAGE_ROOT, "fixtures", connectorName, "raw", runId);
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(join(baseDir, "records"), { recursive: true });
|
|
151
|
+
mkdirSync(join(baseDir, "aria"), { recursive: true });
|
|
152
|
+
mkdirSync(join(baseDir, "dom"), { recursive: true });
|
|
153
|
+
mkdirSync(join(baseDir, "locators"), { recursive: true });
|
|
154
|
+
mkdirSync(join(baseDir, "pages"), { recursive: true });
|
|
155
|
+
mkdirSync(join(baseDir, "screenshots"), { recursive: true });
|
|
156
|
+
mkdirSync(join(baseDir, "traces"), { recursive: true });
|
|
157
|
+
mkdirSync(join(baseDir, "http"), { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
+
process.stderr.write(`[capture] mkdir failed: ${message}\n`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let httpSeq = 0;
|
|
165
|
+
let traceCheckpointHook = null;
|
|
166
|
+
let succeeded = false;
|
|
167
|
+
let finalized = false;
|
|
168
|
+
return {
|
|
169
|
+
runId,
|
|
170
|
+
baseDir,
|
|
171
|
+
keepOnSuccess,
|
|
172
|
+
setTraceCheckpointHook(hook) {
|
|
173
|
+
traceCheckpointHook = hook;
|
|
174
|
+
},
|
|
175
|
+
markSucceeded() {
|
|
176
|
+
succeeded = true;
|
|
177
|
+
},
|
|
178
|
+
finalize() {
|
|
179
|
+
if (finalized) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
finalized = true;
|
|
183
|
+
if (keepOnSuccess || !succeeded) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
rmSync(baseDir, { force: true, recursive: true });
|
|
188
|
+
process.stderr.write(`[capture] run succeeded; raw capture deleted (${baseDir})\n`);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
process.stderr.write(`[capture] cleanup failed for ${baseDir}: ${message}\n`);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
recordRecord(msg) {
|
|
196
|
+
try {
|
|
197
|
+
const file = join(baseDir, "records", `${safeLabel(msg.stream)}.jsonl`);
|
|
198
|
+
appendFileSync(file, `${JSON.stringify(msg.data)}\n`);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
202
|
+
process.stderr.write(`[capture] record write failed: ${message}\n`);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
async captureDom(page, label) {
|
|
206
|
+
const safe = safeLabel(label);
|
|
207
|
+
await captureDomHtml(page, baseDir, label, safe);
|
|
208
|
+
await capturePageMetadata(page, baseDir, label, safe);
|
|
209
|
+
await captureAriaSnapshot(page, baseDir, label, safe);
|
|
210
|
+
await captureScreenshot(page, baseDir, label, safe);
|
|
211
|
+
if (traceCheckpointHook) {
|
|
212
|
+
await traceCheckpointHook(label).catch((err) => {
|
|
213
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
214
|
+
process.stderr.write(`[capture] trace checkpoint failed for ${label}: ${message}\n`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async captureLocatorProbe(page, label, probes) {
|
|
219
|
+
const safe = safeLabel(label);
|
|
220
|
+
const results = await Promise.all(probes.map((probe) => runLocatorProbe(page, probe)));
|
|
221
|
+
await writeLocatorProbeReport(page, baseDir, label, safe, results);
|
|
222
|
+
},
|
|
223
|
+
captureHttp(label, body, meta = {}) {
|
|
224
|
+
try {
|
|
225
|
+
httpSeq += 1;
|
|
226
|
+
const idx = String(httpSeq).padStart(4, "0");
|
|
227
|
+
const file = join(baseDir, "http", `${idx}-${safeLabel(label)}.json`);
|
|
228
|
+
const payload = { label, meta, body };
|
|
229
|
+
writeFileSync(file, JSON.stringify(payload, null, 2));
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
233
|
+
process.stderr.write(`[capture] http write failed for ${label}: ${message}\n`);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isMainModule(importMetaUrl: string): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
export function isMainModule(importMetaUrl) {
|
|
4
|
+
const entry = process.argv[1];
|
|
5
|
+
if (!entry) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
if (importMetaUrl === pathToFileURL(entry).href) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return realpathSync(fileURLToPath(importMetaUrl)) === realpathSync(entry);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { LocalDeviceRecordEnvelope } from "./local-device-envelope.js";
|
|
2
|
+
export declare const LOCAL_DEVICE_ENDPOINTS: {
|
|
3
|
+
readonly exchangeEnrollment: "/_ref/device-exporters/enroll";
|
|
4
|
+
readonly heartbeat: (deviceId: string) => string;
|
|
5
|
+
readonly ingestBatch: (deviceId: string) => string;
|
|
6
|
+
readonly localCollectorGap: (deviceId: string, sourceInstanceId: string) => string;
|
|
7
|
+
readonly localCollectorGapRecovered: (deviceId: string, sourceInstanceId: string) => string;
|
|
8
|
+
readonly sourceInstanceState: (deviceId: string, sourceInstanceId: string) => string;
|
|
9
|
+
};
|
|
10
|
+
export interface LocalDeviceClientOptions {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
deviceId?: string;
|
|
13
|
+
deviceToken?: string;
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
export interface EnrollmentExchangeRequest {
|
|
17
|
+
device_label?: string;
|
|
18
|
+
enrollment_code: string;
|
|
19
|
+
}
|
|
20
|
+
export interface EnrollmentExchangeResponse {
|
|
21
|
+
connector_id: string;
|
|
22
|
+
device_id: string;
|
|
23
|
+
device_token: string;
|
|
24
|
+
local_binding_name: string;
|
|
25
|
+
source_instance_id: string;
|
|
26
|
+
}
|
|
27
|
+
export interface HeartbeatOutboxDiagnostics {
|
|
28
|
+
backlog_open?: number;
|
|
29
|
+
dead_letter: number;
|
|
30
|
+
leased: number;
|
|
31
|
+
oldest_pending_at?: string | null;
|
|
32
|
+
pending: number;
|
|
33
|
+
retrying: number;
|
|
34
|
+
stale_leases: number;
|
|
35
|
+
succeeded: number;
|
|
36
|
+
total: number;
|
|
37
|
+
}
|
|
38
|
+
export interface HeartbeatRequest {
|
|
39
|
+
connector_id: string;
|
|
40
|
+
outbox?: HeartbeatOutboxDiagnostics;
|
|
41
|
+
records_pending?: number;
|
|
42
|
+
source_instance_id: string;
|
|
43
|
+
status: "starting" | "healthy" | "retrying" | "blocked" | "stopped";
|
|
44
|
+
}
|
|
45
|
+
export interface IngestBatchRequest {
|
|
46
|
+
batch_id: string;
|
|
47
|
+
batch_seq: number;
|
|
48
|
+
body_hash: string;
|
|
49
|
+
connector_id: string;
|
|
50
|
+
device_id: string;
|
|
51
|
+
records: Pick<LocalDeviceRecordEnvelope, "data" | "emitted_at" | "record_key" | "stream">[];
|
|
52
|
+
source_instance_id: string;
|
|
53
|
+
}
|
|
54
|
+
export interface GetSourceInstanceStateRequest {
|
|
55
|
+
sourceInstanceId: string;
|
|
56
|
+
}
|
|
57
|
+
export interface PutSourceInstanceStateRequest {
|
|
58
|
+
sourceInstanceId: string;
|
|
59
|
+
state: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
export interface SourceInstanceStateResponse {
|
|
62
|
+
device_id: string;
|
|
63
|
+
object: "device_source_instance_state";
|
|
64
|
+
source_instance_id: string;
|
|
65
|
+
state: Record<string, unknown>;
|
|
66
|
+
updated_at: string | null;
|
|
67
|
+
}
|
|
68
|
+
export interface AckLocalCollectorGapRequest {
|
|
69
|
+
connector_id: string;
|
|
70
|
+
details?: string;
|
|
71
|
+
first_seen_at: string;
|
|
72
|
+
first_seen_run_id?: string;
|
|
73
|
+
last_run_id?: string;
|
|
74
|
+
next_attempt_backoff_ms: number;
|
|
75
|
+
reason: "policy_budget" | "connector_child_failure";
|
|
76
|
+
retryable: boolean;
|
|
77
|
+
source_instance_id: string;
|
|
78
|
+
stream?: string;
|
|
79
|
+
stream_boundary?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface AckLocalCollectorGapResponse {
|
|
82
|
+
attempt_count: number;
|
|
83
|
+
connector_id: string;
|
|
84
|
+
connector_instance_id: string;
|
|
85
|
+
device_id: string;
|
|
86
|
+
first_seen_at: string | null;
|
|
87
|
+
first_seen_run_id: string | null;
|
|
88
|
+
gap_id: string;
|
|
89
|
+
last_run_id: string | null;
|
|
90
|
+
object: "device_local_collector_gap";
|
|
91
|
+
reason: "policy_budget" | "connector_child_failure";
|
|
92
|
+
retryable: boolean;
|
|
93
|
+
source_instance_id: string;
|
|
94
|
+
status: string;
|
|
95
|
+
stream: string;
|
|
96
|
+
updated_at: string | null;
|
|
97
|
+
}
|
|
98
|
+
export interface RecoverLocalCollectorGapRequest {
|
|
99
|
+
connector_id: string;
|
|
100
|
+
reason: "policy_budget" | "connector_child_failure";
|
|
101
|
+
recovered_run_id?: string;
|
|
102
|
+
source_instance_id: string;
|
|
103
|
+
stream?: string;
|
|
104
|
+
stream_boundary?: string;
|
|
105
|
+
}
|
|
106
|
+
export declare class LocalDeviceHttpError extends Error {
|
|
107
|
+
readonly body: string;
|
|
108
|
+
readonly status: number;
|
|
109
|
+
readonly code: string | null;
|
|
110
|
+
constructor(status: number, body: string);
|
|
111
|
+
}
|
|
112
|
+
export declare class LocalDeviceClient {
|
|
113
|
+
#private;
|
|
114
|
+
constructor(options: LocalDeviceClientOptions);
|
|
115
|
+
exchangeEnrollment(request: EnrollmentExchangeRequest): Promise<EnrollmentExchangeResponse>;
|
|
116
|
+
heartbeat(request: HeartbeatRequest): Promise<{
|
|
117
|
+
ok: true;
|
|
118
|
+
}>;
|
|
119
|
+
ingestBatch(request: IngestBatchRequest): Promise<{
|
|
120
|
+
ok: true;
|
|
121
|
+
}>;
|
|
122
|
+
getSourceInstanceState(request: GetSourceInstanceStateRequest): Promise<SourceInstanceStateResponse>;
|
|
123
|
+
putSourceInstanceState(request: PutSourceInstanceStateRequest): Promise<SourceInstanceStateResponse>;
|
|
124
|
+
ackLocalCollectorGap(request: AckLocalCollectorGapRequest): Promise<AckLocalCollectorGapResponse>;
|
|
125
|
+
recoverLocalCollectorGap(request: RecoverLocalCollectorGapRequest): Promise<AckLocalCollectorGapResponse>;
|
|
126
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { COLLECTOR_PROTOCOL_HEADER, COLLECTOR_PROTOCOL_VERSION } from "./collector-protocol.js";
|
|
2
|
+
export const LOCAL_DEVICE_ENDPOINTS = {
|
|
3
|
+
exchangeEnrollment: "/_ref/device-exporters/enroll",
|
|
4
|
+
heartbeat: (deviceId) => `/_ref/device-exporters/${encodeURIComponent(deviceId)}/heartbeat`,
|
|
5
|
+
ingestBatch: (deviceId) => `/_ref/device-exporters/${encodeURIComponent(deviceId)}/ingest-batches`,
|
|
6
|
+
localCollectorGap: (deviceId, sourceInstanceId) => `/_ref/device-exporters/${encodeURIComponent(deviceId)}/source-instances/${encodeURIComponent(sourceInstanceId)}/local-collector-gaps`,
|
|
7
|
+
localCollectorGapRecovered: (deviceId, sourceInstanceId) => `/_ref/device-exporters/${encodeURIComponent(deviceId)}/source-instances/${encodeURIComponent(sourceInstanceId)}/local-collector-gaps/recovered`,
|
|
8
|
+
sourceInstanceState: (deviceId, sourceInstanceId) => `/_ref/device-exporters/${encodeURIComponent(deviceId)}/source-instances/${encodeURIComponent(sourceInstanceId)}/state`,
|
|
9
|
+
};
|
|
10
|
+
export class LocalDeviceHttpError extends Error {
|
|
11
|
+
body;
|
|
12
|
+
status;
|
|
13
|
+
code;
|
|
14
|
+
constructor(status, body) {
|
|
15
|
+
const parsed = parseLocalDeviceErrorEnvelope(body);
|
|
16
|
+
const detail = parsed?.code ? ` ${parsed.code}` : "";
|
|
17
|
+
super(`local device request failed: ${status}${detail}`);
|
|
18
|
+
this.name = "LocalDeviceHttpError";
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.body = body;
|
|
21
|
+
this.code = parsed?.code ?? null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function parseLocalDeviceErrorEnvelope(body) {
|
|
25
|
+
if (!body) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(body);
|
|
30
|
+
if (parsed && typeof parsed === "object" && parsed.error && typeof parsed.error.code === "string") {
|
|
31
|
+
return { code: parsed.error.code };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
export class LocalDeviceClient {
|
|
39
|
+
#baseUrl;
|
|
40
|
+
#deviceId;
|
|
41
|
+
#deviceToken;
|
|
42
|
+
#fetch;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.#baseUrl = new URL(options.baseUrl);
|
|
45
|
+
this.#deviceId = options.deviceId;
|
|
46
|
+
this.#deviceToken = options.deviceToken;
|
|
47
|
+
this.#fetch = options.fetchImpl ?? fetch;
|
|
48
|
+
}
|
|
49
|
+
exchangeEnrollment(request) {
|
|
50
|
+
return this.#request(LOCAL_DEVICE_ENDPOINTS.exchangeEnrollment, {
|
|
51
|
+
authenticate: false,
|
|
52
|
+
body: request,
|
|
53
|
+
method: "POST",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
heartbeat(request) {
|
|
57
|
+
return this.#request(LOCAL_DEVICE_ENDPOINTS.heartbeat(this.#requireDeviceId()), {
|
|
58
|
+
authenticate: true,
|
|
59
|
+
body: request,
|
|
60
|
+
method: "POST",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
ingestBatch(request) {
|
|
64
|
+
return this.#request(LOCAL_DEVICE_ENDPOINTS.ingestBatch(this.#requireDeviceId()), {
|
|
65
|
+
authenticate: true,
|
|
66
|
+
body: request,
|
|
67
|
+
method: "POST",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
getSourceInstanceState(request) {
|
|
71
|
+
const path = LOCAL_DEVICE_ENDPOINTS.sourceInstanceState(this.#requireDeviceId(), request.sourceInstanceId);
|
|
72
|
+
return this.#request(path, { authenticate: true, method: "GET" });
|
|
73
|
+
}
|
|
74
|
+
putSourceInstanceState(request) {
|
|
75
|
+
const path = LOCAL_DEVICE_ENDPOINTS.sourceInstanceState(this.#requireDeviceId(), request.sourceInstanceId);
|
|
76
|
+
return this.#request(path, {
|
|
77
|
+
authenticate: true,
|
|
78
|
+
body: { state: request.state },
|
|
79
|
+
method: "PUT",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
ackLocalCollectorGap(request) {
|
|
83
|
+
const path = LOCAL_DEVICE_ENDPOINTS.localCollectorGap(this.#requireDeviceId(), request.source_instance_id);
|
|
84
|
+
return this.#request(path, {
|
|
85
|
+
authenticate: true,
|
|
86
|
+
body: request,
|
|
87
|
+
method: "POST",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
recoverLocalCollectorGap(request) {
|
|
91
|
+
const path = LOCAL_DEVICE_ENDPOINTS.localCollectorGapRecovered(this.#requireDeviceId(), request.source_instance_id);
|
|
92
|
+
return this.#request(path, {
|
|
93
|
+
authenticate: true,
|
|
94
|
+
body: request,
|
|
95
|
+
method: "POST",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
#requireDeviceId() {
|
|
99
|
+
if (!this.#deviceId) {
|
|
100
|
+
throw new Error("device id required for authenticated local device request");
|
|
101
|
+
}
|
|
102
|
+
return this.#deviceId;
|
|
103
|
+
}
|
|
104
|
+
async #request(path, options) {
|
|
105
|
+
const headers = {
|
|
106
|
+
accept: "application/json",
|
|
107
|
+
[COLLECTOR_PROTOCOL_HEADER]: COLLECTOR_PROTOCOL_VERSION,
|
|
108
|
+
};
|
|
109
|
+
if (options.body !== undefined) {
|
|
110
|
+
headers["content-type"] = "application/json";
|
|
111
|
+
}
|
|
112
|
+
if (options.authenticate) {
|
|
113
|
+
if (!this.#deviceToken) {
|
|
114
|
+
throw new Error("device token required for authenticated local device request");
|
|
115
|
+
}
|
|
116
|
+
headers.authorization = `Bearer ${this.#deviceToken}`;
|
|
117
|
+
}
|
|
118
|
+
const init = { headers, method: options.method };
|
|
119
|
+
if (options.body !== undefined) {
|
|
120
|
+
init.body = JSON.stringify(options.body);
|
|
121
|
+
}
|
|
122
|
+
const response = await this.#fetch(new URL(path, this.#baseUrl), init);
|
|
123
|
+
const text = await response.text();
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new LocalDeviceHttpError(response.status, text);
|
|
126
|
+
}
|
|
127
|
+
if (!text) {
|
|
128
|
+
return { ok: true };
|
|
129
|
+
}
|
|
130
|
+
return JSON.parse(text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EmittedMessage, RecordData } from "./connector-runtime-protocol.js";
|
|
2
|
+
export interface LocalDeviceRecordEnvelope {
|
|
3
|
+
batch_id: string;
|
|
4
|
+
batch_seq: number;
|
|
5
|
+
body_hash: string;
|
|
6
|
+
connector_id: string;
|
|
7
|
+
data: RecordData;
|
|
8
|
+
device_id: string;
|
|
9
|
+
emitted_at: string;
|
|
10
|
+
record_key: string;
|
|
11
|
+
source_instance_id: string;
|
|
12
|
+
stream: string;
|
|
13
|
+
}
|
|
14
|
+
export interface BuildLocalDeviceRecordEnvelopeInput {
|
|
15
|
+
batchId: string;
|
|
16
|
+
batchSeq: number;
|
|
17
|
+
connectorId: string;
|
|
18
|
+
deviceId: string;
|
|
19
|
+
record: Extract<EmittedMessage, {
|
|
20
|
+
type: "RECORD";
|
|
21
|
+
}>;
|
|
22
|
+
sourceInstanceId: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function canonicalJson(value: unknown): string;
|
|
25
|
+
export declare function hashCanonicalJson(value: unknown): string;
|
|
26
|
+
export declare function buildLocalDeviceRecordEnvelope(input: BuildLocalDeviceRecordEnvelopeInput): LocalDeviceRecordEnvelope;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function canonicalJson(value) {
|
|
3
|
+
return JSON.stringify(toCanonicalValue(value));
|
|
4
|
+
}
|
|
5
|
+
export function hashCanonicalJson(value) {
|
|
6
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
export function buildLocalDeviceRecordEnvelope(input) {
|
|
9
|
+
const body = {
|
|
10
|
+
connector_id: input.connectorId,
|
|
11
|
+
data: toNormalizedRecordData(input.record.data),
|
|
12
|
+
emitted_at: input.record.emitted_at,
|
|
13
|
+
record_key: String(input.record.key),
|
|
14
|
+
stream: input.record.stream,
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
batch_id: input.batchId,
|
|
18
|
+
batch_seq: input.batchSeq,
|
|
19
|
+
body_hash: hashCanonicalJson(body),
|
|
20
|
+
device_id: input.deviceId,
|
|
21
|
+
source_instance_id: input.sourceInstanceId,
|
|
22
|
+
...body,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function toCanonicalValue(value) {
|
|
26
|
+
if (value === null || typeof value !== "object") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
return value.map((item) => toCanonicalValue(item));
|
|
31
|
+
}
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const key of Object.keys(value).sort()) {
|
|
34
|
+
const item = value[key];
|
|
35
|
+
if (item !== undefined) {
|
|
36
|
+
out[key] = toCanonicalValue(item);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function toNormalizedRecordData(data) {
|
|
42
|
+
return toCanonicalValue(data);
|
|
43
|
+
}
|