@savepoint/bridge 1.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/LICENSE +21 -0
- package/README.md +212 -0
- package/USAGE.md +366 -0
- package/capabilities/printers.json +56 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.js +141 -0
- package/dist/discovery.d.ts +8 -0
- package/dist/discovery.js +54 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +59 -0
- package/dist/logs.d.ts +1 -0
- package/dist/logs.js +30 -0
- package/dist/poller.d.ts +19 -0
- package/dist/poller.js +70 -0
- package/dist/printer.d.ts +18 -0
- package/dist/printer.js +82 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +98 -0
- package/dist/service.d.ts +2 -0
- package/dist/service.js +114 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +131 -0
- package/dist/status.d.ts +1 -0
- package/dist/status.js +115 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +75 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/package.json +74 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
const VERSION = '1.0.0';
|
|
4
|
+
const [, , command, ...args] = process.argv;
|
|
5
|
+
const COMMANDS = {
|
|
6
|
+
setup: 'Interactive guided setup wizard',
|
|
7
|
+
start: 'Start the bridge daemon (without installing as a service)',
|
|
8
|
+
status: 'Show running state, connected printers, recent jobs',
|
|
9
|
+
logs: 'Tail the local log file',
|
|
10
|
+
discover: 'Scan for printers on your network and local CUPS queues',
|
|
11
|
+
uninstall: 'Remove OS service and stored token',
|
|
12
|
+
};
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log(pc.bold(`\nSavePoint Bridge v${VERSION}`));
|
|
15
|
+
console.log('Local hardware connector for SavePoint\n');
|
|
16
|
+
console.log(pc.bold('Commands:'));
|
|
17
|
+
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
|
18
|
+
console.log(` ${pc.cyan(cmd.padEnd(12))} ${desc}`);
|
|
19
|
+
}
|
|
20
|
+
console.log('\nOptions:');
|
|
21
|
+
console.log(' --token=<token> Skip token prompt in setup');
|
|
22
|
+
console.log(' --non-interactive Skip all prompts, use defaults');
|
|
23
|
+
console.log(' --no-service Do not install as OS service');
|
|
24
|
+
console.log(' --log-level=debug Verbose output');
|
|
25
|
+
console.log();
|
|
26
|
+
}
|
|
27
|
+
async function main() {
|
|
28
|
+
switch (command) {
|
|
29
|
+
case 'setup': {
|
|
30
|
+
const { runSetup } = await import('./setup.js');
|
|
31
|
+
await runSetup(args);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case 'start': {
|
|
35
|
+
const { runDaemon } = await import('./daemon.js');
|
|
36
|
+
await runDaemon();
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case 'status':
|
|
40
|
+
{
|
|
41
|
+
const { showStatus } = await import('./status.js');
|
|
42
|
+
await showStatus();
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case 'logs':
|
|
46
|
+
{
|
|
47
|
+
const { showLogs } = await import('./logs.js');
|
|
48
|
+
showLogs();
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
case 'discover': {
|
|
52
|
+
const { discoverPrinters } = await import('./discovery.js');
|
|
53
|
+
const found = await discoverPrinters();
|
|
54
|
+
console.log(JSON.stringify(found, null, 2));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'uninstall': {
|
|
58
|
+
const { uninstallService } = await import('./service.js');
|
|
59
|
+
await uninstallService();
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case '--version':
|
|
63
|
+
case 'version':
|
|
64
|
+
console.log(VERSION);
|
|
65
|
+
break;
|
|
66
|
+
default:
|
|
67
|
+
printHelp();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
main().catch(err => {
|
|
71
|
+
console.error(pc.red('Fatal:'), err.message);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
});
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface IntervalConfig {
|
|
2
|
+
activeMs: number;
|
|
3
|
+
idleMs: number;
|
|
4
|
+
idleAfter: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildIntervalStrategy(config: IntervalConfig): {
|
|
7
|
+
next(hadJobs: boolean): number;
|
|
8
|
+
};
|
|
9
|
+
export declare function runDaemon(): Promise<void>;
|
|
10
|
+
export {};
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { log } from './logger.js';
|
|
2
|
+
import { loadToken } from './token.js';
|
|
3
|
+
import { Poller } from './poller.js';
|
|
4
|
+
import { Registry } from './registry.js';
|
|
5
|
+
import { getTransport } from './printer.js';
|
|
6
|
+
import { discoverPrinters } from './discovery.js';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { DATA_PATH } from './logger.js';
|
|
10
|
+
const BASE_URL = process.env.SAVEPOINT_API_URL ?? 'https://api.savepointhq.com';
|
|
11
|
+
const VERSION = '1.0.0';
|
|
12
|
+
const DB_PATH = join(DATA_PATH, 'registry.db');
|
|
13
|
+
const CAPS_PATH = fileURLToPath(new URL('../../capabilities/printers.json', import.meta.url));
|
|
14
|
+
const REGISTRY_REFRESH_MS = 5 * 60 * 1000;
|
|
15
|
+
const HEARTBEAT_MS = 60 * 1000;
|
|
16
|
+
export function buildIntervalStrategy(config) {
|
|
17
|
+
let emptyCount = 0;
|
|
18
|
+
return {
|
|
19
|
+
next(hadJobs) {
|
|
20
|
+
if (hadJobs) {
|
|
21
|
+
emptyCount = 0;
|
|
22
|
+
return config.activeMs;
|
|
23
|
+
}
|
|
24
|
+
emptyCount++;
|
|
25
|
+
return emptyCount >= config.idleAfter ? config.idleMs : config.activeMs;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function runDaemon() {
|
|
30
|
+
const token = await loadToken();
|
|
31
|
+
if (!token) {
|
|
32
|
+
log.notice('No agent token found. Run: savepoint-bridge setup');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const poller = new Poller({ baseUrl: BASE_URL, token, version: VERSION });
|
|
36
|
+
const registry = new Registry(DB_PATH);
|
|
37
|
+
registry.init();
|
|
38
|
+
registry.seedCapabilities(CAPS_PATH);
|
|
39
|
+
log.success('SavePoint Bridge started');
|
|
40
|
+
await syncDiscoveredPrinters(poller);
|
|
41
|
+
await refreshRegistry(poller, registry);
|
|
42
|
+
setInterval(() => refreshRegistry(poller, registry), REGISTRY_REFRESH_MS);
|
|
43
|
+
setInterval(() => poller.heartbeat(), HEARTBEAT_MS);
|
|
44
|
+
setInterval(() => syncDiscoveredPrinters(poller), REGISTRY_REFRESH_MS);
|
|
45
|
+
const strategy = buildIntervalStrategy({ activeMs: 5_000, idleMs: 30_000, idleAfter: 3 });
|
|
46
|
+
async function tick() {
|
|
47
|
+
let hadJobs = false;
|
|
48
|
+
try {
|
|
49
|
+
const jobs = await poller.pollJobs();
|
|
50
|
+
hadJobs = jobs.length > 0;
|
|
51
|
+
for (const job of jobs) {
|
|
52
|
+
await processJob(job, poller, registry);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (e instanceof Error && e.message === 'UNAUTHORIZED') {
|
|
57
|
+
log.notice('Bridge token revoked. Run: savepoint-bridge setup');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
log.error('Poll error', e);
|
|
61
|
+
}
|
|
62
|
+
setTimeout(tick, strategy.next(hadJobs));
|
|
63
|
+
}
|
|
64
|
+
tick();
|
|
65
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
66
|
+
process.on(sig, () => { log.info('Shutting down...'); registry.close(); process.exit(0); });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function refreshRegistry(poller, registry) {
|
|
70
|
+
try {
|
|
71
|
+
const printers = await poller.fetchPrinters();
|
|
72
|
+
registry.upsertPrinters(printers);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
log.warn('Registry refresh failed', e);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function syncDiscoveredPrinters(poller) {
|
|
79
|
+
try {
|
|
80
|
+
const printers = await discoverPrinters();
|
|
81
|
+
await poller.heartbeat(printers);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
log.warn('Printer discovery sync failed', e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function processJob(job, poller, registry) {
|
|
88
|
+
log.info(`Processing job ${job.id} (${job.job_type})`);
|
|
89
|
+
const claimed = await poller.claimJob(job.id);
|
|
90
|
+
if (!claimed) {
|
|
91
|
+
log.info(`Job ${job.id} already claimed — skipping`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const format = job.metadata?.format;
|
|
96
|
+
if (job.printer_id && format) {
|
|
97
|
+
const err = registry.validateFormat(job.printer_id, format);
|
|
98
|
+
if (err) {
|
|
99
|
+
await poller.reportResult(job.id, 'failed', { code: err.code, message: err.message });
|
|
100
|
+
log.warn(`Job ${job.id} failed validation: ${err.message}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!job.content_url) {
|
|
105
|
+
await poller.reportResult(job.id, 'failed', { code: 'NO_CONTENT_URL' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const bytes = await downloadWithRetry(job.content_url);
|
|
109
|
+
const printer = job.printer_id ? registry.getPrinter(job.printer_id) : null;
|
|
110
|
+
if (!printer) {
|
|
111
|
+
await poller.reportResult(job.id, 'failed', { code: 'PRINTER_NOT_FOUND' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const transport = getTransport(printer);
|
|
115
|
+
await transport.send(bytes);
|
|
116
|
+
await poller.reportResult(job.id, 'completed', { code: 'OK' });
|
|
117
|
+
log.success(`Job ${job.id} completed`);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
121
|
+
await poller.reportResult(job.id, 'failed', { code: 'DISPATCH_ERROR', message });
|
|
122
|
+
log.error(`Job ${job.id} failed: ${message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function downloadWithRetry(url, retries = 3) {
|
|
126
|
+
let lastErr;
|
|
127
|
+
for (let i = 0; i < retries; i++) {
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(url);
|
|
130
|
+
if (!res.ok)
|
|
131
|
+
throw new Error(`DOWNLOAD_FAILED: HTTP ${res.status}`);
|
|
132
|
+
return Buffer.from(await res.arrayBuffer());
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
lastErr = e;
|
|
136
|
+
if (i < retries - 1)
|
|
137
|
+
await new Promise(r => setTimeout(r, 2_000 * Math.pow(3, i)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw lastErr;
|
|
141
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createConnection } from 'net';
|
|
2
|
+
import { networkInterfaces } from 'os';
|
|
3
|
+
import { execFile } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
function probePort(host, port, timeoutMs = 1500) {
|
|
7
|
+
return new Promise(resolve => {
|
|
8
|
+
const socket = createConnection({ host, port });
|
|
9
|
+
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
|
10
|
+
socket.on('connect', () => { clearTimeout(timer); socket.end(); resolve(true); });
|
|
11
|
+
socket.on('error', () => { clearTimeout(timer); resolve(false); });
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function getSubnetHosts() {
|
|
15
|
+
const nets = networkInterfaces();
|
|
16
|
+
for (const ifaces of Object.values(nets)) {
|
|
17
|
+
for (const iface of ifaces ?? []) {
|
|
18
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
19
|
+
const prefix = iface.address.split('.').slice(0, 3).join('.');
|
|
20
|
+
return Array.from({ length: 254 }, (_, i) => `${prefix}.${i + 1}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
async function scanNetwork(concurrency = 50) {
|
|
27
|
+
const hosts = getSubnetHosts();
|
|
28
|
+
const found = [];
|
|
29
|
+
for (let i = 0; i < hosts.length; i += concurrency) {
|
|
30
|
+
await Promise.allSettled(hosts.slice(i, i + concurrency).map(async (host) => {
|
|
31
|
+
if (await probePort(host, 9100)) {
|
|
32
|
+
found.push({ name: host, host, port: 9100, connection_type: 'network' });
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
/** Enumerate CUPS system printers (macOS/Linux) using execFile — no shell injection */
|
|
39
|
+
async function scanCups() {
|
|
40
|
+
try {
|
|
41
|
+
const { stdout } = await execFileAsync('lpstat', ['-p']);
|
|
42
|
+
return stdout
|
|
43
|
+
.split('\n')
|
|
44
|
+
.filter(line => line.startsWith('printer '))
|
|
45
|
+
.map(line => ({ name: line.split(' ')[1], connection_type: 'system' }));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return []; // lpstat unavailable (Windows or CUPS not installed)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function discoverPrinters() {
|
|
52
|
+
const [network, cups] = await Promise.all([scanNetwork(), scanCups()]);
|
|
53
|
+
return [...network, ...cups];
|
|
54
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const log: {
|
|
2
|
+
info: (msg: string, data?: unknown) => void;
|
|
3
|
+
success: (msg: string, data?: unknown) => void;
|
|
4
|
+
warn: (msg: string, data?: unknown) => void;
|
|
5
|
+
error: (msg: string, data?: unknown) => void;
|
|
6
|
+
/** Prominent notice — always visible */
|
|
7
|
+
notice: (msg: string) => void;
|
|
8
|
+
};
|
|
9
|
+
export declare const DATA_PATH: string;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
const DATA_DIR = process.env.SAVEPOINT_BRIDGE_DATA_PATH || join(homedir(), '.savepoint-bridge');
|
|
6
|
+
const LOG_DIR = join(DATA_DIR, 'logs');
|
|
7
|
+
const MAX_LOG_DAYS = 7;
|
|
8
|
+
function logFilePath() {
|
|
9
|
+
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
10
|
+
return join(LOG_DIR, `${date}.log`);
|
|
11
|
+
}
|
|
12
|
+
function writeToFile(level, message, data) {
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
15
|
+
pruneOldLogs();
|
|
16
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), level, message, data }) + '\n';
|
|
17
|
+
appendFileSync(logFilePath(), line, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// log file write failure is non-fatal
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function pruneOldLogs() {
|
|
24
|
+
const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000;
|
|
25
|
+
for (const file of readdirSync(LOG_DIR)) {
|
|
26
|
+
if (!file.endsWith('.log'))
|
|
27
|
+
continue;
|
|
28
|
+
const date = new Date(file.slice(0, 10));
|
|
29
|
+
if (Number.isNaN(date.getTime()))
|
|
30
|
+
continue;
|
|
31
|
+
if (date.getTime() < cutoff) {
|
|
32
|
+
rmSync(join(LOG_DIR, file), { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const log = {
|
|
37
|
+
info: (msg, data) => {
|
|
38
|
+
console.log(pc.cyan('ℹ'), msg);
|
|
39
|
+
writeToFile('info', msg, data);
|
|
40
|
+
},
|
|
41
|
+
success: (msg, data) => {
|
|
42
|
+
console.log(pc.green('✔'), msg);
|
|
43
|
+
writeToFile('info', msg, data);
|
|
44
|
+
},
|
|
45
|
+
warn: (msg, data) => {
|
|
46
|
+
console.warn(pc.yellow('⚠'), msg);
|
|
47
|
+
writeToFile('warn', msg, data);
|
|
48
|
+
},
|
|
49
|
+
error: (msg, data) => {
|
|
50
|
+
console.error(pc.red('✖'), msg);
|
|
51
|
+
writeToFile('error', msg, data);
|
|
52
|
+
},
|
|
53
|
+
/** Prominent notice — always visible */
|
|
54
|
+
notice: (msg) => {
|
|
55
|
+
console.log(pc.bold(pc.yellow('\n' + msg + '\n')));
|
|
56
|
+
writeToFile('notice', msg);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export const DATA_PATH = DATA_DIR;
|
package/dist/logs.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function showLogs(limit?: number): void;
|
package/dist/logs.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { DATA_PATH } from './logger.js';
|
|
4
|
+
function getRecentLogFiles(limit = 2) {
|
|
5
|
+
const logDir = join(DATA_PATH, 'logs');
|
|
6
|
+
if (!existsSync(logDir))
|
|
7
|
+
return [];
|
|
8
|
+
return readdirSync(logDir)
|
|
9
|
+
.filter((file) => file.endsWith('.log'))
|
|
10
|
+
.sort()
|
|
11
|
+
.slice(-limit)
|
|
12
|
+
.map((file) => join(logDir, file));
|
|
13
|
+
}
|
|
14
|
+
export function showLogs(limit = 50) {
|
|
15
|
+
const files = getRecentLogFiles();
|
|
16
|
+
if (files.length === 0) {
|
|
17
|
+
console.log('No log files found.');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const entries = files.flatMap((file) => readFileSync(file, 'utf8')
|
|
21
|
+
.split('\n')
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((line) => JSON.parse(line)));
|
|
24
|
+
const recent = entries.slice(-limit);
|
|
25
|
+
for (const entry of recent) {
|
|
26
|
+
const ts = entry.ts.replace('T', ' ').replace('Z', ' UTC');
|
|
27
|
+
const suffix = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
|
|
28
|
+
console.log(`[${ts}] ${entry.level.toUpperCase()} ${entry.message}${suffix}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/poller.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { JobResult, PrintJob } from './types.js';
|
|
2
|
+
import type { DiscoveredPrinter } from './discovery.js';
|
|
3
|
+
export interface PollerConfig {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
version: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class Poller {
|
|
9
|
+
private config;
|
|
10
|
+
private headers;
|
|
11
|
+
constructor(config: PollerConfig);
|
|
12
|
+
private request;
|
|
13
|
+
pollJobs(): Promise<PrintJob[]>;
|
|
14
|
+
/** Returns the claimed job, or null if already claimed by another instance (409) */
|
|
15
|
+
claimJob(jobId: string): Promise<PrintJob | null>;
|
|
16
|
+
reportResult(jobId: string, status: 'completed' | 'failed', result: JobResult): Promise<void>;
|
|
17
|
+
heartbeat(printers?: DiscoveredPrinter[]): Promise<void>;
|
|
18
|
+
fetchPrinters(): Promise<unknown[]>;
|
|
19
|
+
}
|
package/dist/poller.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export class Poller {
|
|
2
|
+
config;
|
|
3
|
+
headers;
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.headers = {
|
|
7
|
+
'Authorization': `Bearer ${config.token}`,
|
|
8
|
+
'X-Agent-Version': config.version,
|
|
9
|
+
'Content-Type': 'application/json',
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
async request(path, init) {
|
|
13
|
+
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
|
14
|
+
...init,
|
|
15
|
+
headers: { ...this.headers, ...init?.headers },
|
|
16
|
+
});
|
|
17
|
+
if (res.status === 401)
|
|
18
|
+
throw new Error('UNAUTHORIZED');
|
|
19
|
+
return res;
|
|
20
|
+
}
|
|
21
|
+
async pollJobs() {
|
|
22
|
+
const res = await this.request('/api/labels/print-jobs');
|
|
23
|
+
if (res.status === 204)
|
|
24
|
+
return [];
|
|
25
|
+
const { job } = await res.json();
|
|
26
|
+
return job ? [job] : [];
|
|
27
|
+
}
|
|
28
|
+
/** Returns the claimed job, or null if already claimed by another instance (409) */
|
|
29
|
+
async claimJob(jobId) {
|
|
30
|
+
const res = await this.request(`/api/labels/print-jobs/${jobId}/claim`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
});
|
|
33
|
+
if (res.status === 409)
|
|
34
|
+
return null;
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new Error(`Claim failed: ${res.status}`);
|
|
37
|
+
const { job } = await res.json();
|
|
38
|
+
return job;
|
|
39
|
+
}
|
|
40
|
+
async reportResult(jobId, status, result) {
|
|
41
|
+
await this.request(`/api/labels/print-jobs/${jobId}/result`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
body: JSON.stringify({ status, result }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async heartbeat(printers) {
|
|
47
|
+
try {
|
|
48
|
+
await this.request('/api/agents/heartbeat', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
version: this.config.version,
|
|
52
|
+
printers: printers?.map((printer) => ({
|
|
53
|
+
name: printer.name,
|
|
54
|
+
host: printer.host ?? null,
|
|
55
|
+
port: printer.port ?? null,
|
|
56
|
+
connection_type: printer.connection_type === 'system' ? 'cups' : printer.connection_type,
|
|
57
|
+
})),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// heartbeat failure is non-fatal — daemon continues
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async fetchPrinters() {
|
|
66
|
+
const res = await this.request('/api/agents/printers');
|
|
67
|
+
const { printers } = await res.json();
|
|
68
|
+
return printers ?? [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface Transport {
|
|
2
|
+
send(bytes: Buffer): Promise<void>;
|
|
3
|
+
}
|
|
4
|
+
interface NetworkOptions {
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
retries?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function createNetworkTransport(host: string, port: number, opts?: NetworkOptions): Transport;
|
|
9
|
+
/** CUPS transport — uses execFile (not exec) to prevent shell injection */
|
|
10
|
+
export declare function createCupsTransport(printerName: string): Transport;
|
|
11
|
+
export declare function createUsbTransport(_devicePath: string): Transport;
|
|
12
|
+
export declare function getTransport(printer: {
|
|
13
|
+
connectionType: string;
|
|
14
|
+
host: string | null;
|
|
15
|
+
port: number | null;
|
|
16
|
+
model?: string | null;
|
|
17
|
+
}): Transport;
|
|
18
|
+
export {};
|
package/dist/printer.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createConnection } from 'net';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export function createNetworkTransport(host, port, opts = {}) {
|
|
6
|
+
const { timeoutMs = 10_000, retries = 3 } = opts;
|
|
7
|
+
async function attempt(bytes) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const socket = createConnection({ host, port });
|
|
10
|
+
const timer = setTimeout(() => {
|
|
11
|
+
socket.destroy();
|
|
12
|
+
reject(new Error(`Connection to ${host}:${port} timed out`));
|
|
13
|
+
}, timeoutMs);
|
|
14
|
+
socket.once('error', (err) => { clearTimeout(timer); socket.destroy(); reject(err); });
|
|
15
|
+
socket.on('connect', () => {
|
|
16
|
+
socket.write(bytes, 'binary', (err) => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
socket.end();
|
|
19
|
+
if (err)
|
|
20
|
+
reject(err);
|
|
21
|
+
else
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
async send(bytes) {
|
|
29
|
+
let lastErr;
|
|
30
|
+
for (let i = 0; i < retries; i++) {
|
|
31
|
+
try {
|
|
32
|
+
await attempt(bytes);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
lastErr = e;
|
|
37
|
+
if (i < retries - 1)
|
|
38
|
+
await new Promise(r => setTimeout(r, 1000 * Math.pow(3, i)));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`CONNECTION_FAILED after ${retries} attempts: ${lastErr?.message}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** CUPS transport — uses execFile (not exec) to prevent shell injection */
|
|
46
|
+
export function createCupsTransport(printerName) {
|
|
47
|
+
return {
|
|
48
|
+
async send(bytes) {
|
|
49
|
+
// lpr args are passed as an array — no shell interpolation
|
|
50
|
+
const { execFile: ef } = await import('child_process');
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
// printerName comes from our own registry, but still use execFile for safety
|
|
53
|
+
const proc = ef('lpr', ['-P', printerName], (err) => err ? reject(err) : resolve());
|
|
54
|
+
proc.stdin?.write(bytes);
|
|
55
|
+
proc.stdin?.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function createUsbTransport(_devicePath) {
|
|
61
|
+
return {
|
|
62
|
+
async send(_bytes) {
|
|
63
|
+
// USB implementation requires device enumeration from discovery module.
|
|
64
|
+
// Full implementation is a v1.1 task — covered under Windows Zadig notes in spec.
|
|
65
|
+
throw new Error('USB transport not yet implemented — use network or CUPS connection type');
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function getTransport(printer) {
|
|
70
|
+
if (printer.connectionType === 'network') {
|
|
71
|
+
if (!printer.host || !printer.port)
|
|
72
|
+
throw new Error('Network printer missing host or port');
|
|
73
|
+
return createNetworkTransport(printer.host, printer.port);
|
|
74
|
+
}
|
|
75
|
+
if (printer.connectionType === 'system') {
|
|
76
|
+
return createCupsTransport(printer.host ?? printer.model ?? 'default');
|
|
77
|
+
}
|
|
78
|
+
if (printer.connectionType === 'usb') {
|
|
79
|
+
return createUsbTransport(printer.host ?? '');
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Unsupported connection type: ${printer.connectionType}`);
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Printer } from './types.js';
|
|
2
|
+
export interface RegistryError {
|
|
3
|
+
code: 'FORMAT_MISMATCH' | 'PRINTER_NOT_FOUND';
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class Registry {
|
|
7
|
+
private db;
|
|
8
|
+
constructor(dbPath: string);
|
|
9
|
+
init(): void;
|
|
10
|
+
seedCapabilities(jsonPath: string): void;
|
|
11
|
+
upsertPrinters(printers: Printer[]): void;
|
|
12
|
+
getPrinter(id: string): Printer | null;
|
|
13
|
+
getExpectedFormat(printerId: string): string | null;
|
|
14
|
+
validateFormat(printerId: string, jobFormat: string): RegistryError | null;
|
|
15
|
+
close(): void;
|
|
16
|
+
listPrinters(): Printer[];
|
|
17
|
+
}
|