@fidt/saul-server 1.0.0 → 1.2.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/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +619 -0
- package/dist/index.d.ts +619 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -6
- package/src/index.ts +0 -7
- package/tsconfig.json +0 -18
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
export * from 'hono';
|
|
3
|
+
export { Hono as WebServer } from 'hono';
|
|
4
|
+
export { cors } from 'hono/cors';
|
|
5
|
+
export { serve } from '@hono/node-server';
|
|
6
|
+
export * from 'socket.io';
|
|
7
|
+
export { createAdapter } from '@socket.io/redis-adapter';
|
|
8
|
+
export { Socket as ClientSocket, io as connectSocket } from 'socket.io-client';
|
|
9
|
+
import * as zod from 'zod';
|
|
10
|
+
export { zod as Zod };
|
|
11
|
+
import { Readable, Writable } from 'stream';
|
|
12
|
+
export * from 'zod-validation-error';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MACHINE TOKEN PROVIDER — lấy và cache token `client_credentials` cho service downstream.
|
|
16
|
+
*
|
|
17
|
+
* Đây là bản dùng chung của đoạn logic vốn nằm trong template codegen `initWsPush`
|
|
18
|
+
* (`__wsPushGetAccessToken`). Reverse tunnel cần đúng luồng đó để bắt tay với gateway, và
|
|
19
|
+
* hai bản copy của một quy trình xin token là hai cơ hội để một bên quên mất `resource`
|
|
20
|
+
* rồi nhận opaque token mà không hiểu vì sao verify JWT lại hỏng.
|
|
21
|
+
*
|
|
22
|
+
* BA THỨ QUYẾT ĐỊNH TÍNH ĐÚNG ĐẮN, không cái nào bỏ được:
|
|
23
|
+
*
|
|
24
|
+
* 1. **`resource`** — điều kiện để oidc-provider bind token vào Saul resource server và phát
|
|
25
|
+
* JWT. Thiếu nó token ra opaque, và lỗi hiện ra ở tận chỗ gateway verify JWT dưới dạng
|
|
26
|
+
* "Unexpected token ... is not valid JSON" — không có gì trỏ ngược về đây.
|
|
27
|
+
* 2. **Refresh sớm 60s** — token lấy về sát hạn sẽ chết giữa lúc đang bay tới gateway, và
|
|
28
|
+
* lỗi hiện ra dưới dạng 401 ngẫu nhiên không tái hiện được.
|
|
29
|
+
* 3. **Gộp lời gọi đồng thời** — agent dispatch song song nhiều call, nên mỗi lần token hết
|
|
30
|
+
* hạn là N request cùng lúc phát hiện điều đó. Không gộp thì identity ăn nguyên burst.
|
|
31
|
+
*/
|
|
32
|
+
interface MachineTokenOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Endpoint token OIDC (grant `client_credentials`). Bỏ trống thì đọc env
|
|
35
|
+
* `OIDC_TOKEN_URL` tại runtime — đọc lúc GỌI chứ không lúc khởi tạo, để thứ tự nạp env
|
|
36
|
+
* không biến thành lỗi cấu hình.
|
|
37
|
+
*/
|
|
38
|
+
oidcTokenUrl?: string;
|
|
39
|
+
/** Client M2M (collection `oidc_client` ở Directus). Không bake vì là secret. */
|
|
40
|
+
clientId?: string;
|
|
41
|
+
clientSecret?: string;
|
|
42
|
+
/** Resource indicator — xem lý do ở đầu file. */
|
|
43
|
+
resource?: string;
|
|
44
|
+
/** Dùng khi `resource` không được khai (thường là base URL của gateway). */
|
|
45
|
+
fallbackResource?: string;
|
|
46
|
+
/** Scope của Saul resource server. */
|
|
47
|
+
scope?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Token TĨNH có sẵn (phải là JWT). Truyền vào thì bỏ qua hoàn toàn client_credentials.
|
|
50
|
+
* Chỉ nên dùng cho demo/script — service thật nên tự xin token để còn xoay vòng được.
|
|
51
|
+
*/
|
|
52
|
+
machineToken?: string;
|
|
53
|
+
/** DI cho test: thay `fetch` thật bằng bản giả. */
|
|
54
|
+
fetchImpl?: typeof fetch;
|
|
55
|
+
}
|
|
56
|
+
declare class MachineTokenProvider {
|
|
57
|
+
private readonly options;
|
|
58
|
+
private cachedToken;
|
|
59
|
+
private expiresAt;
|
|
60
|
+
/** Lời hứa đang bay — để N lời gọi đồng thời chỉ sinh đúng một round-trip. */
|
|
61
|
+
private inflight;
|
|
62
|
+
constructor(options: MachineTokenOptions);
|
|
63
|
+
/**
|
|
64
|
+
* Token hiện hành, tự xin mới khi cần.
|
|
65
|
+
*
|
|
66
|
+
* @param forceRefresh Bỏ qua cache (dùng khi gateway vừa trả 401 — token có thể bị thu
|
|
67
|
+
* hồi trước hạn, và thử lại với đúng token đó thì chỉ 401 thêm lần nữa).
|
|
68
|
+
*/
|
|
69
|
+
getToken(forceRefresh?: boolean): Promise<string>;
|
|
70
|
+
/** Vứt cache — dùng khi đổi cấu hình hoặc muốn ép bắt tay lại từ đầu. */
|
|
71
|
+
reset(): void;
|
|
72
|
+
private requestToken;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Envelope một lời gọi gateway đẩy xuống. */
|
|
76
|
+
interface TunnelDispatchBody {
|
|
77
|
+
module: string;
|
|
78
|
+
function: string;
|
|
79
|
+
params: Record<string, any>;
|
|
80
|
+
}
|
|
81
|
+
interface TunnelAgentOptions extends MachineTokenOptions {
|
|
82
|
+
/** Base URL của gateway (vd `https://saul.example.com`). */
|
|
83
|
+
gatewayUrl: string;
|
|
84
|
+
/** Module mà service này phục vụ. Cần permission `<module>.wsAgent` cho từng cái. */
|
|
85
|
+
modules: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Định danh ổn định qua reconnect — thứ con người dùng để nhận ra "máy nào" trong màn
|
|
88
|
+
* Agents. Bỏ trống thì suy từ hostname + pid.
|
|
89
|
+
*/
|
|
90
|
+
instanceId?: string;
|
|
91
|
+
/** Khả năng tự khai, vd `['upload']`. Gateway dùng thay cho việc probe HTTP. */
|
|
92
|
+
capabilities?: string[];
|
|
93
|
+
/** Bảng dispatch của runtime — thường là `(body) => saul.dispatch(body)`. */
|
|
94
|
+
dispatch: (body: TunnelDispatchBody) => Promise<any>;
|
|
95
|
+
/** Path socket.io của gateway, nếu khác mặc định. */
|
|
96
|
+
path?: string;
|
|
97
|
+
heartbeatMs?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Kết quả lớn hơn ngần này (byte) sẽ đi qua blob store thay vì nằm trong ack. PHẢI khớp
|
|
100
|
+
* `TUNNEL_INLINE_MAX_BYTES` của gateway.
|
|
101
|
+
*/
|
|
102
|
+
inlineMaxBytes?: number;
|
|
103
|
+
/**
|
|
104
|
+
* Router `/saul/upload` của service (`createUploadSubsystem().router`).
|
|
105
|
+
*
|
|
106
|
+
* Có nó thì multipart upload chạy được qua tunnel: gateway đóng gói request HTTP, agent
|
|
107
|
+
* replay vào đúng router này. Không có thì mọi request upload trả lỗi rõ ràng thay vì
|
|
108
|
+
* timeout khó hiểu. `Saul.connectTunnel()` tự truyền vào.
|
|
109
|
+
*/
|
|
110
|
+
uploadRouter?: {
|
|
111
|
+
request(path: string, init?: any): Response | Promise<Response>;
|
|
112
|
+
};
|
|
113
|
+
/** DI cho test: thay socket.io-client bằng bản giả. */
|
|
114
|
+
socketFactory?: (url: string, options: any) => any;
|
|
115
|
+
}
|
|
116
|
+
declare class TunnelAgent {
|
|
117
|
+
private readonly options;
|
|
118
|
+
private socket?;
|
|
119
|
+
private heartbeatTimer?;
|
|
120
|
+
private readonly tokens;
|
|
121
|
+
private readonly instanceId;
|
|
122
|
+
private closed;
|
|
123
|
+
/** Id gateway cấp lúc đăng ký — blob khoá theo nó, nên phải giữ lại sau mỗi lần register. */
|
|
124
|
+
private agentId?;
|
|
125
|
+
private readonly blobBaseUrl;
|
|
126
|
+
private readonly inlineMaxBytes;
|
|
127
|
+
constructor(options: TunnelAgentOptions);
|
|
128
|
+
get connected(): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Mở kết nối và đăng ký.
|
|
131
|
+
*
|
|
132
|
+
* Trả về khi gateway đã NHẬN đăng ký. Đăng ký hỏng thì `connect()` NÉM chứ không im lặng
|
|
133
|
+
* để agent chạy tiếp: một agent tưởng mình đang phục vụ trong khi gateway không biết nó
|
|
134
|
+
* tồn tại là chế độ hỏng không quan sát được — mọi lời gọi trả `AGENT_OFFLINE` còn service
|
|
135
|
+
* thì nằm đó khoẻ mạnh.
|
|
136
|
+
*/
|
|
137
|
+
connect(): Promise<void>;
|
|
138
|
+
/** Ngắt kết nối và dừng heartbeat. */
|
|
139
|
+
close(): Promise<void>;
|
|
140
|
+
/** Khai module với gateway; ném khi không module nào được nhận. */
|
|
141
|
+
private register;
|
|
142
|
+
/** Chạy một lời gọi và ack lại đúng envelope mà gateway đọc. */
|
|
143
|
+
private handleDispatch;
|
|
144
|
+
/**
|
|
145
|
+
* Replay một request multipart upload vào chính Hono router `/saul/upload` của service.
|
|
146
|
+
*
|
|
147
|
+
* KHÔNG dựng lại logic upload ở đây: registry, part store, mã lỗi, ràng buộc kích thước —
|
|
148
|
+
* tất cả đã nằm trong router đó và đã được test. Đóng gói lại thành một `Request` rồi gọi
|
|
149
|
+
* `router.request()` cho ta dùng nguyên bộ ấy, và giữ cho đường HTTP với đường tunnel
|
|
150
|
+
* không thể trôi lệch hành vi.
|
|
151
|
+
*/
|
|
152
|
+
private handleUpload;
|
|
153
|
+
/**
|
|
154
|
+
* Kéo một blob payload về từ gateway.
|
|
155
|
+
*
|
|
156
|
+
* AGENT là bên gọi ra (outbound HTTPS), y như khi mở socket — gateway không bao giờ dial
|
|
157
|
+
* vào đây. Đó là điều giữ cho mô hình vẫn riêng tư dù dữ liệu lớn đi đường HTTP.
|
|
158
|
+
*/
|
|
159
|
+
private pullBlob;
|
|
160
|
+
/** Đẩy kết quả lớn lên gateway, trả về id để gửi trong ack. */
|
|
161
|
+
private pushBlob;
|
|
162
|
+
private fetchWithToken;
|
|
163
|
+
private startHeartbeat;
|
|
164
|
+
private stopHeartbeat;
|
|
165
|
+
private emitWithAck;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
declare class File {
|
|
169
|
+
private content;
|
|
170
|
+
metadata: {
|
|
171
|
+
name: string;
|
|
172
|
+
size: number;
|
|
173
|
+
type: string;
|
|
174
|
+
};
|
|
175
|
+
constructor(meta?: {
|
|
176
|
+
name: string;
|
|
177
|
+
size: number;
|
|
178
|
+
type: string;
|
|
179
|
+
});
|
|
180
|
+
private encodeBase64;
|
|
181
|
+
private decodeBase64;
|
|
182
|
+
/**
|
|
183
|
+
* Đọc tất cả nội dụng của file
|
|
184
|
+
*/
|
|
185
|
+
read(): ArrayBuffer;
|
|
186
|
+
/**
|
|
187
|
+
* Ghi nội dung vào file
|
|
188
|
+
*/
|
|
189
|
+
write(buffer: ArrayBuffer): void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* FILE DẠNG STREAM — cho file mà cả nội dung KHÔNG được phép nằm trong RAM.
|
|
194
|
+
*
|
|
195
|
+
* Khác `File` (buffered, giữ base64 trong bộ nhớ, trần vài chục MB), `FileStream` chỉ giữ
|
|
196
|
+
* metadata + một hàm mở nguồn. Byte chỉ chảy khi có người đọc, nên RAM là hằng số bất kể
|
|
197
|
+
* file 8MB hay 8GB.
|
|
198
|
+
*
|
|
199
|
+
* Dùng được ở CẢ HAI vị trí trong definition:
|
|
200
|
+
* - param → chiều lên: multipart upload, gateway/downstream đưa `Readable` vào đây.
|
|
201
|
+
* - returnType → chiều xuống: handler trả về, runtime pipe thẳng ra browser.
|
|
202
|
+
*
|
|
203
|
+
* Khai kiểu nào là chọn hợp đồng đó. Transport KHÔNG được đánh tráo `File` thành
|
|
204
|
+
* `FileStream` hay ngược lại — một param có shape phụ thuộc transport là thứ tuyệt đối
|
|
205
|
+
* không được xảy ra.
|
|
206
|
+
*/
|
|
207
|
+
/** Brand để nhận diện qua ranh giới package — xem `FileStream.is`. */
|
|
208
|
+
declare const FILE_STREAM_BRAND: unique symbol;
|
|
209
|
+
interface FileStreamMetadata {
|
|
210
|
+
name: string;
|
|
211
|
+
/** Kích thước KHAI BÁO. `0` = không biết trước (dữ liệu sinh on-the-fly). */
|
|
212
|
+
size: number;
|
|
213
|
+
type: string;
|
|
214
|
+
}
|
|
215
|
+
declare class FileStream {
|
|
216
|
+
/** @internal Đánh dấu instance để `is()` nhận ra qua nhiều bản copy của package. */
|
|
217
|
+
readonly [FILE_STREAM_BRAND]: true;
|
|
218
|
+
readonly metadata: FileStreamMetadata;
|
|
219
|
+
private readonly source;
|
|
220
|
+
private consumed;
|
|
221
|
+
constructor(metadata: FileStreamMetadata, source: () => Readable);
|
|
222
|
+
/**
|
|
223
|
+
* Mở nguồn và trả về `Readable`.
|
|
224
|
+
*
|
|
225
|
+
* Gọi lần thứ hai sẽ NÉM. Một stream không phải là value: đọc hai lần thì lần thứ hai
|
|
226
|
+
* hoặc rỗng, hoặc mở lại nguồn và trả về dữ liệu đã cũ — cả hai đều là bug im lặng.
|
|
227
|
+
* Cần đọc nhiều lần thì `buffer()` một lần rồi tự giữ.
|
|
228
|
+
*/
|
|
229
|
+
stream(): Readable;
|
|
230
|
+
/** Đã có ai đọc chưa — cho runtime biết có phải drain trước khi trả response không. */
|
|
231
|
+
get isConsumed(): boolean;
|
|
232
|
+
/** Đẩy toàn bộ nội dung vào một `Writable`, chờ tới khi xong. */
|
|
233
|
+
pipeTo(destination: Writable): Promise<void>;
|
|
234
|
+
/**
|
|
235
|
+
* Đọc hết vào một `Buffer`.
|
|
236
|
+
*
|
|
237
|
+
* `maxBytes` là BẮT BUỘC, không có mặc định: đây là escape hatch duy nhất ra khỏi mô hình
|
|
238
|
+
* streaming, và người dùng nó phải tự nói ra trần mình chấp nhận. Một `buffer()` không
|
|
239
|
+
* trần đặt giữa đường xử lý file 3GB sẽ giết process — mà chỉ giết trong production, với
|
|
240
|
+
* file lớn, chứ không bao giờ trong test.
|
|
241
|
+
*/
|
|
242
|
+
buffer(maxBytes: number): Promise<Buffer>;
|
|
243
|
+
/**
|
|
244
|
+
* Có phải `FileStream` không.
|
|
245
|
+
*
|
|
246
|
+
* Dùng brand chứ KHÔNG dùng `instanceof`: gateway và downstream có thể resolve hai bản
|
|
247
|
+
* copy khác nhau của `@fidt/saul-server` (dual-package hazard), lúc đó `instanceof` trả
|
|
248
|
+
* `false` cho một object hoàn toàn hợp lệ — và nó hỏng theo kiểu chỉ lộ ra khi deploy
|
|
249
|
+
* thật, không bao giờ trong monorepo.
|
|
250
|
+
*/
|
|
251
|
+
static is(value: unknown): value is FileStream;
|
|
252
|
+
/** Dựng từ một `Buffer` sẵn có — tiện cho test và cho handler trả dữ liệu nhỏ. */
|
|
253
|
+
static fromBuffer(metadata: Omit<FileStreamMetadata, 'size'> & {
|
|
254
|
+
size?: number;
|
|
255
|
+
}, data: Buffer): FileStream;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* SaulException — lỗi NGHIỆP VỤ mà handler chủ động ném để từ chối một lời gọi.
|
|
260
|
+
*
|
|
261
|
+
* Khác `Error` trần ở đúng hai điểm, và cả hai đều là lý do nó tồn tại:
|
|
262
|
+
*
|
|
263
|
+
* 1. **Có `code`** đi qua được dây. Envelope lỗi của downstream mang cả `message` lẫn
|
|
264
|
+
* `code`, gateway map sang ack `{ ok:false, error, code }`, client dựng lại thành
|
|
265
|
+
* `SaulException` — nên client bắt được lỗi theo mã chứ không phải so khớp chuỗi tiếng
|
|
266
|
+
* Việt. `Error` trần chỉ còn lại `message` sau chuyến đi.
|
|
267
|
+
* 2. **Là ý ĐỊNH từ chối, không phải sự cố.** Gateway log `Error` lạ như một lỗi server,
|
|
268
|
+
* còn `SaulException` là câu trả lời hợp lệ ("bạn không được sub chat của người khác")
|
|
269
|
+
* nên không cần ồn ào trong log.
|
|
270
|
+
*
|
|
271
|
+
* Chỗ dùng chính là validator của subscription: controller `sub<Model>` ném `SaulException`
|
|
272
|
+
* để từ chối, và cái ném đó nổi lên đúng chỗ client đang `catch` quanh vòng `for await`.
|
|
273
|
+
*/
|
|
274
|
+
declare class SaulException extends Error {
|
|
275
|
+
/** Mã ổn định để client rẽ nhánh. Mặc định là mã chung của một từ chối nghiệp vụ. */
|
|
276
|
+
readonly code: string;
|
|
277
|
+
constructor(message: string, code?: string);
|
|
278
|
+
/**
|
|
279
|
+
* Nhận diện KHÔNG dùng `instanceof`.
|
|
280
|
+
*
|
|
281
|
+
* Gateway, downstream và SDK có thể resolve ba bản copy vật lý khác nhau của package
|
|
282
|
+
* này (pnpm workspace + code sinh ra được copy vào từng service), nên `instanceof` sẽ
|
|
283
|
+
* trả false cho một SaulException hoàn toàn thật. Cùng lý do với `FileStream.is`.
|
|
284
|
+
*/
|
|
285
|
+
static is(value: unknown): value is SaulException;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* PREDICATE — điều kiện lọc mà client khai khi mở một subscription.
|
|
290
|
+
*
|
|
291
|
+
* File này CHỈ có kiểu, không có runtime: việc đánh giá predicate xảy ra ở GATEWAY (nó giữ
|
|
292
|
+
* socket và biết ai đang nghe gì), không ở downstream. Downstream chỉ cần đọc và thu hẹp
|
|
293
|
+
* predicate trong validator `sub<Model>` rồi trả về.
|
|
294
|
+
*
|
|
295
|
+
* Cú pháp `$`-prefix, cố ý KHÁC filter authz kiểu Directus (`_eq`, `_or`) ở
|
|
296
|
+
* `Permission.filter`. Hai thứ trả lời hai câu hỏi khác nhau — filter authz nói "được thấy
|
|
297
|
+
* BẢN GHI nào" và được forward xuống query storage; predicate nói "muốn nhận EVENT nào" và
|
|
298
|
+
* được Saul tự đánh giá in-memory. Trộn cú pháp sẽ khiến người đọc tưởng chúng thay nhau được.
|
|
299
|
+
*/
|
|
300
|
+
/** So sánh trên một field. Nhiều toán tử trong cùng object thì AND với nhau. */
|
|
301
|
+
interface PredicateComparison<V> {
|
|
302
|
+
$eq?: V;
|
|
303
|
+
$ne?: V;
|
|
304
|
+
$gt?: V;
|
|
305
|
+
$gte?: V;
|
|
306
|
+
$lt?: V;
|
|
307
|
+
$lte?: V;
|
|
308
|
+
$in?: V[];
|
|
309
|
+
$nin?: V[];
|
|
310
|
+
/** Field có mặt trong payload hay không — nói về SỰ CÓ MẶT, không phải giá trị. */
|
|
311
|
+
$exists?: boolean;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Predicate trên một Model.
|
|
315
|
+
*
|
|
316
|
+
* Giá trị trần là shorthand của `$eq`: `{ chatId: 12 }` ≡ `{ chatId: { $eq: 12 } }`.
|
|
317
|
+
* Các key cùng cấp AND với nhau, nên `{}` khớp TẤT CẢ — đó là lý do validator `sub<Model>`
|
|
318
|
+
* mặc định TỪ CHỐI, và là lý do nên thu hẹp predicate thay vì chỉ kiểm rồi trả nguyên.
|
|
319
|
+
*
|
|
320
|
+
* KHÔNG có `$regex`: nó là đường ReDoS do client tự khai, chạy ở đường nóng của mọi lần push.
|
|
321
|
+
*/
|
|
322
|
+
type Predicate<T> = {
|
|
323
|
+
[K in keyof T]?: T[K] | PredicateComparison<T[K]>;
|
|
324
|
+
} & {
|
|
325
|
+
$and?: Predicate<T>[];
|
|
326
|
+
$or?: Predicate<T>[];
|
|
327
|
+
$not?: Predicate<T>;
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* Một subscription đang mở, nhìn từ phía server.
|
|
331
|
+
*
|
|
332
|
+
* `id` do gateway sinh TRƯỚC khi gọi validator và truyền xuống qua `ctx.subId`, nên
|
|
333
|
+
* validator lưu lại được để về sau `sub<Model>.unsub(id)` thu hồi (vd user bị mời khỏi
|
|
334
|
+
* phòng chat trong lúc đang nghe).
|
|
335
|
+
*/
|
|
336
|
+
interface Sub<T> {
|
|
337
|
+
id: string;
|
|
338
|
+
predicate: Predicate<T>;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Gộp nhiều predicate bằng AND — cách chuẩn để validator THU HẸP thứ client xin.
|
|
342
|
+
*
|
|
343
|
+
* ```ts
|
|
344
|
+
* return narrow(predicate, { userId: { $eq: ctx.principal.sub } })
|
|
345
|
+
* ```
|
|
346
|
+
*
|
|
347
|
+
* Dùng `$and` thay vì trải phẳng bằng spread: spread sẽ để điều kiện sau GHI ĐÈ điều kiện
|
|
348
|
+
* trước trên cùng một field, tức là client khai `{ userId: { $eq: 'ai-đó' } }` rồi ràng
|
|
349
|
+
* buộc bảo mật ghi đè lên — đúng ý. Nhưng ở chiều ngược lại (`{...cứng, ...client}`) thì
|
|
350
|
+
* client ghi đè ràng buộc bảo mật. `$and` không có chiều nào để nhầm: mọi nhánh đều phải thoả.
|
|
351
|
+
*/
|
|
352
|
+
declare const narrow: <T>(...predicates: (Predicate<T> | null | undefined)[]) => Predicate<T>;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* LỖI CỦA CHUNK STORE, có mã máy-đọc-được.
|
|
356
|
+
*
|
|
357
|
+
* Cùng kiểu với `BinaryStreamError` của đường WebSocket: mỗi tình huống một `code`, và mã
|
|
358
|
+
* đó — chứ không phải chuỗi message — là thứ tầng trên map sang HTTP status. Một bảng map
|
|
359
|
+
* duy nhất, không handler nào tự viết status.
|
|
360
|
+
*/
|
|
361
|
+
type UploadErrorCode = 'UPLOAD_NOT_FOUND' | 'UPLOAD_EXISTS' | 'UPLOAD_EXPIRED' | 'UPLOAD_COMPLETED'
|
|
362
|
+
/** Đang complete dở — một lời gọi complete khác đã chiếm chỗ. */
|
|
363
|
+
| 'UPLOAD_BUSY' | 'PART_OUT_OF_RANGE' | 'PART_IN_FLIGHT' | 'PART_TOO_LARGE' | 'PART_MISSING' | 'SIZE_MISMATCH' | 'CHECKSUM_MISMATCH' | 'DISK_BUDGET' | 'FORBIDDEN';
|
|
364
|
+
declare class UploadError extends Error {
|
|
365
|
+
readonly code: UploadErrorCode;
|
|
366
|
+
constructor(code: UploadErrorCode, message: string);
|
|
367
|
+
static is(value: unknown): value is UploadError;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* MÃ LỖI → HTTP STATUS. Bảng DUY NHẤT, và nó phải phủ hết `UploadErrorCode`.
|
|
371
|
+
*
|
|
372
|
+
* `Record<UploadErrorCode, number>` cố ý không optional: thêm một mã mới mà quên map thì
|
|
373
|
+
* TypeScript báo ngay, thay vì lặng lẽ trả 500 cho một lỗi vốn có nghĩa rõ ràng.
|
|
374
|
+
*/
|
|
375
|
+
declare const UPLOAD_ERROR_STATUS: Record<UploadErrorCode, number>;
|
|
376
|
+
declare const uploadErrorStatus: (code: UploadErrorCode) => number;
|
|
377
|
+
|
|
378
|
+
interface WritePartResult {
|
|
379
|
+
size: number;
|
|
380
|
+
/** sha256 hex của part — client dùng để retry có kiểm chứng. */
|
|
381
|
+
etag: string;
|
|
382
|
+
}
|
|
383
|
+
interface PartInfo {
|
|
384
|
+
partNumber: number;
|
|
385
|
+
size: number;
|
|
386
|
+
}
|
|
387
|
+
interface PartDiskStoreOptions {
|
|
388
|
+
/** Thư mục gốc. Mỗi upload một thư mục con. */
|
|
389
|
+
rootDir: string;
|
|
390
|
+
/** fsync mỗi part trước khi rename. Tắt = đổi durability-khi-crash lấy throughput. */
|
|
391
|
+
fsync?: boolean;
|
|
392
|
+
}
|
|
393
|
+
declare class PartDiskStore {
|
|
394
|
+
private readonly rootDir;
|
|
395
|
+
private readonly fsync;
|
|
396
|
+
constructor(options: PartDiskStoreOptions);
|
|
397
|
+
/**
|
|
398
|
+
* Thư mục của một upload.
|
|
399
|
+
*
|
|
400
|
+
* `assertSafeUploadId` chạy TRƯỚC `join` — đây là bề mặt path-traversal duy nhất của
|
|
401
|
+
* cả tính năng, và `join('/tmp', '../../etc')` là một đường dẫn hoàn toàn hợp lệ.
|
|
402
|
+
*/
|
|
403
|
+
dirFor(uploadId: string): string;
|
|
404
|
+
private pathFor;
|
|
405
|
+
ensureDir(uploadId: string): Promise<void>;
|
|
406
|
+
/**
|
|
407
|
+
* Ghi một part. Trả về số byte thật và sha256.
|
|
408
|
+
*
|
|
409
|
+
* Bốn bước, thứ tự bắt buộc:
|
|
410
|
+
* 1. `open(tmp, 'wx')` — hai PUT cùng part cùng lúc thì một cái thua ngay, thay vì
|
|
411
|
+
* hai writer xen kẽ vào cùng một file.
|
|
412
|
+
* 2. pipeline có đếm byte + hash — vượt trần là abort GIỮA CHỪNG, không đọc hết rồi
|
|
413
|
+
* mới than.
|
|
414
|
+
* 3. fsync (nếu bật) rồi `rename` — rename trong cùng filesystem là atomic, nên
|
|
415
|
+
* file `.part` xuất hiện là đã hoàn chỉnh, không có trạng thái nửa vời.
|
|
416
|
+
* 4. Chỉ khi rename xong mới coi như part tồn tại.
|
|
417
|
+
*/
|
|
418
|
+
writePart(uploadId: string, partNumber: number, body: Readable, opts: {
|
|
419
|
+
maxBytes: number;
|
|
420
|
+
partCount: number;
|
|
421
|
+
declaredLength?: number;
|
|
422
|
+
expectedSha256?: string;
|
|
423
|
+
}): Promise<WritePartResult>;
|
|
424
|
+
/** Mở một part để đọc. Dùng bởi `concatParts`. */
|
|
425
|
+
createPartStream(uploadId: string, partNumber: number, partCount: number): Readable;
|
|
426
|
+
/** Các part ĐANG CÓ THẬT trên đĩa (không tính `.tmp`). */
|
|
427
|
+
listParts(uploadId: string): Promise<PartInfo[]>;
|
|
428
|
+
/** Xoá cả thư mục upload. Trả về số byte giải phóng. Idempotent. */
|
|
429
|
+
removeUpload(uploadId: string): Promise<number>;
|
|
430
|
+
/** Thư mục cũ hơn `olderThanMs` — ứng viên dọn rác. Xem `UploadSweeper`. */
|
|
431
|
+
listStaleDirs(olderThanMs: number, now?: number): Promise<string[]>;
|
|
432
|
+
/** Tổng byte đang chiếm — để đối chiếu lại counter, vốn trôi sau mỗi lần crash. */
|
|
433
|
+
totalBytes(): Promise<number>;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* `uploadId` phải là UUID. Kiểm TRƯỚC mọi thao tác đường dẫn.
|
|
437
|
+
*
|
|
438
|
+
* Không dùng blacklist (`..`, `/`): danh sách cấm luôn thiếu một dạng nào đó
|
|
439
|
+
* (`%2e%2e`, null byte, tên dài quá giới hạn OS). Whitelist một hình dạng đã biết thì không.
|
|
440
|
+
*/
|
|
441
|
+
declare function assertSafeUploadId(uploadId: string): void;
|
|
442
|
+
/** `partNumber` phải là số nguyên trong `1..partCount`. */
|
|
443
|
+
declare function assertSafePartNumber(partNumber: number, partCount: number): void;
|
|
444
|
+
|
|
445
|
+
type UploadState = 'open' | 'completing' | 'completed';
|
|
446
|
+
interface UploadRecord {
|
|
447
|
+
uploadId: string;
|
|
448
|
+
module: string;
|
|
449
|
+
function: string;
|
|
450
|
+
/** Tên param kiểu FileStream của function đích. */
|
|
451
|
+
param: string;
|
|
452
|
+
/** Các param KHÔNG phải file của cùng lời gọi. */
|
|
453
|
+
params: Record<string, unknown>;
|
|
454
|
+
file: {
|
|
455
|
+
name: string;
|
|
456
|
+
size: number;
|
|
457
|
+
type: string;
|
|
458
|
+
};
|
|
459
|
+
chunkSize: number;
|
|
460
|
+
partCount: number;
|
|
461
|
+
/** Deadline TUYỆT ĐỐI do gateway quyết, không bao giờ gia hạn. */
|
|
462
|
+
hardExpiresAt: number;
|
|
463
|
+
/** Deadline TRƯỢT, gia hạn mỗi lần nhận được part. */
|
|
464
|
+
expiresAt: number;
|
|
465
|
+
idleTimeoutMs: number;
|
|
466
|
+
state: UploadState;
|
|
467
|
+
createdAt: number;
|
|
468
|
+
}
|
|
469
|
+
interface UploadRegistryOptions {
|
|
470
|
+
store: PartDiskStore;
|
|
471
|
+
/** Ngân sách đĩa của MÁY NÀY. Vượt thì từ chối init. */
|
|
472
|
+
maxDiskBytes: number;
|
|
473
|
+
}
|
|
474
|
+
declare class UploadRegistry {
|
|
475
|
+
private readonly records;
|
|
476
|
+
private readonly store;
|
|
477
|
+
private readonly maxDiskBytes;
|
|
478
|
+
/**
|
|
479
|
+
* Byte đang chiếm, đếm tăng dần. Đây là con số ƯỚC LƯỢNG — nó trôi sau mỗi lần crash,
|
|
480
|
+
* nên `UploadSweeper` định kỳ tính lại bằng `du` thật. Counter trôi mà không ai sửa thì
|
|
481
|
+
* ngân sách âm thầm khoá mọi upload, và không có gì trong log nói tại sao.
|
|
482
|
+
*/
|
|
483
|
+
private usedBytes;
|
|
484
|
+
constructor(options: UploadRegistryOptions);
|
|
485
|
+
get diskUsedBytes(): number;
|
|
486
|
+
/** Đặt lại bộ đếm sau khi đối chiếu với đĩa thật. Xem `UploadSweeper`. */
|
|
487
|
+
setDiskUsedBytes(bytes: number): void;
|
|
488
|
+
init(record: Omit<UploadRecord, 'state' | 'createdAt' | 'expiresAt'>): Promise<UploadRecord>;
|
|
489
|
+
/**
|
|
490
|
+
* Record của một upload đang MỞ được.
|
|
491
|
+
*
|
|
492
|
+
* Hết hạn thì ném `UPLOAD_EXPIRED` chứ không `UPLOAD_NOT_FOUND`: hai thứ đó nói với
|
|
493
|
+
* client hai điều khác nhau — "id sai, đừng thử nữa" và "chậm quá, làm lại từ init".
|
|
494
|
+
*/
|
|
495
|
+
get(uploadId: string, allowed?: UploadState[]): UploadRecord;
|
|
496
|
+
/** Có record nào không, bất kể trạng thái — cho abort idempotent và cho sweeper. */
|
|
497
|
+
peek(uploadId: string): UploadRecord | undefined;
|
|
498
|
+
list(): UploadRecord[];
|
|
499
|
+
/**
|
|
500
|
+
* Gia hạn deadline sau khi nhận được một part, và cộng chênh lệch byte vào bộ đếm.
|
|
501
|
+
*
|
|
502
|
+
* `delta` chứ không phải `size`: PUT lại một part là GHI ĐÈ, nên phần tăng thêm chỉ là
|
|
503
|
+
* hiệu số. Cộng nguyên `size` mỗi lần retry sẽ thổi phồng bộ đếm cho tới khi ngân sách
|
|
504
|
+
* khoá cả những upload hoàn toàn bình thường.
|
|
505
|
+
*/
|
|
506
|
+
touch(uploadId: string, deltaBytes: number): void;
|
|
507
|
+
/**
|
|
508
|
+
* Chuyển sang `completing`, một lần duy nhất.
|
|
509
|
+
*
|
|
510
|
+
* Đây là chốt chống complete đồng thời: hai request cùng lúc thì cái thứ hai thấy state
|
|
511
|
+
* đã đổi và nhận `UPLOAD_BUSY`, thay vì cả hai cùng ghép file và cùng gọi handler.
|
|
512
|
+
*/
|
|
513
|
+
beginComplete(uploadId: string): UploadRecord;
|
|
514
|
+
/** Quay lại `open` khi complete thất bại — cho client retry mà không mất part đã gửi. */
|
|
515
|
+
rollbackComplete(uploadId: string): void;
|
|
516
|
+
/** Xoá record + part trên đĩa. Idempotent. */
|
|
517
|
+
remove(uploadId: string): Promise<void>;
|
|
518
|
+
/**
|
|
519
|
+
* Ghi `meta.json` cạnh part.
|
|
520
|
+
*
|
|
521
|
+
* Ghi tmp rồi rename để không bao giờ có một `meta.json` cụt: sweeper đọc phải file cụt
|
|
522
|
+
* sẽ coi thư mục là rác và xoá một upload đang chạy dở.
|
|
523
|
+
*/
|
|
524
|
+
private persist;
|
|
525
|
+
/** Đọc `meta.json` của một thư mục — cho sweeper phân biệt upload sống với rác. */
|
|
526
|
+
readPersisted(uploadId: string): Promise<UploadRecord | undefined>;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* DỌN RÁC ĐỊNH KỲ cho chunk store.
|
|
531
|
+
*
|
|
532
|
+
* Ba việc, và việc thứ hai/thứ ba mới là lý do tồn tại của nó: một `setTimeout` per-upload
|
|
533
|
+
* (kiểu `BinaryStreamBuffer` đang dùng cho WS) chỉ dọn được thứ nó biết. Process chết giữa
|
|
534
|
+
* chừng thì timer chết theo, còn file thì ở lại đĩa mãi mãi.
|
|
535
|
+
*
|
|
536
|
+
* 1. Upload quá hạn → xoá record + part.
|
|
537
|
+
* 2. Thư mục KHÔNG có record trong RAM và đã cũ → rác của một process đã chết, xoá.
|
|
538
|
+
* 3. Định kỳ đối chiếu bộ đếm byte với đĩa thật → counter trôi sau crash, mà counter trôi
|
|
539
|
+
* thì ngân sách âm thầm khoá mọi upload và không có gì trong log nói tại sao.
|
|
540
|
+
*/
|
|
541
|
+
interface UploadSweeperOptions {
|
|
542
|
+
registry: UploadRegistry;
|
|
543
|
+
store: PartDiskStore;
|
|
544
|
+
intervalMs?: number;
|
|
545
|
+
/** Thư mục mồ côi phải cũ hơn ngần này mới xoá. Mặc định 2× idle timeout dài nhất. */
|
|
546
|
+
orphanGraceMs?: number;
|
|
547
|
+
/** Bao nhiêu tick thì đối chiếu lại bộ đếm với đĩa (quét `du` không rẻ). */
|
|
548
|
+
reconcileEvery?: number;
|
|
549
|
+
}
|
|
550
|
+
interface SweepResult {
|
|
551
|
+
expired: number;
|
|
552
|
+
orphans: number;
|
|
553
|
+
reconciledBytes?: number;
|
|
554
|
+
}
|
|
555
|
+
declare class UploadSweeper {
|
|
556
|
+
private readonly registry;
|
|
557
|
+
private readonly store;
|
|
558
|
+
private readonly intervalMs;
|
|
559
|
+
private readonly orphanGraceMs;
|
|
560
|
+
private readonly reconcileEvery;
|
|
561
|
+
private timer?;
|
|
562
|
+
private ticks;
|
|
563
|
+
constructor(options: UploadSweeperOptions);
|
|
564
|
+
/** Bắt đầu quét. `unref()` để timer dọn dẹp không giữ process sống. */
|
|
565
|
+
start(): void;
|
|
566
|
+
stop(): void;
|
|
567
|
+
sweep(now?: number): Promise<SweepResult>;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* NỐI CÁC PART thành MỘT `Readable`, không buffer.
|
|
572
|
+
*
|
|
573
|
+
* `Readable.from(async generator)` cho backpressure đúng miễn phí: generator chỉ chạy tiếp
|
|
574
|
+
* khi consumer kéo, nên tốc độ đọc đĩa tự khớp tốc độ ghi ra downstream. Và vì generator
|
|
575
|
+
* chỉ mở part khi tới lượt, ở mọi thời điểm chỉ có **một** fd đang mở — RAM là
|
|
576
|
+
* `highWaterMark`, không phụ thuộc file 8MB hay 8GB.
|
|
577
|
+
*
|
|
578
|
+
* `finally { rs.destroy() }` không phải cho đường thành công (stream tự đóng khi hết) mà cho
|
|
579
|
+
* đường HUỶ: consumer `destroy()` giữa chừng sẽ ném vào generator, và không có `finally` thì
|
|
580
|
+
* part đang đọc dở giữ fd tới tận lúc GC.
|
|
581
|
+
*/
|
|
582
|
+
declare function concatParts(store: PartDiskStore, uploadId: string, partNumbers: number[], partCount: number): Readable;
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* SUB-ROUTER `/saul/upload/*` — phía DOWNSTREAM của multipart upload.
|
|
586
|
+
*
|
|
587
|
+
* Gateway không lưu byte nào; nó bơm thẳng từng part sang đây. Nên đây mới là nơi part nằm
|
|
588
|
+
* trên đĩa và được ghép lại lúc complete.
|
|
589
|
+
*
|
|
590
|
+
* Router này KHÔNG tự gác quyền. Nó tin gateway, đúng như `POST /saul` đang tin — quyền đã
|
|
591
|
+
* được chốt ở `/upload/init` của gateway, trước byte đầu tiên. Đặt thêm một tầng authz ở đây
|
|
592
|
+
* mà không có token của user thì cũng chỉ là kiểm lại chính mình.
|
|
593
|
+
*
|
|
594
|
+
* Chỉ ba biến env, và cả ba đều nói về TÀI NGUYÊN CỦA MÁY NÀY. Mọi thứ thuộc về chính sách
|
|
595
|
+
* (deadline, chunk size, partCount) do gateway truyền xuống trong lời gọi init.
|
|
596
|
+
*/
|
|
597
|
+
interface UploadRouterOptions {
|
|
598
|
+
/** Gọi hàm đích sau khi đã ghép file. Runtime truyền `dispatch` của nó vào đây. */
|
|
599
|
+
dispatch: (body: {
|
|
600
|
+
module: string;
|
|
601
|
+
function: string;
|
|
602
|
+
params: Record<string, unknown>;
|
|
603
|
+
}) => Promise<unknown>;
|
|
604
|
+
rootDir?: string;
|
|
605
|
+
maxDiskBytes?: number;
|
|
606
|
+
fsync?: boolean;
|
|
607
|
+
/** Trần cứng một part. Gateway cũng gác, nhưng ở đây mới đếm byte THẬT. */
|
|
608
|
+
maxPartBytes?: number;
|
|
609
|
+
sweepIntervalMs?: number;
|
|
610
|
+
}
|
|
611
|
+
interface UploadSubsystem {
|
|
612
|
+
router: Hono;
|
|
613
|
+
registry: UploadRegistry;
|
|
614
|
+
store: PartDiskStore;
|
|
615
|
+
sweeper: UploadSweeper;
|
|
616
|
+
}
|
|
617
|
+
declare function createUploadSubsystem(options: UploadRouterOptions): UploadSubsystem;
|
|
618
|
+
|
|
619
|
+
export { File, FileStream, type FileStreamMetadata, type MachineTokenOptions, MachineTokenProvider, PartDiskStore, type PartDiskStoreOptions, type PartInfo, type Predicate, type PredicateComparison, SaulException, type Sub, type SweepResult, TunnelAgent, type TunnelAgentOptions, type TunnelDispatchBody, UPLOAD_ERROR_STATUS, UploadError, type UploadErrorCode, type UploadRecord, UploadRegistry, type UploadRegistryOptions, type UploadRouterOptions, type UploadState, type UploadSubsystem, UploadSweeper, type UploadSweeperOptions, type WritePartResult, assertSafePartNumber, assertSafeUploadId, concatParts, createUploadSubsystem, narrow, uploadErrorStatus };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import {Hono}from'hono';export*from'hono';export{Hono as WebServer}from'hono';export{cors}from'hono/cors';export{serve}from'@hono/node-server';export*from'socket.io';export{createAdapter}from'@socket.io/redis-adapter';import {io}from'socket.io-client';export{io as connectSocket}from'socket.io-client';import {hostname,tmpdir}from'os';import*as zod from'zod';export{zod as Zod };import {Readable,Transform}from'stream';import {pipeline}from'stream/promises';import {createHash}from'crypto';import {createReadStream}from'fs';import {mkdir,open,unlink,rename,readdir,stat,rm,writeFile,readFile}from'fs/promises';import {join}from'path';export*from'zod-validation-error';// @fidt/saul-server - A server library for the Saul framework
|
|
2
|
+
var T=class{constructor(e){this.options=e;}options;cachedToken=null;expiresAt=0;inflight=null;async getToken(e=false){return this.options.machineToken?this.options.machineToken:!e&&this.cachedToken&&Date.now()<this.expiresAt?this.cachedToken:this.inflight?this.inflight:(this.inflight=this.requestToken().finally(()=>{this.inflight=null;}),this.inflight)}reset(){this.cachedToken=null,this.expiresAt=0;}async requestToken(){let{clientId:e,clientSecret:t}=this.options,r=this.options.oidcTokenUrl||N(),n=[];if(r||n.push("oidcTokenUrl (ho\u1EB7c env OIDC_TOKEN_URL)"),e||n.push("clientId"),t||n.push("clientSecret"),n.length)throw new Error(`Machine token thi\u1EBFu c\u1EA5u h\xECnh: ${n.join(", ")}`);let s=this.options.resource||this.options.fallbackResource||"",o=new URLSearchParams({grant_type:"client_credentials",client_id:e,client_secret:t,scope:this.options.scope??"access",resource:s}).toString(),d=await(this.options.fetchImpl??F())(r,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:o}),c=await d.json().catch(()=>{});if(!d.ok){let f=c&&(c.error_description||c.error)||"kh\xF4ng r\xF5 nguy\xEAn nh\xE2n";throw new Error(`OIDC token request th\u1EA5t b\u1EA1i (${d.status}): ${f}`)}if(!c||typeof c.access_token!="string")throw new Error("OIDC token response kh\xF4ng c\xF3 access_token");let p=typeof c.expires_in=="number"?c.expires_in:300;return this.cachedToken=c.access_token,this.expiresAt=Date.now()+Math.max(p*1e3-6e4,0),this.cachedToken}},N=()=>globalThis.process?.env?.OIDC_TOKEN_URL,F=()=>{let a=globalThis.fetch;if(typeof a!="function")throw new Error("FETCH_UNAVAILABLE: runtime kh\xF4ng c\xF3 global fetch (c\u1EA7n Node >= 18)");return a};var E=class a extends Error{code;constructor(e,t="SAUL_EXCEPTION"){super(e),this.name="SaulException",this.code=t,Object.setPrototypeOf(this,new.target.prototype);}static is(e){return e instanceof a||e instanceof Error&&e.name==="SaulException"}};var H="saul:agent:dispatch",G="saul:agent:register",j="saul:agent:heartbeat",q="saul:agent:upload",K=1e4,V=256*1024,D=class{constructor(e){this.options=e;this.tokens=new T({...e,fallbackResource:e.fallbackResource??e.gatewayUrl}),this.instanceId=e.instanceId?.trim()||`${hostname()}-${process.pid}`,this.blobBaseUrl=e.gatewayUrl.replace(/\/+$/,""),this.inlineMaxBytes=e.inlineMaxBytes??V;}options;socket;heartbeatTimer;tokens;instanceId;closed=false;agentId;blobBaseUrl;inlineMaxBytes;get connected(){return this.socket?.connected===true}async connect(){this.closed=false;let e=await this.tokens.getToken(),r=(this.options.socketFactory??((s,o)=>io(s,o)))(this.options.gatewayUrl,{auth:{token:e},path:this.options.path,transports:["websocket"],reconnection:true,randomizationFactor:.5,reconnectionDelay:1e3,reconnectionDelayMax:3e4});this.socket=r;let n=new Promise((s,o)=>{let i=false;r.on("connect",()=>{this.register().then(()=>{i||(i=true,s());}).catch(d=>{if(!i){i=true,o(d);return}console.error("[saul][tunnel] \u0111\u0103ng k\xFD l\u1EA1i th\u1EA5t b\u1EA1i:",d.message);});}),r.on("connect_error",d=>{i||(i=true,o(new Error(`Kh\xF4ng k\u1EBFt n\u1ED1i \u0111\u01B0\u1EE3c gateway '${this.options.gatewayUrl}': ${d.message}`)));});});r.on(H,(s,o)=>{this.handleDispatch(s,o);}),r.on(q,(s,o)=>{this.handleUpload(s,o);}),r.on("disconnect",s=>{console.warn(`[saul][tunnel] m\u1EA5t k\u1EBFt n\u1ED1i t\u1EDBi gateway (${s}) \u2014 \u0111ang th\u1EED n\u1ED1i l\u1EA1i`);}),await n,this.startHeartbeat();}async close(){this.closed=true,this.stopHeartbeat(),this.socket?.disconnect(),this.socket=void 0;}async register(){let e=await this.emitWithAck(G,{instanceId:this.instanceId,modules:this.options.modules,capabilities:this.options.capabilities??[]});if(!e?.ok)throw new Error(`Gateway t\u1EEB ch\u1ED1i \u0111\u0103ng k\xFD agent: ${e?.error??"kh\xF4ng r\xF5 l\xFD do"}${e?.code?` [${e.code}]`:""}`);let t=e.data??{};this.agentId=t.agentId,Array.isArray(t.denied)&&t.denied.length&&console.warn("[saul][tunnel] module b\u1ECB t\u1EEB ch\u1ED1i:",t.denied.map(r=>`${r.module} (${r.code})`).join(", ")),console.log(`[saul][tunnel] \u0111\xE3 \u0111\u0103ng k\xFD ${JSON.stringify(t.accepted??[])} v\u1EDBi gateway (agentId=${t.agentId}, ip=${t.ip??"?"})`);}async handleDispatch(e,t){try{if(!e||typeof e.module!="string"||typeof e.function!="string")throw new Error("Envelope dispatch thi\u1EBFu module/function");let r=e.paramsBlob?await this.pullBlob(e.paramsBlob,e.module):e.params??{},n=await this.options.dispatch({module:e.module,function:e.function,params:r}),s=JSON.stringify(n??null);if(Buffer.byteLength(s,"utf8")>this.inlineMaxBytes){let o=await this.pushBlob(s,e.module);t?.({ok:!0,resultBlob:o});return}t?.({ok:!0,result:n});}catch(r){t?.({ok:false,error:r?.message??"L\u1ED7i kh\xF4ng x\xE1c \u0111\u1ECBnh \u1EDF downstream",code:E.is(r)?r.code:void 0});}}async handleUpload(e,t){try{if(!this.options.uploadRouter)throw new Error("Service n\xE0y kh\xF4ng b\u1EADt multipart upload (thi\u1EBFu uploadRouter)");if(!e||typeof e.path!="string"||typeof e.method!="string")throw new Error("Envelope upload thi\u1EBFu method/path");let r=e.paramsBlob?(await this.pullBlob(e.paramsBlob,e.module)).body:e.params?.body??"",n=r?Buffer.from(String(r),"base64"):void 0,s=await this.options.uploadRouter.request(e.path,{method:e.method,headers:e.headers??{},body:n&&e.method!=="GET"&&e.method!=="DELETE"?n:void 0}),o=await s.text(),i={status:s.status,body:o},d=JSON.stringify(i);if(Buffer.byteLength(d,"utf8")>this.inlineMaxBytes){t?.({ok:!0,resultBlob:await this.pushBlob(d,e.module)});return}t?.({ok:!0,result:i});}catch(r){t?.({ok:false,error:r?.message??"L\u1ED7i upload kh\xF4ng x\xE1c \u0111\u1ECBnh"});}}async pullBlob(e,t){let r=`${this.blobBaseUrl}/tunnel/blob/${encodeURIComponent(e)}?module=${encodeURIComponent(t)}&agentId=${encodeURIComponent(this.agentId??"")}`,n=await this.fetchWithToken(r,{method:"GET"});if(!n.ok){let s=await n.text().catch(()=>"");throw new Error(`Kh\xF4ng k\xE9o \u0111\u01B0\u1EE3c payload t\u1EEB gateway (${n.status}): ${s}`)}return JSON.parse(await n.text())}async pushBlob(e,t){let r=await this.fetchWithToken(`${this.blobBaseUrl}/tunnel/blob`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({module:t,agentId:this.agentId,data:e})}),n=await r.json().catch(()=>{});if(!r.ok||!n?.blobId)throw new Error(`Kh\xF4ng \u0111\u1EA9y \u0111\u01B0\u1EE3c k\u1EBFt qu\u1EA3 l\xEAn gateway (${r.status}): ${n?.error??"kh\xF4ng c\xF3 blobId"}`);return n.blobId}async fetchWithToken(e,t){let r=globalThis.fetch;if(typeof r!="function")throw new Error("FETCH_UNAVAILABLE (c\u1EA7n Node >= 18)");let n=await this.tokens.getToken();return await r(e,{...t,headers:{...t.headers??{},authorization:`Bearer ${n}`}})}startHeartbeat(){this.stopHeartbeat();let e=this.options.heartbeatMs??K;this.heartbeatTimer=setInterval(()=>{this.closed||!this.socket||this.socket.emit(j,{},t=>{t&&t.ok===false&&this.register().catch(r=>console.error("[saul][tunnel] \u0111\u0103ng k\xFD l\u1EA1i sau heartbeat th\u1EA5t b\u1EA1i:",r.message));});},e),this.heartbeatTimer.unref?.();}stopHeartbeat(){this.heartbeatTimer&&clearInterval(this.heartbeatTimer),this.heartbeatTimer=void 0;}emitWithAck(e,t){return new Promise(r=>{this.socket.emit(e,t,r);})}};var I=class{content;metadata;constructor(e){this.metadata=e||{name:"",size:0,type:""},this.content="";}encodeBase64(e){return Buffer.from(e).toString("base64")}decodeBase64(e){let t=Buffer.from(e,"base64"),r=new Uint8Array(t.length);return t.copy(r),r.buffer}read(){if(!this.content)throw new Error("File content is not set");return this.decodeBase64(this.content)}write(e){this.content=this.encodeBase64(e);}};var R=Symbol.for("@fidt/saul.FileStream"),k=class a{[R]=true;metadata;source;consumed=false;constructor(e,t){this.metadata=e,this.source=t;}stream(){if(this.consumed)throw new Error(`FILE_STREAM_CONSUMED: '${this.metadata.name}' \u0111\xE3 \u0111\u01B0\u1EE3c \u0111\u1ECDc r\u1ED3i \u2014 m\u1ED9t FileStream ch\u1EC9 ti\xEAu th\u1EE5 \u0111\u01B0\u1EE3c m\u1ED9t l\u1EA7n`);return this.consumed=true,this.source()}get isConsumed(){return this.consumed}async pipeTo(e){await pipeline(this.stream(),e);}async buffer(e){let t=[],r=0;for await(let n of this.stream()){let s=Buffer.isBuffer(n)?n:Buffer.from(n);if(r+=s.length,r>e)throw new Error(`FILE_STREAM_TOO_LARGE: '${this.metadata.name}' v\u01B0\u1EE3t ${e} byte khi buffer()`);t.push(s);}return Buffer.concat(t,r)}static is(e){return typeof e=="object"&&e!==null&&e[R]===true}static fromBuffer(e,t){return new a({...e,size:e.size??t.length},()=>Readable.from(t))}};var ve=(...a)=>{let e=a.filter(t=>!!t&&Object.keys(t).length>0);return e.length===0?{}:e.length===1?e[0]:{$and:e}};var u=class a extends Error{constructor(t,r){super(r);this.code=t;this.name="UploadError";}code;static is(t){return t instanceof a}},Y={UPLOAD_NOT_FOUND:404,UPLOAD_EXISTS:409,UPLOAD_EXPIRED:410,UPLOAD_COMPLETED:409,UPLOAD_BUSY:409,PART_OUT_OF_RANGE:416,PART_IN_FLIGHT:409,PART_TOO_LARGE:413,PART_MISSING:422,SIZE_MISMATCH:422,CHECKSUM_MISMATCH:422,DISK_BUDGET:503,FORBIDDEN:403},B=a=>Y[a]??500;var se=1024*1024,x=class{rootDir;fsync;constructor(e){this.rootDir=e.rootDir,this.fsync=e.fsync??true;}dirFor(e){return ie(e),join(this.rootDir,e)}pathFor(e,t,r){return ae(t,r),join(this.dirFor(e),`${String(t).padStart(6,"0")}.part`)}async ensureDir(e){await mkdir(this.dirFor(e),{recursive:true});}async writePart(e,t,r,n){let s=this.pathFor(e,t,n.partCount),o=`${s}.tmp`;await this.ensureDir(e);let i;try{i=await open(o,"wx");}catch(l){throw l?.code==="EEXIST"?new u("PART_IN_FLIGHT",`Part ${t} c\u1EE7a '${e}' \u0111ang \u0111\u01B0\u1EE3c ghi b\u1EDFi m\u1ED9t request kh\xE1c`):l}let d=createHash("sha256"),c=0,p=n.declaredLength!==void 0?Math.min(n.maxBytes,n.declaredLength):n.maxBytes,f=new Transform({transform(l,b,S){if(c+=l.length,c>p){S(new u("PART_TOO_LARGE",`Part ${t} v\u01B0\u1EE3t ${p} byte`));return}d.update(l),S(null,l);}});try{await pipeline(r,f,i.createWriteStream()),this.fsync&&await i.sync();}catch(l){throw await i.close().catch(()=>{}),await unlink(o).catch(()=>{}),l}if(await i.close(),n.declaredLength!==void 0&&c!==n.declaredLength)throw await unlink(o).catch(()=>{}),new u("SIZE_MISMATCH",`Part ${t}: nh\u1EADn ${c} byte, content-length khai ${n.declaredLength}`);let h=d.digest("hex");if(n.expectedSha256&&n.expectedSha256!==h)throw await unlink(o).catch(()=>{}),new u("CHECKSUM_MISMATCH",`Part ${t}: sha256 kh\xF4ng kh\u1EDBp (khai ${n.expectedSha256}, th\u1EADt ${h})`);return await rename(o,s),{size:c,etag:h}}createPartStream(e,t,r){return createReadStream(this.pathFor(e,t,r),{highWaterMark:se})}async listParts(e){let t;try{t=await readdir(this.dirFor(e));}catch(n){if(n?.code==="ENOENT")return [];throw n}let r=[];for(let n of t){let s=/^(\d{6})\.part$/.exec(n);if(!s)continue;let o=await stat(join(this.dirFor(e),n));r.push({partNumber:Number.parseInt(s[1],10),size:o.size});}return r.sort((n,s)=>n.partNumber-s.partNumber)}async removeUpload(e){let t=this.dirFor(e),r=0;try{for(let n of await readdir(t)){let s=await stat(join(t,n)).catch(()=>{});s&&(r+=s.size);}}catch(n){if(n?.code==="ENOENT")return 0;throw n}return await rm(t,{recursive:true,force:true}),r}async listStaleDirs(e,t=Date.now()){let r;try{r=await readdir(this.rootDir);}catch(s){if(s?.code==="ENOENT")return [];throw s}let n=[];for(let s of r){let o=await stat(join(this.rootDir,s)).catch(()=>{});o?.isDirectory()&&t-o.mtimeMs>e&&n.push(s);}return n}async totalBytes(){let e;try{e=await readdir(this.rootDir);}catch(r){if(r?.code==="ENOENT")return 0;throw r}let t=0;for(let r of e){let n=join(this.rootDir,r);if((await stat(n).catch(()=>{}))?.isDirectory())for(let o of await readdir(n).catch(()=>[])){let i=await stat(join(n,o)).catch(()=>{});i?.isFile()&&(t+=i.size);}}return t}};function ie(a){if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(a))throw new u("FORBIDDEN",`uploadId kh\xF4ng h\u1EE3p l\u1EC7: '${a}'`)}function ae(a,e){if(!Number.isSafeInteger(a)||a<1||a>e)throw new u("PART_OUT_OF_RANGE",`partNumber ${a} ngo\xE0i d\u1EA3i 1..${e}`)}var _="meta.json",P=class{records=new Map;store;maxDiskBytes;usedBytes=0;constructor(e){this.store=e.store,this.maxDiskBytes=e.maxDiskBytes;}get diskUsedBytes(){return this.usedBytes}setDiskUsedBytes(e){this.usedBytes=Math.max(0,e);}async init(e){if(this.records.has(e.uploadId))throw new u("UPLOAD_EXISTS",`Upload '${e.uploadId}' \u0111\xE3 t\u1ED3n t\u1EA1i`);if(this.usedBytes+e.file.size>this.maxDiskBytes)throw new u("DISK_BUDGET",`H\u1EBFt ng\xE2n s\xE1ch \u0111\u0129a (${this.usedBytes}/${this.maxDiskBytes} byte) \u2014 th\u1EED l\u1EA1i sau`);let t=Date.now(),r={...e,state:"open",createdAt:t,expiresAt:Math.min(t+e.idleTimeoutMs,e.hardExpiresAt)};return this.records.set(r.uploadId,r),await this.store.ensureDir(r.uploadId),await this.persist(r),r}get(e,t=["open"]){let r=this.records.get(e);if(!r)throw new u("UPLOAD_NOT_FOUND",`Upload '${e}' kh\xF4ng t\u1ED3n t\u1EA1i`);if(Date.now()>r.hardExpiresAt||Date.now()>r.expiresAt)throw new u("UPLOAD_EXPIRED",`Upload '${e}' \u0111\xE3 h\u1EBFt h\u1EA1n`);if(!t.includes(r.state))throw new u(r.state==="completed"?"UPLOAD_COMPLETED":"UPLOAD_BUSY",`Upload '${e}' \u0111ang \u1EDF tr\u1EA1ng th\xE1i '${r.state}'`);return r}peek(e){return this.records.get(e)}list(){return [...this.records.values()]}touch(e,t){let r=this.records.get(e);r&&(r.expiresAt=Math.min(Date.now()+r.idleTimeoutMs,r.hardExpiresAt),this.usedBytes=Math.max(0,this.usedBytes+t));}beginComplete(e){let t=this.get(e,["open"]);return t.state="completing",t}rollbackComplete(e){let t=this.records.get(e);t?.state==="completing"&&(t.state="open");}async remove(e){this.records.delete(e);let t=await this.store.removeUpload(e);this.usedBytes=Math.max(0,this.usedBytes-t);}async persist(e){let t=this.store.dirFor(e.uploadId),r=join(t,`${_}.tmp`);await writeFile(r,JSON.stringify(e),"utf-8"),await rename(r,join(t,_));}async readPersisted(e){try{let t=await readFile(join(this.store.dirFor(e),_),"utf-8");return JSON.parse(t)}catch{return}}};var U=class{registry;store;intervalMs;orphanGraceMs;reconcileEvery;timer;ticks=0;constructor(e){this.registry=e.registry,this.store=e.store,this.intervalMs=e.intervalMs??6e4,this.orphanGraceMs=e.orphanGraceMs??20*6e4,this.reconcileEvery=e.reconcileEvery??10;}start(){this.timer||(this.timer=setInterval(()=>{this.sweep().catch(e=>console.error("[saul][upload] sweep l\u1ED7i:",e));},this.intervalMs),typeof this.timer.unref=="function"&&this.timer.unref());}stop(){this.timer&&(clearInterval(this.timer),this.timer=void 0);}async sweep(e=Date.now()){let t={expired:0,orphans:0};for(let r of this.registry.list())(e>r.hardExpiresAt||e>r.expiresAt)&&(await this.registry.remove(r.uploadId),t.expired+=1);for(let r of await this.store.listStaleDirs(this.orphanGraceMs,e))this.registry.peek(r)||(await this.store.removeUpload(r).catch(()=>{}),t.orphans+=1);if(this.ticks+=1,this.ticks%this.reconcileEvery===0){let r=await this.store.totalBytes();this.registry.setDiskUsedBytes(r),t.reconciledBytes=r;}return t}};function O(a,e,t,r){async function*n(){for(let s of t){let o=a.createPartStream(e,s,r);try{for await(let i of o)yield i;}finally{o.destroy();}}}return Readable.from(n())}var M=(a,e)=>{let t=process.env[a],r=t?Number.parseInt(t,10):NaN;return Number.isFinite(r)&&r>0?r:e},ye=(a,e)=>{let t=process.env[a];return t===void 0?e:t==="true"||t==="1"};function ot(a){let e=new x({rootDir:a.rootDir??process.env.SAUL_UPLOAD_TMP_DIR??join(tmpdir(),"saul-uploads"),fsync:a.fsync??ye("SAUL_UPLOAD_FSYNC",true)}),t=new P({store:e,maxDiskBytes:a.maxDiskBytes??M("SAUL_UPLOAD_MAX_DISK_BYTES",20*1024*1024*1024)}),r=new U({registry:t,store:e,intervalMs:a.sweepIntervalMs}),n=a.maxPartBytes??M("SAUL_UPLOAD_MAX_PART_BYTES",32*1024*1024),s=new Hono;return s.onError((o,i)=>u.is(o)?i.json({error:o.message,code:o.code},B(o.code)):(console.error("[saul][upload] l\u1ED7i kh\xF4ng x\xE1c \u0111\u1ECBnh:",o),i.json({error:o instanceof Error?o.message:String(o)},500))),s.post("/init",async o=>{let i=await o.req.json(),d=await t.init(i);return o.json({uploadId:d.uploadId,expiresAt:d.expiresAt})}),s.put("/:uploadId/part/:partNumber",async o=>{let i=o.req.param("uploadId"),d=Number(o.req.param("partNumber")),c=t.get(i),p=o.req.header("content-length");if(!p)throw new u("SIZE_MISMATCH","Thi\u1EBFu content-length \u2014 part ph\u1EA3i khai \u0111\u1ED9 d\xE0i");let f=(await e.listParts(i)).find(b=>b.partNumber===d),h=o.req.raw.body;if(!h)throw new u("SIZE_MISMATCH","Request kh\xF4ng c\xF3 body");let l=await e.writePart(i,d,Readable.fromWeb(h),{maxBytes:n,partCount:c.partCount,declaredLength:Number(p),expectedSha256:o.req.header("x-saul-part-sha256")});return t.touch(i,l.size-(f?.size??0)),o.json({partNumber:d,size:l.size,etag:l.etag})}),s.get("/:uploadId",async o=>{let i=o.req.param("uploadId"),d=t.get(i,["open","completing"]),c=await e.listParts(i);return o.json({uploadId:i,size:d.file.size,chunkSize:d.chunkSize,partCount:d.partCount,received:c,expiresAt:d.expiresAt})}),s.post("/:uploadId/complete",async o=>{let i=o.req.param("uploadId"),{ctx:d}=await o.req.json().catch(()=>({})),c=t.beginComplete(i);try{let p=await e.listParts(i),f=new Set(p.map(m=>m.partNumber)),h=[];for(let m=1;m<=c.partCount;m+=1)f.has(m)||h.push(m);if(h.length>0)throw new u("PART_MISSING",`Thi\u1EBFu part: ${h.slice(0,10).join(", ")}${h.length>10?"\u2026":""}`);let l=p.reduce((m,L)=>m+L.size,0);if(c.file.size>0&&l!==c.file.size)throw new u("SIZE_MISMATCH",`Nh\u1EADn ${l} byte, khai b\xE1o ${c.file.size}`);let b=p.map(m=>m.partNumber),S=new k(c.file,()=>O(e,i,b,c.partCount)),$=await a.dispatch({module:c.module,function:c.function,params:{...c.params,[c.param]:S,ctx:d}});return o.json($)}catch(p){throw t.rollbackComplete(i),p}finally{t.peek(i)?.state!=="open"&&await t.remove(i).catch(()=>{});}}),s.delete("/:uploadId",async o=>(await t.remove(o.req.param("uploadId")).catch(()=>{}),o.body(null,204))),{router:s,registry:t,store:e,sweeper:r}}export{I as File,k as FileStream,T as MachineTokenProvider,x as PartDiskStore,E as SaulException,D as TunnelAgent,Y as UPLOAD_ERROR_STATUS,u as UploadError,P as UploadRegistry,U as UploadSweeper,ae as assertSafePartNumber,ie as assertSafeUploadId,O as concatParts,ot as createUploadSubsystem,ve as narrow,B as uploadErrorStatus};//# sourceMappingURL=index.js.map
|
|
3
|
+
//# sourceMappingURL=index.js.map
|