@shetty4l/core 0.1.3
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/.github/workflows/ci-shared.yml +36 -0
- package/.github/workflows/ci.yml +14 -0
- package/.github/workflows/release-shared.yml +150 -0
- package/.github/workflows/release.yml +19 -0
- package/.husky/pre-commit +3 -0
- package/README.md +88 -0
- package/biome.json +12 -0
- package/bun.lock +65 -0
- package/package.json +37 -0
- package/scripts/install-lib.sh +149 -0
- package/src/cli.ts +109 -0
- package/src/config.ts +200 -0
- package/src/daemon.ts +204 -0
- package/src/http.ts +138 -0
- package/src/index.ts +21 -0
- package/src/result.ts +28 -0
- package/src/scripts/version-bump.ts +144 -0
- package/src/signals.ts +69 -0
- package/src/version.ts +27 -0
- package/test/cli.test.ts +61 -0
- package/test/config.test.ts +263 -0
- package/test/daemon.test.ts +89 -0
- package/test/http.test.ts +152 -0
- package/test/result.test.ts +58 -0
- package/test/signals.test.ts +25 -0
- package/test/version.test.ts +55 -0
- package/tsconfig.json +18 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI argument parsing and command dispatch scaffold.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency CLI primitives shared across all services.
|
|
5
|
+
* Each service defines its own commands and help text.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// --- Arg parsing ---
|
|
9
|
+
|
|
10
|
+
export interface ParsedArgs {
|
|
11
|
+
command: string;
|
|
12
|
+
args: string[];
|
|
13
|
+
json: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse CLI arguments into a command name, positional args, and the --json flag.
|
|
18
|
+
* Strips `--json` from the args array before extracting the command.
|
|
19
|
+
*/
|
|
20
|
+
export function parseArgs(args: string[]): ParsedArgs {
|
|
21
|
+
const filtered = args.filter((a) => a !== "--json");
|
|
22
|
+
const json = args.includes("--json");
|
|
23
|
+
const [command = "help", ...rest] = filtered;
|
|
24
|
+
return { command, args: rest, json };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Uptime formatting ---
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format a duration in seconds into a human-readable string.
|
|
31
|
+
* Examples: "45s", "3m 12s", "2h 15m"
|
|
32
|
+
*/
|
|
33
|
+
export function formatUptime(seconds: number): string {
|
|
34
|
+
if (seconds < 60) return `${seconds}s`;
|
|
35
|
+
if (seconds < 3600) {
|
|
36
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
37
|
+
}
|
|
38
|
+
const hours = Math.floor(seconds / 3600);
|
|
39
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
40
|
+
return `${hours}h ${mins}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Command dispatch ---
|
|
44
|
+
|
|
45
|
+
export type CommandHandler = (
|
|
46
|
+
args: string[],
|
|
47
|
+
json: boolean,
|
|
48
|
+
) => void | Promise<void>;
|
|
49
|
+
|
|
50
|
+
export interface RunCliOpts {
|
|
51
|
+
/** Service name, used in error messages. */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Current version string. */
|
|
54
|
+
version: string;
|
|
55
|
+
/** Map of command name -> handler function. */
|
|
56
|
+
commands: Record<string, CommandHandler>;
|
|
57
|
+
/** Help text to display for --help and the `help` command. */
|
|
58
|
+
help: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the CLI: parse process.argv, dispatch to the matching command handler.
|
|
63
|
+
*
|
|
64
|
+
* Handles --help/-h, --version/-v, and unknown commands automatically.
|
|
65
|
+
* Calls `process.exit(0)` after successful command execution.
|
|
66
|
+
*/
|
|
67
|
+
export async function runCli(opts: RunCliOpts): Promise<void> {
|
|
68
|
+
const rawArgs = process.argv.slice(2);
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
rawArgs.includes("--help") ||
|
|
72
|
+
rawArgs.includes("-h") ||
|
|
73
|
+
rawArgs.length === 0
|
|
74
|
+
) {
|
|
75
|
+
console.log(opts.help);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
|
|
80
|
+
console.log(opts.version);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { command, args, json } = parseArgs(rawArgs);
|
|
85
|
+
|
|
86
|
+
if (command === "help") {
|
|
87
|
+
console.log(opts.help);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (command === "version") {
|
|
92
|
+
if (json) {
|
|
93
|
+
console.log(JSON.stringify({ version: opts.version }));
|
|
94
|
+
} else {
|
|
95
|
+
console.log(opts.version);
|
|
96
|
+
}
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const handler = opts.commands[command];
|
|
101
|
+
if (!handler) {
|
|
102
|
+
console.error(`${opts.name}: unknown command "${command}"`);
|
|
103
|
+
console.error(`Run "${opts.name} --help" for usage.`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await handler(args, json);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration loading primitives.
|
|
3
|
+
*
|
|
4
|
+
* Provides XDG directory resolution, path expansion, env var interpolation,
|
|
5
|
+
* port validation, and a generic JSON config file loader.
|
|
6
|
+
*
|
|
7
|
+
* All functions that can fail with expected errors return Result<T>.
|
|
8
|
+
* No console output — callers decide what to log.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import type { Port, Result } from "./result";
|
|
15
|
+
import { err, ok } from "./result";
|
|
16
|
+
|
|
17
|
+
// --- Directory resolution ---
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the XDG data directory for a service.
|
|
21
|
+
* Uses `$XDG_DATA_HOME/{name}` if set, otherwise `~/.local/share/{name}`.
|
|
22
|
+
*/
|
|
23
|
+
export function getDataDir(name: string): string {
|
|
24
|
+
const xdgData = process.env.XDG_DATA_HOME;
|
|
25
|
+
if (xdgData) {
|
|
26
|
+
return join(xdgData, name);
|
|
27
|
+
}
|
|
28
|
+
return join(homedir(), ".local", "share", name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the XDG config directory for a service.
|
|
33
|
+
* Uses `$XDG_CONFIG_HOME/{name}` if set, otherwise `~/.config/{name}`.
|
|
34
|
+
*/
|
|
35
|
+
export function getConfigDir(name: string): string {
|
|
36
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
37
|
+
if (xdgConfig) {
|
|
38
|
+
return join(xdgConfig, name);
|
|
39
|
+
}
|
|
40
|
+
return join(homedir(), ".config", name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Path expansion ---
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Expand `~` at the start of a path to the user's home directory.
|
|
47
|
+
*/
|
|
48
|
+
export function expandPath(path: string): string {
|
|
49
|
+
if (path.startsWith("~")) {
|
|
50
|
+
return join(homedir(), path.slice(1));
|
|
51
|
+
}
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Env var interpolation ---
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Replace `${ENV_VAR}` patterns in a string with the corresponding env value.
|
|
59
|
+
* Returns Err if a referenced env var is not set.
|
|
60
|
+
*/
|
|
61
|
+
export function interpolateEnvVars(value: string): Result<string> {
|
|
62
|
+
let error: string | undefined;
|
|
63
|
+
|
|
64
|
+
const result = value.replace(
|
|
65
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
|
|
66
|
+
(_match, varName: string) => {
|
|
67
|
+
const envValue = process.env[varName];
|
|
68
|
+
if (envValue === undefined) {
|
|
69
|
+
error = `Config references \${${varName}} but it is not set in the environment`;
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
return envValue;
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (error) return err(error);
|
|
77
|
+
return ok(result);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Recursively walk a JSON-parsed value and interpolate env vars in all strings.
|
|
82
|
+
* Returns Err on the first missing env var.
|
|
83
|
+
*/
|
|
84
|
+
export function interpolateDeep(value: unknown): Result<unknown> {
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
return interpolateEnvVars(value);
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
const results: unknown[] = [];
|
|
90
|
+
for (const item of value) {
|
|
91
|
+
const r = interpolateDeep(item);
|
|
92
|
+
if (!r.ok) return r;
|
|
93
|
+
results.push(r.value);
|
|
94
|
+
}
|
|
95
|
+
return ok(results);
|
|
96
|
+
}
|
|
97
|
+
if (value !== null && typeof value === "object") {
|
|
98
|
+
const result: Record<string, unknown> = {};
|
|
99
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
100
|
+
const r = interpolateDeep(v);
|
|
101
|
+
if (!r.ok) return r;
|
|
102
|
+
result[k] = r.value;
|
|
103
|
+
}
|
|
104
|
+
return ok(result);
|
|
105
|
+
}
|
|
106
|
+
return ok(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Port validation ---
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse and validate a port number from a string value.
|
|
113
|
+
* Returns a branded Port type on success, Err on invalid input.
|
|
114
|
+
*/
|
|
115
|
+
export function parsePort(value: string, source: string): Result<Port> {
|
|
116
|
+
const port = Number.parseInt(value, 10);
|
|
117
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
118
|
+
return err(
|
|
119
|
+
`${source}: "${value}" is not a valid port number (must be 1-65535)`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return ok(port as Port);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- JSON config loader ---
|
|
126
|
+
|
|
127
|
+
export interface LoadJsonConfigOpts<T> {
|
|
128
|
+
/** Service name, used for directory resolution. */
|
|
129
|
+
name: string;
|
|
130
|
+
/** Default config object. All fields should have defaults. */
|
|
131
|
+
defaults: T;
|
|
132
|
+
/** Path to the config file. Defaults to `~/.config/{name}/config.json`. */
|
|
133
|
+
configPath?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ConfigLoadResult<T> {
|
|
137
|
+
/** The resolved configuration. */
|
|
138
|
+
config: T;
|
|
139
|
+
/** Where the config was loaded from: "file" or "defaults". */
|
|
140
|
+
source: "file" | "defaults";
|
|
141
|
+
/** Path that was checked (whether it existed or not). */
|
|
142
|
+
path: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Load a JSON config file with defaults and `${ENV_VAR}` interpolation.
|
|
147
|
+
*
|
|
148
|
+
* Load order:
|
|
149
|
+
* 1. Start with `defaults`
|
|
150
|
+
* 2. Deep-merge fields from the config file (if it exists)
|
|
151
|
+
* 3. Interpolate `${ENV_VAR}` patterns in all string values
|
|
152
|
+
*
|
|
153
|
+
* Returns Result with config and metadata about the load.
|
|
154
|
+
* Services should apply their own env var overrides and validation
|
|
155
|
+
* on the returned config object.
|
|
156
|
+
*/
|
|
157
|
+
export function loadJsonConfig<T extends Record<string, unknown>>(
|
|
158
|
+
opts: LoadJsonConfigOpts<T>,
|
|
159
|
+
): Result<ConfigLoadResult<T>> {
|
|
160
|
+
const filePath =
|
|
161
|
+
opts.configPath ?? join(getConfigDir(opts.name), "config.json");
|
|
162
|
+
|
|
163
|
+
if (!existsSync(filePath)) {
|
|
164
|
+
return ok({
|
|
165
|
+
config: { ...opts.defaults },
|
|
166
|
+
source: "defaults" as const,
|
|
167
|
+
path: filePath,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let rawText: string;
|
|
172
|
+
try {
|
|
173
|
+
rawText = readFileSync(filePath, "utf-8");
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return err(`Failed to read config file ${filePath}: ${e}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let parsed: unknown;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(rawText);
|
|
181
|
+
} catch {
|
|
182
|
+
return err(`Failed to parse config file ${filePath}: invalid JSON`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
186
|
+
return err(`Config file ${filePath}: must be a JSON object`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const interpolated = interpolateDeep(parsed);
|
|
190
|
+
if (!interpolated.ok) return interpolated as Result<never>;
|
|
191
|
+
|
|
192
|
+
return ok({
|
|
193
|
+
config: {
|
|
194
|
+
...opts.defaults,
|
|
195
|
+
...(interpolated.value as Record<string, unknown>),
|
|
196
|
+
} as T,
|
|
197
|
+
source: "file" as const,
|
|
198
|
+
path: filePath,
|
|
199
|
+
});
|
|
200
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon process management.
|
|
3
|
+
*
|
|
4
|
+
* Manages background service processes via PID files.
|
|
5
|
+
* Parameterized by service name so each service can reuse the same logic.
|
|
6
|
+
*
|
|
7
|
+
* No console output — returns structured results for the caller to log.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import type { Result } from "./result";
|
|
13
|
+
import { err, ok } from "./result";
|
|
14
|
+
|
|
15
|
+
// --- Types ---
|
|
16
|
+
|
|
17
|
+
export interface DaemonManagerOpts {
|
|
18
|
+
/** Service name (e.g. "engram"). Used in status messages. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Directory for PID and log files (e.g. ~/.config/engram). */
|
|
21
|
+
configDir: string;
|
|
22
|
+
/** Absolute path to the CLI entry point (e.g. /path/to/src/cli.ts). */
|
|
23
|
+
cliPath: string;
|
|
24
|
+
/** CLI command to run in foreground mode. Defaults to "serve". */
|
|
25
|
+
serveCommand?: string;
|
|
26
|
+
/** Health endpoint URL for verifying the daemon is responsive. */
|
|
27
|
+
healthUrl?: string;
|
|
28
|
+
/** Milliseconds to wait after spawn before health-checking. Defaults to 500. */
|
|
29
|
+
startupWaitMs?: number;
|
|
30
|
+
/** Milliseconds to wait for graceful stop before SIGKILL. Defaults to 5000. */
|
|
31
|
+
stopTimeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DaemonStatus {
|
|
35
|
+
running: boolean;
|
|
36
|
+
pid?: number;
|
|
37
|
+
port?: number;
|
|
38
|
+
uptime?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DaemonManager {
|
|
42
|
+
start(): Promise<Result<DaemonStatus>>;
|
|
43
|
+
stop(): Promise<Result<DaemonStatus>>;
|
|
44
|
+
restart(): Promise<Result<DaemonStatus>>;
|
|
45
|
+
status(): Promise<DaemonStatus>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Internal helpers ---
|
|
49
|
+
|
|
50
|
+
function isProcessRunning(pid: number): boolean {
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readPid(pidFile: string): number | undefined {
|
|
60
|
+
if (!existsSync(pidFile)) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const content = readFileSync(pidFile, "utf-8").trim();
|
|
63
|
+
const pid = Number.parseInt(content, 10);
|
|
64
|
+
return Number.isNaN(pid) ? undefined : pid;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writePid(pidFile: string, pid: number): void {
|
|
71
|
+
Bun.write(pidFile, String(pid));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removePidFile(pidFile: string): void {
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(pidFile);
|
|
77
|
+
} catch {
|
|
78
|
+
// Already gone
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Factory ---
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a daemon manager for a service.
|
|
86
|
+
*
|
|
87
|
+
* The daemon is a `bun run <cliPath> serve` background process.
|
|
88
|
+
* State is tracked via a PID file in `configDir`.
|
|
89
|
+
*/
|
|
90
|
+
export function createDaemonManager(opts: DaemonManagerOpts): DaemonManager {
|
|
91
|
+
const {
|
|
92
|
+
name,
|
|
93
|
+
configDir,
|
|
94
|
+
cliPath,
|
|
95
|
+
serveCommand = "serve",
|
|
96
|
+
healthUrl,
|
|
97
|
+
startupWaitMs = 500,
|
|
98
|
+
stopTimeoutMs = 5000,
|
|
99
|
+
} = opts;
|
|
100
|
+
|
|
101
|
+
const pidFile = join(configDir, `${name}.pid`);
|
|
102
|
+
const logFile = join(configDir, `${name}.log`);
|
|
103
|
+
|
|
104
|
+
async function checkHealth(): Promise<{
|
|
105
|
+
healthy: boolean;
|
|
106
|
+
uptime?: number;
|
|
107
|
+
port?: number;
|
|
108
|
+
}> {
|
|
109
|
+
if (!healthUrl) return { healthy: false };
|
|
110
|
+
try {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
113
|
+
const res = await fetch(healthUrl, { signal: controller.signal });
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
if (!res.ok) return { healthy: false };
|
|
116
|
+
const data = (await res.json()) as {
|
|
117
|
+
uptime?: number;
|
|
118
|
+
port?: number;
|
|
119
|
+
};
|
|
120
|
+
return { healthy: true, uptime: data.uptime, port: data.port };
|
|
121
|
+
} catch {
|
|
122
|
+
return { healthy: false };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const manager: DaemonManager = {
|
|
127
|
+
async status(): Promise<DaemonStatus> {
|
|
128
|
+
const pid = readPid(pidFile);
|
|
129
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
130
|
+
if (pid) removePidFile(pidFile);
|
|
131
|
+
return { running: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const health = await checkHealth();
|
|
135
|
+
return {
|
|
136
|
+
running: true,
|
|
137
|
+
pid,
|
|
138
|
+
uptime: health.uptime,
|
|
139
|
+
port: health.port,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async start(): Promise<Result<DaemonStatus>> {
|
|
144
|
+
const current = await manager.status();
|
|
145
|
+
if (current.running) {
|
|
146
|
+
return err(`${name}: already running (PID ${current.pid})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!existsSync(configDir)) {
|
|
150
|
+
mkdirSync(configDir, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const proc = Bun.spawn(["bun", "run", cliPath, serveCommand], {
|
|
154
|
+
stdout: Bun.file(logFile),
|
|
155
|
+
stderr: Bun.file(logFile),
|
|
156
|
+
stdin: "ignore",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
writePid(pidFile, proc.pid);
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, startupWaitMs));
|
|
161
|
+
|
|
162
|
+
const status = await manager.status();
|
|
163
|
+
if (status.running) {
|
|
164
|
+
return ok(status);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
removePidFile(pidFile);
|
|
168
|
+
return err(`${name}: failed to start (check ${logFile})`);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async stop(): Promise<Result<DaemonStatus>> {
|
|
172
|
+
const current = await manager.status();
|
|
173
|
+
if (!current.running || !current.pid) {
|
|
174
|
+
removePidFile(pidFile);
|
|
175
|
+
return err(`${name}: not running`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
process.kill(current.pid, "SIGTERM");
|
|
179
|
+
|
|
180
|
+
const interval = 100;
|
|
181
|
+
let waited = 0;
|
|
182
|
+
while (waited < stopTimeoutMs) {
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
184
|
+
waited += interval;
|
|
185
|
+
if (!isProcessRunning(current.pid)) break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isProcessRunning(current.pid)) {
|
|
189
|
+
process.kill(current.pid, "SIGKILL");
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
removePidFile(pidFile);
|
|
194
|
+
return ok({ running: false, pid: current.pid });
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async restart(): Promise<Result<DaemonStatus>> {
|
|
198
|
+
await manager.stop();
|
|
199
|
+
return manager.start();
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return manager;
|
|
204
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server infrastructure.
|
|
3
|
+
*
|
|
4
|
+
* Shared Bun.serve wrapper with automatic CORS handling,
|
|
5
|
+
* health endpoint, and JSON response utilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// --- Constants ---
|
|
9
|
+
|
|
10
|
+
const CORS_HEADERS: Readonly<Record<string, string>> = {
|
|
11
|
+
"Access-Control-Allow-Origin": "*",
|
|
12
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
13
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// --- Response utilities ---
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Standard CORS headers for local-first services.
|
|
20
|
+
* Returns a fresh copy to prevent mutation.
|
|
21
|
+
*/
|
|
22
|
+
export function corsHeaders(): Record<string, string> {
|
|
23
|
+
return { ...CORS_HEADERS };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* CORS preflight response (204 No Content).
|
|
28
|
+
*/
|
|
29
|
+
export function corsPreflightResponse(): Response {
|
|
30
|
+
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* JSON success response with CORS headers.
|
|
35
|
+
*/
|
|
36
|
+
export function jsonOk(body: unknown, status: number = 200): Response {
|
|
37
|
+
return Response.json(body, { status, headers: corsHeaders() });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* JSON error response with CORS headers.
|
|
42
|
+
*/
|
|
43
|
+
export function jsonError(status: number, message: string): Response {
|
|
44
|
+
return Response.json({ error: message }, { status, headers: corsHeaders() });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Health endpoint response with standard fields.
|
|
49
|
+
* Pass `extra` to include service-specific health data.
|
|
50
|
+
*/
|
|
51
|
+
export function healthResponse(
|
|
52
|
+
version: string,
|
|
53
|
+
startTime: number,
|
|
54
|
+
extra?: Record<string, unknown>,
|
|
55
|
+
): Response {
|
|
56
|
+
return jsonOk({
|
|
57
|
+
status: "healthy",
|
|
58
|
+
version,
|
|
59
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
60
|
+
...extra,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Server ---
|
|
65
|
+
|
|
66
|
+
export interface ServerOpts {
|
|
67
|
+
/** Port to listen on. */
|
|
68
|
+
port: number;
|
|
69
|
+
/** Hostname to bind to. Defaults to "127.0.0.1". */
|
|
70
|
+
host?: string;
|
|
71
|
+
/** Service version string (included in /health response). */
|
|
72
|
+
version: string;
|
|
73
|
+
/**
|
|
74
|
+
* Application request handler.
|
|
75
|
+
* Called for all requests that are NOT OPTIONS preflight or GET /health.
|
|
76
|
+
* Return `null` to signal "not found" (server will return 404).
|
|
77
|
+
*/
|
|
78
|
+
onRequest: (
|
|
79
|
+
req: Request,
|
|
80
|
+
url: URL,
|
|
81
|
+
) => Response | Promise<Response> | null | Promise<Response | null>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface HttpServer {
|
|
85
|
+
/** Actual port the server is listening on. */
|
|
86
|
+
port: number;
|
|
87
|
+
/** Stop the server. */
|
|
88
|
+
stop: () => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create and start an HTTP server with automatic CORS and /health handling.
|
|
93
|
+
*
|
|
94
|
+
* Request handling order:
|
|
95
|
+
* 1. OPTIONS -> CORS preflight response
|
|
96
|
+
* 2. GET /health -> standard health response
|
|
97
|
+
* 3. Everything else -> `opts.onRequest()`
|
|
98
|
+
* 4. If onRequest returns null -> 404
|
|
99
|
+
*/
|
|
100
|
+
export function createServer(opts: ServerOpts): HttpServer {
|
|
101
|
+
const { port, host = "127.0.0.1", version, onRequest } = opts;
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
|
|
104
|
+
const server = Bun.serve({
|
|
105
|
+
port,
|
|
106
|
+
hostname: host,
|
|
107
|
+
async fetch(req: Request): Promise<Response> {
|
|
108
|
+
const url = new URL(req.url);
|
|
109
|
+
|
|
110
|
+
if (req.method === "OPTIONS") {
|
|
111
|
+
return corsPreflightResponse();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (url.pathname === "/health" && req.method === "GET") {
|
|
115
|
+
return healthResponse(version, startTime);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const result = await onRequest(req, url);
|
|
120
|
+
if (result === null) {
|
|
121
|
+
return jsonError(404, `Not found: ${url.pathname}`);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("HTTP request error:", error);
|
|
126
|
+
return jsonError(
|
|
127
|
+
500,
|
|
128
|
+
error instanceof Error ? error.message : "Internal server error",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
port: server.port ?? port,
|
|
136
|
+
stop: () => server.stop(),
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shetty4l/core
|
|
3
|
+
*
|
|
4
|
+
* Shared infrastructure primitives for Bun/TypeScript services.
|
|
5
|
+
*
|
|
6
|
+
* Import from the root for convenience, or from sub-paths for specificity:
|
|
7
|
+
* import { config, http } from "@shetty4l/core"
|
|
8
|
+
* import { parsePort } from "@shetty4l/core/config"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export * as cli from "./cli";
|
|
12
|
+
// Domain modules — exported as namespaces
|
|
13
|
+
export * as config from "./config";
|
|
14
|
+
export * as daemon from "./daemon";
|
|
15
|
+
export * as http from "./http";
|
|
16
|
+
export type { Err, Ok, Port, Result } from "./result";
|
|
17
|
+
export { err, ok } from "./result";
|
|
18
|
+
export type { ShutdownOpts } from "./signals";
|
|
19
|
+
export { onShutdown } from "./signals";
|
|
20
|
+
// Universal primitives — exported directly
|
|
21
|
+
export { readVersion } from "./version";
|
package/src/result.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Result type for explicit error handling.
|
|
3
|
+
*
|
|
4
|
+
* Use Result for expected failures (invalid input, missing files, parse errors).
|
|
5
|
+
* Use throw for programmer errors (invariant violations, bugs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// --- Result type ---
|
|
9
|
+
|
|
10
|
+
export type Ok<T> = { readonly ok: true; readonly value: T };
|
|
11
|
+
export type Err<E> = { readonly ok: false; readonly error: E };
|
|
12
|
+
export type Result<T, E = string> = Ok<T> | Err<E>;
|
|
13
|
+
|
|
14
|
+
export function ok<T>(value: T): Ok<T> {
|
|
15
|
+
return { ok: true, value };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function err<E>(error: E): Err<E> {
|
|
19
|
+
return { ok: false, error };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Branded types ---
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A validated port number (1-65535).
|
|
26
|
+
* Created only via `config.parsePort()`.
|
|
27
|
+
*/
|
|
28
|
+
export type Port = number & { readonly __brand: "Port" };
|