@simplysm/service-common 13.0.0-beta.51 → 13.0.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/protocol/create-service-protocol.d.ts +59 -0
- package/dist/protocol/create-service-protocol.d.ts.map +1 -0
- package/dist/protocol/create-service-protocol.js +106 -0
- package/dist/protocol/create-service-protocol.js.map +6 -0
- package/dist/protocol/service-protocol.d.ts +2 -62
- package/dist/protocol/service-protocol.d.ts.map +1 -1
- package/dist/protocol/service-protocol.js +2 -131
- package/dist/protocol/service-protocol.js.map +1 -1
- package/dist/types.d.ts +0 -29
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +2 -2
- package/package.json +3 -3
- package/src/protocol/create-service-protocol.ts +223 -0
- package/src/protocol/service-protocol.ts +2 -200
- package/src/types.ts +0 -31
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Bytes } from "@simplysm/core-common";
|
|
2
|
+
import "@simplysm/core-common";
|
|
3
|
+
import { type ServiceMessage } from "./protocol.types";
|
|
4
|
+
/**
|
|
5
|
+
* 서비스 프로토콜 인터페이스
|
|
6
|
+
*
|
|
7
|
+
* Binary Protocol V2:
|
|
8
|
+
* - Header: 28 bytes (UUID 16 + TotalSize 8 + Index 4)
|
|
9
|
+
* - Body: JSON
|
|
10
|
+
* - 자동 청킹: 3MB 초과 시 300KB 단위 분할
|
|
11
|
+
* - 최대 메시지: 100MB
|
|
12
|
+
*/
|
|
13
|
+
export interface ServiceProtocol {
|
|
14
|
+
/**
|
|
15
|
+
* 메시지 인코딩 (필요 시 자동 분할)
|
|
16
|
+
*/
|
|
17
|
+
encode(uuid: string, message: ServiceMessage): {
|
|
18
|
+
chunks: Bytes[];
|
|
19
|
+
totalSize: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* 메시지 디코딩 (분할 패킷 자동 조립)
|
|
23
|
+
*/
|
|
24
|
+
decode<T extends ServiceMessage>(bytes: Bytes): ServiceMessageDecodeResult<T>;
|
|
25
|
+
/**
|
|
26
|
+
* 프로토콜 인스턴스를 정리한다.
|
|
27
|
+
*
|
|
28
|
+
* 내부 청크 누적기의 GC 타이머를 해제하고 메모리를 정리한다.
|
|
29
|
+
* 프로토콜 인스턴스 사용이 끝나면 반드시 호출해야 한다.
|
|
30
|
+
*/
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 메시지 디코딩 결과 타입 (유니온)
|
|
35
|
+
*
|
|
36
|
+
* - `type: "complete"`: 모든 청크가 수신되어 메시지 조립이 완료됨
|
|
37
|
+
* - `type: "progress"`: 분할 메시지 수신 중 (일부 청크만 도착)
|
|
38
|
+
*/
|
|
39
|
+
export type ServiceMessageDecodeResult<T extends ServiceMessage> = {
|
|
40
|
+
type: "complete";
|
|
41
|
+
uuid: string;
|
|
42
|
+
message: T;
|
|
43
|
+
} | {
|
|
44
|
+
type: "progress";
|
|
45
|
+
uuid: string;
|
|
46
|
+
totalSize: number;
|
|
47
|
+
completedSize: number;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* 서비스 프로토콜 인코더/디코더 생성
|
|
51
|
+
*
|
|
52
|
+
* Binary Protocol V2:
|
|
53
|
+
* - Header: 28 bytes (UUID 16 + TotalSize 8 + Index 4)
|
|
54
|
+
* - Body: JSON
|
|
55
|
+
* - 자동 청킹: 3MB 초과 시 300KB 단위 분할
|
|
56
|
+
* - 최대 메시지: 100MB
|
|
57
|
+
*/
|
|
58
|
+
export declare function createServiceProtocol(): ServiceProtocol;
|
|
59
|
+
//# sourceMappingURL=create-service-protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-service-protocol.d.ts","sourceRoot":"","sources":["../../src/protocol/create-service-protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,uBAAuB,CAAC;AAE/B,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAExE;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG;QAAE,MAAM,EAAE,KAAK,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAEtF;;OAEG;IACH,MAAM,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,EAAE,KAAK,GAAG,0BAA0B,CAAC,CAAC,CAAC,CAAC;IAE9E;;;;;OAKG;IACH,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,MAAM,0BAA0B,CAAC,CAAC,SAAS,cAAc,IAC3D;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,IAAI,eAAe,CAyKvD"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import "@simplysm/core-common";
|
|
2
|
+
import { ArgumentError, jsonStringify, jsonParse, LazyGcMap, Uuid, bytesConcat } from "@simplysm/core-common";
|
|
3
|
+
import { PROTOCOL_CONFIG } from "./protocol.types.js";
|
|
4
|
+
function createServiceProtocol() {
|
|
5
|
+
const accumulator = new LazyGcMap({
|
|
6
|
+
gcInterval: PROTOCOL_CONFIG.GC_INTERVAL,
|
|
7
|
+
expireTime: PROTOCOL_CONFIG.EXPIRE_TIME
|
|
8
|
+
});
|
|
9
|
+
function encodeChunk(header, bodyBytes) {
|
|
10
|
+
const headerBytes = new Uint8Array(28);
|
|
11
|
+
const uuidBytes = new Uuid(header.uuid).toBytes();
|
|
12
|
+
headerBytes.set(uuidBytes, 0);
|
|
13
|
+
const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
14
|
+
headerView.setBigUint64(16, BigInt(header.totalSize), false);
|
|
15
|
+
headerView.setUint32(24, header.index, false);
|
|
16
|
+
return bytesConcat([headerBytes, ...bodyBytes ? [bodyBytes] : []]);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
encode(uuid, message) {
|
|
20
|
+
const msgJson = jsonStringify([message.name, ..."body" in message ? [message.body] : []]);
|
|
21
|
+
const msgBytes = new TextEncoder().encode(msgJson);
|
|
22
|
+
const totalSize = msgBytes.length;
|
|
23
|
+
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
24
|
+
throw new ArgumentError("\uBA54\uC2DC\uC9C0 \uD06C\uAE30\uAC00 \uC81C\uD55C\uC744 \uCD08\uACFC\uD588\uC2B5\uB2C8\uB2E4.", {
|
|
25
|
+
totalSize,
|
|
26
|
+
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (totalSize <= PROTOCOL_CONFIG.SPLIT_MESSAGE_SIZE) {
|
|
30
|
+
return { chunks: [encodeChunk({ uuid, totalSize, index: 0 }, msgBytes)], totalSize };
|
|
31
|
+
}
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let offset = 0;
|
|
34
|
+
let index = 0;
|
|
35
|
+
while (offset < totalSize) {
|
|
36
|
+
const chunkBodyBytes = msgBytes.subarray(offset, offset + PROTOCOL_CONFIG.CHUNK_SIZE);
|
|
37
|
+
const chunk = encodeChunk({ uuid, totalSize, index }, chunkBodyBytes);
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
offset += PROTOCOL_CONFIG.CHUNK_SIZE;
|
|
40
|
+
index++;
|
|
41
|
+
}
|
|
42
|
+
return { chunks, totalSize };
|
|
43
|
+
},
|
|
44
|
+
decode(bytes) {
|
|
45
|
+
if (bytes.length < 28) {
|
|
46
|
+
throw new ArgumentError("\uBC84\uD37C \uD06C\uAE30\uAC00 \uD5E4\uB354 \uD06C\uAE30\uBCF4\uB2E4 \uC791\uC2B5\uB2C8\uB2E4.", {
|
|
47
|
+
bufferSize: bytes.length,
|
|
48
|
+
minimumSize: 28
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const uuidBytes = bytes.subarray(0, 16);
|
|
52
|
+
const uuid = Uuid.fromBytes(uuidBytes).toString();
|
|
53
|
+
const headerView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
54
|
+
const totalSize = Number(headerView.getBigUint64(16, false));
|
|
55
|
+
const index = headerView.getUint32(24, false);
|
|
56
|
+
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
57
|
+
throw new ArgumentError("\uBA54\uC2DC\uC9C0 \uD06C\uAE30\uAC00 \uC81C\uD55C\uC744 \uCD08\uACFC\uD588\uC2B5\uB2C8\uB2E4.", {
|
|
58
|
+
totalSize,
|
|
59
|
+
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const bodyBytes = bytes.subarray(28);
|
|
63
|
+
const accItem = accumulator.getOrCreate(uuid, () => ({
|
|
64
|
+
totalSize,
|
|
65
|
+
completedSize: 0,
|
|
66
|
+
chunks: []
|
|
67
|
+
}));
|
|
68
|
+
if (accItem.chunks[index] == null) {
|
|
69
|
+
accItem.chunks[index] = bodyBytes;
|
|
70
|
+
accItem.completedSize += bodyBytes.length;
|
|
71
|
+
}
|
|
72
|
+
if (accItem.completedSize < accItem.totalSize) {
|
|
73
|
+
return {
|
|
74
|
+
type: "progress",
|
|
75
|
+
uuid,
|
|
76
|
+
totalSize,
|
|
77
|
+
completedSize: accItem.completedSize
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
accumulator.delete(uuid);
|
|
81
|
+
const resultBytes = bytesConcat(accItem.chunks.filterExists());
|
|
82
|
+
let messageArr;
|
|
83
|
+
try {
|
|
84
|
+
messageArr = jsonParse(new TextDecoder().decode(resultBytes));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new ArgumentError("\uBA54\uC2DC\uC9C0 \uB514\uCF54\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4.", { uuid, cause: err });
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
type: "complete",
|
|
90
|
+
uuid,
|
|
91
|
+
message: {
|
|
92
|
+
name: messageArr[0],
|
|
93
|
+
body: messageArr[1]
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
dispose() {
|
|
99
|
+
accumulator.dispose();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
createServiceProtocol
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=create-service-protocol.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/protocol/create-service-protocol.ts"],
|
|
4
|
+
"mappings": "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",
|
|
5
|
+
"names": []
|
|
6
|
+
}
|
|
@@ -1,63 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { type ServiceMessage } from "./protocol.types";
|
|
4
|
-
/**
|
|
5
|
-
* 서비스 프로토콜 인코더/디코더
|
|
6
|
-
*
|
|
7
|
-
* Binary Protocol V2:
|
|
8
|
-
* - Header: 28 bytes (UUID 16 + TotalSize 8 + Index 4)
|
|
9
|
-
* - Body: JSON
|
|
10
|
-
* - 자동 청킹: 3MB 초과 시 300KB 단위 분할
|
|
11
|
-
* - 최대 메시지: 100MB
|
|
12
|
-
*/
|
|
13
|
-
export declare class ServiceProtocol {
|
|
14
|
-
/**
|
|
15
|
-
* 메시지 인코딩 (필요 시 자동 분할)
|
|
16
|
-
*/
|
|
17
|
-
encode(uuid: string, message: ServiceMessage): {
|
|
18
|
-
chunks: Bytes[];
|
|
19
|
-
totalSize: number;
|
|
20
|
-
};
|
|
21
|
-
/**
|
|
22
|
-
* 메시지 청크 인코딩 (헤더 + 바디)
|
|
23
|
-
*
|
|
24
|
-
* 헤더 구조 (28 bytes, Big Endian):
|
|
25
|
-
* ```
|
|
26
|
-
* Offset Size Field
|
|
27
|
-
* ------ ---- -----
|
|
28
|
-
* 0 16 UUID (binary)
|
|
29
|
-
* 16 8 TotalSize (uint64)
|
|
30
|
-
* 24 4 Index (uint32)
|
|
31
|
-
* ```
|
|
32
|
-
*/
|
|
33
|
-
private _encode;
|
|
34
|
-
private readonly _accumulator;
|
|
35
|
-
/**
|
|
36
|
-
* 프로토콜 인스턴스를 정리한다.
|
|
37
|
-
*
|
|
38
|
-
* 내부 청크 누적기의 GC 타이머를 해제하고 메모리를 정리한다.
|
|
39
|
-
* 프로토콜 인스턴스 사용이 끝나면 반드시 호출해야 한다.
|
|
40
|
-
*/
|
|
41
|
-
dispose(): void;
|
|
42
|
-
/**
|
|
43
|
-
* 메시지 디코딩 (분할 패킷 자동 조립)
|
|
44
|
-
*/
|
|
45
|
-
decode<T extends ServiceMessage>(bytes: Bytes): ServiceMessageDecodeResult<T>;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* 메시지 디코딩 결과 타입 (유니온)
|
|
49
|
-
*
|
|
50
|
-
* - `type: "complete"`: 모든 청크가 수신되어 메시지 조립이 완료됨
|
|
51
|
-
* - `type: "progress"`: 분할 메시지 수신 중 (일부 청크만 도착)
|
|
52
|
-
*/
|
|
53
|
-
export type ServiceMessageDecodeResult<T extends ServiceMessage> = {
|
|
54
|
-
type: "complete";
|
|
55
|
-
uuid: string;
|
|
56
|
-
message: T;
|
|
57
|
-
} | {
|
|
58
|
-
type: "progress";
|
|
59
|
-
uuid: string;
|
|
60
|
-
totalSize: number;
|
|
61
|
-
completedSize: number;
|
|
62
|
-
};
|
|
1
|
+
export type { ServiceProtocol, ServiceMessageDecodeResult } from "./create-service-protocol";
|
|
2
|
+
export { createServiceProtocol } from "./create-service-protocol";
|
|
63
3
|
//# sourceMappingURL=service-protocol.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-protocol.d.ts","sourceRoot":"","sources":["../../src/protocol/service-protocol.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"service-protocol.d.ts","sourceRoot":"","sources":["../../src/protocol/service-protocol.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,0BAA0B,EAAE,MAAM,2BAA2B,CAAC;AAC7F,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC"}
|
|
@@ -1,134 +1,5 @@
|
|
|
1
|
-
import "
|
|
2
|
-
import { ArgumentError, jsonStringify, jsonParse, LazyGcMap, Uuid, bytesConcat } from "@simplysm/core-common";
|
|
3
|
-
import { PROTOCOL_CONFIG } from "./protocol.types.js";
|
|
4
|
-
class ServiceProtocol {
|
|
5
|
-
// -------------------------------------------------------------------
|
|
6
|
-
// Encoding
|
|
7
|
-
// -------------------------------------------------------------------
|
|
8
|
-
/**
|
|
9
|
-
* 메시지 인코딩 (필요 시 자동 분할)
|
|
10
|
-
*/
|
|
11
|
-
encode(uuid, message) {
|
|
12
|
-
const msgJson = jsonStringify([message.name, ..."body" in message ? [message.body] : []]);
|
|
13
|
-
const msgBytes = new TextEncoder().encode(msgJson);
|
|
14
|
-
const totalSize = msgBytes.length;
|
|
15
|
-
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
16
|
-
throw new ArgumentError("\uBA54\uC2DC\uC9C0 \uD06C\uAE30\uAC00 \uC81C\uD55C\uC744 \uCD08\uACFC\uD588\uC2B5\uB2C8\uB2E4.", {
|
|
17
|
-
totalSize,
|
|
18
|
-
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
if (totalSize <= PROTOCOL_CONFIG.SPLIT_MESSAGE_SIZE) {
|
|
22
|
-
return { chunks: [this._encode({ uuid, totalSize, index: 0 }, msgBytes)], totalSize };
|
|
23
|
-
}
|
|
24
|
-
const chunks = [];
|
|
25
|
-
let offset = 0;
|
|
26
|
-
let index = 0;
|
|
27
|
-
while (offset < totalSize) {
|
|
28
|
-
const chunkBodyBytes = msgBytes.subarray(offset, offset + PROTOCOL_CONFIG.CHUNK_SIZE);
|
|
29
|
-
const chunk = this._encode({ uuid, totalSize, index }, chunkBodyBytes);
|
|
30
|
-
chunks.push(chunk);
|
|
31
|
-
offset += PROTOCOL_CONFIG.CHUNK_SIZE;
|
|
32
|
-
index++;
|
|
33
|
-
}
|
|
34
|
-
return { chunks, totalSize };
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* 메시지 청크 인코딩 (헤더 + 바디)
|
|
38
|
-
*
|
|
39
|
-
* 헤더 구조 (28 bytes, Big Endian):
|
|
40
|
-
* ```
|
|
41
|
-
* Offset Size Field
|
|
42
|
-
* ------ ---- -----
|
|
43
|
-
* 0 16 UUID (binary)
|
|
44
|
-
* 16 8 TotalSize (uint64)
|
|
45
|
-
* 24 4 Index (uint32)
|
|
46
|
-
* ```
|
|
47
|
-
*/
|
|
48
|
-
_encode(header, bodyBytes) {
|
|
49
|
-
const headerBytes = new Uint8Array(28);
|
|
50
|
-
const uuidBytes = new Uuid(header.uuid).toBytes();
|
|
51
|
-
headerBytes.set(uuidBytes, 0);
|
|
52
|
-
const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
53
|
-
headerView.setBigUint64(16, BigInt(header.totalSize), false);
|
|
54
|
-
headerView.setUint32(24, header.index, false);
|
|
55
|
-
return bytesConcat([headerBytes, ...bodyBytes ? [bodyBytes] : []]);
|
|
56
|
-
}
|
|
57
|
-
// -------------------------------------------------------------------
|
|
58
|
-
// Decoding
|
|
59
|
-
// -------------------------------------------------------------------
|
|
60
|
-
_accumulator = new LazyGcMap({
|
|
61
|
-
gcInterval: PROTOCOL_CONFIG.GC_INTERVAL,
|
|
62
|
-
expireTime: PROTOCOL_CONFIG.EXPIRE_TIME
|
|
63
|
-
});
|
|
64
|
-
/**
|
|
65
|
-
* 프로토콜 인스턴스를 정리한다.
|
|
66
|
-
*
|
|
67
|
-
* 내부 청크 누적기의 GC 타이머를 해제하고 메모리를 정리한다.
|
|
68
|
-
* 프로토콜 인스턴스 사용이 끝나면 반드시 호출해야 한다.
|
|
69
|
-
*/
|
|
70
|
-
dispose() {
|
|
71
|
-
this._accumulator.dispose();
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* 메시지 디코딩 (분할 패킷 자동 조립)
|
|
75
|
-
*/
|
|
76
|
-
decode(bytes) {
|
|
77
|
-
if (bytes.length < 28) {
|
|
78
|
-
throw new ArgumentError("\uBC84\uD37C \uD06C\uAE30\uAC00 \uD5E4\uB354 \uD06C\uAE30\uBCF4\uB2E4 \uC791\uC2B5\uB2C8\uB2E4.", {
|
|
79
|
-
bufferSize: bytes.length,
|
|
80
|
-
minimumSize: 28
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
const uuidBytes = bytes.subarray(0, 16);
|
|
84
|
-
const uuid = Uuid.fromBytes(uuidBytes).toString();
|
|
85
|
-
const headerView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
86
|
-
const totalSize = Number(headerView.getBigUint64(16, false));
|
|
87
|
-
const index = headerView.getUint32(24, false);
|
|
88
|
-
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
89
|
-
throw new ArgumentError("\uBA54\uC2DC\uC9C0 \uD06C\uAE30\uAC00 \uC81C\uD55C\uC744 \uCD08\uACFC\uD588\uC2B5\uB2C8\uB2E4.", {
|
|
90
|
-
totalSize,
|
|
91
|
-
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
const bodyBytes = bytes.subarray(28);
|
|
95
|
-
const accItem = this._accumulator.getOrCreate(uuid, () => ({
|
|
96
|
-
totalSize,
|
|
97
|
-
completedSize: 0,
|
|
98
|
-
chunks: []
|
|
99
|
-
}));
|
|
100
|
-
if (accItem.chunks[index] == null) {
|
|
101
|
-
accItem.chunks[index] = bodyBytes;
|
|
102
|
-
accItem.completedSize += bodyBytes.length;
|
|
103
|
-
}
|
|
104
|
-
if (accItem.completedSize < accItem.totalSize) {
|
|
105
|
-
return {
|
|
106
|
-
type: "progress",
|
|
107
|
-
uuid,
|
|
108
|
-
totalSize,
|
|
109
|
-
completedSize: accItem.completedSize
|
|
110
|
-
};
|
|
111
|
-
} else {
|
|
112
|
-
this._accumulator.delete(uuid);
|
|
113
|
-
const resultBytes = bytesConcat(accItem.chunks.filterExists());
|
|
114
|
-
let messageArr;
|
|
115
|
-
try {
|
|
116
|
-
messageArr = jsonParse(new TextDecoder().decode(resultBytes));
|
|
117
|
-
} catch (err) {
|
|
118
|
-
throw new ArgumentError("\uBA54\uC2DC\uC9C0 \uB514\uCF54\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4.", { uuid, cause: err });
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
type: "complete",
|
|
122
|
-
uuid,
|
|
123
|
-
message: {
|
|
124
|
-
name: messageArr[0],
|
|
125
|
-
body: messageArr[1]
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
1
|
+
import { createServiceProtocol } from "./create-service-protocol.js";
|
|
131
2
|
export {
|
|
132
|
-
|
|
3
|
+
createServiceProtocol
|
|
133
4
|
};
|
|
134
5
|
//# sourceMappingURL=service-protocol.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/protocol/service-protocol.ts"],
|
|
4
|
-
"mappings": "AACA,
|
|
4
|
+
"mappings": "AACA,SAAS,6BAA6B;",
|
|
5
5
|
"names": []
|
|
6
6
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,32 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 이벤트 리스너 타입 정의용 추상 클래스
|
|
3
|
-
*
|
|
4
|
-
* - 상속만 하면 됨 (프로퍼티 구현 불필요)
|
|
5
|
-
* - $info, $data는 타입 추출용 (런타임 미사용)
|
|
6
|
-
* - eventName은 mangle 안전한 이벤트 식별자
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* export class SharedDataChangeEvent extends ServiceEventListener<
|
|
10
|
-
* { name: string; filter: unknown },
|
|
11
|
-
* (string | number)[] | undefined
|
|
12
|
-
* > {
|
|
13
|
-
* readonly eventName = "SharedDataChangeEvent";
|
|
14
|
-
* }
|
|
15
|
-
*
|
|
16
|
-
* // 클라이언트에서 사용
|
|
17
|
-
* await client.addEventListener(
|
|
18
|
-
* SharedDataChangeEvent,
|
|
19
|
-
* { name: "test", filter: null },
|
|
20
|
-
* (data) => console.log(data)
|
|
21
|
-
* );
|
|
22
|
-
*/
|
|
23
|
-
export declare abstract class ServiceEventListener<TInfo, TData> {
|
|
24
|
-
/** mangle 안전한 이벤트 식별자 (상속 시 필수 구현) */
|
|
25
|
-
abstract readonly eventName: string;
|
|
26
|
-
/** 타입 추출용 (런타임 미사용) */
|
|
27
|
-
readonly $info: TInfo;
|
|
28
|
-
readonly $data: TData;
|
|
29
|
-
}
|
|
30
1
|
/**
|
|
31
2
|
* 파일 업로드 결과
|
|
32
3
|
*
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,aAAa;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAC;CACd"}
|
package/dist/types.js
CHANGED
package/dist/types.js.map
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplysm/service-common",
|
|
3
3
|
"sideEffects": false,
|
|
4
|
-
"version": "13.0.
|
|
4
|
+
"version": "13.0.1",
|
|
5
5
|
"description": "심플리즘 패키지 - 서비스 모듈 (common)",
|
|
6
6
|
"author": "김석래",
|
|
7
7
|
"repository": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"src"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@simplysm/core-common": "13.0.
|
|
22
|
-
"@simplysm/orm-common": "13.0.
|
|
21
|
+
"@simplysm/core-common": "13.0.1",
|
|
22
|
+
"@simplysm/orm-common": "13.0.1"
|
|
23
23
|
}
|
|
24
24
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Bytes } from "@simplysm/core-common";
|
|
2
|
+
import "@simplysm/core-common";
|
|
3
|
+
import { ArgumentError, jsonStringify, jsonParse, LazyGcMap, Uuid, bytesConcat } from "@simplysm/core-common";
|
|
4
|
+
import { PROTOCOL_CONFIG, type ServiceMessage } from "./protocol.types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 서비스 프로토콜 인터페이스
|
|
8
|
+
*
|
|
9
|
+
* Binary Protocol V2:
|
|
10
|
+
* - Header: 28 bytes (UUID 16 + TotalSize 8 + Index 4)
|
|
11
|
+
* - Body: JSON
|
|
12
|
+
* - 자동 청킹: 3MB 초과 시 300KB 단위 분할
|
|
13
|
+
* - 최대 메시지: 100MB
|
|
14
|
+
*/
|
|
15
|
+
export interface ServiceProtocol {
|
|
16
|
+
/**
|
|
17
|
+
* 메시지 인코딩 (필요 시 자동 분할)
|
|
18
|
+
*/
|
|
19
|
+
encode(uuid: string, message: ServiceMessage): { chunks: Bytes[]; totalSize: number };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 메시지 디코딩 (분할 패킷 자동 조립)
|
|
23
|
+
*/
|
|
24
|
+
decode<T extends ServiceMessage>(bytes: Bytes): ServiceMessageDecodeResult<T>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 프로토콜 인스턴스를 정리한다.
|
|
28
|
+
*
|
|
29
|
+
* 내부 청크 누적기의 GC 타이머를 해제하고 메모리를 정리한다.
|
|
30
|
+
* 프로토콜 인스턴스 사용이 끝나면 반드시 호출해야 한다.
|
|
31
|
+
*/
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 메시지 디코딩 결과 타입 (유니온)
|
|
37
|
+
*
|
|
38
|
+
* - `type: "complete"`: 모든 청크가 수신되어 메시지 조립이 완료됨
|
|
39
|
+
* - `type: "progress"`: 분할 메시지 수신 중 (일부 청크만 도착)
|
|
40
|
+
*/
|
|
41
|
+
export type ServiceMessageDecodeResult<T extends ServiceMessage> =
|
|
42
|
+
| { type: "complete"; uuid: string; message: T }
|
|
43
|
+
| { type: "progress"; uuid: string; totalSize: number; completedSize: number };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 서비스 프로토콜 인코더/디코더 생성
|
|
47
|
+
*
|
|
48
|
+
* Binary Protocol V2:
|
|
49
|
+
* - Header: 28 bytes (UUID 16 + TotalSize 8 + Index 4)
|
|
50
|
+
* - Body: JSON
|
|
51
|
+
* - 자동 청킹: 3MB 초과 시 300KB 단위 분할
|
|
52
|
+
* - 최대 메시지: 100MB
|
|
53
|
+
*/
|
|
54
|
+
export function createServiceProtocol(): ServiceProtocol {
|
|
55
|
+
// -------------------------------------------------------------------
|
|
56
|
+
// State
|
|
57
|
+
// -------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const accumulator = new LazyGcMap<
|
|
60
|
+
string,
|
|
61
|
+
{
|
|
62
|
+
totalSize: number;
|
|
63
|
+
completedSize: number;
|
|
64
|
+
chunks: (Bytes | undefined)[];
|
|
65
|
+
}
|
|
66
|
+
>({
|
|
67
|
+
gcInterval: PROTOCOL_CONFIG.GC_INTERVAL,
|
|
68
|
+
expireTime: PROTOCOL_CONFIG.EXPIRE_TIME,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// -------------------------------------------------------------------
|
|
72
|
+
// Encoding Helper
|
|
73
|
+
// -------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 메시지 청크 인코딩 (헤더 + 바디)
|
|
77
|
+
*
|
|
78
|
+
* 헤더 구조 (28 bytes, Big Endian):
|
|
79
|
+
* ```
|
|
80
|
+
* Offset Size Field
|
|
81
|
+
* ------ ---- -----
|
|
82
|
+
* 0 16 UUID (binary)
|
|
83
|
+
* 16 8 TotalSize (uint64)
|
|
84
|
+
* 24 4 Index (uint32)
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
function encodeChunk(
|
|
88
|
+
header: {
|
|
89
|
+
uuid: string;
|
|
90
|
+
totalSize: number;
|
|
91
|
+
index: number;
|
|
92
|
+
},
|
|
93
|
+
bodyBytes?: Bytes,
|
|
94
|
+
): Bytes {
|
|
95
|
+
const headerBytes = new Uint8Array(28);
|
|
96
|
+
|
|
97
|
+
// UUID (0-15)
|
|
98
|
+
const uuidBytes = new Uuid(header.uuid).toBytes();
|
|
99
|
+
headerBytes.set(uuidBytes, 0);
|
|
100
|
+
|
|
101
|
+
// TotalSize (16-23), Index (24-27)
|
|
102
|
+
const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
103
|
+
headerView.setBigUint64(16, BigInt(header.totalSize), false);
|
|
104
|
+
headerView.setUint32(24, header.index, false);
|
|
105
|
+
|
|
106
|
+
return bytesConcat([headerBytes, ...(bodyBytes ? [bodyBytes] : [])]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -------------------------------------------------------------------
|
|
110
|
+
// Public API
|
|
111
|
+
// -------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
encode(uuid: string, message: ServiceMessage): { chunks: Bytes[]; totalSize: number } {
|
|
115
|
+
const msgJson = jsonStringify([message.name, ...("body" in message ? [message.body] : [])]);
|
|
116
|
+
const msgBytes = new TextEncoder().encode(msgJson);
|
|
117
|
+
|
|
118
|
+
const totalSize = msgBytes.length;
|
|
119
|
+
|
|
120
|
+
// 전체 사이즈 제한 체크 (가장 먼저 수행)
|
|
121
|
+
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
122
|
+
throw new ArgumentError("메시지 크기가 제한을 초과했습니다.", {
|
|
123
|
+
totalSize,
|
|
124
|
+
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 사이즈가 작으면 그대로 반환
|
|
129
|
+
if (totalSize <= PROTOCOL_CONFIG.SPLIT_MESSAGE_SIZE) {
|
|
130
|
+
return { chunks: [encodeChunk({ uuid, totalSize, index: 0 }, msgBytes)], totalSize };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 분할 처리
|
|
134
|
+
const chunks: Bytes[] = [];
|
|
135
|
+
let offset = 0;
|
|
136
|
+
let index = 0;
|
|
137
|
+
|
|
138
|
+
while (offset < totalSize) {
|
|
139
|
+
const chunkBodyBytes = msgBytes.subarray(offset, offset + PROTOCOL_CONFIG.CHUNK_SIZE);
|
|
140
|
+
|
|
141
|
+
const chunk = encodeChunk({ uuid, totalSize, index }, chunkBodyBytes);
|
|
142
|
+
chunks.push(chunk);
|
|
143
|
+
|
|
144
|
+
offset += PROTOCOL_CONFIG.CHUNK_SIZE;
|
|
145
|
+
index++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { chunks, totalSize };
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
decode<T extends ServiceMessage>(bytes: Bytes): ServiceMessageDecodeResult<T> {
|
|
152
|
+
if (bytes.length < 28) {
|
|
153
|
+
throw new ArgumentError("버퍼 크기가 헤더 크기보다 작습니다.", {
|
|
154
|
+
bufferSize: bytes.length,
|
|
155
|
+
minimumSize: 28,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 1. 헤더 읽기
|
|
160
|
+
|
|
161
|
+
// UUID
|
|
162
|
+
const uuidBytes = bytes.subarray(0, 16);
|
|
163
|
+
const uuid = Uuid.fromBytes(uuidBytes).toString();
|
|
164
|
+
|
|
165
|
+
// TOTAL_SIZE, INDEX
|
|
166
|
+
const headerView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
167
|
+
const totalSize = Number(headerView.getBigUint64(16, false));
|
|
168
|
+
const index = headerView.getUint32(24, false);
|
|
169
|
+
|
|
170
|
+
// 전체 사이즈 제한 체크 (가장 먼저 수행)
|
|
171
|
+
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
172
|
+
throw new ArgumentError("메시지 크기가 제한을 초과했습니다.", {
|
|
173
|
+
totalSize,
|
|
174
|
+
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const bodyBytes = bytes.subarray(28);
|
|
179
|
+
|
|
180
|
+
const accItem = accumulator.getOrCreate(uuid, () => ({
|
|
181
|
+
totalSize,
|
|
182
|
+
completedSize: 0,
|
|
183
|
+
chunks: [],
|
|
184
|
+
}));
|
|
185
|
+
if (accItem.chunks[index] == null) {
|
|
186
|
+
// 패킷중복 방어
|
|
187
|
+
accItem.chunks[index] = bodyBytes;
|
|
188
|
+
accItem.completedSize += bodyBytes.length;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (accItem.completedSize < accItem.totalSize) {
|
|
192
|
+
return {
|
|
193
|
+
type: "progress",
|
|
194
|
+
uuid: uuid,
|
|
195
|
+
totalSize: totalSize,
|
|
196
|
+
completedSize: accItem.completedSize,
|
|
197
|
+
};
|
|
198
|
+
} else {
|
|
199
|
+
accumulator.delete(uuid); // 메모리 해제
|
|
200
|
+
|
|
201
|
+
const resultBytes = bytesConcat(accItem.chunks.filterExists());
|
|
202
|
+
let messageArr: [string, unknown];
|
|
203
|
+
try {
|
|
204
|
+
messageArr = jsonParse<[string, unknown]>(new TextDecoder().decode(resultBytes));
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new ArgumentError("메시지 디코딩에 실패했습니다.", { uuid, cause: err });
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
type: "complete",
|
|
210
|
+
uuid: uuid,
|
|
211
|
+
message: {
|
|
212
|
+
name: messageArr[0],
|
|
213
|
+
body: messageArr[1],
|
|
214
|
+
} as T,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
dispose(): void {
|
|
220
|
+
accumulator.dispose();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -1,200 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { ArgumentError, jsonStringify, jsonParse, LazyGcMap, Uuid, bytesConcat } from "@simplysm/core-common";
|
|
4
|
-
import { PROTOCOL_CONFIG, type ServiceMessage } from "./protocol.types";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* 서비스 프로토콜 인코더/디코더
|
|
8
|
-
*
|
|
9
|
-
* Binary Protocol V2:
|
|
10
|
-
* - Header: 28 bytes (UUID 16 + TotalSize 8 + Index 4)
|
|
11
|
-
* - Body: JSON
|
|
12
|
-
* - 자동 청킹: 3MB 초과 시 300KB 단위 분할
|
|
13
|
-
* - 최대 메시지: 100MB
|
|
14
|
-
*/
|
|
15
|
-
export class ServiceProtocol {
|
|
16
|
-
// -------------------------------------------------------------------
|
|
17
|
-
// Encoding
|
|
18
|
-
// -------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* 메시지 인코딩 (필요 시 자동 분할)
|
|
22
|
-
*/
|
|
23
|
-
encode(uuid: string, message: ServiceMessage): { chunks: Bytes[]; totalSize: number } {
|
|
24
|
-
const msgJson = jsonStringify([message.name, ...("body" in message ? [message.body] : [])]);
|
|
25
|
-
const msgBytes = new TextEncoder().encode(msgJson);
|
|
26
|
-
|
|
27
|
-
const totalSize = msgBytes.length;
|
|
28
|
-
|
|
29
|
-
// 전체 사이즈 제한 체크 (가장 먼저 수행)
|
|
30
|
-
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
31
|
-
throw new ArgumentError("메시지 크기가 제한을 초과했습니다.", {
|
|
32
|
-
totalSize,
|
|
33
|
-
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 사이즈가 작으면 그대로 반환
|
|
38
|
-
if (totalSize <= PROTOCOL_CONFIG.SPLIT_MESSAGE_SIZE) {
|
|
39
|
-
return { chunks: [this._encode({ uuid, totalSize, index: 0 }, msgBytes)], totalSize };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 분할 처리
|
|
43
|
-
const chunks: Bytes[] = [];
|
|
44
|
-
let offset = 0;
|
|
45
|
-
let index = 0;
|
|
46
|
-
|
|
47
|
-
while (offset < totalSize) {
|
|
48
|
-
const chunkBodyBytes = msgBytes.subarray(offset, offset + PROTOCOL_CONFIG.CHUNK_SIZE);
|
|
49
|
-
|
|
50
|
-
const chunk = this._encode({ uuid, totalSize, index }, chunkBodyBytes);
|
|
51
|
-
chunks.push(chunk);
|
|
52
|
-
|
|
53
|
-
offset += PROTOCOL_CONFIG.CHUNK_SIZE;
|
|
54
|
-
index++;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { chunks, totalSize };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 메시지 청크 인코딩 (헤더 + 바디)
|
|
62
|
-
*
|
|
63
|
-
* 헤더 구조 (28 bytes, Big Endian):
|
|
64
|
-
* ```
|
|
65
|
-
* Offset Size Field
|
|
66
|
-
* ------ ---- -----
|
|
67
|
-
* 0 16 UUID (binary)
|
|
68
|
-
* 16 8 TotalSize (uint64)
|
|
69
|
-
* 24 4 Index (uint32)
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
private _encode(
|
|
73
|
-
header: {
|
|
74
|
-
uuid: string;
|
|
75
|
-
totalSize: number;
|
|
76
|
-
index: number;
|
|
77
|
-
},
|
|
78
|
-
bodyBytes?: Bytes,
|
|
79
|
-
): Bytes {
|
|
80
|
-
const headerBytes = new Uint8Array(28);
|
|
81
|
-
|
|
82
|
-
// UUID (0-15)
|
|
83
|
-
const uuidBytes = new Uuid(header.uuid).toBytes();
|
|
84
|
-
headerBytes.set(uuidBytes, 0);
|
|
85
|
-
|
|
86
|
-
// TotalSize (16-23), Index (24-27)
|
|
87
|
-
const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
88
|
-
headerView.setBigUint64(16, BigInt(header.totalSize), false);
|
|
89
|
-
headerView.setUint32(24, header.index, false);
|
|
90
|
-
|
|
91
|
-
return bytesConcat([headerBytes, ...(bodyBytes ? [bodyBytes] : [])]);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// -------------------------------------------------------------------
|
|
95
|
-
// Decoding
|
|
96
|
-
// -------------------------------------------------------------------
|
|
97
|
-
|
|
98
|
-
private readonly _accumulator = new LazyGcMap<
|
|
99
|
-
string,
|
|
100
|
-
{
|
|
101
|
-
totalSize: number;
|
|
102
|
-
completedSize: number;
|
|
103
|
-
chunks: (Bytes | undefined)[];
|
|
104
|
-
}
|
|
105
|
-
>({
|
|
106
|
-
gcInterval: PROTOCOL_CONFIG.GC_INTERVAL,
|
|
107
|
-
expireTime: PROTOCOL_CONFIG.EXPIRE_TIME,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* 프로토콜 인스턴스를 정리한다.
|
|
112
|
-
*
|
|
113
|
-
* 내부 청크 누적기의 GC 타이머를 해제하고 메모리를 정리한다.
|
|
114
|
-
* 프로토콜 인스턴스 사용이 끝나면 반드시 호출해야 한다.
|
|
115
|
-
*/
|
|
116
|
-
dispose(): void {
|
|
117
|
-
this._accumulator.dispose();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* 메시지 디코딩 (분할 패킷 자동 조립)
|
|
122
|
-
*/
|
|
123
|
-
decode<T extends ServiceMessage>(bytes: Bytes): ServiceMessageDecodeResult<T> {
|
|
124
|
-
if (bytes.length < 28) {
|
|
125
|
-
throw new ArgumentError("버퍼 크기가 헤더 크기보다 작습니다.", {
|
|
126
|
-
bufferSize: bytes.length,
|
|
127
|
-
minimumSize: 28,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// 1. 헤더 읽기
|
|
132
|
-
|
|
133
|
-
// UUID
|
|
134
|
-
const uuidBytes = bytes.subarray(0, 16);
|
|
135
|
-
const uuid = Uuid.fromBytes(uuidBytes).toString();
|
|
136
|
-
|
|
137
|
-
// TOTAL_SIZE, INDEX
|
|
138
|
-
const headerView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
139
|
-
const totalSize = Number(headerView.getBigUint64(16, false));
|
|
140
|
-
const index = headerView.getUint32(24, false);
|
|
141
|
-
|
|
142
|
-
// 전체 사이즈 제한 체크 (가장 먼저 수행)
|
|
143
|
-
if (totalSize > PROTOCOL_CONFIG.MAX_TOTAL_SIZE) {
|
|
144
|
-
throw new ArgumentError("메시지 크기가 제한을 초과했습니다.", {
|
|
145
|
-
totalSize,
|
|
146
|
-
maxSize: PROTOCOL_CONFIG.MAX_TOTAL_SIZE,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const bodyBytes = bytes.subarray(28);
|
|
151
|
-
|
|
152
|
-
const accItem = this._accumulator.getOrCreate(uuid, () => ({
|
|
153
|
-
totalSize,
|
|
154
|
-
completedSize: 0,
|
|
155
|
-
chunks: [],
|
|
156
|
-
}));
|
|
157
|
-
if (accItem.chunks[index] == null) {
|
|
158
|
-
// 패킷중복 방어
|
|
159
|
-
accItem.chunks[index] = bodyBytes;
|
|
160
|
-
accItem.completedSize += bodyBytes.length;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (accItem.completedSize < accItem.totalSize) {
|
|
164
|
-
return {
|
|
165
|
-
type: "progress",
|
|
166
|
-
uuid: uuid,
|
|
167
|
-
totalSize: totalSize,
|
|
168
|
-
completedSize: accItem.completedSize,
|
|
169
|
-
};
|
|
170
|
-
} else {
|
|
171
|
-
this._accumulator.delete(uuid); // 메모리 해제
|
|
172
|
-
|
|
173
|
-
const resultBytes = bytesConcat(accItem.chunks.filterExists());
|
|
174
|
-
let messageArr: [string, unknown];
|
|
175
|
-
try {
|
|
176
|
-
messageArr = jsonParse<[string, unknown]>(new TextDecoder().decode(resultBytes));
|
|
177
|
-
} catch (err) {
|
|
178
|
-
throw new ArgumentError("메시지 디코딩에 실패했습니다.", { uuid, cause: err });
|
|
179
|
-
}
|
|
180
|
-
return {
|
|
181
|
-
type: "complete",
|
|
182
|
-
uuid: uuid,
|
|
183
|
-
message: {
|
|
184
|
-
name: messageArr[0],
|
|
185
|
-
body: messageArr[1],
|
|
186
|
-
} as T,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* 메시지 디코딩 결과 타입 (유니온)
|
|
194
|
-
*
|
|
195
|
-
* - `type: "complete"`: 모든 청크가 수신되어 메시지 조립이 완료됨
|
|
196
|
-
* - `type: "progress"`: 분할 메시지 수신 중 (일부 청크만 도착)
|
|
197
|
-
*/
|
|
198
|
-
export type ServiceMessageDecodeResult<T extends ServiceMessage> =
|
|
199
|
-
| { type: "complete"; uuid: string; message: T }
|
|
200
|
-
| { type: "progress"; uuid: string; totalSize: number; completedSize: number };
|
|
1
|
+
export type { ServiceProtocol, ServiceMessageDecodeResult } from "./create-service-protocol";
|
|
2
|
+
export { createServiceProtocol } from "./create-service-protocol";
|
package/src/types.ts
CHANGED
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 이벤트 리스너 타입 정의용 추상 클래스
|
|
3
|
-
*
|
|
4
|
-
* - 상속만 하면 됨 (프로퍼티 구현 불필요)
|
|
5
|
-
* - $info, $data는 타입 추출용 (런타임 미사용)
|
|
6
|
-
* - eventName은 mangle 안전한 이벤트 식별자
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* export class SharedDataChangeEvent extends ServiceEventListener<
|
|
10
|
-
* { name: string; filter: unknown },
|
|
11
|
-
* (string | number)[] | undefined
|
|
12
|
-
* > {
|
|
13
|
-
* readonly eventName = "SharedDataChangeEvent";
|
|
14
|
-
* }
|
|
15
|
-
*
|
|
16
|
-
* // 클라이언트에서 사용
|
|
17
|
-
* await client.addEventListener(
|
|
18
|
-
* SharedDataChangeEvent,
|
|
19
|
-
* { name: "test", filter: null },
|
|
20
|
-
* (data) => console.log(data)
|
|
21
|
-
* );
|
|
22
|
-
*/
|
|
23
|
-
export abstract class ServiceEventListener<TInfo, TData> {
|
|
24
|
-
/** mangle 안전한 이벤트 식별자 (상속 시 필수 구현) */
|
|
25
|
-
abstract readonly eventName: string;
|
|
26
|
-
|
|
27
|
-
/** 타입 추출용 (런타임 미사용) */
|
|
28
|
-
declare readonly $info: TInfo;
|
|
29
|
-
declare readonly $data: TData;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
1
|
/**
|
|
33
2
|
* 파일 업로드 결과
|
|
34
3
|
*
|