diff options
| author | Dax <[email protected]> | 2026-04-15 10:26:20 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-15 14:26:20 +0000 |
| commit | be9432a893dd1662c10ff41c7ab552bcba8f3e1b (patch) | |
| tree | f49000b3dd9c3bea5247d319e8fcbd4fb879b7b0 /packages/shared/src | |
| parent | af20191d1cd60a7f4a421ad81eca5053f7deace1 (diff) | |
| download | opencode-be9432a893dd1662c10ff41c7ab552bcba8f3e1b.tar.gz opencode-be9432a893dd1662c10ff41c7ab552bcba8f3e1b.zip | |
shared package (#22626)
Diffstat (limited to 'packages/shared/src')
| -rw-r--r-- | packages/shared/src/filesystem.ts | 236 | ||||
| -rw-r--r-- | packages/shared/src/util/array.ts | 10 | ||||
| -rw-r--r-- | packages/shared/src/util/binary.ts | 41 | ||||
| -rw-r--r-- | packages/shared/src/util/encode.ts | 51 | ||||
| -rw-r--r-- | packages/shared/src/util/error.ts | 54 | ||||
| -rw-r--r-- | packages/shared/src/util/fn.ts | 11 | ||||
| -rw-r--r-- | packages/shared/src/util/glob.ts | 34 | ||||
| -rw-r--r-- | packages/shared/src/util/identifier.ts | 48 | ||||
| -rw-r--r-- | packages/shared/src/util/iife.ts | 3 | ||||
| -rw-r--r-- | packages/shared/src/util/lazy.ts | 11 | ||||
| -rw-r--r-- | packages/shared/src/util/module.ts | 10 | ||||
| -rw-r--r-- | packages/shared/src/util/path.ts | 37 | ||||
| -rw-r--r-- | packages/shared/src/util/retry.ts | 41 | ||||
| -rw-r--r-- | packages/shared/src/util/slug.ts | 74 |
14 files changed, 661 insertions, 0 deletions
diff --git a/packages/shared/src/filesystem.ts b/packages/shared/src/filesystem.ts new file mode 100644 index 000000000..44346be8f --- /dev/null +++ b/packages/shared/src/filesystem.ts @@ -0,0 +1,236 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { dirname, join, relative, resolve as pathResolve } from "path" +import { realpathSync } from "fs" +import * as NFS from "fs/promises" +import { lookup } from "mime-types" +import { Effect, FileSystem, Layer, Schema, Context } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { Glob } from "./util/glob" + +export namespace AppFileSystem { + export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", { + method: Schema.String, + cause: Schema.optional(Schema.Defect), + }) {} + + export type Error = PlatformError | FileSystemError + + export interface DirEntry { + readonly name: string + readonly type: "file" | "directory" | "symlink" | "other" + } + + export interface Interface extends FileSystem.FileSystem { + readonly isDir: (path: string) => Effect.Effect<boolean> + readonly isFile: (path: string) => Effect.Effect<boolean> + readonly existsSafe: (path: string) => Effect.Effect<boolean> + readonly readJson: (path: string) => Effect.Effect<unknown, Error> + readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error> + readonly ensureDir: (path: string) => Effect.Effect<void, Error> + readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error> + readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error> + readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error> + readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error> + readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error> + readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error> + readonly globMatch: (pattern: string, filepath: string) => boolean + } + + export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) { + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) + }) + + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { + const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) + return info?.type === "Directory" + }) + + const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) { + const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) + return info?.type === "File" + }) + + const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { + return yield* Effect.tryPromise({ + try: async () => { + const entries = await NFS.readdir(dirPath, { withFileTypes: true }) + return entries.map( + (e): DirEntry => ({ + name: e.name, + type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other", + }), + ) + }, + catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }), + }) + }) + + const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { + const text = yield* fs.readFileString(path) + return JSON.parse(text) + }) + + const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) { + const content = JSON.stringify(data, null, 2) + yield* fs.writeFileString(path, content) + if (mode) yield* fs.chmod(path, mode) + }) + + const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { + yield* fs.makeDirectory(path, { recursive: true }) + }) + + const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( + path: string, + content: string | Uint8Array, + mode?: number, + ) { + const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content) + + yield* write.pipe( + Effect.catchIf( + (e) => e.reason._tag === "NotFound", + () => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + yield* write + }), + ), + ) + if (mode) yield* fs.chmod(path, mode) + }) + + const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) { + return yield* Effect.tryPromise({ + try: () => Glob.scan(pattern, options), + catch: (cause) => new FileSystemError({ method: "glob", cause }), + }) + }) + + const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { + const result: string[] = [] + let current = start + while (true) { + const search = join(current, target) + if (yield* fs.exists(search)) result.push(search) + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { + const result: string[] = [] + let current = options.start + while (true) { + for (const target of options.targets) { + const search = join(current, target) + if (yield* fs.exists(search)) result.push(search) + } + if (options.stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { + const result: string[] = [] + let current = start + while (true) { + const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe( + Effect.catch(() => Effect.succeed([] as string[])), + ) + result.push(...matches) + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + return Service.of({ + ...fs, + existsSafe, + isDir, + isFile, + readDirectoryEntries, + readJson, + writeJson, + ensureDir, + writeWithDirs, + findUp, + up, + globUp, + glob, + globMatch: Glob.match, + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) + + // Pure helpers that don't need Effect (path manipulation, sync operations) + export function mimeType(p: string): string { + return lookup(p) || "application/octet-stream" + } + + export function normalizePath(p: string): string { + if (process.platform !== "win32") return p + const resolved = pathResolve(windowsPath(p)) + try { + return realpathSync.native(resolved) + } catch { + return resolved + } + } + + export function normalizePathPattern(p: string): string { + if (process.platform !== "win32") return p + if (p === "*") return p + const match = p.match(/^(.*)[\\/]\*$/) + if (!match) return normalizePath(p) + const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] + return join(normalizePath(dir), "*") + } + + export function resolve(p: string): string { + const resolved = pathResolve(windowsPath(p)) + try { + return normalizePath(realpathSync(resolved)) + } catch (e: any) { + if (e?.code === "ENOENT") return normalizePath(resolved) + throw e + } + } + + export function windowsPath(p: string): string { + if (process.platform !== "win32") return p + return p + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + } + + export function overlaps(a: string, b: string) { + const relA = relative(a, b) + const relB = relative(b, a) + return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + } + + export function contains(parent: string, child: string) { + return !relative(parent, child).startsWith("..") + } +} diff --git a/packages/shared/src/util/array.ts b/packages/shared/src/util/array.ts new file mode 100644 index 000000000..1fb8ac69e --- /dev/null +++ b/packages/shared/src/util/array.ts @@ -0,0 +1,10 @@ +export function findLast<T>( + items: readonly T[], + predicate: (item: T, index: number, items: readonly T[]) => boolean, +): T | undefined { + for (let i = items.length - 1; i >= 0; i -= 1) { + const item = items[i] + if (predicate(item, i, items)) return item + } + return undefined +} diff --git a/packages/shared/src/util/binary.ts b/packages/shared/src/util/binary.ts new file mode 100644 index 000000000..3d8f61851 --- /dev/null +++ b/packages/shared/src/util/binary.ts @@ -0,0 +1,41 @@ +export namespace Binary { + export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } { + let left = 0 + let right = array.length - 1 + + while (left <= right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId === id) { + return { found: true, index: mid } + } else if (midId < id) { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return { found: false, index: left } + } + + export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] { + const id = compare(item) + let left = 0 + let right = array.length + + while (left < right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId < id) { + left = mid + 1 + } else { + right = mid + } + } + + array.splice(left, 0, item) + return array + } +} diff --git a/packages/shared/src/util/encode.ts b/packages/shared/src/util/encode.ts new file mode 100644 index 000000000..e4c6e70ac --- /dev/null +++ b/packages/shared/src/util/encode.ts @@ -0,0 +1,51 @@ +export function base64Encode(value: string) { + const bytes = new TextEncoder().encode(value) + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("") + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +export function base64Decode(value: string) { + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/")) + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)) + return new TextDecoder().decode(bytes) +} + +export async function hash(content: string, algorithm = "SHA-256"): Promise<string> { + const encoder = new TextEncoder() + const data = encoder.encode(content) + const hashBuffer = await crypto.subtle.digest(algorithm, data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") + return hashHex +} + +export function checksum(content: string): string | undefined { + if (!content) return undefined + let hash = 0x811c9dc5 + for (let i = 0; i < content.length; i++) { + hash ^= content.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(36) +} + +export function sampledChecksum(content: string, limit = 500_000): string | undefined { + if (!content) return undefined + if (content.length <= limit) return checksum(content) + + const size = 4096 + const points = [ + 0, + Math.floor(content.length * 0.25), + Math.floor(content.length * 0.5), + Math.floor(content.length * 0.75), + content.length - size, + ] + const hashes = points + .map((point) => { + const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2))) + return checksum(content.slice(start, start + size)) ?? "" + }) + .join(":") + return `${content.length}:${hashes}` +} diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts new file mode 100644 index 000000000..12c27a0a7 --- /dev/null +++ b/packages/shared/src/util/error.ts @@ -0,0 +1,54 @@ +import z from "zod" + +export abstract class NamedError extends Error { + abstract schema(): z.core.$ZodType + abstract toObject(): { name: string; data: any } + + static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) { + const schema = z + .object({ + name: z.literal(name), + data, + }) + .meta({ + ref: name, + }) + const result = class extends NamedError { + public static readonly Schema = schema + + public override readonly name = name as Name + + constructor( + public readonly data: z.input<Data>, + options?: ErrorOptions, + ) { + super(name, options) + this.name = name + } + + static isInstance(input: any): input is InstanceType<typeof result> { + return typeof input === "object" && "name" in input && input.name === name + } + + schema() { + return schema + } + + toObject() { + return { + name: name, + data: this.data, + } + } + } + Object.defineProperty(result, "name", { value: name }) + return result + } + + public static readonly Unknown = NamedError.create( + "UnknownError", + z.object({ + message: z.string(), + }), + ) +} diff --git a/packages/shared/src/util/fn.ts b/packages/shared/src/util/fn.ts new file mode 100644 index 000000000..9efe4622f --- /dev/null +++ b/packages/shared/src/util/fn.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) { + const result = (input: z.infer<T>) => { + const parsed = schema.parse(input) + return cb(parsed) + } + result.force = (input: z.infer<T>) => cb(input) + result.schema = schema + return result +} diff --git a/packages/shared/src/util/glob.ts b/packages/shared/src/util/glob.ts new file mode 100644 index 000000000..febf062da --- /dev/null +++ b/packages/shared/src/util/glob.ts @@ -0,0 +1,34 @@ +import { glob, globSync, type GlobOptions } from "glob" +import { minimatch } from "minimatch" + +export namespace Glob { + export interface Options { + cwd?: string + absolute?: boolean + include?: "file" | "all" + dot?: boolean + symlink?: boolean + } + + function toGlobOptions(options: Options): GlobOptions { + return { + cwd: options.cwd, + absolute: options.absolute, + dot: options.dot, + follow: options.symlink ?? false, + nodir: options.include !== "all", + } + } + + export async function scan(pattern: string, options: Options = {}): Promise<string[]> { + return glob(pattern, toGlobOptions(options)) as Promise<string[]> + } + + export function scanSync(pattern: string, options: Options = {}): string[] { + return globSync(pattern, toGlobOptions(options)) as string[] + } + + export function match(pattern: string, filepath: string): boolean { + return minimatch(filepath, pattern, { dot: true }) + } +} diff --git a/packages/shared/src/util/identifier.ts b/packages/shared/src/util/identifier.ts new file mode 100644 index 000000000..ba28a351b --- /dev/null +++ b/packages/shared/src/util/identifier.ts @@ -0,0 +1,48 @@ +import { randomBytes } from "crypto" + +export namespace Identifier { + const LENGTH = 26 + + // State for monotonic ID generation + let lastTimestamp = 0 + let counter = 0 + + export function ascending() { + return create(false) + } + + export function descending() { + return create(true) + } + + function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let result = "" + const bytes = randomBytes(length) + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % 62] + } + return result + } + + export function create(descending: boolean, timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + now = descending ? ~now : now + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return timeBytes.toString("hex") + randomBase62(LENGTH - 12) + } +} diff --git a/packages/shared/src/util/iife.ts b/packages/shared/src/util/iife.ts new file mode 100644 index 000000000..ca9ae6c10 --- /dev/null +++ b/packages/shared/src/util/iife.ts @@ -0,0 +1,3 @@ +export function iife<T>(fn: () => T) { + return fn() +} diff --git a/packages/shared/src/util/lazy.ts b/packages/shared/src/util/lazy.ts new file mode 100644 index 000000000..935ebe0f9 --- /dev/null +++ b/packages/shared/src/util/lazy.ts @@ -0,0 +1,11 @@ +export function lazy<T>(fn: () => T) { + let value: T | undefined + let loaded = false + + return (): T => { + if (loaded) return value as T + loaded = true + value = fn() + return value as T + } +} diff --git a/packages/shared/src/util/module.ts b/packages/shared/src/util/module.ts new file mode 100644 index 000000000..6ed3b23d7 --- /dev/null +++ b/packages/shared/src/util/module.ts @@ -0,0 +1,10 @@ +import { createRequire } from "node:module" +import path from "node:path" + +export namespace Module { + export function resolve(id: string, dir: string) { + try { + return createRequire(path.join(dir, "package.json")).resolve(id) + } catch {} + } +} diff --git a/packages/shared/src/util/path.ts b/packages/shared/src/util/path.ts new file mode 100644 index 000000000..bb191f512 --- /dev/null +++ b/packages/shared/src/util/path.ts @@ -0,0 +1,37 @@ +export function getFilename(path: string | undefined) { + if (!path) return "" + const trimmed = path.replace(/[\/\\]+$/, "") + const parts = trimmed.split(/[\/\\]/) + return parts[parts.length - 1] ?? "" +} + +export function getDirectory(path: string | undefined) { + if (!path) return "" + const trimmed = path.replace(/[\/\\]+$/, "") + const parts = trimmed.split(/[\/\\]/) + return parts.slice(0, parts.length - 1).join("/") + "/" +} + +export function getFileExtension(path: string | undefined) { + if (!path) return "" + const parts = path.split(".") + return parts[parts.length - 1] +} + +export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) { + const filename = getFilename(path) + if (filename.length <= maxLength) return filename + const lastDot = filename.lastIndexOf(".") + const ext = lastDot <= 0 ? "" : filename.slice(lastDot) + const available = maxLength - ext.length - 1 // -1 for ellipsis + if (available <= 0) return filename.slice(0, maxLength - 1) + "…" + return filename.slice(0, available) + "…" + ext +} + +export function truncateMiddle(text: string, maxLength: number = 20) { + if (text.length <= maxLength) return text + const available = maxLength - 1 // -1 for ellipsis + const start = Math.ceil(available / 2) + const end = Math.floor(available / 2) + return text.slice(0, start) + "…" + text.slice(-end) +} diff --git a/packages/shared/src/util/retry.ts b/packages/shared/src/util/retry.ts new file mode 100644 index 000000000..0014a604c --- /dev/null +++ b/packages/shared/src/util/retry.ts @@ -0,0 +1,41 @@ +export interface RetryOptions { + attempts?: number + delay?: number + factor?: number + maxDelay?: number + retryIf?: (error: unknown) => boolean +} + +const TRANSIENT_MESSAGES = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", +] + +function isTransientError(error: unknown): boolean { + if (!error) return false + const message = String(error instanceof Error ? error.message : error).toLowerCase() + return TRANSIENT_MESSAGES.some((m) => message.includes(m)) +} + +export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> { + const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options + + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt === attempts - 1 || !retryIf(error)) throw error + const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + throw lastError +} diff --git a/packages/shared/src/util/slug.ts b/packages/shared/src/util/slug.ts new file mode 100644 index 000000000..62cf0e57b --- /dev/null +++ b/packages/shared/src/util/slug.ts @@ -0,0 +1,74 @@ +export namespace Slug { + const ADJECTIVES = [ + "brave", + "calm", + "clever", + "cosmic", + "crisp", + "curious", + "eager", + "gentle", + "glowing", + "happy", + "hidden", + "jolly", + "kind", + "lucky", + "mighty", + "misty", + "neon", + "nimble", + "playful", + "proud", + "quick", + "quiet", + "shiny", + "silent", + "stellar", + "sunny", + "swift", + "tidy", + "witty", + ] as const + + const NOUNS = [ + "cabin", + "cactus", + "canyon", + "circuit", + "comet", + "eagle", + "engine", + "falcon", + "forest", + "garden", + "harbor", + "island", + "knight", + "lagoon", + "meadow", + "moon", + "mountain", + "nebula", + "orchid", + "otter", + "panda", + "pixel", + "planet", + "river", + "rocket", + "sailor", + "squid", + "star", + "tiger", + "wizard", + "wolf", + ] as const + + export function create() { + return [ + ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)], + NOUNS[Math.floor(Math.random() * NOUNS.length)], + ].join("-") + } +} |
