@fidt/saul-server 1.1.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.d.cts CHANGED
@@ -5,11 +5,166 @@ export { cors } from 'hono/cors';
5
5
  export { serve } from '@hono/node-server';
6
6
  export * from 'socket.io';
7
7
  export { createAdapter } from '@socket.io/redis-adapter';
8
+ export { Socket as ClientSocket, io as connectSocket } from 'socket.io-client';
8
9
  import * as zod from 'zod';
9
10
  export { zod as Zod };
10
11
  import { Readable, Writable } from 'stream';
11
12
  export * from 'zod-validation-error';
12
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
+
13
168
  declare class File {
14
169
  private content;
15
170
  metadata: {
@@ -461,4 +616,4 @@ interface UploadSubsystem {
461
616
  }
462
617
  declare function createUploadSubsystem(options: UploadRouterOptions): UploadSubsystem;
463
618
 
464
- export { File, FileStream, type FileStreamMetadata, PartDiskStore, type PartDiskStoreOptions, type PartInfo, type Predicate, type PredicateComparison, SaulException, type Sub, type SweepResult, 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 };
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.d.ts CHANGED
@@ -5,11 +5,166 @@ export { cors } from 'hono/cors';
5
5
  export { serve } from '@hono/node-server';
6
6
  export * from 'socket.io';
7
7
  export { createAdapter } from '@socket.io/redis-adapter';
8
+ export { Socket as ClientSocket, io as connectSocket } from 'socket.io-client';
8
9
  import * as zod from 'zod';
9
10
  export { zod as Zod };
10
11
  import { Readable, Writable } from 'stream';
11
12
  export * from 'zod-validation-error';
12
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
+
13
168
  declare class File {
14
169
  private content;
15
170
  metadata: {
@@ -461,4 +616,4 @@ interface UploadSubsystem {
461
616
  }
462
617
  declare function createUploadSubsystem(options: UploadRouterOptions): UploadSubsystem;
463
618
 
464
- export { File, FileStream, type FileStreamMetadata, PartDiskStore, type PartDiskStoreOptions, type PartInfo, type Predicate, type PredicateComparison, SaulException, type Sub, type SweepResult, 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 };
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 CHANGED
@@ -1,3 +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*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';import {tmpdir}from'os';export*from'zod-validation-error';// @fidt/saul-server - A server library for the Saul framework
2
- var R=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 _=Symbol.for("@fidt/saul.FileStream"),P=class s{[_]=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 a=Buffer.isBuffer(n)?n:Buffer.from(n);if(r+=a.length,r>e)throw new Error(`FILE_STREAM_TOO_LARGE: '${this.metadata.name}' v\u01B0\u1EE3t ${e} byte khi buffer()`);t.push(a);}return Buffer.concat(t,r)}static is(e){return typeof e=="object"&&e!==null&&e[_]===true}static fromBuffer(e,t){return new s({...e,size:e.size??t.length},()=>Readable.from(t))}};var I=class s 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 s||e instanceof Error&&e.name==="SaulException"}};var pe=(...s)=>{let e=s.filter(t=>!!t&&Object.keys(t).length>0);return e.length===0?{}:e.length===1?e[0]:{$and:e}};var c=class s extends Error{constructor(t,r){super(r);this.code=t;this.name="UploadError";}code;static is(t){return t instanceof s}},$={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},O=s=>$[s]??500;var W=1024*1024,x=class{rootDir;fsync;constructor(e){this.rootDir=e.rootDir,this.fsync=e.fsync??true;}dirFor(e){return X(e),join(this.rootDir,e)}pathFor(e,t,r){return V(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 a=this.pathFor(e,t,n.partCount),o=`${a}.tmp`;await this.ensureDir(e);let i;try{i=await open(o,"wx");}catch(u){throw u?.code==="EEXIST"?new c("PART_IN_FLIGHT",`Part ${t} c\u1EE7a '${e}' \u0111ang \u0111\u01B0\u1EE3c ghi b\u1EDFi m\u1ED9t request kh\xE1c`):u}let p=createHash("sha256"),d=0,m=n.declaredLength!==void 0?Math.min(n.maxBytes,n.declaredLength):n.maxBytes,y=new Transform({transform(u,b,S){if(d+=u.length,d>m){S(new c("PART_TOO_LARGE",`Part ${t} v\u01B0\u1EE3t ${m} byte`));return}p.update(u),S(null,u);}});try{await pipeline(r,y,i.createWriteStream()),this.fsync&&await i.sync();}catch(u){throw await i.close().catch(()=>{}),await unlink(o).catch(()=>{}),u}if(await i.close(),n.declaredLength!==void 0&&d!==n.declaredLength)throw await unlink(o).catch(()=>{}),new c("SIZE_MISMATCH",`Part ${t}: nh\u1EADn ${d} byte, content-length khai ${n.declaredLength}`);let l=p.digest("hex");if(n.expectedSha256&&n.expectedSha256!==l)throw await unlink(o).catch(()=>{}),new c("CHECKSUM_MISMATCH",`Part ${t}: sha256 kh\xF4ng kh\u1EDBp (khai ${n.expectedSha256}, th\u1EADt ${l})`);return await rename(o,a),{size:d,etag:l}}createPartStream(e,t,r){return createReadStream(this.pathFor(e,t,r),{highWaterMark:W})}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 a=/^(\d{6})\.part$/.exec(n);if(!a)continue;let o=await stat(join(this.dirFor(e),n));r.push({partNumber:Number.parseInt(a[1],10),size:o.size});}return r.sort((n,a)=>n.partNumber-a.partNumber)}async removeUpload(e){let t=this.dirFor(e),r=0;try{for(let n of await readdir(t)){let a=await stat(join(t,n)).catch(()=>{});a&&(r+=a.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(a){if(a?.code==="ENOENT")return [];throw a}let n=[];for(let a of r){let o=await stat(join(this.rootDir,a)).catch(()=>{});o?.isDirectory()&&t-o.mtimeMs>e&&n.push(a);}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 X(s){if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s))throw new c("FORBIDDEN",`uploadId kh\xF4ng h\u1EE3p l\u1EC7: '${s}'`)}function V(s,e){if(!Number.isSafeInteger(s)||s<1||s>e)throw new c("PART_OUT_OF_RANGE",`partNumber ${s} ngo\xE0i d\u1EA3i 1..${e}`)}var T="meta.json",D=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 c("UPLOAD_EXISTS",`Upload '${e.uploadId}' \u0111\xE3 t\u1ED3n t\u1EA1i`);if(this.usedBytes+e.file.size>this.maxDiskBytes)throw new c("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 c("UPLOAD_NOT_FOUND",`Upload '${e}' kh\xF4ng t\u1ED3n t\u1EA1i`);if(Date.now()>r.hardExpiresAt||Date.now()>r.expiresAt)throw new c("UPLOAD_EXPIRED",`Upload '${e}' \u0111\xE3 h\u1EBFt h\u1EA1n`);if(!t.includes(r.state))throw new c(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,`${T}.tmp`);await writeFile(r,JSON.stringify(e),"utf-8"),await rename(r,join(t,T));}async readPersisted(e){try{let t=await readFile(join(this.store.dirFor(e),T),"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 v(s,e,t,r){async function*n(){for(let a of t){let o=s.createPartStream(e,a,r);try{for await(let i of o)yield i;}finally{o.destroy();}}}return Readable.from(n())}var M=(s,e)=>{let t=process.env[s],r=t?Number.parseInt(t,10):NaN;return Number.isFinite(r)&&r>0?r:e},ne=(s,e)=>{let t=process.env[s];return t===void 0?e:t==="true"||t==="1"};function Fe(s){let e=new x({rootDir:s.rootDir??process.env.SAUL_UPLOAD_TMP_DIR??join(tmpdir(),"saul-uploads"),fsync:s.fsync??ne("SAUL_UPLOAD_FSYNC",true)}),t=new D({store:e,maxDiskBytes:s.maxDiskBytes??M("SAUL_UPLOAD_MAX_DISK_BYTES",20*1024*1024*1024)}),r=new U({registry:t,store:e,intervalMs:s.sweepIntervalMs}),n=s.maxPartBytes??M("SAUL_UPLOAD_MAX_PART_BYTES",32*1024*1024),a=new Hono;return a.onError((o,i)=>c.is(o)?i.json({error:o.message,code:o.code},O(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))),a.post("/init",async o=>{let i=await o.req.json(),p=await t.init(i);return o.json({uploadId:p.uploadId,expiresAt:p.expiresAt})}),a.put("/:uploadId/part/:partNumber",async o=>{let i=o.req.param("uploadId"),p=Number(o.req.param("partNumber")),d=t.get(i),m=o.req.header("content-length");if(!m)throw new c("SIZE_MISMATCH","Thi\u1EBFu content-length \u2014 part ph\u1EA3i khai \u0111\u1ED9 d\xE0i");let y=(await e.listParts(i)).find(b=>b.partNumber===p),l=o.req.raw.body;if(!l)throw new c("SIZE_MISMATCH","Request kh\xF4ng c\xF3 body");let u=await e.writePart(i,p,Readable.fromWeb(l),{maxBytes:n,partCount:d.partCount,declaredLength:Number(m),expectedSha256:o.req.header("x-saul-part-sha256")});return t.touch(i,u.size-(y?.size??0)),o.json({partNumber:p,size:u.size,etag:u.etag})}),a.get("/:uploadId",async o=>{let i=o.req.param("uploadId"),p=t.get(i,["open","completing"]),d=await e.listParts(i);return o.json({uploadId:i,size:p.file.size,chunkSize:p.chunkSize,partCount:p.partCount,received:d,expiresAt:p.expiresAt})}),a.post("/:uploadId/complete",async o=>{let i=o.req.param("uploadId"),{ctx:p}=await o.req.json().catch(()=>({})),d=t.beginComplete(i);try{let m=await e.listParts(i),y=new Set(m.map(f=>f.partNumber)),l=[];for(let f=1;f<=d.partCount;f+=1)y.has(f)||l.push(f);if(l.length>0)throw new c("PART_MISSING",`Thi\u1EBFu part: ${l.slice(0,10).join(", ")}${l.length>10?"\u2026":""}`);let u=m.reduce((f,k)=>f+k.size,0);if(d.file.size>0&&u!==d.file.size)throw new c("SIZE_MISMATCH",`Nh\u1EADn ${u} byte, khai b\xE1o ${d.file.size}`);let b=m.map(f=>f.partNumber),S=new P(d.file,()=>v(e,i,b,d.partCount)),B=await s.dispatch({module:d.module,function:d.function,params:{...d.params,[d.param]:S,ctx:p}});return o.json(B)}catch(m){throw t.rollbackComplete(i),m}finally{t.peek(i)?.state!=="open"&&await t.remove(i).catch(()=>{});}}),a.delete("/:uploadId",async o=>(await t.remove(o.req.param("uploadId")).catch(()=>{}),o.body(null,204))),{router:a,registry:t,store:e,sweeper:r}}export{R as File,P as FileStream,x as PartDiskStore,I as SaulException,$ as UPLOAD_ERROR_STATUS,c as UploadError,D as UploadRegistry,U as UploadSweeper,V as assertSafePartNumber,X as assertSafeUploadId,v as concatParts,Fe as createUploadSubsystem,pe as narrow,O as uploadErrorStatus};//# sourceMappingURL=index.js.map
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
3
  //# sourceMappingURL=index.js.map