branch:
extras.ts
16948 bytesRaw
import { createHash } from "node:crypto";
import type {
StateArchiveEntry,
StateFileDetection,
StateFindEntry,
StateFindOptions,
StateHashOptions,
StateJsonUpdateOperation,
StateJsonUpdateResult,
StateTreeNode,
StateTreeOptions,
StateTreeSummary
} from "./backend";
import { decodeText, diffContent, encodeText } from "./helpers";
type PathToken = string | number;
type TreeOps = {
lstat(path: string): Promise<{
type: "file" | "directory" | "symlink";
size: number;
mtime: Date;
} | null>;
readdirWithFileTypes(path: string): Promise<
Array<{
name: string;
type: "file" | "directory" | "symlink";
}>
>;
resolvePath(base: string, path: string): Promise<string>;
};
export function queryJsonValue(value: unknown, query: string): unknown {
const tokens = parseJsonPath(query);
let current = value;
for (const token of tokens) {
if (typeof token === "number") {
if (!Array.isArray(current)) {
throw new Error(`JSON query expected array access at [${token}]`);
}
current = current[token];
} else {
if (current === null || typeof current !== "object") {
throw new Error(`JSON query expected object access at "${token}"`);
}
current = (current as Record<string, unknown>)[token];
}
}
return current;
}
export function updateJsonValue(
input: unknown,
operations: StateJsonUpdateOperation[],
filePath: string
): StateJsonUpdateResult {
const clone = structuredClone(input) as unknown;
for (const operation of operations) {
const tokens = parseJsonPath(operation.path);
if (operation.op === "set") {
setJsonPathValue(clone, tokens, operation.value);
} else {
deleteJsonPathValue(clone, tokens);
}
}
const content = JSON.stringify(clone, null, 2) + "\n";
return {
value: clone,
content,
diff:
JSON.stringify(input) === JSON.stringify(clone)
? ""
: diffContent(
JSON.stringify(input, null, 2) + "\n",
content,
filePath,
filePath
),
operationsApplied: operations.length
};
}
export async function buildTree(
root: string,
ops: TreeOps,
options: StateTreeOptions = {}
): Promise<StateTreeNode> {
const seenDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
return buildTreeNode(root, 0, seenDepth, ops);
}
export async function summarizeTree(
root: string,
ops: TreeOps,
options: StateTreeOptions = {}
): Promise<StateTreeSummary> {
const tree = await buildTree(root, ops, options);
const summary: StateTreeSummary = {
files: 0,
directories: 0,
symlinks: 0,
totalBytes: 0,
maxDepth: 0
};
summarizeNode(tree, 0, summary);
return summary;
}
export async function findInTree(
root: string,
ops: TreeOps,
options: StateFindOptions = {}
): Promise<StateFindEntry[]> {
const results: StateFindEntry[] = [];
const matcher =
options.pathPattern !== undefined ? globToRegex(options.pathPattern) : null;
const nameMatcher =
options.name !== undefined ? globToRegex(options.name) : null;
const types = Array.isArray(options.type)
? new Set(options.type)
: options.type
? new Set([options.type])
: null;
await visitFind(
root,
root,
0,
ops,
options,
matcher,
nameMatcher,
types,
results
);
return results;
}
export function detectFile(
path: string,
bytes: Uint8Array
): StateFileDetection {
const extension = getExtension(path);
const text = isLikelyText(bytes);
const mime =
MIME_BY_EXTENSION[extension ?? ""] ??
(text ? "text/plain" : "application/octet-stream");
return {
mime,
extension: extension ?? undefined,
binary: !text,
description: `${mime}${extension ? ` (${extension})` : ""}`
};
}
export function hashBytes(
bytes: Uint8Array,
options: StateHashOptions = {}
): string {
const algorithm = options.algorithm ?? "sha256";
return createHash(algorithm).update(bytes).digest("hex");
}
export async function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
return transformBytes(bytes, new CompressionStream("gzip"));
}
export async function gunzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
return transformBytes(bytes, new DecompressionStream("gzip"));
}
export function buildTar(entries: Array<TarInputEntry>): Uint8Array {
const chunks: Uint8Array[] = [];
for (const entry of entries) {
const header = createTarHeader(entry);
chunks.push(header);
if (entry.type === "file") {
chunks.push(entry.bytes);
const remainder = entry.bytes.byteLength % 512;
if (remainder !== 0) {
chunks.push(new Uint8Array(512 - remainder));
}
}
}
chunks.push(new Uint8Array(1024));
return concatBytes(chunks);
}
export function listTar(bytes: Uint8Array): StateArchiveEntry[] {
return parseTar(bytes)
.map((entry) => ({
path: entry.path,
type: entry.type,
size: entry.size
}))
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}
export function extractTar(
bytes: Uint8Array
): Array<{ path: string; type: "file" | "directory"; bytes?: Uint8Array }> {
return parseTar(bytes).map((entry) =>
entry.type === "file"
? { path: entry.path, type: entry.type, bytes: entry.bytes }
: { path: entry.path, type: entry.type }
);
}
export type TarInputEntry =
| {
path: string;
type: "directory";
}
| {
path: string;
type: "file";
bytes: Uint8Array;
};
function parseJsonPath(query: string): PathToken[] {
const trimmed = query.trim();
if (!trimmed || trimmed === ".") {
return [];
}
let cursor = trimmed.startsWith(".") ? trimmed.slice(1) : trimmed;
const tokens: PathToken[] = [];
while (cursor.length > 0) {
if (cursor[0] === "[") {
const close = cursor.indexOf("]");
if (close === -1) {
throw new Error(`Invalid JSON path: ${query}`);
}
tokens.push(Number(cursor.slice(1, close)));
cursor = cursor.slice(close + 1);
if (cursor.startsWith(".")) cursor = cursor.slice(1);
continue;
}
const dot = cursor.search(/[.[\]]/);
if (dot === -1) {
tokens.push(cursor);
break;
}
tokens.push(cursor.slice(0, dot));
cursor = cursor.slice(dot);
if (cursor.startsWith(".")) cursor = cursor.slice(1);
}
return tokens.filter((token) => token !== "");
}
function setJsonPathValue(
root: unknown,
tokens: PathToken[],
value: unknown
): void {
if (tokens.length === 0) {
throw new Error("JSON update path must not be empty");
}
let current = root as Record<string, unknown> | unknown[];
for (let index = 0; index < tokens.length - 1; index++) {
const token = tokens[index];
const nextToken = tokens[index + 1];
if (typeof token === "number") {
if (!Array.isArray(current)) {
throw new Error(`JSON update expected array at [${token}]`);
}
if (current[token] === undefined) {
current[token] = typeof nextToken === "number" ? [] : {};
}
current = current[token] as Record<string, unknown> | unknown[];
} else {
if (
current === null ||
typeof current !== "object" ||
Array.isArray(current)
) {
throw new Error(`JSON update expected object at "${token}"`);
}
if ((current as Record<string, unknown>)[token] === undefined) {
(current as Record<string, unknown>)[token] =
typeof nextToken === "number" ? [] : {};
}
current = (current as Record<string, unknown>)[token] as
| Record<string, unknown>
| unknown[];
}
}
const finalToken = tokens[tokens.length - 1];
if (typeof finalToken === "number") {
if (!Array.isArray(current)) {
throw new Error(`JSON update expected array at [${finalToken}]`);
}
current[finalToken] = value;
} else {
if (current === null || typeof current !== "object") {
throw new Error(`JSON update expected object at "${finalToken}"`);
}
(current as Record<string, unknown>)[finalToken] = value;
}
}
function deleteJsonPathValue(root: unknown, tokens: PathToken[]): void {
if (tokens.length === 0) {
throw new Error("JSON delete path must not be empty");
}
let current = root as Record<string, unknown> | unknown[];
for (let index = 0; index < tokens.length - 1; index++) {
const token = tokens[index];
const next =
typeof token === "number"
? (current as unknown[])[token]
: (current as Record<string, unknown>)[token];
current = next as Record<string, unknown> | unknown[];
if (current === undefined) {
return;
}
}
const finalToken = tokens[tokens.length - 1];
if (typeof finalToken === "number") {
if (Array.isArray(current) && finalToken < current.length) {
current.splice(finalToken, 1);
}
} else if (current && typeof current === "object") {
delete (current as Record<string, unknown>)[finalToken];
}
}
async function buildTreeNode(
path: string,
depth: number,
maxDepth: number,
ops: TreeOps
): Promise<StateTreeNode> {
const stat = await ops.lstat(path);
if (!stat) {
throw new Error(`ENOENT: no such file or directory: ${path}`);
}
const node: StateTreeNode = {
path,
name: path === "/" ? "/" : path.slice(path.lastIndexOf("/") + 1),
type: stat.type,
size: stat.size
};
if (stat.type === "directory" && depth < maxDepth) {
const entries = await ops.readdirWithFileTypes(path);
node.children = [];
for (const entry of entries) {
const childPath = await ops.resolvePath(path, entry.name);
node.children.push(
await buildTreeNode(childPath, depth + 1, maxDepth, ops)
);
}
}
return node;
}
function summarizeNode(
node: StateTreeNode,
depth: number,
summary: StateTreeSummary
): void {
summary.maxDepth = Math.max(summary.maxDepth, depth);
if (node.type === "file") {
summary.files++;
summary.totalBytes += node.size;
} else if (node.type === "directory") {
summary.directories++;
} else {
summary.symlinks++;
}
for (const child of node.children ?? []) {
summarizeNode(child, depth + 1, summary);
}
}
async function visitFind(
path: string,
root: string,
depth: number,
ops: TreeOps,
options: StateFindOptions,
pathMatcher: RegExp | null,
nameMatcher: RegExp | null,
types: Set<string> | null,
results: StateFindEntry[]
): Promise<void> {
const stat = await ops.lstat(path);
if (!stat) return;
const name = path === "/" ? "/" : path.slice(path.lastIndexOf("/") + 1);
if (
matchesFind(
path,
name,
depth,
stat,
options,
pathMatcher,
nameMatcher,
types
)
) {
results.push({
path,
name,
type: stat.type,
depth,
size: stat.size,
mtime: stat.mtime
});
}
if (stat.type === "directory") {
const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
if (depth >= maxDepth) return;
const entries = await ops.readdirWithFileTypes(path);
for (const entry of entries) {
const child = await ops.resolvePath(path, entry.name);
await visitFind(
child,
root,
depth + 1,
ops,
options,
pathMatcher,
nameMatcher,
types,
results
);
}
}
}
function matchesFind(
path: string,
name: string,
depth: number,
stat: { type: string; size: number; mtime: Date },
options: StateFindOptions,
pathMatcher: RegExp | null,
nameMatcher: RegExp | null,
types: Set<string> | null
): boolean {
if (depth < (options.minDepth ?? 0)) return false;
if (types && !types.has(stat.type)) return false;
if (pathMatcher && !pathMatcher.test(path)) return false;
if (nameMatcher && !nameMatcher.test(name)) return false;
if (options.sizeMin !== undefined && stat.size < options.sizeMin)
return false;
if (options.sizeMax !== undefined && stat.size > options.sizeMax)
return false;
if (options.mtimeAfter && stat.mtime <= new Date(options.mtimeAfter))
return false;
if (options.mtimeBefore && stat.mtime >= new Date(options.mtimeBefore))
return false;
if (options.empty === true && !(stat.type === "directory" || stat.size === 0))
return false;
return true;
}
async function transformBytes(
bytes: Uint8Array,
stream: CompressionStream | DecompressionStream
): Promise<Uint8Array> {
const input = new Blob([new Uint8Array(bytes)]).stream();
const transformed = input.pipeThrough(stream);
const chunks: Uint8Array[] = [];
const reader = transformed.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return concatBytes(chunks);
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
function createTarHeader(entry: TarInputEntry): Uint8Array {
const header = new Uint8Array(512);
writeAscii(header, 0, 100, entry.path);
writeOctal(header, 100, 8, entry.type === "directory" ? 0o755 : 0o644);
writeOctal(header, 108, 8, 0);
writeOctal(header, 116, 8, 0);
writeOctal(
header,
124,
12,
entry.type === "file" ? entry.bytes.byteLength : 0
);
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
for (let i = 148; i < 156; i++) header[i] = 32;
header[156] =
entry.type === "directory" ? "5".charCodeAt(0) : "0".charCodeAt(0);
writeAscii(header, 257, 6, "ustar");
writeAscii(header, 263, 2, "00");
const checksum = header.reduce((sum, value) => sum + value, 0);
writeOctal(header, 148, 8, checksum);
return header;
}
function parseTar(bytes: Uint8Array): Array<{
path: string;
type: "file" | "directory";
size: number;
bytes: Uint8Array;
}> {
const entries: Array<{
path: string;
type: "file" | "directory";
size: number;
bytes: Uint8Array;
}> = [];
let offset = 0;
while (offset + 512 <= bytes.byteLength) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const path = readAscii(header, 0, 100);
const size = readOctal(header, 124, 12);
const typeFlag = String.fromCharCode(header[156] || 48);
const type = typeFlag === "5" ? "directory" : "file";
offset += 512;
const body = bytes.subarray(offset, offset + size);
entries.push({
path,
type,
size,
bytes: new Uint8Array(body)
});
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function writeAscii(
buffer: Uint8Array,
offset: number,
length: number,
value: string
): void {
const bytes = encodeText(value);
buffer.set(bytes.subarray(0, length), offset);
}
function writeOctal(
buffer: Uint8Array,
offset: number,
length: number,
value: number
): void {
const str = value.toString(8).padStart(length - 2, "0") + "\0 ";
writeAscii(buffer, offset, length, str);
}
function readAscii(buffer: Uint8Array, offset: number, length: number): string {
const value = decodeText(buffer.subarray(offset, offset + length));
return value.split("\u0000", 1)[0].trim();
}
function readOctal(buffer: Uint8Array, offset: number, length: number): number {
const text = readAscii(buffer, offset, length).trim();
return text ? Number.parseInt(text, 8) : 0;
}
function getExtension(path: string): string | null {
const idx = path.lastIndexOf(".");
return idx === -1 ? null : path.slice(idx + 1).toLowerCase();
}
function isLikelyText(bytes: Uint8Array): boolean {
for (const byte of bytes.subarray(0, Math.min(bytes.length, 512))) {
if (byte === 0) return false;
if (byte < 9) return false;
}
return true;
}
function globToRegex(pattern: string): RegExp {
let i = 0;
let re = "^";
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
if (pattern[i + 1] === "*") {
i += 2;
if (pattern[i] === "/") {
re += "(?:.+/)?";
i++;
} else {
re += ".*";
}
} else {
re += "[^/]*";
i++;
}
} else if (ch === "?") {
re += "[^/]";
i++;
} else {
re += ch.replace(/[.+^$|\\()]/g, "\\$&");
i++;
}
}
re += "$";
return new RegExp(re);
}
const MIME_BY_EXTENSION: Record<string, string> = {
js: "application/javascript",
ts: "application/typescript",
json: "application/json",
html: "text/html",
css: "text/css",
md: "text/markdown",
txt: "text/plain",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
tar: "application/x-tar",
gz: "application/gzip"
};