@andrew_l/app 0.3.7
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/LICENSE +21 -0
- package/README.md +302 -0
- package/bin/vrun +29 -0
- package/dist/index.cjs +2678 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +651 -0
- package/dist/index.d.mts +651 -0
- package/dist/index.d.ts +651 -0
- package/dist/index.mjs +2624 -0
- package/dist/index.mjs.map +1 -0
- package/dist/vrun.cjs +13 -0
- package/dist/vrun.cjs.map +1 -0
- package/dist/vrun.d.cts +2 -0
- package/dist/vrun.d.mts +2 -0
- package/dist/vrun.d.ts +2 -0
- package/dist/vrun.mjs +11 -0
- package/dist/vrun.mjs.map +1 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Andrew L. <andrew.io.dev@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Application Toolkit
|
|
2
|
+
|
|
3
|
+
[![npm version][npm-version-src]][npm-version-href]
|
|
4
|
+
![license][license-src]
|
|
5
|
+
|
|
6
|
+
Define application entry points with typed props, lifecycle hooks, and run them via the `vrun` CLI — no boilerplate config or argument parsing needed.
|
|
7
|
+
|
|
8
|
+
[Documentation](https://men232.github.io/toolkit/reference/@andrew_l/app/)
|
|
9
|
+
|
|
10
|
+
<!-- install placeholder -->
|
|
11
|
+
|
|
12
|
+
## 🚀 Example Usage
|
|
13
|
+
|
|
14
|
+
### Define an app
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// server.app.ts
|
|
18
|
+
import { defineApp } from '@andrew_l/app';
|
|
19
|
+
|
|
20
|
+
export default defineApp({
|
|
21
|
+
name: 'server',
|
|
22
|
+
description: 'HTTP server',
|
|
23
|
+
|
|
24
|
+
props: {
|
|
25
|
+
port: {
|
|
26
|
+
type: Number,
|
|
27
|
+
default: () => 3000,
|
|
28
|
+
description: 'Port to listen on',
|
|
29
|
+
},
|
|
30
|
+
host: {
|
|
31
|
+
type: String,
|
|
32
|
+
default: () => '0.0.0.0',
|
|
33
|
+
env: 'HOST',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
setup() {
|
|
38
|
+
const server = createServer();
|
|
39
|
+
return { server };
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async entry(props) {
|
|
43
|
+
await this.server.listen(props.port, props.host);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async stop() {
|
|
47
|
+
await this.server.close();
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Run with `vrun`
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Run a single app
|
|
56
|
+
vrun server.app.js
|
|
57
|
+
|
|
58
|
+
# Pass props as CLI flags
|
|
59
|
+
vrun server.app.js --port 8080
|
|
60
|
+
|
|
61
|
+
# TypeScript + watch mode (dev)
|
|
62
|
+
vrun server.app.ts --dev --watch
|
|
63
|
+
|
|
64
|
+
# Run across multiple worker threads
|
|
65
|
+
vrun server.app.js --threads 4
|
|
66
|
+
|
|
67
|
+
# Run multiple apps at once
|
|
68
|
+
vrun server.app.js worker.app.ts
|
|
69
|
+
|
|
70
|
+
# Interactively pick apps from a folder
|
|
71
|
+
vrun list ./apps
|
|
72
|
+
|
|
73
|
+
# Run all apps in a folder
|
|
74
|
+
vrun folder ./apps
|
|
75
|
+
|
|
76
|
+
# Run apps from a JSON manifest
|
|
77
|
+
vrun json apps.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Props
|
|
81
|
+
|
|
82
|
+
Props are declared per app and automatically wired to CLI flags and environment variables:
|
|
83
|
+
|
|
84
|
+
| Option | Description |
|
|
85
|
+
| ---------- | ---------------------------------------------------------- |
|
|
86
|
+
| `type` | `String`, `Number`, `Boolean`, `Date` — drives CLI parsing |
|
|
87
|
+
| `default` | Factory function for the default value |
|
|
88
|
+
| `env` | Env variable name(s) to read from |
|
|
89
|
+
| `alias` | Short CLI flag (e.g. `alias: 'p'` → `-p`) |
|
|
90
|
+
| `required` | Fail startup if the value is missing |
|
|
91
|
+
| `enum` | Restrict to a set of string values |
|
|
92
|
+
| `parser` | Custom string → value parser |
|
|
93
|
+
|
|
94
|
+
Prop names are converted to `--kebab-case` CLI flags automatically.
|
|
95
|
+
|
|
96
|
+
### Lifecycle
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
setup() → entry() → [running] → stop() → shutdown()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Hook | Called | `this` context |
|
|
103
|
+
| ----------------- | -------------------------------------------------------------------- | --------------------- |
|
|
104
|
+
| `setup(props)` | Once on startup — return an object to populate `this` in later hooks | `{}` |
|
|
105
|
+
| `entry(props)` | Each time the app starts | setup state + methods |
|
|
106
|
+
| `stop(props)` | Each time the app stops | setup state + methods |
|
|
107
|
+
| `shutdown(props)` | Before process exit | setup state + methods |
|
|
108
|
+
|
|
109
|
+
### Methods
|
|
110
|
+
|
|
111
|
+
Define reusable methods bound to the setup state:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
export default defineApp({
|
|
115
|
+
name: 'worker',
|
|
116
|
+
methods: {
|
|
117
|
+
greet(name: string) {
|
|
118
|
+
console.log(`Hello, ${name}`);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
entry() {
|
|
122
|
+
this.greet('world');
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## 🔁 Workers
|
|
128
|
+
|
|
129
|
+
Workers are long-running background processors driven by a pluggable **strategy**. The strategy controls when tasks are enqueued; the worker controls how many run in parallel and calls your `entry` function for each one.
|
|
130
|
+
|
|
131
|
+
### Define a worker
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// clock.worker.ts
|
|
135
|
+
import { IntervalStrategy, defineWorker } from '@andrew_l/app';
|
|
136
|
+
|
|
137
|
+
export default defineWorker({
|
|
138
|
+
name: 'clock',
|
|
139
|
+
|
|
140
|
+
// Built-in strategy: fire a task every second
|
|
141
|
+
executeStrategy: new IntervalStrategy({ intervalSeconds: 1 }),
|
|
142
|
+
|
|
143
|
+
// Concurrency options
|
|
144
|
+
taskParallel: 2, // tasks running at the same time (default: 2)
|
|
145
|
+
taskLimit: 50, // max queue depth before backpressure fires (default: 50)
|
|
146
|
+
|
|
147
|
+
entry() {
|
|
148
|
+
// this.timerSequence — context field added by IntervalStrategy
|
|
149
|
+
this.log.info('tick #%d', this.timerSequence);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Setup state and methods
|
|
155
|
+
|
|
156
|
+
`setup` and `methods` work the same as in `defineApp`. `this.worker` is always available in every hook.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
export default defineWorker({
|
|
160
|
+
name: 'mailer',
|
|
161
|
+
executeStrategy: new IntervalStrategy({ intervalSeconds: 30 }),
|
|
162
|
+
|
|
163
|
+
setup() {
|
|
164
|
+
return { transport: createTransport() };
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
methods: {
|
|
168
|
+
async send(to: string, body: string) {
|
|
169
|
+
await this.transport.sendMail({ to, body });
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async entry() {
|
|
174
|
+
const pending = await fetchPending();
|
|
175
|
+
for (const msg of pending) {
|
|
176
|
+
await this.send(msg.to, msg.body);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Return values from `entry`
|
|
183
|
+
|
|
184
|
+
Return a `WorkerResult` (or an array) to signal success or skip. Returning nothing is treated as `{ success: true }`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import type { WorkerResult } from '@andrew_l/app';
|
|
188
|
+
|
|
189
|
+
entry(): WorkerResult {
|
|
190
|
+
if (nothingToDo) {
|
|
191
|
+
return { skip: true, code: 'empty' };
|
|
192
|
+
}
|
|
193
|
+
return { success: true, code: 'processed', count: 5 };
|
|
194
|
+
},
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Custom strategy
|
|
198
|
+
|
|
199
|
+
Implement `WorkerStrategy<C>` where `C` extends `WorkerStrategy.Context` to carry per-task data into `entry`.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import type { WorkerInstance, WorkerStrategy } from '@andrew_l/app';
|
|
203
|
+
|
|
204
|
+
// 1. Declare the per-task context your strategy produces
|
|
205
|
+
interface QueueTask extends WorkerStrategy.Context {
|
|
206
|
+
jobId: string;
|
|
207
|
+
payload: unknown;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 2. Implement the strategy
|
|
211
|
+
class RedisQueueStrategy implements WorkerStrategy<QueueTask> {
|
|
212
|
+
private worker!: WorkerInstance;
|
|
213
|
+
private sub!: RedisClient;
|
|
214
|
+
|
|
215
|
+
doSetup({ worker }: { worker: WorkerInstance }) {
|
|
216
|
+
this.worker = worker;
|
|
217
|
+
this.sub = createRedisClient();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
startSignal() {
|
|
221
|
+
this.sub.subscribe('jobs', message => {
|
|
222
|
+
this.worker.addTask(this.createTask(message));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
stopSignal(done: () => void) {
|
|
227
|
+
this.sub.unsubscribe('jobs');
|
|
228
|
+
done();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
doShutdown() {
|
|
232
|
+
this.sub.quit();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
createTask(message: string): QueueTask {
|
|
236
|
+
const { jobId, payload } = JSON.parse(message);
|
|
237
|
+
return { jobId, payload };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Optional: veto a task before entry runs
|
|
241
|
+
executeSignal(ctx: QueueTask) {
|
|
242
|
+
if (isDuplicate(ctx.jobId)) {
|
|
243
|
+
return { skip: true, code: 'duplicate' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { success: true, code: 'ok' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Optional: react to the result after entry finishes
|
|
250
|
+
completeSignal(ctx: QueueTask, result: WorkerResult | WorkerResult[]) {
|
|
251
|
+
ack(ctx.jobId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Optional: pause ingestion when queue is full
|
|
255
|
+
overloadedSignal() {
|
|
256
|
+
this.sub.pause();
|
|
257
|
+
}
|
|
258
|
+
availableSignal() {
|
|
259
|
+
this.sub.resume();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 3. Use it
|
|
264
|
+
export default defineWorker({
|
|
265
|
+
name: 'job-processor',
|
|
266
|
+
executeStrategy: new RedisQueueStrategy(),
|
|
267
|
+
|
|
268
|
+
entry() {
|
|
269
|
+
// this.jobId and this.payload are fully typed
|
|
270
|
+
await processJob(this.jobId, this.payload);
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Strategy interface reference
|
|
276
|
+
|
|
277
|
+
| Method / Hook | Required | Description |
|
|
278
|
+
| ----------------------------- | -------- | -------------------------------------------------------------- |
|
|
279
|
+
| `doSetup({ worker })` | ✓ | Called once during worker setup — store the `WorkerInstance` |
|
|
280
|
+
| `startSignal()` | ✓ | Start producing tasks (open subscriptions, start timers, etc.) |
|
|
281
|
+
| `stopSignal(done)` | ✓ | Drain/close the source, then call `done()` to close the queue |
|
|
282
|
+
| `doShutdown()` | ✓ | Final cleanup after the pool drains |
|
|
283
|
+
| `createTask()` | ✓ | Return a fresh per-task context object |
|
|
284
|
+
| `executeSignal(ctx)` | | Veto a task before `entry` runs; return skip to drop it |
|
|
285
|
+
| `completeSignal(ctx, result)` | | Called after `entry` finishes with the result |
|
|
286
|
+
| `overloadedSignal()` | | Fired once when queue depth exceeds 80 % of `taskLimit` |
|
|
287
|
+
| `availableSignal()` | | Fired once when queue depth drops back below the threshold |
|
|
288
|
+
| `handleEntryError(err)` | | Convert an uncaught entry error into a `WorkerResult` |
|
|
289
|
+
|
|
290
|
+
## 🤔 Why Use This Package?
|
|
291
|
+
|
|
292
|
+
- **No boilerplate** — no manual `process.argv` parsing, no `.env` wiring, no signal handlers
|
|
293
|
+
- **Typed props** — define once, get CLI flags, env variables, and TypeScript types for free
|
|
294
|
+
- **Structured lifecycle** — clear separation between setup, run, stop, and shutdown
|
|
295
|
+
- **Worker threads** — scale any app to N threads with a single `--threads` flag
|
|
296
|
+
- **Dev mode** — run TypeScript directly with `--dev`; use `--watch` to reload on file changes
|
|
297
|
+
|
|
298
|
+
<!-- Badges -->
|
|
299
|
+
|
|
300
|
+
[npm-version-src]: https://img.shields.io/npm/v/@andrew_l/app?style=flat
|
|
301
|
+
[npm-version-href]: https://npmjs.com/package/@andrew_l/app
|
|
302
|
+
[license-src]: https://img.shields.io/npm/l/@andrew_l/app?style=flat
|
package/bin/vrun
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
4
|
+
SCRIPT_FILE="$ROOT_DIR/../dist/vrun.mjs"
|
|
5
|
+
TSX_BIN="$ROOT_DIR/../node_modules/.bin/tsx"
|
|
6
|
+
DEV_MODE=0
|
|
7
|
+
WATCH_MODE=0
|
|
8
|
+
ARGS=()
|
|
9
|
+
|
|
10
|
+
for arg in "$@"; do
|
|
11
|
+
if [ "$arg" == "--dev" ]; then
|
|
12
|
+
DEV_MODE=1
|
|
13
|
+
ARGS+=("$arg")
|
|
14
|
+
elif [ "$arg" == "--watch" ]; then
|
|
15
|
+
WATCH_MODE=1
|
|
16
|
+
else
|
|
17
|
+
ARGS+=("$arg")
|
|
18
|
+
fi
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
if [ $DEV_MODE -eq 1 ]; then
|
|
22
|
+
if [ $WATCH_MODE -eq 1 ]; then
|
|
23
|
+
exec env VRUN=true VRUN_TS_MODE=tsx VRUN_WATCH=true "$TSX_BIN" watch $SCRIPT_FILE "${ARGS[@]}"
|
|
24
|
+
else
|
|
25
|
+
exec env VRUN=true VRUN_TS_MODE=tsx-register node $SCRIPT_FILE "$@"
|
|
26
|
+
fi
|
|
27
|
+
else
|
|
28
|
+
exec env VRUN=true node $SCRIPT_FILE "$@"
|
|
29
|
+
fi
|