@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 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