pidnap 0.0.0-dev.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.
@@ -0,0 +1,255 @@
1
+ import { Cron } from "croner";
2
+ import * as v from "valibot";
3
+ import { LazyProcess, type ProcessDefinition } from "./lazy-process.ts";
4
+ import type { Logger } from "./logger.ts";
5
+
6
+ // Retry configuration schema
7
+ export const RetryConfigSchema = v.object({
8
+ maxRetries: v.number(),
9
+ delayMs: v.optional(v.number()),
10
+ });
11
+
12
+ export type RetryConfig = v.InferOutput<typeof RetryConfigSchema>;
13
+
14
+ // Cron process options schema
15
+ export const CronProcessOptionsSchema = v.object({
16
+ schedule: v.string(),
17
+ retry: v.optional(RetryConfigSchema),
18
+ runOnStart: v.optional(v.boolean()),
19
+ });
20
+
21
+ export type CronProcessOptions = v.InferOutput<typeof CronProcessOptionsSchema>;
22
+
23
+ // State
24
+ export const CronProcessStateSchema = v.picklist([
25
+ "idle",
26
+ "scheduled",
27
+ "running",
28
+ "retrying",
29
+ "queued",
30
+ "stopping",
31
+ "stopped",
32
+ ]);
33
+
34
+ export type CronProcessState = v.InferOutput<typeof CronProcessStateSchema>;
35
+
36
+ const DEFAULT_RETRY_DELAY = 1000;
37
+
38
+ export class CronProcess {
39
+ readonly name: string;
40
+ private lazyProcess: LazyProcess;
41
+ private options: CronProcessOptions;
42
+ private logger: Logger;
43
+ private cronJob: Cron | null = null;
44
+
45
+ // State tracking
46
+ private _state: CronProcessState = "idle";
47
+ private _runCount: number = 0;
48
+ private _failCount: number = 0;
49
+ private currentRetryAttempt: number = 0;
50
+ private queuedRun: boolean = false;
51
+ private stopRequested: boolean = false;
52
+ private retryTimeout: ReturnType<typeof setTimeout> | null = null;
53
+
54
+ constructor(
55
+ name: string,
56
+ definition: ProcessDefinition,
57
+ options: CronProcessOptions,
58
+ logger: Logger,
59
+ ) {
60
+ this.name = name;
61
+ this.options = options;
62
+ this.logger = logger;
63
+ this.lazyProcess = new LazyProcess(name, definition, logger);
64
+ }
65
+
66
+ get state(): CronProcessState {
67
+ return this._state;
68
+ }
69
+
70
+ get runCount(): number {
71
+ return this._runCount;
72
+ }
73
+
74
+ get failCount(): number {
75
+ return this._failCount;
76
+ }
77
+
78
+ get nextRun(): Date | null {
79
+ if (!this.cronJob) return null;
80
+ const next = this.cronJob.nextRun();
81
+ return next ?? null;
82
+ }
83
+
84
+ start(): void {
85
+ if (this._state === "scheduled" || this._state === "running" || this._state === "queued") {
86
+ throw new Error(`CronProcess "${this.name}" is already ${this._state}`);
87
+ }
88
+
89
+ if (this._state === "stopping") {
90
+ throw new Error(`CronProcess "${this.name}" is currently stopping`);
91
+ }
92
+
93
+ this.stopRequested = false;
94
+ this.logger.info(`Starting cron schedule: ${this.options.schedule}`);
95
+
96
+ // Create cron job with UTC timezone
97
+ this.cronJob = new Cron(this.options.schedule, { timezone: "UTC" }, () => {
98
+ this.onCronTick();
99
+ });
100
+
101
+ this._state = "scheduled";
102
+
103
+ // Run immediately if configured
104
+ if (this.options.runOnStart) {
105
+ this.executeJob();
106
+ }
107
+ }
108
+
109
+ async stop(timeout?: number): Promise<void> {
110
+ this.stopRequested = true;
111
+
112
+ // Stop the cron job
113
+ if (this.cronJob) {
114
+ this.cronJob.stop();
115
+ this.cronJob = null;
116
+ }
117
+
118
+ // Clear any pending retry timeout
119
+ if (this.retryTimeout) {
120
+ clearTimeout(this.retryTimeout);
121
+ this.retryTimeout = null;
122
+ }
123
+
124
+ if (this._state === "idle" || this._state === "stopped") {
125
+ this._state = "stopped";
126
+ return;
127
+ }
128
+
129
+ // If running, stop the current job
130
+ if (this._state === "running" || this._state === "retrying" || this._state === "queued") {
131
+ this._state = "stopping";
132
+ await this.lazyProcess.stop(timeout);
133
+ }
134
+
135
+ this._state = "stopped";
136
+ this.queuedRun = false;
137
+ this.logger.info(`CronProcess stopped`);
138
+ }
139
+
140
+ async trigger(): Promise<void> {
141
+ if (this.stopRequested) {
142
+ throw new Error(`CronProcess "${this.name}" is stopped`);
143
+ }
144
+
145
+ // If already queued, just return (already have a run pending)
146
+ if (this._state === "queued") {
147
+ return;
148
+ }
149
+
150
+ // If already running, queue this trigger
151
+ if (this._state === "running" || this._state === "retrying") {
152
+ this.queuedRun = true;
153
+ this._state = "queued";
154
+ this.logger.info(`Run queued (current job still running)`);
155
+ return;
156
+ }
157
+
158
+ await this.executeJob();
159
+ }
160
+
161
+ private onCronTick(): void {
162
+ if (this.stopRequested) return;
163
+
164
+ // If already running, queue the next run
165
+ if (this._state === "running" || this._state === "retrying" || this._state === "queued") {
166
+ this.queuedRun = true;
167
+ if (this._state !== "queued") {
168
+ this._state = "queued";
169
+ }
170
+ this.logger.info(`Cron tick: run queued (current job still running)`);
171
+ return;
172
+ }
173
+
174
+ this.executeJob();
175
+ }
176
+
177
+ private async executeJob(): Promise<void> {
178
+ if (this.stopRequested) return;
179
+
180
+ this._state = "running";
181
+ this.currentRetryAttempt = 0;
182
+ this.logger.info(`Executing job`);
183
+
184
+ await this.runJobWithRetry();
185
+ }
186
+
187
+ private async runJobWithRetry(): Promise<void> {
188
+ if (this.stopRequested) return;
189
+
190
+ // Reset and start the process
191
+ await this.lazyProcess.reset();
192
+ this.lazyProcess.start();
193
+
194
+ const exitState = await this.lazyProcess.waitForExit();
195
+ if (this.stopRequested && exitState === "error") {
196
+ this._state = "stopped";
197
+ return;
198
+ }
199
+ this.handleJobComplete(exitState === "error");
200
+ }
201
+
202
+ private handleJobComplete(failed: boolean): void {
203
+ if (this.stopRequested) {
204
+ this._state = "stopped";
205
+ return;
206
+ }
207
+
208
+ if (failed) {
209
+ const maxRetries = this.options.retry?.maxRetries ?? 0;
210
+
211
+ if (this.currentRetryAttempt < maxRetries) {
212
+ // Retry
213
+ this.currentRetryAttempt++;
214
+ this._state = "retrying";
215
+ const delayMs = this.options.retry?.delayMs ?? DEFAULT_RETRY_DELAY;
216
+
217
+ this.logger.warn(
218
+ `Job failed, retrying in ${delayMs}ms (attempt ${this.currentRetryAttempt}/${maxRetries})`,
219
+ );
220
+
221
+ this.retryTimeout = setTimeout(() => {
222
+ this.retryTimeout = null;
223
+ if (this.stopRequested) {
224
+ this._state = "stopped";
225
+ return;
226
+ }
227
+ this.runJobWithRetry();
228
+ }, delayMs);
229
+ return;
230
+ }
231
+
232
+ // All retries exhausted
233
+ this._failCount++;
234
+ this.logger.error(`Job failed after ${this.currentRetryAttempt} retries`);
235
+ } else {
236
+ this._runCount++;
237
+ this.logger.info(`Job completed successfully`);
238
+ }
239
+
240
+ // Check for queued run
241
+ if (this.queuedRun) {
242
+ this.queuedRun = false;
243
+ this.logger.info(`Starting queued run`);
244
+ this.executeJob();
245
+ return;
246
+ }
247
+
248
+ // Back to scheduled state
249
+ if (this.cronJob) {
250
+ this._state = "scheduled";
251
+ } else {
252
+ this._state = "stopped";
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,237 @@
1
+ import { parse } from "dotenv";
2
+ import { existsSync, globSync, watch, readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve, join, basename } from "node:path";
5
+
6
+ export type EnvChangeCallback = (changedKeys: string[]) => void;
7
+
8
+ export interface EnvManagerConfig {
9
+ /**
10
+ * Directory to search for .env files
11
+ * Defaults to process.cwd()
12
+ */
13
+ cwd?: string;
14
+
15
+ /**
16
+ * Explicit env file paths to load
17
+ * Key is the identifier (e.g., "global", "app1")
18
+ * Value is the file path relative to cwd or absolute
19
+ */
20
+ files?: Record<string, string>;
21
+
22
+ /**
23
+ * Enable file watching for env files
24
+ * Defaults to false
25
+ */
26
+ watch?: boolean;
27
+ }
28
+
29
+ export class EnvManager {
30
+ private env: Map<string, Record<string, string>> = new Map();
31
+ private cwd: string;
32
+ private watchEnabled: boolean;
33
+ private watchers: Map<string, ReturnType<typeof watch>> = new Map();
34
+ private fileToKeys: Map<string, Set<string>> = new Map();
35
+ private changeCallbacks: Set<EnvChangeCallback> = new Set();
36
+ private reloadDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
37
+
38
+ constructor(config: EnvManagerConfig = {}) {
39
+ this.cwd = config.cwd ?? process.cwd();
40
+ this.watchEnabled = config.watch ?? false;
41
+
42
+ // Load .env and .env.* files from cwd
43
+ this.loadEnvFilesFromCwd();
44
+
45
+ // Load explicitly specified files
46
+ if (config.files) {
47
+ for (const [key, filePath] of Object.entries(config.files)) {
48
+ this.loadEnvFile(key, filePath);
49
+ }
50
+ }
51
+ }
52
+
53
+ registerFile(key: string, filePath: string): void {
54
+ this.loadEnvFile(key, filePath);
55
+ }
56
+
57
+ getEnvForKey(key: string): Record<string, string> {
58
+ return this.env.get(key) ?? {};
59
+ }
60
+
61
+ /**
62
+ * Load .env and .env.* files from the cwd
63
+ */
64
+ private loadEnvFilesFromCwd(): void {
65
+ // Load .env file as global
66
+ const dotEnvPath = resolve(this.cwd, ".env");
67
+ if (existsSync(dotEnvPath)) {
68
+ this.loadEnvFile("global", dotEnvPath);
69
+ }
70
+
71
+ // Load .env.* files
72
+ try {
73
+ const pattern = join(this.cwd, ".env.*");
74
+ const envFiles = globSync(pattern);
75
+
76
+ for (const filePath of envFiles) {
77
+ // Extract the suffix after .env.
78
+ const fileName = basename(filePath);
79
+ const match = fileName.match(/^\.env\.(.+)$/);
80
+ if (match) {
81
+ const suffix = match[1];
82
+ this.loadEnvFile(suffix, filePath);
83
+ }
84
+ }
85
+ } catch (err) {
86
+ console.warn("Failed to scan env files:", err);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Load a single env file and store it in the map
92
+ */
93
+ private loadEnvFile(key: string, filePath: string): void {
94
+ const absolutePath = resolve(this.cwd, filePath);
95
+
96
+ if (!existsSync(absolutePath)) {
97
+ return; // Silently skip non-existent files
98
+ }
99
+
100
+ try {
101
+ const content = readFileSync(absolutePath, "utf-8");
102
+ const parsed = parse(content);
103
+ this.env.set(key, parsed);
104
+
105
+ // Track which file maps to which key
106
+ if (!this.fileToKeys.has(absolutePath)) {
107
+ this.fileToKeys.set(absolutePath, new Set());
108
+ }
109
+ this.fileToKeys.get(absolutePath)!.add(key);
110
+
111
+ // Start watching if enabled and not already watching
112
+ if (this.watchEnabled && !this.watchers.has(absolutePath)) {
113
+ this.watchFile(absolutePath);
114
+ }
115
+ } catch (err) {
116
+ console.warn(`Failed to load env file: ${absolutePath}`, err);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Watch a file for changes
122
+ */
123
+ private watchFile(absolutePath: string): void {
124
+ try {
125
+ const watcher = watch(absolutePath, (eventType) => {
126
+ if (eventType === "change") {
127
+ this.handleFileChange(absolutePath);
128
+ }
129
+ });
130
+
131
+ this.watchers.set(absolutePath, watcher);
132
+ } catch (err) {
133
+ console.warn(`Failed to watch env file: ${absolutePath}`, err);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Handle file change with debouncing
139
+ */
140
+ private handleFileChange(absolutePath: string): void {
141
+ // Clear existing timer if any
142
+ const existingTimer = this.reloadDebounceTimers.get(absolutePath);
143
+ if (existingTimer) {
144
+ clearTimeout(existingTimer);
145
+ }
146
+
147
+ // Debounce reload by 100ms to avoid multiple rapid reloads
148
+ const timer = setTimeout(() => {
149
+ this.reloadFile(absolutePath);
150
+ this.reloadDebounceTimers.delete(absolutePath);
151
+ }, 100);
152
+
153
+ this.reloadDebounceTimers.set(absolutePath, timer);
154
+ }
155
+
156
+ /**
157
+ * Reload a file and notify callbacks
158
+ */
159
+ private reloadFile(absolutePath: string): void {
160
+ const keys = this.fileToKeys.get(absolutePath);
161
+ if (!keys) return;
162
+
163
+ readFile(absolutePath, "utf-8")
164
+ .then((content) => parse(content))
165
+ .then((parsed) => {
166
+ const changedKeys: string[] = [];
167
+ for (const key of keys) {
168
+ this.env.set(key, parsed);
169
+ changedKeys.push(key);
170
+ }
171
+
172
+ // Notify all callbacks
173
+ if (changedKeys.length > 0) {
174
+ for (const callback of this.changeCallbacks) {
175
+ callback(changedKeys);
176
+ }
177
+ }
178
+ })
179
+ .catch((err) => {
180
+ console.warn(`Failed to reload env file: ${absolutePath}`, err);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Register a callback to be called when env files change
186
+ * Returns a function to unregister the callback
187
+ */
188
+ onChange(callback: EnvChangeCallback): () => void {
189
+ this.changeCallbacks.add(callback);
190
+ return () => {
191
+ this.changeCallbacks.delete(callback);
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Stop watching all files and cleanup
197
+ */
198
+ dispose(): void {
199
+ // Clear all timers
200
+ for (const timer of this.reloadDebounceTimers.values()) {
201
+ clearTimeout(timer);
202
+ }
203
+ this.reloadDebounceTimers.clear();
204
+
205
+ // Close all watchers
206
+ for (const watcher of this.watchers.values()) {
207
+ watcher.close();
208
+ }
209
+ this.watchers.clear();
210
+
211
+ // Clear callbacks
212
+ this.changeCallbacks.clear();
213
+ }
214
+
215
+ /**
216
+ * Get environment variables for a specific process
217
+ * Merges global env with process-specific env
218
+ * Process-specific env variables override global ones
219
+ */
220
+ getEnvVars(processKey?: string): Record<string, string> {
221
+ const globalEnv = this.env.get("global") ?? {};
222
+
223
+ if (!processKey) {
224
+ return { ...globalEnv };
225
+ }
226
+
227
+ const processEnv = this.env.get(processKey) ?? {};
228
+ return { ...globalEnv, ...processEnv };
229
+ }
230
+
231
+ /**
232
+ * Get all loaded env maps (for debugging/inspection)
233
+ */
234
+ getAllEnv(): ReadonlyMap<string, Record<string, string>> {
235
+ return this.env;
236
+ }
237
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { ManagerConfig } from "./manager.ts";
2
+
3
+ export function defineConfig(config: ManagerConfig) {
4
+ return config;
5
+ }
6
+
7
+ export * from "./restarting-process.ts";
8
+ export * from "./cron-process.ts";
9
+ export * from "./task-list.ts";
10
+ export * from "./lazy-process.ts";
11
+ export * from "./env-manager.ts";
12
+ export * from "./logger.ts";