@spfn/core 0.2.0-beta.1 → 0.2.0-beta.10
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/README.md +262 -1092
- package/dist/{boss-D-fGtVgM.d.ts → boss-DI1r4kTS.d.ts} +68 -11
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +159 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +36 -0
- package/dist/config/index.js +15 -6
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.js +40 -6
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +82 -3
- package/dist/env/index.js +81 -14
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +87 -0
- package/dist/env/loader.js +70 -0
- package/dist/env/loader.js.map +1 -0
- package/dist/event/index.d.ts +3 -70
- package/dist/event/index.js +10 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +82 -0
- package/dist/event/sse/client.js +115 -0
- package/dist/event/sse/client.js.map +1 -0
- package/dist/event/sse/index.d.ts +40 -0
- package/dist/event/sse/index.js +92 -0
- package/dist/event/sse/index.js.map +1 -0
- package/dist/job/index.d.ts +54 -8
- package/dist/job/index.js +61 -12
- package/dist/job/index.js.map +1 -1
- package/dist/middleware/index.d.ts +102 -11
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +36 -4
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +62 -15
- package/dist/nextjs/server.js +102 -33
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +227 -15
- package/dist/route/index.js +307 -31
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +2 -31
- package/dist/router-Di7ENoah.d.ts +151 -0
- package/dist/server/index.d.ts +153 -6
- package/dist/server/index.js +216 -14
- package/dist/server/index.js.map +1 -1
- package/dist/types-B-e_f2dQ.d.ts +121 -0
- package/dist/{types-DRG2XMTR.d.ts → types-BOPTApC2.d.ts} +91 -3
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +346 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +477 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +116 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +241 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +307 -0
- package/package.json +18 -3
package/docs/env.md
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
|
|
3
|
+
Type-safe environment variable management with schema-based validation.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// src/config/env.ts
|
|
9
|
+
import {
|
|
10
|
+
defineEnvSchema,
|
|
11
|
+
envString,
|
|
12
|
+
envNumber,
|
|
13
|
+
envBoolean,
|
|
14
|
+
envEnum,
|
|
15
|
+
createEnvRegistry,
|
|
16
|
+
parsePostgresUrl,
|
|
17
|
+
} from '@spfn/core/env';
|
|
18
|
+
|
|
19
|
+
// 1. Define schema
|
|
20
|
+
const schema = defineEnvSchema({
|
|
21
|
+
DATABASE_URL: envString({
|
|
22
|
+
description: 'PostgreSQL connection URL',
|
|
23
|
+
required: true,
|
|
24
|
+
sensitive: true,
|
|
25
|
+
validator: parsePostgresUrl,
|
|
26
|
+
}),
|
|
27
|
+
PORT: envNumber({
|
|
28
|
+
description: 'Server port',
|
|
29
|
+
default: 3000,
|
|
30
|
+
}),
|
|
31
|
+
DEBUG: envBoolean({
|
|
32
|
+
description: 'Enable debug mode',
|
|
33
|
+
default: false,
|
|
34
|
+
}),
|
|
35
|
+
LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
|
|
36
|
+
description: 'Logging level',
|
|
37
|
+
default: 'info',
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 2. Create registry and validate
|
|
42
|
+
const registry = createEnvRegistry(schema);
|
|
43
|
+
export const env = registry.validate();
|
|
44
|
+
|
|
45
|
+
// 3. Use with full type safety
|
|
46
|
+
env.DATABASE_URL // string (required)
|
|
47
|
+
env.PORT // number (default: 3000)
|
|
48
|
+
env.DEBUG // boolean (default: false)
|
|
49
|
+
env.LOG_LEVEL // 'debug' | 'info' | 'warn' | 'error'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Schema Definition
|
|
55
|
+
|
|
56
|
+
### `defineEnvSchema(schema)`
|
|
57
|
+
|
|
58
|
+
Define environment variable schema with auto-filled keys.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
const schema = defineEnvSchema({
|
|
62
|
+
API_KEY: envString({ description: 'API key', required: true }),
|
|
63
|
+
// Automatically adds key: 'API_KEY'
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Schema Type Helpers
|
|
68
|
+
|
|
69
|
+
#### `envString(options)`
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
API_KEY: envString({
|
|
73
|
+
description: 'API authentication key',
|
|
74
|
+
required: true,
|
|
75
|
+
sensitive: true,
|
|
76
|
+
minLength: 32,
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### `envNumber(options)`
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
PORT: envNumber({
|
|
84
|
+
description: 'Server port',
|
|
85
|
+
default: 3000,
|
|
86
|
+
validator: createNumberParser({ min: 1, max: 65535 }),
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### `envBoolean(options)`
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
DEBUG: envBoolean({
|
|
94
|
+
description: 'Enable debug mode',
|
|
95
|
+
default: false,
|
|
96
|
+
})
|
|
97
|
+
// Parses: 'true', '1', 'yes' → true
|
|
98
|
+
// Parses: 'false', '0', 'no' → false
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### `envUrl(options)`
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
API_URL: envUrl({
|
|
105
|
+
description: 'API endpoint URL',
|
|
106
|
+
required: true,
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### `envEnum(allowed, options)`
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
|
|
114
|
+
description: 'Logging level',
|
|
115
|
+
default: 'info',
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### `envJson<T>(options)`
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
CONFIG: envJson<{ host: string; port: number }>({
|
|
123
|
+
description: 'JSON configuration',
|
|
124
|
+
required: true,
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Schema Options
|
|
129
|
+
|
|
130
|
+
| Option | Type | Description |
|
|
131
|
+
|--------|------|-------------|
|
|
132
|
+
| `description` | `string` | Variable description |
|
|
133
|
+
| `required` | `boolean` | Whether variable is required |
|
|
134
|
+
| `default` | `T` | Default value if not set |
|
|
135
|
+
| `validator` | `(value: string) => T` | Custom validation/transform |
|
|
136
|
+
| `fallbackKeys` | `string[]` | Fallback environment variable keys |
|
|
137
|
+
| `minLength` | `number` | Minimum string length |
|
|
138
|
+
| `sensitive` | `boolean` | Mark as sensitive (masked in logs) |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## EnvRegistry
|
|
143
|
+
|
|
144
|
+
### Creating Registry
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import { createEnvRegistry } from '@spfn/core/env';
|
|
148
|
+
|
|
149
|
+
const registry = createEnvRegistry(schema);
|
|
150
|
+
const env = registry.validate();
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Lazy Validation
|
|
154
|
+
|
|
155
|
+
Values are validated when accessed (Proxy-based):
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
const env = registry.validate();
|
|
159
|
+
|
|
160
|
+
// Later, when accessed:
|
|
161
|
+
console.log(env.DATABASE_URL); // Validates at this point
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Fallback Keys
|
|
165
|
+
|
|
166
|
+
Support legacy environment variable names:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
DATABASE_URL: envString({
|
|
170
|
+
description: 'Database URL',
|
|
171
|
+
required: true,
|
|
172
|
+
fallbackKeys: ['DB_URL', 'POSTGRES_URL'],
|
|
173
|
+
})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Parsers
|
|
179
|
+
|
|
180
|
+
### String Parsers
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { parseString, createStringParser } from '@spfn/core/env';
|
|
184
|
+
|
|
185
|
+
parseString(' hello '); // 'hello'
|
|
186
|
+
|
|
187
|
+
const apiKeyParser = createStringParser({
|
|
188
|
+
minLength: 32,
|
|
189
|
+
maxLength: 128,
|
|
190
|
+
pattern: /^[A-Za-z0-9_-]+$/,
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Number Parsers
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { parseNumber, createNumberParser, parseInteger } from '@spfn/core/env';
|
|
198
|
+
|
|
199
|
+
parseNumber('42'); // 42
|
|
200
|
+
|
|
201
|
+
const portParser = createNumberParser({
|
|
202
|
+
min: 1,
|
|
203
|
+
max: 65535,
|
|
204
|
+
integer: true,
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### URL Parsers
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { parseUrl, parsePostgresUrl, parseRedisUrl } from '@spfn/core/env';
|
|
212
|
+
|
|
213
|
+
parseUrl('https://api.example.com');
|
|
214
|
+
parsePostgresUrl('postgres://user:pass@localhost:5432/db');
|
|
215
|
+
parseRedisUrl('redis://localhost:6379');
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Enum Parser
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { parseEnum, createEnumParser } from '@spfn/core/env';
|
|
222
|
+
|
|
223
|
+
parseEnum('info', ['debug', 'info', 'warn', 'error']);
|
|
224
|
+
|
|
225
|
+
const logLevelParser = createEnumParser(
|
|
226
|
+
['debug', 'info', 'warn', 'error'],
|
|
227
|
+
true // case-insensitive
|
|
228
|
+
);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Array Parser
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import { parseArray, createArrayParser } from '@spfn/core/env';
|
|
235
|
+
|
|
236
|
+
parseArray('a,b,c'); // ['a', 'b', 'c']
|
|
237
|
+
parseArray('a|b|c', { separator: '|' }); // ['a', 'b', 'c']
|
|
238
|
+
|
|
239
|
+
const portsParser = createArrayParser(
|
|
240
|
+
createNumberParser({ min: 1, max: 65535, integer: true })
|
|
241
|
+
);
|
|
242
|
+
portsParser('3000,4000,5000'); // [3000, 4000, 5000]
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Security Parsers
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { createSecureSecretParser, createPasswordParser } from '@spfn/core/env';
|
|
249
|
+
|
|
250
|
+
// Entropy-based secret validation
|
|
251
|
+
const secretParser = createSecureSecretParser({
|
|
252
|
+
minLength: 32,
|
|
253
|
+
minUniqueChars: 16,
|
|
254
|
+
minEntropy: 3.5,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Password strength validation
|
|
258
|
+
const passwordParser = createPasswordParser({
|
|
259
|
+
minLength: 12,
|
|
260
|
+
requireUppercase: true,
|
|
261
|
+
requireLowercase: true,
|
|
262
|
+
requireNumber: true,
|
|
263
|
+
requireSpecial: true,
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Parser Composition
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
import { chain, withFallback, optional } from '@spfn/core/env';
|
|
271
|
+
|
|
272
|
+
// Chain parsers
|
|
273
|
+
const apiKeyParser = chain(
|
|
274
|
+
parseString,
|
|
275
|
+
createStringParser({ minLength: 32 })
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Fallback value
|
|
279
|
+
const configParser = withFallback(parseJson, { host: 'localhost' });
|
|
280
|
+
|
|
281
|
+
// Optional (returns undefined for empty)
|
|
282
|
+
const optionalRedisParser = optional(parseRedisUrl);
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Environment File Loading
|
|
288
|
+
|
|
289
|
+
### SPFN Server
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
import { loadEnv } from '@spfn/core/env/loader';
|
|
293
|
+
|
|
294
|
+
loadEnv(); // Loads .env, .env.local, .env.server, .env.server.local
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Loading Priority
|
|
298
|
+
|
|
299
|
+
1. `.env` - 기본값
|
|
300
|
+
2. `.env.local` - 로컬 오버라이드
|
|
301
|
+
3. `.env.server` - 서버 전용 기본값
|
|
302
|
+
4. `.env.server.local` - 서버 전용 민감정보
|
|
303
|
+
|
|
304
|
+
### Options
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
loadEnv({
|
|
308
|
+
cwd: '/path/to/project',
|
|
309
|
+
debug: true,
|
|
310
|
+
override: false,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Load once (prevent duplicate calls)
|
|
314
|
+
import { loadEnvOnce } from '@spfn/core/env/loader';
|
|
315
|
+
loadEnvOnce();
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Security Separation (Next.js + SPFN)
|
|
321
|
+
|
|
322
|
+
### File Structure
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
project/
|
|
326
|
+
├── .env # 기본값 (커밋 O)
|
|
327
|
+
├── .env.local # Next.js용 (커밋 X)
|
|
328
|
+
├── .env.server # SPFN 전용 기본값 (커밋 O)
|
|
329
|
+
└── .env.server.local # SPFN 전용 민감정보 (커밋 X)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Which File for What?
|
|
333
|
+
|
|
334
|
+
| 환경변수 | 파일 | 이유 |
|
|
335
|
+
|----------|------|------|
|
|
336
|
+
| `NEXT_PUBLIC_*` | `.env.local` | 브라우저 노출 OK |
|
|
337
|
+
| `SPFN_API_URL` | `.env.local` | Next.js에서 사용 |
|
|
338
|
+
| `DATABASE_URL` | `.env.server.local` | SPFN 전용, 민감정보 |
|
|
339
|
+
| `SESSION_SECRET` | `.env.server.local` | SPFN 전용, 민감정보 |
|
|
340
|
+
|
|
341
|
+
### Schema with `nextjs` Option
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
DATABASE_URL: envString({
|
|
345
|
+
description: 'PostgreSQL connection URL',
|
|
346
|
+
required: true,
|
|
347
|
+
sensitive: true,
|
|
348
|
+
nextjs: false, // SPFN 서버에서만 사용
|
|
349
|
+
}),
|
|
350
|
+
|
|
351
|
+
SPFN_API_URL: envString({
|
|
352
|
+
description: 'Backend API URL',
|
|
353
|
+
required: true,
|
|
354
|
+
nextjs: true, // Next.js에서도 사용
|
|
355
|
+
}),
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Type Inference
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
import type { InferEnvType } from '@spfn/core/env';
|
|
364
|
+
|
|
365
|
+
const schema = defineEnvSchema({
|
|
366
|
+
DATABASE_URL: envString({ required: true }),
|
|
367
|
+
PORT: envNumber({ default: 3000 }),
|
|
368
|
+
DEBUG: envBoolean({}),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
type Env = InferEnvType<typeof schema>;
|
|
372
|
+
// {
|
|
373
|
+
// DATABASE_URL: string; // required
|
|
374
|
+
// PORT: number; // has default
|
|
375
|
+
// DEBUG?: boolean | undefined; // optional
|
|
376
|
+
// }
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Complete Example
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
// src/config/env.ts
|
|
385
|
+
import {
|
|
386
|
+
defineEnvSchema,
|
|
387
|
+
envString,
|
|
388
|
+
envNumber,
|
|
389
|
+
envBoolean,
|
|
390
|
+
envEnum,
|
|
391
|
+
createEnvRegistry,
|
|
392
|
+
parsePostgresUrl,
|
|
393
|
+
createSecureSecretParser,
|
|
394
|
+
createNumberParser,
|
|
395
|
+
} from '@spfn/core/env';
|
|
396
|
+
|
|
397
|
+
const schema = defineEnvSchema({
|
|
398
|
+
// Database
|
|
399
|
+
DATABASE_URL: envString({
|
|
400
|
+
description: 'PostgreSQL connection URL',
|
|
401
|
+
required: true,
|
|
402
|
+
sensitive: true,
|
|
403
|
+
validator: parsePostgresUrl,
|
|
404
|
+
}),
|
|
405
|
+
|
|
406
|
+
// Server
|
|
407
|
+
PORT: envNumber({
|
|
408
|
+
description: 'Server port',
|
|
409
|
+
default: 3000,
|
|
410
|
+
validator: createNumberParser({ min: 1, max: 65535, integer: true }),
|
|
411
|
+
}),
|
|
412
|
+
|
|
413
|
+
// Security
|
|
414
|
+
SESSION_SECRET: envString({
|
|
415
|
+
description: 'Session encryption secret',
|
|
416
|
+
required: true,
|
|
417
|
+
sensitive: true,
|
|
418
|
+
validator: createSecureSecretParser({ minLength: 32 }),
|
|
419
|
+
}),
|
|
420
|
+
|
|
421
|
+
// Environment
|
|
422
|
+
NODE_ENV: envEnum(['development', 'staging', 'production', 'test'] as const, {
|
|
423
|
+
description: 'Node environment',
|
|
424
|
+
default: 'development',
|
|
425
|
+
}),
|
|
426
|
+
|
|
427
|
+
// Logging
|
|
428
|
+
LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
|
|
429
|
+
description: 'Log level',
|
|
430
|
+
default: 'info',
|
|
431
|
+
}),
|
|
432
|
+
|
|
433
|
+
// Optional
|
|
434
|
+
REDIS_URL: envString({
|
|
435
|
+
description: 'Redis connection URL',
|
|
436
|
+
required: false,
|
|
437
|
+
}),
|
|
438
|
+
|
|
439
|
+
DEBUG: envBoolean({
|
|
440
|
+
description: 'Enable debug mode',
|
|
441
|
+
default: false,
|
|
442
|
+
}),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const registry = createEnvRegistry(schema);
|
|
446
|
+
export const env = registry.validate();
|
|
447
|
+
export type Env = typeof env;
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Best Practices
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
// 1. Centralize in single file
|
|
456
|
+
// src/config/env.ts
|
|
457
|
+
|
|
458
|
+
// 2. Use descriptive descriptions
|
|
459
|
+
DATABASE_URL: envString({
|
|
460
|
+
description: 'PostgreSQL connection URL for primary database',
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
// 3. Mark sensitive variables
|
|
464
|
+
API_SECRET: envString({
|
|
465
|
+
sensitive: true,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
// 4. Provide fallback keys for migrations
|
|
469
|
+
DATABASE_URL: envString({
|
|
470
|
+
fallbackKeys: ['DB_URL', 'POSTGRES_URL'],
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
// 5. Use strong validators for secrets
|
|
474
|
+
SESSION_SECRET: envString({
|
|
475
|
+
validator: createSecureSecretParser({ minLength: 32, minEntropy: 3.5 }),
|
|
476
|
+
})
|
|
477
|
+
```
|