@simplysm/storage 1.0.135 → 13.0.0-beta.2
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/.cache/typecheck-node.tsbuildinfo +1 -0
- package/.cache/typecheck-tests-node.tsbuildinfo +1 -0
- package/README.md +262 -0
- package/dist/clients/ftp-storage-client.js +126 -0
- package/dist/clients/ftp-storage-client.js.map +7 -0
- package/dist/clients/sftp-storage-client.js +108 -0
- package/dist/clients/sftp-storage-client.js.map +7 -0
- package/dist/core-common/src/common.types.d.ts +74 -0
- package/dist/core-common/src/common.types.d.ts.map +1 -0
- package/dist/core-common/src/env.d.ts +6 -0
- package/dist/core-common/src/env.d.ts.map +1 -0
- package/dist/core-common/src/errors/argument-error.d.ts +25 -0
- package/dist/core-common/src/errors/argument-error.d.ts.map +1 -0
- package/dist/core-common/src/errors/not-implemented-error.d.ts +29 -0
- package/dist/core-common/src/errors/not-implemented-error.d.ts.map +1 -0
- package/dist/core-common/src/errors/sd-error.d.ts +27 -0
- package/dist/core-common/src/errors/sd-error.d.ts.map +1 -0
- package/dist/core-common/src/errors/timeout-error.d.ts +31 -0
- package/dist/core-common/src/errors/timeout-error.d.ts.map +1 -0
- package/dist/core-common/src/extensions/arr-ext.d.ts +15 -0
- package/dist/core-common/src/extensions/arr-ext.d.ts.map +1 -0
- package/dist/core-common/src/extensions/arr-ext.helpers.d.ts +19 -0
- package/dist/core-common/src/extensions/arr-ext.helpers.d.ts.map +1 -0
- package/dist/core-common/src/extensions/arr-ext.types.d.ts +215 -0
- package/dist/core-common/src/extensions/arr-ext.types.d.ts.map +1 -0
- package/dist/core-common/src/extensions/map-ext.d.ts +57 -0
- package/dist/core-common/src/extensions/map-ext.d.ts.map +1 -0
- package/dist/core-common/src/extensions/set-ext.d.ts +36 -0
- package/dist/core-common/src/extensions/set-ext.d.ts.map +1 -0
- package/dist/core-common/src/features/debounce-queue.d.ts +53 -0
- package/dist/core-common/src/features/debounce-queue.d.ts.map +1 -0
- package/dist/core-common/src/features/event-emitter.d.ts +66 -0
- package/dist/core-common/src/features/event-emitter.d.ts.map +1 -0
- package/dist/core-common/src/features/serial-queue.d.ts +47 -0
- package/dist/core-common/src/features/serial-queue.d.ts.map +1 -0
- package/dist/core-common/src/index.d.ts +32 -0
- package/dist/core-common/src/index.d.ts.map +1 -0
- package/dist/core-common/src/types/date-only.d.ts +152 -0
- package/dist/core-common/src/types/date-only.d.ts.map +1 -0
- package/dist/core-common/src/types/date-time.d.ts +96 -0
- package/dist/core-common/src/types/date-time.d.ts.map +1 -0
- package/dist/core-common/src/types/lazy-gc-map.d.ts +80 -0
- package/dist/core-common/src/types/lazy-gc-map.d.ts.map +1 -0
- package/dist/core-common/src/types/time.d.ts +68 -0
- package/dist/core-common/src/types/time.d.ts.map +1 -0
- package/dist/core-common/src/types/uuid.d.ts +35 -0
- package/dist/core-common/src/types/uuid.d.ts.map +1 -0
- package/dist/core-common/src/utils/bytes.d.ts +51 -0
- package/dist/core-common/src/utils/bytes.d.ts.map +1 -0
- package/dist/core-common/src/utils/date-format.d.ts +90 -0
- package/dist/core-common/src/utils/date-format.d.ts.map +1 -0
- package/dist/core-common/src/utils/json.d.ts +34 -0
- package/dist/core-common/src/utils/json.d.ts.map +1 -0
- package/dist/core-common/src/utils/num.d.ts +60 -0
- package/dist/core-common/src/utils/num.d.ts.map +1 -0
- package/dist/core-common/src/utils/obj.d.ts +258 -0
- package/dist/core-common/src/utils/obj.d.ts.map +1 -0
- package/dist/core-common/src/utils/path.d.ts +23 -0
- package/dist/core-common/src/utils/path.d.ts.map +1 -0
- package/dist/core-common/src/utils/primitive.d.ts +18 -0
- package/dist/core-common/src/utils/primitive.d.ts.map +1 -0
- package/dist/core-common/src/utils/str.d.ts +103 -0
- package/dist/core-common/src/utils/str.d.ts.map +1 -0
- package/dist/core-common/src/utils/template-strings.d.ts +84 -0
- package/dist/core-common/src/utils/template-strings.d.ts.map +1 -0
- package/dist/core-common/src/utils/transferable.d.ts +47 -0
- package/dist/core-common/src/utils/transferable.d.ts.map +1 -0
- package/dist/core-common/src/utils/wait.d.ts +19 -0
- package/dist/core-common/src/utils/wait.d.ts.map +1 -0
- package/dist/core-common/src/utils/xml.d.ts +36 -0
- package/dist/core-common/src/utils/xml.d.ts.map +1 -0
- package/dist/core-common/src/zip/sd-zip.d.ts +80 -0
- package/dist/core-common/src/zip/sd-zip.d.ts.map +1 -0
- package/dist/index.js +7 -10
- package/dist/index.js.map +7 -1
- package/dist/storage/src/clients/ftp-storage-client.d.ts +56 -0
- package/dist/storage/src/clients/ftp-storage-client.d.ts.map +1 -0
- package/dist/storage/src/clients/sftp-storage-client.d.ts +48 -0
- package/dist/storage/src/clients/sftp-storage-client.d.ts.map +1 -0
- package/dist/storage/src/index.d.ts +7 -0
- package/dist/storage/src/index.d.ts.map +1 -0
- package/dist/storage/src/storage-factory.d.ts +20 -0
- package/dist/storage/src/storage-factory.d.ts.map +1 -0
- package/dist/storage/src/types/storage-conn-config.d.ts +7 -0
- package/dist/storage/src/types/storage-conn-config.d.ts.map +1 -0
- package/dist/storage/src/types/storage-type.d.ts +2 -0
- package/dist/storage/src/types/storage-type.d.ts.map +1 -0
- package/dist/storage/src/types/storage.d.ts +19 -0
- package/dist/storage/src/types/storage.d.ts.map +1 -0
- package/dist/storage-factory.js +35 -0
- package/dist/storage-factory.js.map +7 -0
- package/dist/types/storage-conn-config.js +1 -0
- package/dist/types/storage-conn-config.js.map +7 -0
- package/dist/types/storage-type.js +1 -0
- package/dist/types/storage-type.js.map +7 -0
- package/dist/types/storage.js +1 -0
- package/dist/types/storage.js.map +7 -0
- package/package.json +24 -1
- package/src/clients/ftp-storage-client.ts +146 -0
- package/src/clients/sftp-storage-client.ts +135 -0
- package/src/index.ts +14 -4
- package/src/storage-factory.ts +47 -0
- package/src/types/storage-conn-config.ts +6 -0
- package/src/types/storage-type.ts +1 -0
- package/src/types/storage.ts +20 -0
- package/tests/ftp-storage-client.spec.ts +259 -0
- package/tests/sftp-storage-client.spec.ts +251 -0
- package/tests/storage-factory.spec.ts +160 -0
- package/dist/common/IStorage.d.ts +0 -7
- package/dist/common/IStorage.js +0 -3
- package/dist/common/IStorage.js.map +0 -1
- package/dist/ftp/FtpStorage.d.ts +0 -11
- package/dist/ftp/FtpStorage.js +0 -165
- package/dist/ftp/FtpStorage.js.map +0 -1
- package/dist/ftp/IFtpConnectionConfig.d.ts +0 -6
- package/dist/ftp/IFtpConnectionConfig.js +0 -3
- package/dist/ftp/IFtpConnectionConfig.js.map +0 -1
- package/dist/index.d.ts +0 -4
- package/src/common/IStorage.ts +0 -9
- package/src/ftp/FtpStorage.ts +0 -87
- package/src/ftp/IFtpConnectionConfig.ts +0 -6
- package/tsconfig.build.json +0 -17
- package/tsconfig.json +0 -17
- package/tslint.json +0 -3
package/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# @simplysm/storage
|
|
2
|
+
|
|
3
|
+
A storage client package that supports FTP, FTPS, and SFTP protocols. Through the unified `Storage` interface, you can perform file upload, download, directory manipulation and other operations with the same API regardless of protocol.
|
|
4
|
+
|
|
5
|
+
Using `StorageFactory`, you can automatically manage connection/disconnection, and you can also directly instantiate `FtpStorageClient` or `SftpStorageClient` if needed.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @simplysm/storage
|
|
11
|
+
# or
|
|
12
|
+
pnpm add @simplysm/storage
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Dependencies
|
|
16
|
+
|
|
17
|
+
| Package | Description |
|
|
18
|
+
|--------|------|
|
|
19
|
+
| `@simplysm/core-common` | Common utilities (`Bytes` type, etc.) |
|
|
20
|
+
| `basic-ftp` | FTP/FTPS protocol implementation |
|
|
21
|
+
| `ssh2-sftp-client` | SFTP protocol implementation |
|
|
22
|
+
|
|
23
|
+
## Core Modules
|
|
24
|
+
|
|
25
|
+
### Export List
|
|
26
|
+
|
|
27
|
+
| Module | Type | Description |
|
|
28
|
+
|------|------|------|
|
|
29
|
+
| `StorageFactory` | Class | Creates clients based on storage type and automatically manages connection/disconnection |
|
|
30
|
+
| `FtpStorageClient` | Class | FTP/FTPS protocol client (based on `basic-ftp`) |
|
|
31
|
+
| `SftpStorageClient` | Class | SFTP protocol client (based on `ssh2-sftp-client`) |
|
|
32
|
+
| `Storage` | Interface | Common interface implemented by all storage clients |
|
|
33
|
+
| `StorageConnConfig` | Interface | Connection configuration |
|
|
34
|
+
| `FileInfo` | Interface | Directory entry information |
|
|
35
|
+
| `StorageType` | Type | Storage protocol types (`"ftp" \| "ftps" \| "sftp"`) |
|
|
36
|
+
|
|
37
|
+
## Type Definitions
|
|
38
|
+
|
|
39
|
+
### StorageConnConfig
|
|
40
|
+
|
|
41
|
+
Configuration required for server connection.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
interface StorageConnConfig {
|
|
45
|
+
host: string; // Server host
|
|
46
|
+
port?: number; // Port (FTP default: 21, SFTP default: 22)
|
|
47
|
+
user?: string; // Username
|
|
48
|
+
pass?: string; // Password
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### FileInfo
|
|
53
|
+
|
|
54
|
+
File/directory information returned by `readdir()`.
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
interface FileInfo {
|
|
58
|
+
name: string; // File or directory name
|
|
59
|
+
isFile: boolean; // true if file, false if directory
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### StorageType
|
|
64
|
+
|
|
65
|
+
Supported storage protocol types.
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
type StorageType = "ftp" | "ftps" | "sftp";
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Value | Protocol | Default Port | Description |
|
|
72
|
+
|-----|---------|----------|------|
|
|
73
|
+
| `"ftp"` | FTP | 21 | Unencrypted FTP |
|
|
74
|
+
| `"ftps"` | FTPS | 21 | TLS-encrypted FTP |
|
|
75
|
+
| `"sftp"` | SFTP | 22 | SSH-based file transfer |
|
|
76
|
+
|
|
77
|
+
### Storage Interface
|
|
78
|
+
|
|
79
|
+
Common interface implemented by all storage clients (`FtpStorageClient`, `SftpStorageClient`). `Bytes` is a `Uint8Array` type alias defined in `@simplysm/core-common`.
|
|
80
|
+
|
|
81
|
+
| Method | Signature | Description |
|
|
82
|
+
|--------|---------|------|
|
|
83
|
+
| `connect` | `(config: StorageConnConfig) => Promise<void>` | Connect to server |
|
|
84
|
+
| `close` | `() => Promise<void>` | Close connection |
|
|
85
|
+
| `put` | `(localPathOrBuffer: string \| Bytes, storageFilePath: string) => Promise<void>` | Upload file (local path or byte data) |
|
|
86
|
+
| `readFile` | `(filePath: string) => Promise<Bytes>` | Download file (returns `Bytes`) |
|
|
87
|
+
| `readdir` | `(dirPath: string) => Promise<FileInfo[]>` | List directory contents |
|
|
88
|
+
| `remove` | `(filePath: string) => Promise<void>` | Delete file |
|
|
89
|
+
| `exists` | `(filePath: string) => Promise<boolean>` | Check if file/directory exists |
|
|
90
|
+
| `mkdir` | `(dirPath: string) => Promise<void>` | Create directory (recursive) |
|
|
91
|
+
| `rename` | `(fromPath: string, toPath: string) => Promise<void>` | Rename file/directory |
|
|
92
|
+
| `uploadDir` | `(fromPath: string, toPath: string) => Promise<void>` | Upload entire local directory to remote |
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
### StorageFactory (Recommended)
|
|
97
|
+
|
|
98
|
+
`StorageFactory.connect()` automatically manages connection and disconnection with a callback pattern. The connection is always closed even if an exception occurs in the callback, so it's recommended over using clients directly.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { StorageFactory } from "@simplysm/storage";
|
|
102
|
+
|
|
103
|
+
// FTP connection
|
|
104
|
+
const result = await StorageFactory.connect("ftp", {
|
|
105
|
+
host: "ftp.example.com",
|
|
106
|
+
port: 21,
|
|
107
|
+
user: "username",
|
|
108
|
+
pass: "password",
|
|
109
|
+
}, async (client) => {
|
|
110
|
+
// Upload local file to remote server
|
|
111
|
+
await client.put("/local/path/file.txt", "/remote/path/file.txt");
|
|
112
|
+
|
|
113
|
+
// Upload byte data directly
|
|
114
|
+
const data = new TextEncoder().encode("hello world");
|
|
115
|
+
await client.put(data, "/remote/path/hello.txt");
|
|
116
|
+
|
|
117
|
+
// Download remote file
|
|
118
|
+
const content = await client.readFile("/remote/path/file.txt");
|
|
119
|
+
|
|
120
|
+
// The callback's return value becomes the return value of StorageFactory.connect()
|
|
121
|
+
return content;
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// FTPS connection (TLS encryption)
|
|
127
|
+
await StorageFactory.connect("ftps", {
|
|
128
|
+
host: "ftps.example.com",
|
|
129
|
+
user: "username",
|
|
130
|
+
pass: "password",
|
|
131
|
+
}, async (client) => {
|
|
132
|
+
await client.put("/local/file.txt", "/remote/file.txt");
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// SFTP connection
|
|
138
|
+
await StorageFactory.connect("sftp", {
|
|
139
|
+
host: "sftp.example.com",
|
|
140
|
+
port: 22,
|
|
141
|
+
user: "username",
|
|
142
|
+
pass: "password",
|
|
143
|
+
}, async (client) => {
|
|
144
|
+
// List directory contents
|
|
145
|
+
const files = await client.readdir("/remote/path");
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
console.log(`${file.name} - ${file.isFile ? "File" : "Directory"}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Upload entire directory
|
|
151
|
+
await client.uploadDir("/local/dir", "/remote/dir");
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### FtpStorageClient (Direct Usage)
|
|
156
|
+
|
|
157
|
+
Client that uses FTP or FTPS protocol. The `secure` parameter in the constructor determines whether to use FTPS.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { FtpStorageClient } from "@simplysm/storage";
|
|
161
|
+
|
|
162
|
+
// FTP client (secure: false is default)
|
|
163
|
+
const client = new FtpStorageClient();
|
|
164
|
+
|
|
165
|
+
// FTPS client
|
|
166
|
+
const secureClient = new FtpStorageClient(true);
|
|
167
|
+
|
|
168
|
+
await client.connect({
|
|
169
|
+
host: "ftp.example.com",
|
|
170
|
+
port: 21,
|
|
171
|
+
user: "username",
|
|
172
|
+
pass: "password",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// Upload file - from local file path
|
|
177
|
+
await client.put("/local/path/file.txt", "/remote/path/file.txt");
|
|
178
|
+
|
|
179
|
+
// Upload file - from Uint8Array byte data
|
|
180
|
+
const bytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
|
|
181
|
+
await client.put(bytes, "/remote/path/hello.bin");
|
|
182
|
+
|
|
183
|
+
// Download file (returns Bytes, i.e. Uint8Array)
|
|
184
|
+
const data = await client.readFile("/remote/path/file.txt");
|
|
185
|
+
const text = new TextDecoder().decode(data);
|
|
186
|
+
|
|
187
|
+
// List directory contents
|
|
188
|
+
const files = await client.readdir("/remote/path");
|
|
189
|
+
|
|
190
|
+
// Check if file/directory exists
|
|
191
|
+
const exists = await client.exists("/remote/path/file.txt");
|
|
192
|
+
|
|
193
|
+
// Create directory (creates parent directories too)
|
|
194
|
+
await client.mkdir("/remote/new/nested/path");
|
|
195
|
+
|
|
196
|
+
// Rename file
|
|
197
|
+
await client.rename("/remote/old-name.txt", "/remote/new-name.txt");
|
|
198
|
+
|
|
199
|
+
// Delete file
|
|
200
|
+
await client.remove("/remote/path/file.txt");
|
|
201
|
+
|
|
202
|
+
// Upload entire local directory to remote
|
|
203
|
+
await client.uploadDir("/local/dir", "/remote/dir");
|
|
204
|
+
} finally {
|
|
205
|
+
// Connection must be closed
|
|
206
|
+
await client.close();
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### SftpStorageClient (Direct Usage)
|
|
211
|
+
|
|
212
|
+
Client that uses SFTP protocol. It implements the same `Storage` interface as `FtpStorageClient`, so the API is identical.
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { SftpStorageClient } from "@simplysm/storage";
|
|
216
|
+
|
|
217
|
+
const client = new SftpStorageClient();
|
|
218
|
+
|
|
219
|
+
await client.connect({
|
|
220
|
+
host: "sftp.example.com",
|
|
221
|
+
port: 22,
|
|
222
|
+
user: "username",
|
|
223
|
+
pass: "password",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// All methods of the Storage interface can be used identically
|
|
228
|
+
await client.put("/local/path/file.txt", "/remote/path/file.txt");
|
|
229
|
+
const data = await client.readFile("/remote/path/file.txt");
|
|
230
|
+
const files = await client.readdir("/remote/path");
|
|
231
|
+
const exists = await client.exists("/remote/path/file.txt");
|
|
232
|
+
await client.mkdir("/remote/new/path");
|
|
233
|
+
await client.rename("/remote/old.txt", "/remote/new.txt");
|
|
234
|
+
await client.remove("/remote/path/file.txt");
|
|
235
|
+
await client.uploadDir("/local/dir", "/remote/dir");
|
|
236
|
+
} finally {
|
|
237
|
+
await client.close();
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Important Notes
|
|
242
|
+
|
|
243
|
+
### Connection Management
|
|
244
|
+
|
|
245
|
+
- Using `StorageFactory.connect()` is recommended. The connection is automatically closed when the callback ends, and closure is guaranteed in the `finally` block even if an exception occurs.
|
|
246
|
+
- When using clients directly, you must call `close()` with a `try/finally` pattern. Otherwise, connections may leak.
|
|
247
|
+
- Calling `connect()` again on an already connected instance will cause an error. If reconnection is needed, call `close()` first.
|
|
248
|
+
- Calling `close()` when already closed does not cause an error.
|
|
249
|
+
|
|
250
|
+
### exists() Behavior
|
|
251
|
+
|
|
252
|
+
- FTP: Checks files with the `SIZE` command (O(1)), and on failure, lists the parent directory to check if a directory exists. Performance may degrade in directories with many entries.
|
|
253
|
+
- SFTP: Uses `ssh2-sftp-client`'s `exists()` method, returns `true` for files (`"-"`), directories (`"d"`), and symbolic links (`"l"`).
|
|
254
|
+
- Both implementations return `false` instead of throwing exceptions when the parent directory doesn't exist or on network/permission errors.
|
|
255
|
+
|
|
256
|
+
### Byte Data Type
|
|
257
|
+
|
|
258
|
+
- `Bytes` used in the return type of `readFile()` and input type of `put()` is a `Uint8Array` type alias defined in `@simplysm/core-common`.
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
Apache-2.0
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { bytesConcat, SdError } from "@simplysm/core-common";
|
|
2
|
+
import ftp from "basic-ftp";
|
|
3
|
+
import { PassThrough, Readable } from "stream";
|
|
4
|
+
class FtpStorageClient {
|
|
5
|
+
constructor(_secure = false) {
|
|
6
|
+
this._secure = _secure;
|
|
7
|
+
}
|
|
8
|
+
_client;
|
|
9
|
+
/**
|
|
10
|
+
* FTP 서버에 연결합니다.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* - 연결 후 반드시 {@link close}로 연결을 종료해야 합니다.
|
|
14
|
+
* - 동일 인스턴스에서 여러 번 호출하지 마세요. (연결 누수 발생)
|
|
15
|
+
* - 자동 연결/종료 관리가 필요하면 {@link StorageFactory.connect}를 사용하세요. (권장)
|
|
16
|
+
*/
|
|
17
|
+
async connect(config) {
|
|
18
|
+
if (this._client !== void 0) {
|
|
19
|
+
throw new SdError("\uC774\uBBF8 FTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 close()\uB97C \uD638\uCD9C\uD558\uC138\uC694.");
|
|
20
|
+
}
|
|
21
|
+
const client = new ftp.Client();
|
|
22
|
+
try {
|
|
23
|
+
await client.access({
|
|
24
|
+
host: config.host,
|
|
25
|
+
port: config.port,
|
|
26
|
+
user: config.user,
|
|
27
|
+
password: config.pass,
|
|
28
|
+
secure: this._secure
|
|
29
|
+
});
|
|
30
|
+
this._client = client;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
client.close();
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
_requireClient() {
|
|
37
|
+
if (this._client === void 0) {
|
|
38
|
+
throw new SdError("FTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4\uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
|
|
39
|
+
}
|
|
40
|
+
return this._client;
|
|
41
|
+
}
|
|
42
|
+
/** 디렉토리를 생성합니다. 상위 디렉토리가 없으면 함께 생성합니다. */
|
|
43
|
+
async mkdir(dirPath) {
|
|
44
|
+
await this._requireClient().ensureDir(dirPath);
|
|
45
|
+
}
|
|
46
|
+
async rename(fromPath, toPath) {
|
|
47
|
+
await this._requireClient().rename(fromPath, toPath);
|
|
48
|
+
}
|
|
49
|
+
async readdir(dirPath) {
|
|
50
|
+
const fileInfos = await this._requireClient().list(dirPath);
|
|
51
|
+
return fileInfos.map((item) => ({ name: item.name, isFile: item.isFile }));
|
|
52
|
+
}
|
|
53
|
+
async readFile(filePath) {
|
|
54
|
+
const client = this._requireClient();
|
|
55
|
+
const chunks = [];
|
|
56
|
+
const writable = new PassThrough();
|
|
57
|
+
writable.on("data", (chunk) => {
|
|
58
|
+
chunks.push(chunk);
|
|
59
|
+
});
|
|
60
|
+
await client.downloadTo(writable, filePath);
|
|
61
|
+
return bytesConcat(chunks);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 파일 또는 디렉토리 존재 여부를 확인합니다.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* 파일 확인 시 size() 명령으로 O(1) 성능을 제공합니다.
|
|
68
|
+
* 디렉토리 확인 시 상위 디렉토리 목록을 조회하므로, 항목 수가 많으면 성능이 저하될 수 있습니다.
|
|
69
|
+
*
|
|
70
|
+
* 슬래시가 없는 경로(예: `file.txt`)는 루트 디렉토리(`/`)에서 검색합니다.
|
|
71
|
+
*
|
|
72
|
+
* 상위 디렉토리가 존재하지 않는 경우에도 false를 반환합니다.
|
|
73
|
+
* 네트워크 오류, 권한 오류 등 모든 예외도 false를 반환합니다.
|
|
74
|
+
*/
|
|
75
|
+
async exists(filePath) {
|
|
76
|
+
try {
|
|
77
|
+
await this._requireClient().size(filePath);
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
try {
|
|
81
|
+
const lastSlash = filePath.lastIndexOf("/");
|
|
82
|
+
const dirPath = lastSlash > 0 ? filePath.substring(0, lastSlash) : "/";
|
|
83
|
+
const fileName = filePath.substring(lastSlash + 1);
|
|
84
|
+
const list = await this._requireClient().list(dirPath);
|
|
85
|
+
return list.some((item) => item.name === fileName);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async remove(filePath) {
|
|
92
|
+
await this._requireClient().remove(filePath);
|
|
93
|
+
}
|
|
94
|
+
/** 로컬 파일 경로 또는 바이트 데이터를 원격 경로에 업로드합니다. */
|
|
95
|
+
async put(localPathOrBuffer, storageFilePath) {
|
|
96
|
+
let param;
|
|
97
|
+
if (typeof localPathOrBuffer === "string") {
|
|
98
|
+
param = localPathOrBuffer;
|
|
99
|
+
} else {
|
|
100
|
+
param = Readable.from(localPathOrBuffer);
|
|
101
|
+
}
|
|
102
|
+
await this._requireClient().uploadFrom(param, storageFilePath);
|
|
103
|
+
}
|
|
104
|
+
async uploadDir(fromPath, toPath) {
|
|
105
|
+
await this._requireClient().uploadFromDir(fromPath, toPath);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 연결을 종료합니다.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* 이미 종료된 상태에서 호출해도 에러가 발생하지 않습니다.
|
|
112
|
+
* 종료 후에는 동일 인스턴스에서 {@link connect}를 다시 호출하여 재연결할 수 있습니다.
|
|
113
|
+
*/
|
|
114
|
+
close() {
|
|
115
|
+
if (this._client === void 0) {
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
}
|
|
118
|
+
this._client.close();
|
|
119
|
+
this._client = void 0;
|
|
120
|
+
return Promise.resolve();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export {
|
|
124
|
+
FtpStorageClient
|
|
125
|
+
};
|
|
126
|
+
//# sourceMappingURL=ftp-storage-client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/clients/ftp-storage-client.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Bytes } from \"@simplysm/core-common\";\nimport { bytesConcat, SdError } from \"@simplysm/core-common\";\nimport ftp from \"basic-ftp\";\nimport { PassThrough, Readable } from \"stream\";\nimport type { Storage, FileInfo } from \"../types/storage\";\nimport type { StorageConnConfig } from \"../types/storage-conn-config\";\n\n/**\n * FTP/FTPS \uD504\uB85C\uD1A0\uCF5C\uC744 \uC0AC\uC6A9\uD558\uB294 \uC2A4\uD1A0\uB9AC\uC9C0 \uD074\uB77C\uC774\uC5B8\uD2B8.\n *\n * @remarks\n * \uC0DD\uC131\uC790\uC758 `secure` \uD30C\uB77C\uBBF8\uD130\uB85C FTPS \uC0AC\uC6A9 \uC5EC\uBD80\uB97C \uC124\uC815\uD569\uB2C8\uB2E4.\n * \uC9C1\uC811 \uC0AC\uC6A9\uBCF4\uB2E4 {@link StorageFactory.connect}\uB97C \uD1B5\uD55C \uC0AC\uC6A9\uC744 \uAD8C\uC7A5\uD569\uB2C8\uB2E4.\n */\nexport class FtpStorageClient implements Storage {\n private _client: ftp.Client | undefined;\n\n constructor(private readonly _secure: boolean = false) {}\n\n /**\n * FTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uD569\uB2C8\uB2E4.\n *\n * @remarks\n * - \uC5F0\uACB0 \uD6C4 \uBC18\uB4DC\uC2DC {@link close}\uB85C \uC5F0\uACB0\uC744 \uC885\uB8CC\uD574\uC57C \uD569\uB2C8\uB2E4.\n * - \uB3D9\uC77C \uC778\uC2A4\uD134\uC2A4\uC5D0\uC11C \uC5EC\uB7EC \uBC88 \uD638\uCD9C\uD558\uC9C0 \uB9C8\uC138\uC694. (\uC5F0\uACB0 \uB204\uC218 \uBC1C\uC0DD)\n * - \uC790\uB3D9 \uC5F0\uACB0/\uC885\uB8CC \uAD00\uB9AC\uAC00 \uD544\uC694\uD558\uBA74 {@link StorageFactory.connect}\uB97C \uC0AC\uC6A9\uD558\uC138\uC694. (\uAD8C\uC7A5)\n */\n async connect(config: StorageConnConfig): Promise<void> {\n if (this._client !== undefined) {\n throw new SdError(\"\uC774\uBBF8 FTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 close()\uB97C \uD638\uCD9C\uD558\uC138\uC694.\");\n }\n const client = new ftp.Client();\n try {\n await client.access({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.pass,\n secure: this._secure,\n });\n this._client = client;\n } catch (err) {\n client.close();\n throw err;\n }\n }\n\n private _requireClient(): ftp.Client {\n if (this._client === undefined) {\n throw new SdError(\"FTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4\uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.\");\n }\n return this._client;\n }\n\n /** \uB514\uB809\uD1A0\uB9AC\uB97C \uC0DD\uC131\uD569\uB2C8\uB2E4. \uC0C1\uC704 \uB514\uB809\uD1A0\uB9AC\uAC00 \uC5C6\uC73C\uBA74 \uD568\uAED8 \uC0DD\uC131\uD569\uB2C8\uB2E4. */\n async mkdir(dirPath: string): Promise<void> {\n await this._requireClient().ensureDir(dirPath);\n }\n\n async rename(fromPath: string, toPath: string): Promise<void> {\n await this._requireClient().rename(fromPath, toPath);\n }\n\n async readdir(dirPath: string): Promise<FileInfo[]> {\n const fileInfos = await this._requireClient().list(dirPath);\n return fileInfos.map((item) => ({ name: item.name, isFile: item.isFile }));\n }\n\n async readFile(filePath: string): Promise<Bytes> {\n const client = this._requireClient();\n const chunks: Bytes[] = [];\n const writable = new PassThrough();\n writable.on(\"data\", (chunk: Uint8Array) => {\n chunks.push(chunk);\n });\n await client.downloadTo(writable, filePath);\n return bytesConcat(chunks);\n }\n\n /**\n * \uD30C\uC77C \uB610\uB294 \uB514\uB809\uD1A0\uB9AC \uC874\uC7AC \uC5EC\uBD80\uB97C \uD655\uC778\uD569\uB2C8\uB2E4.\n *\n * @remarks\n * \uD30C\uC77C \uD655\uC778 \uC2DC size() \uBA85\uB839\uC73C\uB85C O(1) \uC131\uB2A5\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.\n * \uB514\uB809\uD1A0\uB9AC \uD655\uC778 \uC2DC \uC0C1\uC704 \uB514\uB809\uD1A0\uB9AC \uBAA9\uB85D\uC744 \uC870\uD68C\uD558\uBBC0\uB85C, \uD56D\uBAA9 \uC218\uAC00 \uB9CE\uC73C\uBA74 \uC131\uB2A5\uC774 \uC800\uD558\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4.\n *\n * \uC2AC\uB798\uC2DC\uAC00 \uC5C6\uB294 \uACBD\uB85C(\uC608: `file.txt`)\uB294 \uB8E8\uD2B8 \uB514\uB809\uD1A0\uB9AC(`/`)\uC5D0\uC11C \uAC80\uC0C9\uD569\uB2C8\uB2E4.\n *\n * \uC0C1\uC704 \uB514\uB809\uD1A0\uB9AC\uAC00 \uC874\uC7AC\uD558\uC9C0 \uC54A\uB294 \uACBD\uC6B0\uC5D0\uB3C4 false\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4.\n * \uB124\uD2B8\uC6CC\uD06C \uC624\uB958, \uAD8C\uD55C \uC624\uB958 \uB4F1 \uBAA8\uB4E0 \uC608\uC678\uB3C4 false\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4.\n */\n async exists(filePath: string): Promise<boolean> {\n try {\n // \uD30C\uC77C\uC778 \uACBD\uC6B0 size()\uB85C \uBE60\uB974\uAC8C \uD655\uC778 (O(1))\n await this._requireClient().size(filePath);\n return true;\n } catch {\n // size() \uC2E4\uD328 \uC2DC \uB514\uB809\uD1A0\uB9AC\uC77C \uC218 \uC788\uC73C\uBBC0\uB85C list()\uB85C \uD655\uC778\n try {\n const lastSlash = filePath.lastIndexOf(\"/\");\n const dirPath = lastSlash > 0 ? filePath.substring(0, lastSlash) : \"/\";\n const fileName = filePath.substring(lastSlash + 1);\n const list = await this._requireClient().list(dirPath);\n return list.some((item) => item.name === fileName);\n } catch {\n return false;\n }\n }\n }\n\n async remove(filePath: string): Promise<void> {\n await this._requireClient().remove(filePath);\n }\n\n /** \uB85C\uCEEC \uD30C\uC77C \uACBD\uB85C \uB610\uB294 \uBC14\uC774\uD2B8 \uB370\uC774\uD130\uB97C \uC6D0\uACA9 \uACBD\uB85C\uC5D0 \uC5C5\uB85C\uB4DC\uD569\uB2C8\uB2E4. */\n async put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void> {\n let param: string | Readable;\n if (typeof localPathOrBuffer === \"string\") {\n param = localPathOrBuffer;\n } else {\n param = Readable.from(localPathOrBuffer);\n }\n await this._requireClient().uploadFrom(param, storageFilePath);\n }\n\n async uploadDir(fromPath: string, toPath: string): Promise<void> {\n await this._requireClient().uploadFromDir(fromPath, toPath);\n }\n\n /**\n * \uC5F0\uACB0\uC744 \uC885\uB8CC\uD569\uB2C8\uB2E4.\n *\n * @remarks\n * \uC774\uBBF8 \uC885\uB8CC\uB41C \uC0C1\uD0DC\uC5D0\uC11C \uD638\uCD9C\uD574\uB3C4 \uC5D0\uB7EC\uAC00 \uBC1C\uC0DD\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.\n * \uC885\uB8CC \uD6C4\uC5D0\uB294 \uB3D9\uC77C \uC778\uC2A4\uD134\uC2A4\uC5D0\uC11C {@link connect}\uB97C \uB2E4\uC2DC \uD638\uCD9C\uD558\uC5EC \uC7AC\uC5F0\uACB0\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.\n */\n close(): Promise<void> {\n if (this._client === undefined) {\n return Promise.resolve();\n }\n\n this._client.close();\n this._client = undefined;\n return Promise.resolve();\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { SdError } from "@simplysm/core-common";
|
|
2
|
+
import SftpClient from "ssh2-sftp-client";
|
|
3
|
+
class SftpStorageClient {
|
|
4
|
+
_client;
|
|
5
|
+
/**
|
|
6
|
+
* SFTP 서버에 연결합니다.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* - 연결 후 반드시 {@link close}로 연결을 종료해야 합니다.
|
|
10
|
+
* - 동일 인스턴스에서 여러 번 호출하지 마세요. (연결 누수 발생)
|
|
11
|
+
* - 자동 연결/종료 관리가 필요하면 {@link StorageFactory.connect}를 사용하세요. (권장)
|
|
12
|
+
*/
|
|
13
|
+
async connect(config) {
|
|
14
|
+
if (this._client !== void 0) {
|
|
15
|
+
throw new SdError("\uC774\uBBF8 SFTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 close()\uB97C \uD638\uCD9C\uD558\uC138\uC694.");
|
|
16
|
+
}
|
|
17
|
+
const client = new SftpClient();
|
|
18
|
+
try {
|
|
19
|
+
await client.connect({
|
|
20
|
+
host: config.host,
|
|
21
|
+
port: config.port,
|
|
22
|
+
username: config.user,
|
|
23
|
+
password: config.pass
|
|
24
|
+
});
|
|
25
|
+
this._client = client;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
await client.end();
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
_requireClient() {
|
|
32
|
+
if (this._client === void 0) {
|
|
33
|
+
throw new SdError("SFTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4\uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
|
|
34
|
+
}
|
|
35
|
+
return this._client;
|
|
36
|
+
}
|
|
37
|
+
/** 디렉토리를 생성합니다. 상위 디렉토리가 없으면 함께 생성합니다. */
|
|
38
|
+
async mkdir(dirPath) {
|
|
39
|
+
await this._requireClient().mkdir(dirPath, true);
|
|
40
|
+
}
|
|
41
|
+
async rename(fromPath, toPath) {
|
|
42
|
+
await this._requireClient().rename(fromPath, toPath);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 파일 또는 디렉토리 존재 여부를 확인합니다.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* 상위 디렉토리가 존재하지 않는 경우에도 false를 반환합니다.
|
|
49
|
+
* 네트워크 오류, 권한 오류 등 모든 예외도 false를 반환합니다.
|
|
50
|
+
*/
|
|
51
|
+
async exists(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
const result = await this._requireClient().exists(filePath);
|
|
54
|
+
return typeof result === "string";
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async readdir(dirPath) {
|
|
60
|
+
const list = await this._requireClient().list(dirPath);
|
|
61
|
+
return list.map((item) => ({
|
|
62
|
+
name: item.name,
|
|
63
|
+
isFile: item.type === "-"
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
async readFile(filePath) {
|
|
67
|
+
const result = await this._requireClient().get(filePath);
|
|
68
|
+
if (result instanceof Uint8Array) {
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
if (typeof result === "string") {
|
|
72
|
+
return new TextEncoder().encode(result);
|
|
73
|
+
}
|
|
74
|
+
throw new SdError("\uC608\uC0C1\uCE58 \uBABB\uD55C \uC751\uB2F5 \uD0C0\uC785\uC785\uB2C8\uB2E4.");
|
|
75
|
+
}
|
|
76
|
+
async remove(filePath) {
|
|
77
|
+
await this._requireClient().delete(filePath);
|
|
78
|
+
}
|
|
79
|
+
/** 로컬 파일 경로 또는 바이트 데이터를 원격 경로에 업로드합니다. */
|
|
80
|
+
async put(localPathOrBuffer, storageFilePath) {
|
|
81
|
+
if (typeof localPathOrBuffer === "string") {
|
|
82
|
+
await this._requireClient().fastPut(localPathOrBuffer, storageFilePath);
|
|
83
|
+
} else {
|
|
84
|
+
await this._requireClient().put(Buffer.from(localPathOrBuffer), storageFilePath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async uploadDir(fromPath, toPath) {
|
|
88
|
+
await this._requireClient().uploadDir(fromPath, toPath);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 연결을 종료합니다.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* 이미 종료된 상태에서 호출해도 에러가 발생하지 않습니다.
|
|
95
|
+
* 종료 후에는 동일 인스턴스에서 {@link connect}를 다시 호출하여 재연결할 수 있습니다.
|
|
96
|
+
*/
|
|
97
|
+
async close() {
|
|
98
|
+
if (this._client === void 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await this._client.end();
|
|
102
|
+
this._client = void 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
SftpStorageClient
|
|
107
|
+
};
|
|
108
|
+
//# sourceMappingURL=sftp-storage-client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/clients/sftp-storage-client.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Bytes } from \"@simplysm/core-common\";\nimport { SdError } from \"@simplysm/core-common\";\nimport SftpClient from \"ssh2-sftp-client\";\nimport type { Storage, FileInfo } from \"../types/storage\";\nimport type { StorageConnConfig } from \"../types/storage-conn-config\";\n\n// ssh2-sftp-client \uB77C\uC774\uBE0C\uB7EC\uB9AC \uD0C0\uC785 \uC815\uC758\uC5D0\uC11C Buffer \uC0AC\uC6A9\ntype SftpGetResult = string | NodeJS.WritableStream | Bytes;\n\n/**\n * SFTP \uD504\uB85C\uD1A0\uCF5C\uC744 \uC0AC\uC6A9\uD558\uB294 \uC2A4\uD1A0\uB9AC\uC9C0 \uD074\uB77C\uC774\uC5B8\uD2B8.\n *\n * @remarks\n * \uC9C1\uC811 \uC0AC\uC6A9\uBCF4\uB2E4 {@link StorageFactory.connect}\uB97C \uD1B5\uD55C \uC0AC\uC6A9\uC744 \uAD8C\uC7A5\uD569\uB2C8\uB2E4.\n */\nexport class SftpStorageClient implements Storage {\n private _client: SftpClient | undefined;\n\n /**\n * SFTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uD569\uB2C8\uB2E4.\n *\n * @remarks\n * - \uC5F0\uACB0 \uD6C4 \uBC18\uB4DC\uC2DC {@link close}\uB85C \uC5F0\uACB0\uC744 \uC885\uB8CC\uD574\uC57C \uD569\uB2C8\uB2E4.\n * - \uB3D9\uC77C \uC778\uC2A4\uD134\uC2A4\uC5D0\uC11C \uC5EC\uB7EC \uBC88 \uD638\uCD9C\uD558\uC9C0 \uB9C8\uC138\uC694. (\uC5F0\uACB0 \uB204\uC218 \uBC1C\uC0DD)\n * - \uC790\uB3D9 \uC5F0\uACB0/\uC885\uB8CC \uAD00\uB9AC\uAC00 \uD544\uC694\uD558\uBA74 {@link StorageFactory.connect}\uB97C \uC0AC\uC6A9\uD558\uC138\uC694. (\uAD8C\uC7A5)\n */\n async connect(config: StorageConnConfig): Promise<void> {\n if (this._client !== undefined) {\n throw new SdError(\"\uC774\uBBF8 SFTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 close()\uB97C \uD638\uCD9C\uD558\uC138\uC694.\");\n }\n\n const client = new SftpClient();\n try {\n await client.connect({\n host: config.host,\n port: config.port,\n username: config.user,\n password: config.pass,\n });\n this._client = client;\n } catch (err) {\n await client.end();\n throw err;\n }\n }\n\n private _requireClient(): SftpClient {\n if (this._client === undefined) {\n throw new SdError(\"SFTP \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uB418\uC5B4\uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.\");\n }\n return this._client;\n }\n\n /** \uB514\uB809\uD1A0\uB9AC\uB97C \uC0DD\uC131\uD569\uB2C8\uB2E4. \uC0C1\uC704 \uB514\uB809\uD1A0\uB9AC\uAC00 \uC5C6\uC73C\uBA74 \uD568\uAED8 \uC0DD\uC131\uD569\uB2C8\uB2E4. */\n async mkdir(dirPath: string): Promise<void> {\n await this._requireClient().mkdir(dirPath, true);\n }\n\n async rename(fromPath: string, toPath: string): Promise<void> {\n await this._requireClient().rename(fromPath, toPath);\n }\n\n /**\n * \uD30C\uC77C \uB610\uB294 \uB514\uB809\uD1A0\uB9AC \uC874\uC7AC \uC5EC\uBD80\uB97C \uD655\uC778\uD569\uB2C8\uB2E4.\n *\n * @remarks\n * \uC0C1\uC704 \uB514\uB809\uD1A0\uB9AC\uAC00 \uC874\uC7AC\uD558\uC9C0 \uC54A\uB294 \uACBD\uC6B0\uC5D0\uB3C4 false\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4.\n * \uB124\uD2B8\uC6CC\uD06C \uC624\uB958, \uAD8C\uD55C \uC624\uB958 \uB4F1 \uBAA8\uB4E0 \uC608\uC678\uB3C4 false\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4.\n */\n async exists(filePath: string): Promise<boolean> {\n try {\n // ssh2-sftp-client\uC758 exists()\uB294 false | 'd' | '-' | 'l' \uB97C \uBC18\uD658\uD55C\uB2E4.\n // false: \uC874\uC7AC\uD558\uC9C0 \uC54A\uC74C, 'd': \uB514\uB809\uD1A0\uB9AC, '-': \uD30C\uC77C, 'l': \uC2EC\uBCFC\uB9AD \uB9C1\uD06C\n const result = await this._requireClient().exists(filePath);\n return typeof result === \"string\";\n } catch {\n return false;\n }\n }\n\n async readdir(dirPath: string): Promise<FileInfo[]> {\n const list = await this._requireClient().list(dirPath);\n return list.map((item) => ({\n name: item.name,\n isFile: item.type === \"-\",\n }));\n }\n\n async readFile(filePath: string): Promise<Bytes> {\n // ssh2-sftp-client\uC758 get()\uC740 dst \uBBF8\uC804\uB2EC \uC2DC Buffer\uB97C \uBC18\uD658\uD55C\uB2E4.\n // \uD0C0\uC785 \uC815\uC758(string | WritableStream | Buffer)\uC640 \uB2EC\uB9AC \uC2E4\uC81C\uB85C\uB294 Buffer\uB9CC \uBC18\uD658\uB41C\uB2E4.\n const result = (await this._requireClient().get(filePath)) as SftpGetResult;\n if (result instanceof Uint8Array) {\n return result;\n }\n // \uD0C0\uC785 \uC815\uC758\uC0C1 string\uB3C4 \uAC00\uB2A5\uD558\uBBC0\uB85C \uBC29\uC5B4 \uCF54\uB4DC\n if (typeof result === \"string\") {\n return new TextEncoder().encode(result);\n }\n throw new SdError(\"\uC608\uC0C1\uCE58 \uBABB\uD55C \uC751\uB2F5 \uD0C0\uC785\uC785\uB2C8\uB2E4.\");\n }\n\n async remove(filePath: string): Promise<void> {\n await this._requireClient().delete(filePath);\n }\n\n /** \uB85C\uCEEC \uD30C\uC77C \uACBD\uB85C \uB610\uB294 \uBC14\uC774\uD2B8 \uB370\uC774\uD130\uB97C \uC6D0\uACA9 \uACBD\uB85C\uC5D0 \uC5C5\uB85C\uB4DC\uD569\uB2C8\uB2E4. */\n async put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void> {\n if (typeof localPathOrBuffer === \"string\") {\n await this._requireClient().fastPut(localPathOrBuffer, storageFilePath);\n } else {\n // eslint-disable-next-line no-restricted-globals -- ssh2-sftp-client \uB77C\uC774\uBE0C\uB7EC\uB9AC \uC694\uAD6C\uC0AC\uD56D\n await this._requireClient().put(Buffer.from(localPathOrBuffer), storageFilePath);\n }\n }\n\n async uploadDir(fromPath: string, toPath: string): Promise<void> {\n await this._requireClient().uploadDir(fromPath, toPath);\n }\n\n /**\n * \uC5F0\uACB0\uC744 \uC885\uB8CC\uD569\uB2C8\uB2E4.\n *\n * @remarks\n * \uC774\uBBF8 \uC885\uB8CC\uB41C \uC0C1\uD0DC\uC5D0\uC11C \uD638\uCD9C\uD574\uB3C4 \uC5D0\uB7EC\uAC00 \uBC1C\uC0DD\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.\n * \uC885\uB8CC \uD6C4\uC5D0\uB294 \uB3D9\uC77C \uC778\uC2A4\uD134\uC2A4\uC5D0\uC11C {@link connect}\uB97C \uB2E4\uC2DC \uD638\uCD9C\uD558\uC5EC \uC7AC\uC5F0\uACB0\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.\n */\n async close(): Promise<void> {\n if (this._client === undefined) {\n return;\n }\n await this._client.end();\n this._client = undefined;\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,eAAe;AACxB,OAAO,gBAAgB;AAahB,MAAM,kBAAqC;AAAA,EACxC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUR,MAAM,QAAQ,QAA0C;AACtD,QAAI,KAAK,YAAY,QAAW;AAC9B,YAAM,IAAI,QAAQ,oJAA2C;AAAA,IAC/D;AAEA,UAAM,SAAS,IAAI,WAAW;AAC9B,QAAI;AACF,YAAM,OAAO,QAAQ;AAAA,QACnB,MAAM,OAAO;AAAA,QACb,MAAM,OAAO;AAAA,QACb,UAAU,OAAO;AAAA,QACjB,UAAU,OAAO;AAAA,MACnB,CAAC;AACD,WAAK,UAAU;AAAA,IACjB,SAAS,KAAK;AACZ,YAAM,OAAO,IAAI;AACjB,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,iBAA6B;AACnC,QAAI,KAAK,YAAY,QAAW;AAC9B,YAAM,IAAI,QAAQ,wFAAuB;AAAA,IAC3C;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,MAAM,MAAM,SAAgC;AAC1C,UAAM,KAAK,eAAe,EAAE,MAAM,SAAS,IAAI;AAAA,EACjD;AAAA,EAEA,MAAM,OAAO,UAAkB,QAA+B;AAC5D,UAAM,KAAK,eAAe,EAAE,OAAO,UAAU,MAAM;AAAA,EACrD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,OAAO,UAAoC;AAC/C,QAAI;AAGF,YAAM,SAAS,MAAM,KAAK,eAAe,EAAE,OAAO,QAAQ;AAC1D,aAAO,OAAO,WAAW;AAAA,IAC3B,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,QAAQ,SAAsC;AAClD,UAAM,OAAO,MAAM,KAAK,eAAe,EAAE,KAAK,OAAO;AACrD,WAAO,KAAK,IAAI,CAAC,UAAU;AAAA,MACzB,MAAM,KAAK;AAAA,MACX,QAAQ,KAAK,SAAS;AAAA,IACxB,EAAE;AAAA,EACJ;AAAA,EAEA,MAAM,SAAS,UAAkC;AAG/C,UAAM,SAAU,MAAM,KAAK,eAAe,EAAE,IAAI,QAAQ;AACxD,QAAI,kBAAkB,YAAY;AAChC,aAAO;AAAA,IACT;AAEA,QAAI,OAAO,WAAW,UAAU;AAC9B,aAAO,IAAI,YAAY,EAAE,OAAO,MAAM;AAAA,IACxC;AACA,UAAM,IAAI,QAAQ,8EAAkB;AAAA,EACtC;AAAA,EAEA,MAAM,OAAO,UAAiC;AAC5C,UAAM,KAAK,eAAe,EAAE,OAAO,QAAQ;AAAA,EAC7C;AAAA;AAAA,EAGA,MAAM,IAAI,mBAAmC,iBAAwC;AACnF,QAAI,OAAO,sBAAsB,UAAU;AACzC,YAAM,KAAK,eAAe,EAAE,QAAQ,mBAAmB,eAAe;AAAA,IACxE,OAAO;AAEL,YAAM,KAAK,eAAe,EAAE,IAAI,OAAO,KAAK,iBAAiB,GAAG,eAAe;AAAA,IACjF;AAAA,EACF;AAAA,EAEA,MAAM,UAAU,UAAkB,QAA+B;AAC/D,UAAM,KAAK,eAAe,EAAE,UAAU,UAAU,MAAM;AAAA,EACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,QAAuB;AAC3B,QAAI,KAAK,YAAY,QAAW;AAC9B;AAAA,IACF;AACA,UAAM,KAAK,QAAQ,IAAI;AACvB,SAAK,UAAU;AAAA,EACjB;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DateTime } from "./types/date-time";
|
|
2
|
+
import { DateOnly } from "./types/date-only";
|
|
3
|
+
import { Time } from "./types/time";
|
|
4
|
+
import { Uuid } from "./types/uuid";
|
|
5
|
+
/**
|
|
6
|
+
* Buffer 대신 사용하는 바이너리 타입
|
|
7
|
+
*/
|
|
8
|
+
export type Bytes = Uint8Array;
|
|
9
|
+
/**
|
|
10
|
+
* Primitive 타입 매핑
|
|
11
|
+
* orm-common과 공유
|
|
12
|
+
*/
|
|
13
|
+
export type PrimitiveTypeMap = {
|
|
14
|
+
string: string;
|
|
15
|
+
number: number;
|
|
16
|
+
boolean: boolean;
|
|
17
|
+
DateTime: DateTime;
|
|
18
|
+
DateOnly: DateOnly;
|
|
19
|
+
Time: Time;
|
|
20
|
+
Uuid: Uuid;
|
|
21
|
+
Bytes: Bytes;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Primitive 타입 문자열 키
|
|
25
|
+
*/
|
|
26
|
+
export type PrimitiveTypeStr = keyof PrimitiveTypeMap;
|
|
27
|
+
/**
|
|
28
|
+
* Primitive 타입 유니온
|
|
29
|
+
*/
|
|
30
|
+
export type PrimitiveType = PrimitiveTypeMap[PrimitiveTypeStr] | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* 깊은 Partial 타입
|
|
33
|
+
*
|
|
34
|
+
* 객체의 모든 속성을 재귀적으로 선택적(optional)으로 만듭니다.
|
|
35
|
+
* Primitive 타입(string, number, boolean 등)은 그대로 유지하고,
|
|
36
|
+
* 객체/배열 타입만 재귀적으로 Partial을 적용합니다.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* interface User {
|
|
41
|
+
* name: string;
|
|
42
|
+
* profile: {
|
|
43
|
+
* age: number;
|
|
44
|
+
* address: { city: string };
|
|
45
|
+
* };
|
|
46
|
+
* }
|
|
47
|
+
*
|
|
48
|
+
* // 모든 깊이의 속성이 선택적이 됨
|
|
49
|
+
* const partial: DeepPartial<User> = {
|
|
50
|
+
* profile: { address: {} }
|
|
51
|
+
* };
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export type DeepPartial<T> = Partial<{
|
|
55
|
+
[K in keyof T]: T[K] extends PrimitiveType ? T[K] : DeepPartial<T[K]>;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* 생성자 타입
|
|
59
|
+
*
|
|
60
|
+
* 클래스 생성자를 타입으로 표현할 때 사용합니다.
|
|
61
|
+
* 주로 의존성 주입, 팩토리 패턴, instanceof 체크 등에서 활용됩니다.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* function create<T>(ctor: Type<T>): T {
|
|
65
|
+
* return new ctor();
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* class MyClass { name = "test"; }
|
|
69
|
+
* const instance = create(MyClass); // MyClass 인스턴스
|
|
70
|
+
*/
|
|
71
|
+
export interface Type<T> extends Function {
|
|
72
|
+
new (...args: unknown[]): T;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=common.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"common.types.d.ts","sourceRoot":"","sources":["../../../../core-common/src/common.types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAIpC;;GAEG;AACH,MAAM,MAAM,KAAK,GAAG,UAAU,CAAC;AAM/B;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;IACnB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,KAAK,CAAC;CACd,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,gBAAgB,CAAC;AAEtD;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,gBAAgB,CAAC,gBAAgB,CAAC,GAAG,SAAS,CAAC;AAM3E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI,OAAO,CAAC;KAClC,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,aAAa,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CACtE,CAAC,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,IAAI,CAAC,CAAC,CAAE,SAAQ,QAAQ;IACvC,KAAK,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC;CAC7B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../../../core-common/src/env.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,GAAG,EAAE;IAChB,GAAG,EAAE,OAAO,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAKxB,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SdError } from "./sd-error";
|
|
2
|
+
/**
|
|
3
|
+
* 인수 오류
|
|
4
|
+
*
|
|
5
|
+
* 잘못된 인수를 받았을 때 발생시키는 에러이다.
|
|
6
|
+
* 인수 객체를 YAML 형식으로 메시지에 포함하여 디버깅을 용이하게 한다.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // 인수 객체만 전달
|
|
10
|
+
* throw new ArgumentError({ userId: 123, name: null });
|
|
11
|
+
* // 결과 메시지: "인수가 잘못되었습니다.\n\nuserId: 123\nname: null"
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // 커스텀 메시지와 인수 객체 전달
|
|
15
|
+
* throw new ArgumentError("유효하지 않은 사용자", { userId: 123 });
|
|
16
|
+
* // 결과 메시지: "유효하지 않은 사용자\n\nuserId: 123"
|
|
17
|
+
*/
|
|
18
|
+
export declare class ArgumentError extends SdError {
|
|
19
|
+
/** 기본 메시지("인수가 잘못되었습니다.")와 함께 인수 객체를 YAML 형식으로 출력 */
|
|
20
|
+
constructor(argObj: Record<string, unknown>);
|
|
21
|
+
/** 커스텀 메시지와 함께 인수 객체를 YAML 형식으로 출력 */
|
|
22
|
+
constructor(message: string, argObj: Record<string, unknown>);
|
|
23
|
+
constructor(arg1: Record<string, unknown> | string, arg2?: Record<string, unknown>);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=argument-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"argument-error.d.ts","sourceRoot":"","sources":["../../../../../core-common/src/errors/argument-error.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAErC;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,aAAc,SAAQ,OAAO;IACxC,qDAAqD;gBACzC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC3C,sCAAsC;gBAC1B,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;gBAChD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAYnF"}
|