@tienne/gestalt 0.108.0 → 0.110.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/CLAUDE.md +1 -0
- package/dist/package.json +1 -1
- package/dist/src/cli/commands/peer.d.ts +49 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -0
- package/dist/src/cli/commands/peer.js +302 -0
- package/dist/src/cli/commands/peer.js.map +1 -0
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +81 -2
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/peer/client.d.ts +9 -0
- package/dist/src/peer/client.d.ts.map +1 -0
- package/dist/src/peer/client.js +51 -0
- package/dist/src/peer/client.js.map +1 -0
- package/dist/src/peer/daemon.d.ts +83 -0
- package/dist/src/peer/daemon.d.ts.map +1 -0
- package/dist/src/peer/daemon.js +516 -0
- package/dist/src/peer/daemon.js.map +1 -0
- package/dist/src/peer/errors.d.ts +2 -0
- package/dist/src/peer/errors.d.ts.map +1 -0
- package/dist/src/peer/errors.js +4 -0
- package/dist/src/peer/errors.js.map +1 -0
- package/dist/src/peer/feed.d.ts +103 -0
- package/dist/src/peer/feed.d.ts.map +1 -0
- package/dist/src/peer/feed.js +167 -0
- package/dist/src/peer/feed.js.map +1 -0
- package/dist/src/peer/index.d.ts +24 -0
- package/dist/src/peer/index.d.ts.map +1 -0
- package/dist/src/peer/index.js +14 -0
- package/dist/src/peer/index.js.map +1 -0
- package/dist/src/peer/keys.d.ts +26 -0
- package/dist/src/peer/keys.d.ts.map +1 -0
- package/dist/src/peer/keys.js +95 -0
- package/dist/src/peer/keys.js.map +1 -0
- package/dist/src/peer/locate.d.ts +44 -0
- package/dist/src/peer/locate.d.ts.map +1 -0
- package/dist/src/peer/locate.js +116 -0
- package/dist/src/peer/locate.js.map +1 -0
- package/dist/src/peer/lock.d.ts +32 -0
- package/dist/src/peer/lock.d.ts.map +1 -0
- package/dist/src/peer/lock.js +147 -0
- package/dist/src/peer/lock.js.map +1 -0
- package/dist/src/peer/mirror.d.ts +21 -0
- package/dist/src/peer/mirror.d.ts.map +1 -0
- package/dist/src/peer/mirror.js +68 -0
- package/dist/src/peer/mirror.js.map +1 -0
- package/dist/src/peer/network.d.ts +148 -0
- package/dist/src/peer/network.d.ts.map +1 -0
- package/dist/src/peer/network.js +809 -0
- package/dist/src/peer/network.js.map +1 -0
- package/dist/src/peer/paths.d.ts +23 -0
- package/dist/src/peer/paths.d.ts.map +1 -0
- package/dist/src/peer/paths.js +50 -0
- package/dist/src/peer/paths.js.map +1 -0
- package/dist/src/peer/protocol.d.ts +112 -0
- package/dist/src/peer/protocol.d.ts.map +1 -0
- package/dist/src/peer/protocol.js +8 -0
- package/dist/src/peer/protocol.js.map +1 -0
- package/dist/src/peer/redact.d.ts +30 -0
- package/dist/src/peer/redact.d.ts.map +1 -0
- package/dist/src/peer/redact.js +214 -0
- package/dist/src/peer/redact.js.map +1 -0
- package/dist/src/peer/relay.d.ts +70 -0
- package/dist/src/peer/relay.d.ts.map +1 -0
- package/dist/src/peer/relay.js +410 -0
- package/dist/src/peer/relay.js.map +1 -0
- package/dist/src/peer/roster.d.ts +89 -0
- package/dist/src/peer/roster.d.ts.map +1 -0
- package/dist/src/peer/roster.js +160 -0
- package/dist/src/peer/roster.js.map +1 -0
- package/dist/src/peer/seal.d.ts +37 -0
- package/dist/src/peer/seal.d.ts.map +1 -0
- package/dist/src/peer/seal.js +115 -0
- package/dist/src/peer/seal.js.map +1 -0
- package/dist/src/peer/share.d.ts +41 -0
- package/dist/src/peer/share.d.ts.map +1 -0
- package/dist/src/peer/share.js +70 -0
- package/dist/src/peer/share.js.map +1 -0
- package/dist/src/peer/state.d.ts +68 -0
- package/dist/src/peer/state.d.ts.map +1 -0
- package/dist/src/peer/state.js +161 -0
- package/dist/src/peer/state.js.map +1 -0
- package/dist/src/peer/tail.d.ts +55 -0
- package/dist/src/peer/tail.d.ts.map +1 -0
- package/dist/src/peer/tail.js +148 -0
- package/dist/src/peer/tail.js.map +1 -0
- package/dist/src/peer/transform.d.ts +79 -0
- package/dist/src/peer/transform.d.ts.map +1 -0
- package/dist/src/peer/transform.js +237 -0
- package/dist/src/peer/transform.js.map +1 -0
- package/dist/src/peer/transport.d.ts +89 -0
- package/dist/src/peer/transport.d.ts.map +1 -0
- package/dist/src/peer/transport.js +345 -0
- package/dist/src/peer/transport.js.map +1 -0
- package/dist/src/peer/viewer-html.d.ts +10 -0
- package/dist/src/peer/viewer-html.d.ts.map +1 -0
- package/dist/src/peer/viewer-html.js +165 -0
- package/dist/src/peer/viewer-html.js.map +1 -0
- package/dist/src/peer/viewer.d.ts +58 -0
- package/dist/src/peer/viewer.d.ts.map +1 -0
- package/dist/src/peer/viewer.js +249 -0
- package/dist/src/peer/viewer.js.map +1 -0
- package/dist/src/peer/wire.d.ts +90 -0
- package/dist/src/peer/wire.d.ts.map +1 -0
- package/dist/src/peer/wire.js +10 -0
- package/dist/src/peer/wire.js.map +1 -0
- package/dist/src/peer/ws.d.ts +47 -0
- package/dist/src/peer/ws.d.ts.map +1 -0
- package/dist/src/peer/ws.js +308 -0
- package/dist/src/peer/ws.js.map +1 -0
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
export const DAEMON_NOT_RUNNING = '`gestalt peer daemon`을 먼저 실행해 주세요';
|
|
3
|
+
export class DaemonUnavailableError extends Error {
|
|
4
|
+
socketPath;
|
|
5
|
+
constructor(socketPath) {
|
|
6
|
+
super(`peer 데몬에 연결하지 못했어요 (${socketPath}). ${DAEMON_NOT_RUNNING}`);
|
|
7
|
+
this.socketPath = socketPath;
|
|
8
|
+
this.name = 'DaemonUnavailableError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** 데몬에 요청 한 줄을 보내고 답 한 줄을 받는다 */
|
|
12
|
+
export function request(sock, req, timeoutMs = 5_000) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const socket = createConnection(sock);
|
|
15
|
+
let buf = '';
|
|
16
|
+
let settled = false;
|
|
17
|
+
const finish = (fn) => {
|
|
18
|
+
if (settled)
|
|
19
|
+
return;
|
|
20
|
+
settled = true;
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
socket.destroy();
|
|
23
|
+
fn();
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`peer 데몬이 ${timeoutMs}ms 안에 답하지 않았어요`))), timeoutMs);
|
|
26
|
+
socket.setEncoding('utf8');
|
|
27
|
+
socket.once('connect', () => socket.write(JSON.stringify(req) + '\n'));
|
|
28
|
+
socket.on('data', (chunk) => {
|
|
29
|
+
buf += chunk;
|
|
30
|
+
const nl = buf.indexOf('\n');
|
|
31
|
+
if (nl === -1)
|
|
32
|
+
return;
|
|
33
|
+
const line = buf.slice(0, nl);
|
|
34
|
+
finish(() => {
|
|
35
|
+
try {
|
|
36
|
+
resolve(JSON.parse(line));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
reject(new Error('peer 데몬 답이 JSON이 아니에요'));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
socket.once('error', (err) => {
|
|
44
|
+
finish(() => reject(err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
|
|
45
|
+
? new DaemonUnavailableError(sock)
|
|
46
|
+
: err));
|
|
47
|
+
});
|
|
48
|
+
socket.once('close', () => finish(() => reject(new Error('peer 데몬이 답 없이 연결을 끊었어요'))));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/peer/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAG5C,MAAM,CAAC,MAAM,kBAAkB,GAAG,mCAAmC,CAAC;AAEtE,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IAC1B;IAArB,YAAqB,UAAkB;QACrC,KAAK,CAAC,uBAAuB,UAAU,MAAM,kBAAkB,EAAE,CAAC,CAAC;QADhD,eAAU,GAAV,UAAU,CAAQ;QAErC,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC;IACvC,CAAC;CACF;AAED,iCAAiC;AACjC,MAAM,UAAU,OAAO,CACrB,IAAY,EACZ,GAAgB,EAChB,SAAS,GAAG,KAAK;IAEjB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACtC,IAAI,GAAG,GAAG,EAAE,CAAC;QACb,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,MAAM,GAAG,CAAC,EAAc,EAAQ,EAAE;YACtC,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,MAAM,CAAC,OAAO,EAAE,CAAC;YACjB,EAAE,EAAE,CAAC;QACP,CAAC,CAAC;QACF,MAAM,KAAK,GAAG,UAAU,CACtB,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY,SAAS,gBAAgB,CAAC,CAAC,CAAC,EAC5E,SAAS,CACV,CAAC;QAEF,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAC3B,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;QACvE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YAClC,GAAG,IAAI,KAAK,CAAC;YACb,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC7B,IAAI,EAAE,KAAK,CAAC,CAAC;gBAAE,OAAO;YACtB,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC9B,MAAM,CAAC,GAAG,EAAE;gBACV,IAAI,CAAC;oBACH,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAoB,CAAC,CAAC;gBAC/C,CAAC;gBAAC,MAAM,CAAC;oBACP,MAAM,CAAC,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC,CAAC;gBAC7C,CAAC;YACH,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAA0B,EAAE,EAAE;YAClD,MAAM,CAAC,GAAG,EAAE,CACV,MAAM,CACJ,GAAG,CAAC,IAAI,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,KAAK,cAAc;gBAClD,CAAC,CAAC,IAAI,sBAAsB,CAAC,IAAI,CAAC;gBAClC,CAAC,CAAC,GAAG,CACR,CACF,CAAC;QACJ,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CACxB,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAC1D,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { PeerRequest, PeerResponse } from './protocol.js';
|
|
2
|
+
import type { PeerNetworkOptions } from './network.js';
|
|
3
|
+
export interface PeerDaemonOptions {
|
|
4
|
+
root: string;
|
|
5
|
+
/** 기본은 `<root>/state.db`. 테스트는 `.gestalt-test/peer-*.db`를 넘긴다 */
|
|
6
|
+
dbPath?: string;
|
|
7
|
+
/** 대화 기록을 다시 읽는 간격 */
|
|
8
|
+
pollMs?: number;
|
|
9
|
+
/** 가리기에서 `~`로 바꿀 홈. 테스트가 가짜 홈을 넣는다 */
|
|
10
|
+
home?: string;
|
|
11
|
+
/** 릴레이에 다시 붙는 간격 같은 것. 테스트가 줄인다 */
|
|
12
|
+
network?: PeerNetworkOptions['transport'];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `gestalt peer daemon`. 공유를 들고 대화 기록을 따라 읽어 로컬 뷰어로 흘린다. 팀에
|
|
16
|
+
* 들었으면 팀 릴레이에 붙어 팀원에게 공유를 넘기고 팀원 공유를 받아 로컬 뷰어로 옮긴다.
|
|
17
|
+
*
|
|
18
|
+
* 바깥으로 나가는 연결은 팀 릴레이 하나다. 소켓은 0600, 디렉토리는 0700이라 같은 OS
|
|
19
|
+
* 사용자만 명령을 넣는다. 뷰어는 `127.0.0.1`에만 붙는다.
|
|
20
|
+
*/
|
|
21
|
+
export declare class PeerDaemon {
|
|
22
|
+
private readonly options;
|
|
23
|
+
private ipc;
|
|
24
|
+
private viewer;
|
|
25
|
+
private state;
|
|
26
|
+
private network;
|
|
27
|
+
private key;
|
|
28
|
+
private lock;
|
|
29
|
+
private shares;
|
|
30
|
+
private timer;
|
|
31
|
+
private startedAt;
|
|
32
|
+
private conns;
|
|
33
|
+
/** 이 데몬이 만든 소켓 파일. stop은 소켓 자리가 아직 이것일 때만 지운다 */
|
|
34
|
+
private ownSocket;
|
|
35
|
+
private readonly sock;
|
|
36
|
+
/** listen할 임시 이름. 권한을 좁힌 뒤 진짜 이름으로 링크한다 */
|
|
37
|
+
private readonly tmpSock;
|
|
38
|
+
private readonly pollMs;
|
|
39
|
+
constructor(options: PeerDaemonOptions);
|
|
40
|
+
get socketPath(): string;
|
|
41
|
+
get viewerPort(): number | undefined;
|
|
42
|
+
start(): Promise<void>;
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
/** 테스트가 타이머를 기다리지 않고 한 번 돌릴 때 쓴다 */
|
|
45
|
+
tick(now?: number): void;
|
|
46
|
+
/**
|
|
47
|
+
* closeShare는 DB에 쓰다가 던질 수 있다. tick에서 던지면 뒤 공유를 못 돌고 stop에서
|
|
48
|
+
* 던지면 뷰어와 IPC 서버와 소켓 파일 정리까지 건너뛴다. unshare에서 던지면 공유는 이미
|
|
49
|
+
* 종료됐는데 실패로 답해서 다시 unshare하면 열린 공유가 아니라고 나온다. 공유는 던지기
|
|
50
|
+
* 전에 이미 맵에서 빠지므로 남는 건 DB 기록 하나다.
|
|
51
|
+
*/
|
|
52
|
+
private closeShareSafely;
|
|
53
|
+
handle(req: PeerRequest): PeerResponse | Promise<PeerResponse>;
|
|
54
|
+
private withNetwork;
|
|
55
|
+
private withNetworkAsync;
|
|
56
|
+
private viewerUrl;
|
|
57
|
+
private announceShares;
|
|
58
|
+
private openShare;
|
|
59
|
+
private closeShare;
|
|
60
|
+
private info;
|
|
61
|
+
private status;
|
|
62
|
+
/**
|
|
63
|
+
* 남은 소켓 파일을 치운다. 누가 받고 있으면 데몬이 이미 떠 있는 거라 던진다.
|
|
64
|
+
* 소켓이 아닌 파일이면 안 지운다 — 경로를 잘못 줬을 때 남의 파일을 지우지 않게.
|
|
65
|
+
*
|
|
66
|
+
* 데몬 잠금(`peer.lock`)을 쥔 뒤에만 부른다. 잠금을 쥔 데몬은 하나라서 확인하고 지우는
|
|
67
|
+
* 사이에 다른 데몬이 새 소켓을 만들 수 없다. 잠금 없이 소켓을 만드는 옛 버전 데몬이
|
|
68
|
+
* 끼어드는 경우만 남아서 지우기 직전에 같은 파일인지 한 번 더 본다.
|
|
69
|
+
*/
|
|
70
|
+
private clearStaleSocket;
|
|
71
|
+
/**
|
|
72
|
+
* 임시 이름으로 listen하고 0600으로 좁힌 뒤 진짜 이름에 하드 링크를 건다.
|
|
73
|
+
*
|
|
74
|
+
* 진짜 이름에 바로 listen하면 net.Server.close가 그 경로를 무조건 지운다(libuv). 그새
|
|
75
|
+
* 다른 데몬이 그 자리를 차지했으면 남의 소켓이 사라진다. 링크는 이름이 이미 있으면
|
|
76
|
+
* 실패하므로 동시에 뜬 데몬끼리 서로 덮어쓰지도 않는다.
|
|
77
|
+
*/
|
|
78
|
+
private listenIpc;
|
|
79
|
+
private removeOwnSocket;
|
|
80
|
+
private serve;
|
|
81
|
+
private answer;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=daemon.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../../../src/peer/daemon.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAgB,WAAW,EAAE,YAAY,EAAa,MAAM,eAAe,CAAC;AAQxF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGvD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sBAAsB;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,OAAO,CAAC,EAAE,kBAAkB,CAAC,WAAW,CAAC,CAAC;CAC3C;AAsBD;;;;;;GAMG;AACH,qBAAa,UAAU;IAkBT,OAAO,CAAC,QAAQ,CAAC,OAAO;IAjBpC,OAAO,CAAC,GAAG,CAAuB;IAClC,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,OAAO,CAA4B;IAC3C,OAAO,CAAC,GAAG,CAA2B;IACtC,OAAO,CAAC,IAAI,CAA2B;IACvC,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,KAAK,CAAqB;IAClC,iDAAiD;IACjD,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,2CAA2C;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEH,OAAO,EAAE,iBAAiB;IAevD,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,UAAU,IAAI,MAAM,GAAG,SAAS,CAEnC;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA6CtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAiC3B,oCAAoC;IACpC,IAAI,CAAC,GAAG,SAAa,GAAG,IAAI;IAyB5B;;;;;OAKG;IACH,OAAO,CAAC,gBAAgB;IAQxB,MAAM,CAAC,GAAG,EAAE,WAAW,GAAG,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;IAuD9D,OAAO,CAAC,WAAW;YASL,gBAAgB;IAS9B,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,SAAS;IA8DjB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,IAAI;IAuBZ,OAAO,CAAC,MAAM;IAed;;;;;;;OAOG;YACW,gBAAgB;IAkB9B;;;;;;OAMG;IACH,OAAO,CAAC,SAAS;IA+BjB,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,KAAK;YAoCC,MAAM;CAoBrB"}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, linkSync, lstatSync, realpathSync, statSync, unlinkSync, } from 'node:fs';
|
|
3
|
+
import { createConnection, createServer } from 'node:net';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { isToolsLevel } from './redact.js';
|
|
6
|
+
import { ensurePrivateDir, MAX_SOCKET_PATH_BYTES, socketPath, statePath } from './paths.js';
|
|
7
|
+
import { PeerState } from './state.js';
|
|
8
|
+
import { ShareStream } from './share.js';
|
|
9
|
+
import { ViewerServer } from './viewer.js';
|
|
10
|
+
import { DEFAULT_EXPIRES_MINUTES, MAX_EXPIRES_MINUTES, MAX_REQUEST_BYTES } from './protocol.js';
|
|
11
|
+
import { isSessionId } from './locate.js';
|
|
12
|
+
import { errMessage } from './errors.js';
|
|
13
|
+
import { loadOrCreateMachineKey } from './keys.js';
|
|
14
|
+
import { acquireDaemonLock, releaseDaemonLock } from './lock.js';
|
|
15
|
+
import { PeerNetwork, PeerNetworkError } from './network.js';
|
|
16
|
+
const DEFAULT_POLL_MS = 500;
|
|
17
|
+
/** stop이 IPC 서버가 닫히길 기다리는 상한. 넘으면 기다리지 않고 정리를 마친다 */
|
|
18
|
+
const STOP_TIMEOUT_MS = 2_000;
|
|
19
|
+
/**
|
|
20
|
+
* `gestalt peer daemon`. 공유를 들고 대화 기록을 따라 읽어 로컬 뷰어로 흘린다. 팀에
|
|
21
|
+
* 들었으면 팀 릴레이에 붙어 팀원에게 공유를 넘기고 팀원 공유를 받아 로컬 뷰어로 옮긴다.
|
|
22
|
+
*
|
|
23
|
+
* 바깥으로 나가는 연결은 팀 릴레이 하나다. 소켓은 0600, 디렉토리는 0700이라 같은 OS
|
|
24
|
+
* 사용자만 명령을 넣는다. 뷰어는 `127.0.0.1`에만 붙는다.
|
|
25
|
+
*/
|
|
26
|
+
export class PeerDaemon {
|
|
27
|
+
options;
|
|
28
|
+
ipc = null;
|
|
29
|
+
viewer;
|
|
30
|
+
state = null;
|
|
31
|
+
network = null;
|
|
32
|
+
key = null;
|
|
33
|
+
lock = null;
|
|
34
|
+
shares = new Map();
|
|
35
|
+
timer = null;
|
|
36
|
+
startedAt = 0;
|
|
37
|
+
conns = new Set();
|
|
38
|
+
/** 이 데몬이 만든 소켓 파일. stop은 소켓 자리가 아직 이것일 때만 지운다 */
|
|
39
|
+
ownSocket = null;
|
|
40
|
+
sock;
|
|
41
|
+
/** listen할 임시 이름. 권한을 좁힌 뒤 진짜 이름으로 링크한다 */
|
|
42
|
+
tmpSock;
|
|
43
|
+
pollMs;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.options = options;
|
|
46
|
+
this.sock = socketPath(options.root);
|
|
47
|
+
this.tmpSock = join(options.root, `${process.pid}.s`);
|
|
48
|
+
this.pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
49
|
+
this.viewer = new ViewerServer({
|
|
50
|
+
lookup: (id) => {
|
|
51
|
+
const s = this.shares.get(id);
|
|
52
|
+
if (s)
|
|
53
|
+
return { stream: s.stream, token: s.token };
|
|
54
|
+
return this.network?.lookupWatch(id);
|
|
55
|
+
},
|
|
56
|
+
onConnect: (id) => this.state?.audit('viewer_connect', id),
|
|
57
|
+
onDisconnect: (id) => this.state?.audit('viewer_disconnect', id),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
get socketPath() {
|
|
61
|
+
return this.sock;
|
|
62
|
+
}
|
|
63
|
+
get viewerPort() {
|
|
64
|
+
return this.viewer.port;
|
|
65
|
+
}
|
|
66
|
+
async start() {
|
|
67
|
+
for (const path of [this.sock, this.tmpSock]) {
|
|
68
|
+
if (Buffer.byteLength(path) > MAX_SOCKET_PATH_BYTES) {
|
|
69
|
+
throw new Error(`소켓 경로가 너무 길어요(${MAX_SOCKET_PATH_BYTES}바이트 한도): ${path}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
ensurePrivateDir(this.options.root);
|
|
73
|
+
const lock = await acquireDaemonLock(this.options.root, this.sock);
|
|
74
|
+
let state = null;
|
|
75
|
+
try {
|
|
76
|
+
await this.clearStaleSocket();
|
|
77
|
+
state = new PeerState(this.options.dbPath ?? statePath(this.options.root));
|
|
78
|
+
this.state = state;
|
|
79
|
+
for (const id of state.closeDangling('daemon_restart')) {
|
|
80
|
+
state.audit('share_close', id, 'daemon_restart');
|
|
81
|
+
}
|
|
82
|
+
this.key = loadOrCreateMachineKey(this.options.root);
|
|
83
|
+
await this.viewer.start(0);
|
|
84
|
+
await this.listenIpc();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
// stop()은 부르지 않는다. 여기까지 연 건 뷰어와 DB뿐이고 stop은 소켓 자리까지
|
|
88
|
+
// 치우는 경로라 반쯤 뜬 데몬이 이미 떠 있는 다른 데몬의 자리를 건드릴 수 있다
|
|
89
|
+
await this.viewer.stop();
|
|
90
|
+
state?.close();
|
|
91
|
+
this.state = null;
|
|
92
|
+
releaseDaemonLock(lock);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
this.lock = lock;
|
|
96
|
+
this.network = new PeerNetwork({
|
|
97
|
+
state,
|
|
98
|
+
key: this.key,
|
|
99
|
+
lookupShare: (id) => {
|
|
100
|
+
const s = this.shares.get(id);
|
|
101
|
+
return s ? { stream: s.stream, shareKey: s.shareKey } : undefined;
|
|
102
|
+
},
|
|
103
|
+
transport: this.options.network,
|
|
104
|
+
});
|
|
105
|
+
this.network.start();
|
|
106
|
+
this.startedAt = Date.now();
|
|
107
|
+
this.timer = setInterval(() => this.tick(), this.pollMs);
|
|
108
|
+
this.timer.unref();
|
|
109
|
+
}
|
|
110
|
+
async stop() {
|
|
111
|
+
if (this.timer)
|
|
112
|
+
clearInterval(this.timer);
|
|
113
|
+
this.timer = null;
|
|
114
|
+
for (const id of [...this.shares.keys()])
|
|
115
|
+
this.closeShareSafely(id, 'daemon_stop');
|
|
116
|
+
const ipc = this.ipc;
|
|
117
|
+
this.ipc = null;
|
|
118
|
+
const network = this.network;
|
|
119
|
+
this.network = null;
|
|
120
|
+
try {
|
|
121
|
+
await network?.stop().catch((err) => {
|
|
122
|
+
process.stderr.write(`[peer] network stop failed: ${errMessage(err)}\n`);
|
|
123
|
+
});
|
|
124
|
+
await this.viewer.stop();
|
|
125
|
+
if (ipc) {
|
|
126
|
+
// 열린 연결이 남아 있으면 close 콜백이 안 불려 stop이 끝나지 않는다
|
|
127
|
+
for (const c of this.conns)
|
|
128
|
+
c.destroy();
|
|
129
|
+
await new Promise((resolve) => {
|
|
130
|
+
const timer = setTimeout(resolve, STOP_TIMEOUT_MS);
|
|
131
|
+
ipc.close(() => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
this.removeOwnSocket();
|
|
140
|
+
this.state?.close();
|
|
141
|
+
this.state = null;
|
|
142
|
+
if (this.lock)
|
|
143
|
+
releaseDaemonLock(this.lock);
|
|
144
|
+
this.lock = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** 테스트가 타이머를 기다리지 않고 한 번 돌릴 때 쓴다 */
|
|
148
|
+
tick(now = Date.now()) {
|
|
149
|
+
for (const [id, share] of [...this.shares]) {
|
|
150
|
+
try {
|
|
151
|
+
if (now >= share.expiresAt) {
|
|
152
|
+
this.closeShareSafely(id, 'expired');
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const before = share.stream.unknownLines;
|
|
156
|
+
const { unknownGrew } = share.stream.poll();
|
|
157
|
+
if (unknownGrew) {
|
|
158
|
+
const added = share.stream.unknownLines - before;
|
|
159
|
+
this.state?.audit('unknown_lines', id, JSON.stringify({ added, claudeVersion: share.stream.claudeVersion ?? null }));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
// setInterval 밖으로 던지면 데몬이 죽는다. 그 공유만 종료하고 나머지는 계속 돈다
|
|
164
|
+
process.stderr.write(`[peer] share ${id} failed: ${errMessage(err)}\n`);
|
|
165
|
+
this.closeShareSafely(id, 'error');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* closeShare는 DB에 쓰다가 던질 수 있다. tick에서 던지면 뒤 공유를 못 돌고 stop에서
|
|
171
|
+
* 던지면 뷰어와 IPC 서버와 소켓 파일 정리까지 건너뛴다. unshare에서 던지면 공유는 이미
|
|
172
|
+
* 종료됐는데 실패로 답해서 다시 unshare하면 열린 공유가 아니라고 나온다. 공유는 던지기
|
|
173
|
+
* 전에 이미 맵에서 빠지므로 남는 건 DB 기록 하나다.
|
|
174
|
+
*/
|
|
175
|
+
closeShareSafely(id, reason) {
|
|
176
|
+
try {
|
|
177
|
+
this.closeShare(id, reason);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
process.stderr.write(`[peer] share ${id} close failed: ${errMessage(err)}\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
handle(req) {
|
|
184
|
+
switch (req.op) {
|
|
185
|
+
case 'ping':
|
|
186
|
+
return { ok: true, result: 'pong' };
|
|
187
|
+
case 'status':
|
|
188
|
+
return { ok: true, result: this.status() };
|
|
189
|
+
case 'shares':
|
|
190
|
+
return { ok: true, result: [...this.shares.keys()].map((id) => this.info(id)) };
|
|
191
|
+
case 'share':
|
|
192
|
+
return this.openShare(req);
|
|
193
|
+
case 'unshare': {
|
|
194
|
+
if (typeof req.id !== 'string' || !this.shares.has(req.id)) {
|
|
195
|
+
return { ok: false, error: `열린 공유가 아니에요: ${String(req.id)}` };
|
|
196
|
+
}
|
|
197
|
+
this.closeShareSafely(req.id, 'unshare');
|
|
198
|
+
return { ok: true, result: { id: req.id } };
|
|
199
|
+
}
|
|
200
|
+
case 'init':
|
|
201
|
+
return this.withNetwork((n) => n.init(String(req.person), String(req.relay)));
|
|
202
|
+
case 'invite':
|
|
203
|
+
return this.withNetwork((n) => n.invite(String(req.person)));
|
|
204
|
+
case 'join': {
|
|
205
|
+
const timeout = req.timeoutMs;
|
|
206
|
+
return this.withNetworkAsync((n) => n.join(String(req.code), Number.isFinite(timeout) && timeout > 0 ? timeout : undefined));
|
|
207
|
+
}
|
|
208
|
+
case 'remove':
|
|
209
|
+
return this.withNetwork((n) => n.remove(String(req.person)));
|
|
210
|
+
case 'members':
|
|
211
|
+
return this.withNetwork((n) => n.members());
|
|
212
|
+
case 'watch':
|
|
213
|
+
return this.withNetworkAsync(async (n) => {
|
|
214
|
+
const w = await n.watch(String(req.link));
|
|
215
|
+
return { id: w.id, share: w.share, owner: w.owner, url: this.viewerUrl(w.id, w.token) };
|
|
216
|
+
});
|
|
217
|
+
case 'watches':
|
|
218
|
+
return this.withNetwork((n) => n.listWatches().map((w) => ({
|
|
219
|
+
...w,
|
|
220
|
+
url: this.viewerUrl(w.id, n.lookupWatch(w.id).token),
|
|
221
|
+
})));
|
|
222
|
+
case 'unwatch': {
|
|
223
|
+
const id = String(req.id);
|
|
224
|
+
return this.withNetwork((n) => {
|
|
225
|
+
if (!n.unwatch(id))
|
|
226
|
+
throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${id}`);
|
|
227
|
+
return { id };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
default:
|
|
231
|
+
return { ok: false, error: `모르는 요청이에요: ${String(req.op)}` };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
withNetwork(fn) {
|
|
235
|
+
if (!this.network)
|
|
236
|
+
return { ok: false, error: '데몬이 아직 안 떴어요' };
|
|
237
|
+
try {
|
|
238
|
+
return { ok: true, result: fn(this.network) };
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
return { ok: false, error: errMessage(err) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async withNetworkAsync(fn) {
|
|
245
|
+
if (!this.network)
|
|
246
|
+
return { ok: false, error: '데몬이 아직 안 떴어요' };
|
|
247
|
+
try {
|
|
248
|
+
return { ok: true, result: await fn(this.network) };
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
return { ok: false, error: errMessage(err) };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
viewerUrl(id, token) {
|
|
255
|
+
return `http://127.0.0.1:${this.viewer.port}/s/${id}?t=${token}`;
|
|
256
|
+
}
|
|
257
|
+
announceShares() {
|
|
258
|
+
this.network?.announce([...this.shares.keys()]);
|
|
259
|
+
}
|
|
260
|
+
openShare(req) {
|
|
261
|
+
const path = req.transcriptPath;
|
|
262
|
+
if (typeof path !== 'string' || !isAbsolute(path)) {
|
|
263
|
+
return { ok: false, error: 'transcriptPath는 절대경로여야 해요' };
|
|
264
|
+
}
|
|
265
|
+
// 아무 파일이나 열지 못하게 대화 기록 꼴만 받는다
|
|
266
|
+
if (!path.endsWith('.jsonl'))
|
|
267
|
+
return { ok: false, error: '.jsonl 파일만 공유할 수 있어요' };
|
|
268
|
+
let real;
|
|
269
|
+
try {
|
|
270
|
+
real = realpathSync(path);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return { ok: false, error: `대화 기록 파일이 없어요: ${path}` };
|
|
274
|
+
}
|
|
275
|
+
// `x.jsonl -> 다른 파일` 심링크는 이름만 맞는다. 검사와 따라 읽기를 실제 경로에 건다
|
|
276
|
+
if (!real.endsWith('.jsonl'))
|
|
277
|
+
return { ok: false, error: '.jsonl 파일만 공유할 수 있어요' };
|
|
278
|
+
try {
|
|
279
|
+
if (!statSync(real).isFile())
|
|
280
|
+
return { ok: false, error: `파일이 아니에요: ${path}` };
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return { ok: false, error: `대화 기록 파일이 없어요: ${path}` };
|
|
284
|
+
}
|
|
285
|
+
if (req.sessionId !== undefined && !isSessionId(req.sessionId)) {
|
|
286
|
+
return { ok: false, error: 'sessionId 형식이 아니에요' };
|
|
287
|
+
}
|
|
288
|
+
const tools = req.tools ?? 'summary';
|
|
289
|
+
if (!isToolsLevel(tools))
|
|
290
|
+
return { ok: false, error: '--tools는 full, summary, none 중 하나예요' };
|
|
291
|
+
const minutes = req.expiresMinutes ?? DEFAULT_EXPIRES_MINUTES;
|
|
292
|
+
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > MAX_EXPIRES_MINUTES) {
|
|
293
|
+
return { ok: false, error: `--expires는 1~${MAX_EXPIRES_MINUTES}분이에요` };
|
|
294
|
+
}
|
|
295
|
+
const id = randomBytes(6).toString('base64url');
|
|
296
|
+
const token = randomBytes(24).toString('base64url');
|
|
297
|
+
const shareKey = randomBytes(32).toString('base64url');
|
|
298
|
+
const createdAt = Date.now();
|
|
299
|
+
const expiresAt = createdAt + Math.round(minutes * 60_000);
|
|
300
|
+
const stream = new ShareStream({ id, transcriptPath: real, tools, home: this.options.home });
|
|
301
|
+
stream.poll();
|
|
302
|
+
this.shares.set(id, {
|
|
303
|
+
stream,
|
|
304
|
+
token,
|
|
305
|
+
shareKey,
|
|
306
|
+
sessionId: req.sessionId ?? null,
|
|
307
|
+
createdAt,
|
|
308
|
+
expiresAt,
|
|
309
|
+
});
|
|
310
|
+
this.state.insertShare({
|
|
311
|
+
id,
|
|
312
|
+
sessionId: req.sessionId ?? null,
|
|
313
|
+
transcriptPath: real,
|
|
314
|
+
tools,
|
|
315
|
+
createdAt,
|
|
316
|
+
expiresAt,
|
|
317
|
+
});
|
|
318
|
+
this.state.audit('share_open', id, JSON.stringify({ tools, expiresAt }));
|
|
319
|
+
this.announceShares();
|
|
320
|
+
return { ok: true, result: this.info(id) };
|
|
321
|
+
}
|
|
322
|
+
closeShare(id, reason) {
|
|
323
|
+
const share = this.shares.get(id);
|
|
324
|
+
if (!share)
|
|
325
|
+
return;
|
|
326
|
+
this.shares.delete(id);
|
|
327
|
+
share.stream.close(reason);
|
|
328
|
+
this.announceShares();
|
|
329
|
+
this.state?.closeShare(id, reason);
|
|
330
|
+
this.state?.audit('share_close', id, reason);
|
|
331
|
+
}
|
|
332
|
+
info(id) {
|
|
333
|
+
const s = this.shares.get(id);
|
|
334
|
+
if (!s)
|
|
335
|
+
return undefined;
|
|
336
|
+
return {
|
|
337
|
+
id,
|
|
338
|
+
sessionId: s.sessionId,
|
|
339
|
+
transcriptPath: s.stream.transcriptPath,
|
|
340
|
+
tools: s.stream.tools,
|
|
341
|
+
createdAt: s.createdAt,
|
|
342
|
+
expiresAt: s.expiresAt,
|
|
343
|
+
status: s.stream.currentStatus,
|
|
344
|
+
viewers: s.stream.viewerCount,
|
|
345
|
+
events: s.stream.eventCount,
|
|
346
|
+
unknownLines: s.stream.unknownLines,
|
|
347
|
+
failedLines: s.stream.failedLines,
|
|
348
|
+
oversizedLines: s.stream.oversizedLines,
|
|
349
|
+
droppedEvents: s.stream.droppedEvents,
|
|
350
|
+
title: s.stream.sessionTitle,
|
|
351
|
+
url: this.viewerUrl(id, s.token),
|
|
352
|
+
link: this.network?.linkFor(id, s.shareKey),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
status() {
|
|
356
|
+
let viewers = 0;
|
|
357
|
+
for (const s of this.shares.values())
|
|
358
|
+
viewers += s.stream.viewerCount;
|
|
359
|
+
return {
|
|
360
|
+
pid: process.pid,
|
|
361
|
+
root: this.options.root,
|
|
362
|
+
viewerPort: this.viewer.port ?? 0,
|
|
363
|
+
startedAt: this.startedAt,
|
|
364
|
+
shares: this.shares.size,
|
|
365
|
+
viewers,
|
|
366
|
+
machineKey: this.key?.id ?? '',
|
|
367
|
+
team: this.network?.status() ?? null,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* 남은 소켓 파일을 치운다. 누가 받고 있으면 데몬이 이미 떠 있는 거라 던진다.
|
|
372
|
+
* 소켓이 아닌 파일이면 안 지운다 — 경로를 잘못 줬을 때 남의 파일을 지우지 않게.
|
|
373
|
+
*
|
|
374
|
+
* 데몬 잠금(`peer.lock`)을 쥔 뒤에만 부른다. 잠금을 쥔 데몬은 하나라서 확인하고 지우는
|
|
375
|
+
* 사이에 다른 데몬이 새 소켓을 만들 수 없다. 잠금 없이 소켓을 만드는 옛 버전 데몬이
|
|
376
|
+
* 끼어드는 경우만 남아서 지우기 직전에 같은 파일인지 한 번 더 본다.
|
|
377
|
+
*/
|
|
378
|
+
async clearStaleSocket() {
|
|
379
|
+
if (!existsSync(this.sock))
|
|
380
|
+
return;
|
|
381
|
+
const stale = lstatSync(this.sock);
|
|
382
|
+
if (!stale.isSocket()) {
|
|
383
|
+
throw new Error(`소켓 자리에 다른 파일이 있어요: ${this.sock}`);
|
|
384
|
+
}
|
|
385
|
+
const alive = await new Promise((resolve) => {
|
|
386
|
+
const c = createConnection(this.sock);
|
|
387
|
+
c.once('connect', () => {
|
|
388
|
+
c.destroy();
|
|
389
|
+
resolve(true);
|
|
390
|
+
});
|
|
391
|
+
c.once('error', () => resolve(false));
|
|
392
|
+
});
|
|
393
|
+
if (alive)
|
|
394
|
+
throw new Error(`데몬이 이미 떠 있어요: ${this.sock}`);
|
|
395
|
+
if (sameFile(this.sock, stale))
|
|
396
|
+
unlinkSync(this.sock);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* 임시 이름으로 listen하고 0600으로 좁힌 뒤 진짜 이름에 하드 링크를 건다.
|
|
400
|
+
*
|
|
401
|
+
* 진짜 이름에 바로 listen하면 net.Server.close가 그 경로를 무조건 지운다(libuv). 그새
|
|
402
|
+
* 다른 데몬이 그 자리를 차지했으면 남의 소켓이 사라진다. 링크는 이름이 이미 있으면
|
|
403
|
+
* 실패하므로 동시에 뜬 데몬끼리 서로 덮어쓰지도 않는다.
|
|
404
|
+
*/
|
|
405
|
+
listenIpc() {
|
|
406
|
+
const tmp = this.tmpSock;
|
|
407
|
+
if (existsSync(tmp) && lstatSync(tmp).isSocket())
|
|
408
|
+
unlinkSync(tmp);
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
const server = createServer((socket) => this.serve(socket));
|
|
411
|
+
server.once('error', reject);
|
|
412
|
+
server.listen(tmp, () => {
|
|
413
|
+
server.off('error', reject);
|
|
414
|
+
server.on('error', (err) => process.stderr.write(`[peer] ipc error: ${err.message}\n`));
|
|
415
|
+
try {
|
|
416
|
+
chmodSync(tmp, 0o600);
|
|
417
|
+
linkSync(tmp, this.sock);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
server.close();
|
|
421
|
+
const code = err.code;
|
|
422
|
+
reject(code === 'EEXIST' ? new Error(`데몬이 이미 떠 있어요: ${this.sock}`) : err);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
unlinkSync(tmp);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// 남아도 close가 지운다
|
|
430
|
+
}
|
|
431
|
+
const st = lstatSync(this.sock);
|
|
432
|
+
this.ownSocket = { dev: st.dev, ino: st.ino };
|
|
433
|
+
this.ipc = server;
|
|
434
|
+
resolve();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
removeOwnSocket() {
|
|
439
|
+
const own = this.ownSocket;
|
|
440
|
+
this.ownSocket = null;
|
|
441
|
+
if (!own)
|
|
442
|
+
return;
|
|
443
|
+
try {
|
|
444
|
+
if (sameFile(this.sock, own))
|
|
445
|
+
unlinkSync(this.sock);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
// 이미 없으면 그만이다
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
serve(socket) {
|
|
452
|
+
let buf = '';
|
|
453
|
+
let rejected = false;
|
|
454
|
+
// 오래 걸리는 요청(join, watch)이 있어도 답은 받은 차례대로 쓴다
|
|
455
|
+
let queue = Promise.resolve();
|
|
456
|
+
this.conns.add(socket);
|
|
457
|
+
socket.on('close', () => this.conns.delete(socket));
|
|
458
|
+
socket.setEncoding('utf8');
|
|
459
|
+
socket.on('error', () => socket.destroy());
|
|
460
|
+
socket.on('data', (chunk) => {
|
|
461
|
+
if (rejected)
|
|
462
|
+
return;
|
|
463
|
+
buf += chunk;
|
|
464
|
+
if (Buffer.byteLength(buf) > MAX_REQUEST_BYTES) {
|
|
465
|
+
rejected = true;
|
|
466
|
+
buf = '';
|
|
467
|
+
// 바로 destroy하면 거절 답이 나가기 전에 끊길 수 있다
|
|
468
|
+
socket.end(JSON.stringify({ ok: false, error: '요청이 너무 커요' }) + '\n', () => socket.destroy());
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
let nl = buf.indexOf('\n');
|
|
472
|
+
while (nl !== -1) {
|
|
473
|
+
const line = buf.slice(0, nl);
|
|
474
|
+
buf = buf.slice(nl + 1);
|
|
475
|
+
if (line.trim() !== '') {
|
|
476
|
+
queue = queue.then(async () => {
|
|
477
|
+
const res = await this.answer(line);
|
|
478
|
+
if (!socket.destroyed)
|
|
479
|
+
socket.write(JSON.stringify(res) + '\n');
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
nl = buf.indexOf('\n');
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
async answer(line) {
|
|
487
|
+
let req;
|
|
488
|
+
try {
|
|
489
|
+
req = JSON.parse(line);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return { ok: false, error: 'JSON이 아니에요' };
|
|
493
|
+
}
|
|
494
|
+
if (typeof req !== 'object' ||
|
|
495
|
+
req === null ||
|
|
496
|
+
typeof req.op !== 'string') {
|
|
497
|
+
return { ok: false, error: 'op가 없어요' };
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
return await this.handle(req);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
return { ok: false, error: errMessage(err) };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function sameFile(path, id) {
|
|
508
|
+
try {
|
|
509
|
+
const st = lstatSync(path);
|
|
510
|
+
return st.dev === id.dev && st.ino === id.ino;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
//# sourceMappingURL=daemon.js.map
|