@forwardimpact/libutil 0.1.60

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/finder.js ADDED
@@ -0,0 +1,148 @@
1
+ import fs from "fs";
2
+ import fsAsync from "fs/promises";
3
+ import path from "path";
4
+ import { createRequire } from "node:module";
5
+
6
+ /**
7
+ * Finder class for project path resolution and symlink management
8
+ * Handles filesystem operations for linking generated code to packages
9
+ */
10
+ export class Finder {
11
+ #logger;
12
+ #process;
13
+
14
+ /**
15
+ * Creates a new Finder instance
16
+ * @param {object} fs - Filesystem module (fs/promises)
17
+ * @param {object} logger - Logger instance for debug output
18
+ * @param {object} process - Process environment access (for testing)
19
+ */
20
+ constructor(fs, logger, process = global.process) {
21
+ if (!fs) throw new Error("fs is required");
22
+ if (!logger) throw new Error("logger is required");
23
+ if (!process) throw new Error("process is required");
24
+
25
+ this.#logger = logger;
26
+ this.#process = process;
27
+ }
28
+
29
+ /**
30
+ * Searches upward from one or more roots for a target file or directory.
31
+ * @param {string} root - Starting directory to search from
32
+ * @param {string} relativePath - Relative path to append while traversing upward
33
+ * @param {number} maxDepth - Maximum parent levels to check
34
+ * @returns {string|null} Found absolute path or null
35
+ */
36
+ findUpward(root, relativePath, maxDepth = 3) {
37
+ let current = root;
38
+ for (let depth = 0; depth < maxDepth; depth++) {
39
+ const candidate = path.join(current, relativePath);
40
+ if (fs.existsSync(candidate)) {
41
+ return candidate;
42
+ }
43
+ const parent = path.dirname(current);
44
+ if (parent === current) break;
45
+ current = parent;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Find the project root directory
52
+ * @param {string} startPath - Starting directory path
53
+ * @returns {string} Project root directory path
54
+ */
55
+ findProjectRoot(startPath) {
56
+ const projectRoot = this.findUpward(startPath, "package.json", 5);
57
+ if (projectRoot) {
58
+ return path.dirname(projectRoot);
59
+ }
60
+
61
+ throw new Error("Could not find project root");
62
+ }
63
+
64
+ /**
65
+ * Resolve the actual filesystem path to a package
66
+ * Works both in monorepo (./packages) and when installed as dependency
67
+ * @param {string} projectRoot - Project root directory path
68
+ * @param {"libtype"|"librpc"} packageName - Package name without scope
69
+ * @returns {string} Absolute path to package directory
70
+ */
71
+ findPackagePath(projectRoot, packageName) {
72
+ const fullPackageName = `@forwardimpact/${packageName}`;
73
+
74
+ // First try local monorepo structures
75
+ for (const dir of ["libraries", "packages"]) {
76
+ const localPath = path.join(projectRoot, dir, packageName);
77
+ if (fs.existsSync(localPath)) {
78
+ return localPath;
79
+ }
80
+ }
81
+
82
+ // Fall back to Node module resolution for installed packages
83
+ const require = createRequire(path.join(projectRoot, "package.json"));
84
+
85
+ // Resolve the package.json path
86
+ const packageJsonPath = require.resolve(`${fullPackageName}/package.json`);
87
+ return path.dirname(packageJsonPath);
88
+ }
89
+
90
+ /**
91
+ * Resolve the generated directory path for a package
92
+ * @param {string} projectRoot - Project root directory path
93
+ * @param {"libtype"|"librpc"} packageName - Package name without scope
94
+ * @returns {string} Absolute path to package's generated directory
95
+ */
96
+ findGeneratedPath(projectRoot, packageName) {
97
+ const packagePath = this.findPackagePath(projectRoot, packageName);
98
+ return path.join(packagePath, "generated");
99
+ }
100
+
101
+ /**
102
+ * Create symlink from source to target directory
103
+ * @param {string} sourcePath - Source directory path
104
+ * @param {string} targetPath - Target directory path
105
+ * @returns {Promise<void>}
106
+ */
107
+ async createSymlink(sourcePath, targetPath) {
108
+ // Ensure the source directory exists
109
+ await fsAsync.mkdir(sourcePath, { recursive: true });
110
+
111
+ // Remove the existing target if it exists
112
+ try {
113
+ const stats = await fsAsync.lstat(targetPath);
114
+ if (stats.isSymbolicLink()) {
115
+ await fsAsync.unlink(targetPath);
116
+ } else {
117
+ await fsAsync.rm(targetPath, { recursive: true, force: true });
118
+ }
119
+ } catch {
120
+ // Target doesn't exist, which is fine
121
+ }
122
+
123
+ // Create the symlink
124
+ await fsAsync.symlink(sourcePath, targetPath, "dir");
125
+ this.#logger.debug("Finder", "Created symlink", {
126
+ source_path: sourcePath,
127
+ target_path: targetPath,
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Create symlinks to the generated directory for standard packages
133
+ * Attempts to find project root and create symlinks, but won't fail in test environments
134
+ * @param {string} generatedPath - Path to generated code directory
135
+ * @returns {Promise<void>}
136
+ */
137
+ async createPackageSymlinks(generatedPath) {
138
+ const projectRoot = this.findProjectRoot(this.#process.cwd());
139
+ const packageNames = ["libtype", "librpc"];
140
+
141
+ const promises = packageNames.map(async (packageName) => {
142
+ const targetPath = this.findGeneratedPath(projectRoot, packageName);
143
+ await this.createSymlink(generatedPath, targetPath);
144
+ });
145
+
146
+ await Promise.all(promises);
147
+ }
148
+ }
package/http.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Parses the JSON body from an incoming HTTP request.
3
+ * @param {import('http').IncomingMessage} req - The HTTP request object.
4
+ * @returns {Promise<object>} The parsed JSON object, or an empty object if parsing fails.
5
+ */
6
+ export function parseJsonBody(req) {
7
+ return new Promise((resolve, reject) => {
8
+ let body = "";
9
+ req.on("data", (chunk) => {
10
+ body += chunk;
11
+ });
12
+ req.on("end", () => {
13
+ try {
14
+ resolve(JSON.parse(body));
15
+ } catch {
16
+ resolve({});
17
+ }
18
+ });
19
+ req.on("error", reject);
20
+ });
21
+ }
package/index.js ADDED
@@ -0,0 +1,154 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { spawn } from "child_process";
5
+
6
+ import { Tokenizer, ranks } from "./tokenizer.js";
7
+ import { Finder } from "./finder.js";
8
+ import { BundleDownloader } from "./downloader.js";
9
+ import { TarExtractor } from "./extractor.js";
10
+
11
+ /**
12
+ * Updates or creates an environment variable in .env file
13
+ * @param {string} key - Environment variable name (e.g., "SERVICE_SECRET")
14
+ * @param {string} value - Environment variable value
15
+ * @param {string} [envPath] - Path to .env file (defaults to .env in current directory)
16
+ */
17
+ export async function updateEnvFile(key, value, envPath = ".env") {
18
+ const fullPath = path.resolve(envPath);
19
+ let content = "";
20
+
21
+ try {
22
+ content = await fs.readFile(fullPath, "utf8");
23
+ } catch (error) {
24
+ // It's ok if the file doesn't exist
25
+ if (error.code !== "ENOENT") throw error;
26
+ }
27
+
28
+ const envLine = `${key}=${value}`;
29
+ const lines = content.split("\n");
30
+ let found = false;
31
+
32
+ // Look for existing key line (both active and commented)
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (lines[i].startsWith(`${key}=`) || lines[i].startsWith(`# ${key}=`)) {
35
+ lines[i] = envLine;
36
+ found = true;
37
+ break;
38
+ }
39
+ }
40
+
41
+ // If not found, add it to the end
42
+ if (!found) {
43
+ if (content && !content.endsWith("\n")) {
44
+ lines.push("");
45
+ }
46
+ lines.push(envLine);
47
+ }
48
+
49
+ // Write back to file
50
+ await fs.writeFile(fullPath, lines.join("\n"));
51
+ }
52
+
53
+ /**
54
+ * Generates a deterministic hash from multiple input values
55
+ * @param {...string} values - Values to hash together
56
+ * @returns {string} The first 16 characters of SHA256 hash
57
+ */
58
+ export function generateHash(...values) {
59
+ const input = values.filter(Boolean).join(".");
60
+ return crypto
61
+ .createHash("sha256")
62
+ .update(input)
63
+ .digest("hex")
64
+ .substring(0, 8);
65
+ }
66
+
67
+ /**
68
+ * Generates a unique session ID for conversation tracking
69
+ * @returns {string} Unique session identifier
70
+ */
71
+ export function generateUUID() {
72
+ return crypto.randomUUID();
73
+ }
74
+
75
+ /**
76
+ * Helper function to count tokens
77
+ * @param {string} text - Text to count tokens for
78
+ * @param {Tokenizer} tokenizer - Tokenizer instance
79
+ * @returns {number} Approximate token count
80
+ */
81
+ export function countTokens(text, tokenizer) {
82
+ if (!tokenizer) tokenizer = createTokenizer();
83
+ return tokenizer.encode(text).length;
84
+ }
85
+
86
+ /**
87
+ * Creates a new tokenizer instance
88
+ * @returns {Tokenizer} New tokenizer instance
89
+ */
90
+ export function createTokenizer() {
91
+ return new Tokenizer(ranks);
92
+ }
93
+
94
+ /**
95
+ * Creates a BundleDownloader instance configured for generated code management.
96
+ * Used in containerized deployments to download pre-generated code bundles.
97
+ * @param {Function} createStorage - Storage factory function from libstorage
98
+ * @returns {Promise<BundleDownloader>} Configured BundleDownloader instance
99
+ */
100
+ export async function createBundleDownloader(createStorage) {
101
+ if (!createStorage) throw new Error("createStorage is required");
102
+
103
+ // Dynamic import to avoid circular dependency with libtelemetry
104
+ const { createLogger } = await import("@forwardimpact/libtelemetry");
105
+ const logger = createLogger("generated");
106
+ const finder = new Finder(fs, logger);
107
+ const extractor = new TarExtractor(fs, path);
108
+
109
+ return new BundleDownloader(createStorage, finder, logger, extractor);
110
+ }
111
+
112
+ /**
113
+ * Executes command line arguments as child process, similar to execv() in C
114
+ * @param {number} [shift] - Number of arguments to skip from process.argv before extracting command
115
+ * @returns {void} Function does not return - exits parent process
116
+ */
117
+ export function execLine(shift = 0) {
118
+ const args = process.argv.slice(2 + shift);
119
+ if (args.length === 0) return;
120
+
121
+ // Look for '--' delimiter and use everything after it as the command
122
+ const index = args.indexOf("--");
123
+ const line = index !== -1 ? args.slice(index + 1) : args;
124
+
125
+ if (line.length === 0) return;
126
+
127
+ const [command, ...commandArgs] = line;
128
+ const child = spawn(command, commandArgs, {
129
+ stdio: "inherit",
130
+ env: process.env,
131
+ });
132
+
133
+ // Forward signals to child process
134
+ ["SIGTERM", "SIGINT", "SIGQUIT"].forEach((signal) => {
135
+ process.on(signal, () => child.kill(signal));
136
+ });
137
+
138
+ child.on("error", (error) => {
139
+ console.error("Error:", error);
140
+ process.exit(1);
141
+ });
142
+
143
+ child.on("exit", (code, signal) => {
144
+ process.exit(signal ? 1 : code || 0);
145
+ });
146
+ }
147
+
148
+ export { Finder } from "./finder.js";
149
+ export { BundleDownloader } from "./downloader.js";
150
+ export { TarExtractor, ZipExtractor } from "./extractor.js";
151
+ export { ProcessorBase } from "./processor.js";
152
+ export { Retry, createRetry } from "./retry.js";
153
+ export { parseJsonBody } from "./http.js";
154
+ export { waitFor } from "./wait.js";
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@forwardimpact/libutil",
3
+ "version": "0.1.60",
4
+ "description": "Utility functions and utilities for Guide",
5
+ "license": "Apache-2.0",
6
+ "author": "D. Olsson <hi@senzilla.io>",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "bin": {
10
+ "fit-download-bundle": "./bin/fit-download-bundle.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=22.0.0"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test test/*.test.js"
17
+ },
18
+ "dependencies": {
19
+ "@forwardimpact/libtelemetry": "^0.1.22"
20
+ },
21
+ "devDependencies": {
22
+ "@forwardimpact/libharness": "^0.1.5"
23
+ }
24
+ }
package/processor.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Base class for batch processor implementations with common batch management logic
3
+ */
4
+ export class ProcessorBase {
5
+ #logger;
6
+ #batchSize;
7
+
8
+ /**
9
+ * Creates a new processor instance
10
+ * @param {object} logger - Logger instance for debug output
11
+ * @param {number} batchSize - Size of batches for processing (default: 4)
12
+ */
13
+ constructor(logger, batchSize = 4) {
14
+ if (!logger) throw new Error("logger is required");
15
+ if (typeof batchSize !== "number" || batchSize < 1) {
16
+ throw new Error("batchSize must be a positive number");
17
+ }
18
+
19
+ this.#logger = logger;
20
+ this.#batchSize = batchSize;
21
+ }
22
+
23
+ /**
24
+ * Gets the logger instance for use in subclasses
25
+ * @returns {object} Logger instance
26
+ * @protected
27
+ */
28
+ get logger() {
29
+ return this.#logger;
30
+ }
31
+
32
+ /**
33
+ * Processes items in batches
34
+ * @param {any[]} items - Items to process
35
+ * @param {string} context - Processing context label
36
+ * @returns {Promise<any[]>} Processed results
37
+ */
38
+ async process(items, context = "items") {
39
+ if (!Array.isArray(items)) {
40
+ throw new Error("items must be an array");
41
+ }
42
+
43
+ if (items.length === 0) {
44
+ this.#logger.debug("Processor", "No items to process", { context });
45
+ return;
46
+ }
47
+
48
+ this.#logger.debug("Processor", "Starting batch", {
49
+ total: items.length,
50
+ context,
51
+ });
52
+
53
+ let currentBatch = [];
54
+ let processedCount = 0;
55
+
56
+ for (let i = 0; i < items.length; i++) {
57
+ currentBatch.push(items[i]);
58
+
59
+ if (currentBatch.length >= this.#batchSize) {
60
+ await this.processBatch(
61
+ currentBatch,
62
+ processedCount,
63
+ items.length,
64
+ context,
65
+ );
66
+ processedCount += currentBatch.length;
67
+ currentBatch = [];
68
+ }
69
+ }
70
+
71
+ if (currentBatch.length > 0) {
72
+ await this.processBatch(
73
+ currentBatch,
74
+ processedCount,
75
+ items.length,
76
+ context,
77
+ );
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Processes a batch of items
83
+ * @param {any[]} batch - Batch to process
84
+ * @param {number} processed - Number already processed
85
+ * @param {number} total - Total number of items
86
+ * @param {object} context - Processing context
87
+ * @returns {Promise<any[]>} Batch results
88
+ */
89
+ async processBatch(batch, processed, total, context) {
90
+ const batchSize = batch.length;
91
+
92
+ this.#logger.debug("Processor", "Processing batch", {
93
+ items:
94
+ batchSize > 1
95
+ ? `${processed + 1}-${processed + batchSize}/${total}`
96
+ : `${processed + 1}/${total}`,
97
+ context,
98
+ });
99
+
100
+ const promises = batch.map(async (item, itemIndex) => {
101
+ const globalIndex = processed + itemIndex;
102
+ try {
103
+ return await this.processItem(item);
104
+ } catch (error) {
105
+ this.#logger.debug("Processor", "Skipping, failed to process item", {
106
+ item: `${globalIndex + 1}/${total}`,
107
+ context,
108
+ error: error.message,
109
+ });
110
+ return null;
111
+ }
112
+ });
113
+
114
+ await Promise.all(promises);
115
+ }
116
+
117
+ /**
118
+ * Processes a single item (must be implemented by subclass)
119
+ * @param {any} _item - Item to process
120
+ * @returns {Promise<any>} Processed result
121
+ */
122
+ async processItem(_item) {
123
+ throw new Error("processItem must be implemented by subclass");
124
+ }
125
+ }
package/retry.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Retry class for handling transient errors with exponential backoff
3
+ */
4
+ export class Retry {
5
+ #retries;
6
+ #delay;
7
+
8
+ /**
9
+ * Creates a new Retry instance
10
+ * @param {object} [config] - Optional configuration object
11
+ * @param {number} [config.retries] - Maximum number of retry attempts (default: 10)
12
+ * @param {number} [config.delay] - Initial delay in milliseconds (default: 1000)
13
+ */
14
+ constructor(config = {}) {
15
+ this.#retries = config.retries ?? 10;
16
+ this.#delay = config.delay ?? 1000;
17
+ }
18
+
19
+ /**
20
+ * Checks if an HTTP status code or error should trigger a retry
21
+ * @param {number} status - HTTP status code
22
+ * @returns {boolean} True if the status should be retried
23
+ * @private
24
+ */
25
+ #isRetryableStatus(status) {
26
+ // Retry on rate limits, transient server errors, and client timeouts
27
+ return (
28
+ status === 429 ||
29
+ status === 499 ||
30
+ status === 500 ||
31
+ status === 502 ||
32
+ status === 503 ||
33
+ status === 504
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Checks if an error is a network error that should trigger a retry
39
+ * @param {Error} error - Error object
40
+ * @returns {boolean} True if the error should be retried
41
+ * @private
42
+ */
43
+ #isRetryableError(error) {
44
+ const message = error.message.toLowerCase();
45
+
46
+ // Check for HTTP status codes in error messages (e.g., "HTTP 499: status code 499")
47
+ const httpStatusMatch = message.match(/http (\d{3})/);
48
+ if (httpStatusMatch) {
49
+ const statusCode = parseInt(httpStatusMatch[1], 10);
50
+ if (this.#isRetryableStatus(statusCode)) {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ return (
56
+ message.includes("network") ||
57
+ message.includes("timeout") ||
58
+ message.includes("econnrefused") ||
59
+ message.includes("econnreset") ||
60
+ message.includes("etimedout") ||
61
+ message.includes("unavailable") ||
62
+ message.includes("fetch failed") ||
63
+ message.includes("unexpected eof")
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Executes a function with exponential backoff retry logic for transient errors
69
+ * @param {() => Promise<Response>} requestFn - Function that returns a fetch promise
70
+ * @returns {Promise<Response>} Response from successful request
71
+ * @throws {Error} When all retry attempts are exhausted
72
+ */
73
+ async execute(requestFn) {
74
+ let lastError;
75
+
76
+ for (let attempt = 0; attempt <= this.#retries; attempt++) {
77
+ try {
78
+ const response = await requestFn();
79
+
80
+ // Check if we should retry based on status code
81
+ if (
82
+ response?.status &&
83
+ this.#isRetryableStatus(response.status) &&
84
+ attempt < this.#retries
85
+ ) {
86
+ // Add jitter to exponential backoff to avoid thundering herd
87
+ const exponentialDelay = this.#delay * Math.pow(2, attempt);
88
+ const jitter = Math.random() * 0.3 * exponentialDelay;
89
+ const wait = exponentialDelay + jitter;
90
+ await new Promise((resolve) => setTimeout(resolve, wait));
91
+ continue;
92
+ }
93
+
94
+ return response;
95
+ } catch (error) {
96
+ lastError = error;
97
+
98
+ // Check if this is a retryable network error
99
+ if (this.#isRetryableError(error) && attempt < this.#retries) {
100
+ const exponentialDelay = this.#delay * Math.pow(2, attempt);
101
+ const jitter = Math.random() * 0.3 * exponentialDelay;
102
+ const wait = exponentialDelay + jitter;
103
+ await new Promise((resolve) => setTimeout(resolve, wait));
104
+ continue;
105
+ }
106
+
107
+ // Non-retryable error or out of retries
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ // This should never be reached, but if it is, throw the last error
113
+ throw lastError || new Error("Retries exhausted without a valid response");
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Factory function to create a Retry instance with optional configuration
119
+ * @param {object} [config] - Optional configuration object
120
+ * @param {number} [config.retries] - Maximum number of retry attempts
121
+ * @param {number} [config.delay] - Initial delay in milliseconds
122
+ * @returns {Retry} Configured Retry instance
123
+ */
124
+ export function createRetry(config) {
125
+ return new Retry(config);
126
+ }