@pg-boss/proxy 0.1.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/README.md +419 -0
- package/dist/auth.d.ts +5 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +14 -0
- package/dist/contracts.d.ts +288 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +424 -0
- package/dist/home.d.ts +13 -0
- package/dist/home.d.ts.map +1 -0
- package/dist/home.js +145 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +270 -0
- package/dist/node.d.ts +31 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +78 -0
- package/dist/routes.d.ts +22 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +128 -0
- package/dist/shutdown.d.ts +12 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +37 -0
- package/dist/types.d.ts +234 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# @pg-boss/proxy
|
|
2
|
+
|
|
3
|
+
A HTTP proxy for pg-boss methods, to support use cases such as platform compatibility and connection pooling or scalability.
|
|
4
|
+
|
|
5
|
+
All background processing is disabled by default (the opposite of how pg-boss normally works). A pg-boss instance is started via `start()`, which opens the database connection.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
**As a library** (import into your own Node app):
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createProxyServerNode } from '@pg-boss/proxy/node'
|
|
13
|
+
|
|
14
|
+
const proxy = createProxyServerNode()
|
|
15
|
+
await proxy.start()
|
|
16
|
+
// Reads DATABASE_URL from process.env, listens on PORT (default 3000)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**From source** (clone the repo and run the built-in dev server):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
DATABASE_URL=postgres://user:pass@host/database npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then visit:
|
|
26
|
+
- http://localhost:3000 - Proxy home page with links to all endpoints
|
|
27
|
+
- http://localhost:3000/docs - Interactive Swagger documentation
|
|
28
|
+
- http://localhost:3000/openapi.json - OpenAPI spec
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## API Usage Examples
|
|
32
|
+
|
|
33
|
+
Once the proxy is running, you can interact with it using any HTTP client:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Send a job to a queue
|
|
37
|
+
curl -X POST http://localhost:3000/api/send \
|
|
38
|
+
-H "Content-Type: application/json" \
|
|
39
|
+
-d '{"name": "my-queue", "data": {"key": "value"}}'
|
|
40
|
+
|
|
41
|
+
# Fetch jobs from a queue
|
|
42
|
+
curl -X POST http://localhost:3000/api/fetch \
|
|
43
|
+
-H "Content-Type: application/json" \
|
|
44
|
+
-d '{"name": "my-queue"}'
|
|
45
|
+
|
|
46
|
+
# Get queue information
|
|
47
|
+
curl "http://localhost:3000/api/getQueue?name=my-queue"
|
|
48
|
+
|
|
49
|
+
# Get all queues
|
|
50
|
+
curl "http://localhost:3000/api/getQueues"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Response Format
|
|
54
|
+
|
|
55
|
+
All endpoints return a consistent JSON envelope:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
// Success
|
|
59
|
+
{ "ok": true, "result": <value | null> }
|
|
60
|
+
|
|
61
|
+
// Error
|
|
62
|
+
{ "ok": false, "error": { "message": "..." } }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The `result` field contains the direct return value of the underlying pg-boss method. HTTP status codes used: `200` for success, `400` for invalid input, `413` for body too large, and `500` for server errors.
|
|
66
|
+
|
|
67
|
+
## Entry Points
|
|
68
|
+
|
|
69
|
+
This package ships a runtime-neutral entry point and a Node-only entry point.
|
|
70
|
+
|
|
71
|
+
### Runtime-neutral (default)
|
|
72
|
+
|
|
73
|
+
Use this when you want a runtime-neutral entry point:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { createProxyService } from '@pg-boss/proxy'
|
|
77
|
+
|
|
78
|
+
const { app, start, stop } = createProxyService({
|
|
79
|
+
options: {
|
|
80
|
+
connectionString: 'postgres://user:pass@host/database'
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await start()
|
|
85
|
+
// later
|
|
86
|
+
await stop()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
If you only need the Hono app and will manage lifecycle yourself:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { createProxyApp } from '@pg-boss/proxy'
|
|
93
|
+
|
|
94
|
+
const { app, boss } = createProxyApp({
|
|
95
|
+
options: {
|
|
96
|
+
connectionString: 'postgres://user:pass@host/database'
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
await boss.start()
|
|
101
|
+
// Use with any Hono-compatible server
|
|
102
|
+
serve({ fetch: app.fetch, port: 3000 })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Node Convenience Entry Point
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { createProxyServiceNode } from '@pg-boss/proxy/node'
|
|
109
|
+
|
|
110
|
+
const { app, start, stop } = createProxyServiceNode()
|
|
111
|
+
|
|
112
|
+
await start()
|
|
113
|
+
// later
|
|
114
|
+
await stop()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If you want a ready-to-listen Node server with automatic shutdown signal wiring:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { createProxyServerNode } from '@pg-boss/proxy/node'
|
|
121
|
+
|
|
122
|
+
const proxy = createProxyServerNode()
|
|
123
|
+
await proxy.start()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`createProxyServerNode` accepts all `ProxyOptions` plus the following Node-specific options:
|
|
127
|
+
|
|
128
|
+
| Option | Type | Default | Description |
|
|
129
|
+
|---|---|---|---|
|
|
130
|
+
| `port` | `number` | `PORT` env or `3000` | Port to listen on |
|
|
131
|
+
| `hostname` | `string` | `HOST` env or `localhost` | Hostname to bind |
|
|
132
|
+
| `shutdownSignals` | `NodeJS.Signals[]` | `['SIGINT', 'SIGTERM']` | Signals that trigger graceful shutdown |
|
|
133
|
+
| `attachSignals` | `boolean` | `true` | Auto-attach shutdown signal handlers |
|
|
134
|
+
| `onListen` | `(info: { port: number }) => void` | - | Called after the server starts listening |
|
|
135
|
+
|
|
136
|
+
You can override environment lookups by passing an `env` object:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const proxy = createProxyServerNode({
|
|
140
|
+
env: {
|
|
141
|
+
DATABASE_URL: 'postgres://user:pass@host/database',
|
|
142
|
+
HOST: '0.0.0.0',
|
|
143
|
+
PORT: '8080'
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Lifecycle Wiring by Runtime
|
|
149
|
+
|
|
150
|
+
`createProxyServerNode` automatically attaches `SIGINT` and `SIGTERM` handlers. Set `attachSignals: false` to opt out and manage shutdown yourself.
|
|
151
|
+
|
|
152
|
+
For `createProxyService` and `createProxyApp` (runtime-neutral), or for non-Node runtimes, wire shutdown manually using `attachShutdownListeners` and the appropriate adapter:
|
|
153
|
+
|
|
154
|
+
### Node
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { attachShutdownListeners, createProxyService, nodeShutdownAdapter } from '@pg-boss/proxy'
|
|
158
|
+
|
|
159
|
+
const { app, start, stop } = createProxyService({
|
|
160
|
+
options: { connectionString: process.env.DATABASE_URL }
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await start()
|
|
164
|
+
|
|
165
|
+
attachShutdownListeners(['SIGINT', 'SIGTERM'], nodeShutdownAdapter, stop)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Deno
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { attachShutdownListeners, createDenoShutdownAdapter, createProxyService } from '@pg-boss/proxy'
|
|
172
|
+
|
|
173
|
+
const { start, stop } = createProxyService({
|
|
174
|
+
options: {
|
|
175
|
+
connectionString: Deno.env.get('DATABASE_URL')
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await start()
|
|
180
|
+
|
|
181
|
+
attachShutdownListeners(['SIGINT', 'SIGTERM'], createDenoShutdownAdapter(), stop)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Bun
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { attachShutdownListeners, createProxyService, bunShutdownAdapter } from '@pg-boss/proxy'
|
|
188
|
+
|
|
189
|
+
const { start, stop } = createProxyService({
|
|
190
|
+
options: { connectionString: process.env.DATABASE_URL }
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await start()
|
|
194
|
+
|
|
195
|
+
attachShutdownListeners(['SIGINT', 'SIGTERM'], bunShutdownAdapter, stop)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
The proxy accepts the following options:
|
|
201
|
+
|
|
202
|
+
| Option | Type | Default | Description |
|
|
203
|
+
|--------|------|---------|-------------|
|
|
204
|
+
| `options` | `ConstructorOptions` | - | PgBoss constructor options (see below) |
|
|
205
|
+
| `prefix` | `string` | `/api` | URL prefix for all API routes |
|
|
206
|
+
| `env` | `Record<string, string>` | `process.env` | Environment variables |
|
|
207
|
+
| `middleware` | `MiddlewareHandler \| MiddlewareHandler[]` | - | Hono middleware to apply to API routes |
|
|
208
|
+
| `exposeErrors` | `boolean` | `false` | Return actual error messages to clients |
|
|
209
|
+
| `bodyLimit` | `number` | `1048576` (1MB) | Max request body size in bytes |
|
|
210
|
+
| `routes.allow` | `string[]` | all | List of pg-boss methods to expose |
|
|
211
|
+
| `routes.deny` | `string[]` | none | List of pg-boss methods to exclude |
|
|
212
|
+
| `pages.root` | `boolean` | `true` | Enable/disable the root page (`/`) |
|
|
213
|
+
| `pages.docs` | `boolean` | `true` | Enable/disable Swagger docs (`/docs`) |
|
|
214
|
+
| `pages.openapi` | `boolean` | `true` | Enable/disable OpenAPI spec (`/openapi.json`) |
|
|
215
|
+
|
|
216
|
+
### Environment Variables
|
|
217
|
+
|
|
218
|
+
| Variable | Default | Description |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| `DATABASE_URL` | - | PostgreSQL connection string |
|
|
221
|
+
| `PORT` | `3000` | Listening port (Node entry point only) |
|
|
222
|
+
| `HOST` | `localhost` | Listening hostname (Node entry point only) |
|
|
223
|
+
| `PGBOSS_PROXY_AUTH_USERNAME` | - | Basic auth username (must be set with password) |
|
|
224
|
+
| `PGBOSS_PROXY_AUTH_PASSWORD` | - | Basic auth password (must be set with username) |
|
|
225
|
+
|
|
226
|
+
### PgBoss Constructor Options
|
|
227
|
+
|
|
228
|
+
You can pass any PgBoss constructor options via the `options` object:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const { app, boss } = createProxyApp({
|
|
232
|
+
options: {
|
|
233
|
+
connectionString: 'postgres://user:pass@host/database',
|
|
234
|
+
schema: 'custom',
|
|
235
|
+
supervise: true, // enable job supervision (disabled by default)
|
|
236
|
+
schedule: true, // enable job scheduling (disabled by default)
|
|
237
|
+
migrate: true // run migrations on startup (disabled by default)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
By default, `supervise`, `schedule`, and `migrate` are set to `false` to run the proxy in a stateless manner. Set any of these to `true` to enable that functionality.
|
|
243
|
+
|
|
244
|
+
### Request Logging
|
|
245
|
+
|
|
246
|
+
All requests are logged to stdout via the built-in `hono/logger` middleware.
|
|
247
|
+
|
|
248
|
+
### Authentication
|
|
249
|
+
|
|
250
|
+
Basic auth can be enabled via environment variables:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
PGBOSS_PROXY_AUTH_USERNAME=admin
|
|
254
|
+
PGBOSS_PROXY_AUTH_PASSWORD=secret
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Both variables must be set together. When enabled, auth is applied to all routes under the prefix (e.g., `/api/*`). The root page (`/`), Swagger docs (`/docs`), and OpenAPI spec (`/openapi.json`) sit outside the prefix and remain publicly accessible.
|
|
258
|
+
|
|
259
|
+
### Custom Middleware
|
|
260
|
+
|
|
261
|
+
You can add custom Hono middleware to the API routes:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { cors } from 'hono/cors'
|
|
265
|
+
|
|
266
|
+
const { app, boss } = createProxyApp({
|
|
267
|
+
options: { connectionString: 'postgres://user:pass@host/database' },
|
|
268
|
+
middleware: [
|
|
269
|
+
// Add CORS headers
|
|
270
|
+
cors({
|
|
271
|
+
origin: ['https://myapp.com'],
|
|
272
|
+
credentials: true
|
|
273
|
+
}),
|
|
274
|
+
// Add any other Hono-compatible middleware here
|
|
275
|
+
]
|
|
276
|
+
})
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Custom PgBoss Factory
|
|
280
|
+
|
|
281
|
+
For advanced customization, you can provide a custom `bossFactory` function to wrap or modify pg-boss behavior:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { PgBoss } from 'pg-boss'
|
|
285
|
+
|
|
286
|
+
const { app, boss } = createProxyApp({
|
|
287
|
+
bossFactory: (options) => {
|
|
288
|
+
const instance = new PgBoss({
|
|
289
|
+
...options,
|
|
290
|
+
// Custom configuration
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// Wrap methods with logging
|
|
294
|
+
const originalSend = instance.send.bind(instance)
|
|
295
|
+
instance.send = async (...args) => {
|
|
296
|
+
console.log('send called with:', args)
|
|
297
|
+
return originalSend(...args)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return instance
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await boss.start()
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Route Filtering
|
|
308
|
+
|
|
309
|
+
You can allowlist or denylist pg-boss methods to control which API routes are exposed:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const { app, boss } = createProxyApp({
|
|
313
|
+
options: { connectionString: 'postgres://user:pass@host/database' },
|
|
314
|
+
routes: {
|
|
315
|
+
// Only expose safe operations (default: all methods are exposed)
|
|
316
|
+
allow: ['send', 'fetch', 'complete', 'fail', 'getQueue', 'getQueues']
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Or deny specific methods:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
const { app, boss } = createProxyApp({
|
|
325
|
+
options: { connectionString: 'postgres://user:pass@host/database' },
|
|
326
|
+
routes: {
|
|
327
|
+
// Exclude destructive operations
|
|
328
|
+
deny: ['deleteQueue', 'deleteAllJobs', 'deleteStoredJobs']
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Disabling Pages
|
|
334
|
+
|
|
335
|
+
You can disable the root page, docs, or OpenAPI spec:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
const { app, boss } = createProxyApp({
|
|
339
|
+
options: { connectionString: 'postgres://user:pass@host/database' },
|
|
340
|
+
pages: {
|
|
341
|
+
root: false, // Disable the home page
|
|
342
|
+
docs: false, // Disable Swagger UI
|
|
343
|
+
openapi: false // Disable OpenAPI JSON endpoint
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Complete Production Example
|
|
349
|
+
|
|
350
|
+
Here's a production-ready setup with authentication, CORS, and restricted routes:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import { createProxyServerNode } from '@pg-boss/proxy/node'
|
|
354
|
+
import { cors } from 'hono/cors'
|
|
355
|
+
|
|
356
|
+
const proxy = createProxyServerNode({
|
|
357
|
+
options: {
|
|
358
|
+
connectionString: process.env.DATABASE_URL,
|
|
359
|
+
schema: 'pgboss'
|
|
360
|
+
},
|
|
361
|
+
prefix: '/api',
|
|
362
|
+
middleware: [
|
|
363
|
+
cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [] })
|
|
364
|
+
],
|
|
365
|
+
routes: {
|
|
366
|
+
// Only expose safe operations
|
|
367
|
+
allow: [
|
|
368
|
+
'send',
|
|
369
|
+
'sendAfter',
|
|
370
|
+
'sendDebounced',
|
|
371
|
+
'sendThrottled',
|
|
372
|
+
'fetch',
|
|
373
|
+
'complete',
|
|
374
|
+
'fail',
|
|
375
|
+
'cancel',
|
|
376
|
+
'retry',
|
|
377
|
+
'getQueue',
|
|
378
|
+
'getQueues',
|
|
379
|
+
'getSchedules',
|
|
380
|
+
'findJobs'
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
bodyLimit: 1024 * 1024, // 1MB
|
|
384
|
+
exposeErrors: false
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// Server will use HOST and PORT from env (defaults: localhost:3000)
|
|
388
|
+
await proxy.start()
|
|
389
|
+
|
|
390
|
+
console.log(`pg-boss proxy running at http://${proxy.hostname}:${proxy.port}`)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Running from Source
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
# Start dev server
|
|
397
|
+
DATABASE_URL=postgres://user:pass@host/database npm run dev
|
|
398
|
+
|
|
399
|
+
# With custom port
|
|
400
|
+
PORT=8080 DATABASE_URL=postgres://user:pass@host/database npm run dev
|
|
401
|
+
|
|
402
|
+
# With authentication
|
|
403
|
+
DATABASE_URL=postgres://user:pass@host/database \
|
|
404
|
+
PGBOSS_PROXY_AUTH_USERNAME=admin \
|
|
405
|
+
PGBOSS_PROXY_AUTH_PASSWORD=secret \
|
|
406
|
+
npm run dev
|
|
407
|
+
|
|
408
|
+
# Build for production
|
|
409
|
+
npm run build
|
|
410
|
+
|
|
411
|
+
# Start production server
|
|
412
|
+
npm start
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## API Reference
|
|
416
|
+
|
|
417
|
+
- [Interactive API Docs](http://localhost:3000/docs) - Swagger UI for exploring all endpoints
|
|
418
|
+
- [OpenAPI Spec](http://localhost:3000/openapi.json) - Machine-readable API specification
|
|
419
|
+
- [pg-boss Docs](https://timgit.github.io/pg-boss) - pg-boss documentation
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAGpD,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;AAElD,wBAAgB,aAAa,CAAE,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAepF"}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { basicAuth } from 'hono/basic-auth';
|
|
2
|
+
export function configureAuth(app, env, prefix) {
|
|
3
|
+
const username = env.PGBOSS_PROXY_AUTH_USERNAME;
|
|
4
|
+
const password = env.PGBOSS_PROXY_AUTH_PASSWORD;
|
|
5
|
+
if (username && !password) {
|
|
6
|
+
throw new Error('PGBOSS_PROXY_AUTH_PASSWORD is required when PGBOSS_PROXY_AUTH_USERNAME is set');
|
|
7
|
+
}
|
|
8
|
+
if (!username && password) {
|
|
9
|
+
throw new Error('PGBOSS_PROXY_AUTH_USERNAME is required when PGBOSS_PROXY_AUTH_PASSWORD is set');
|
|
10
|
+
}
|
|
11
|
+
if (username && password) {
|
|
12
|
+
app.use(`${prefix}/*`, basicAuth({ username, password }));
|
|
13
|
+
}
|
|
14
|
+
}
|