astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
187 lines (186 loc) • 6.66 kB
JavaScript
import { promises as fs } from "node:fs";
import * as devalue from "devalue";
import xxhash from "xxhash-wasm";
import { emptyDir } from "../core/fs/index.js";
import { DATA_STORE_MANIFEST_FILE } from "./consts.js";
/**
* Sort collections and their entries by key.
*
* Entry insertion order can vary between builds due to concurrent file
* processing (pLimit), so sorting here guarantees stable output regardless of
* processing order. Stable output keeps serialized strings (and the content
* hashes derived from them) deterministic across builds.
*/
function sortCollections(collections) {
return new Map([...collections.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, collection]) => [key, new Map([...collection.entries()].sort(([a], [b]) => a.localeCompare(b)))]));
}
/**
* Serialize collections to a deterministic devalue string.
*/
export function serializeDataStore(collections) {
return devalue.stringify(sortCollections(collections));
}
/**
* Finds the end of the largest UTF-8-safe slice beginning at `startIndex` that
* fits within `maxBytes`. A code point larger than `maxBytes` is returned on
* its own so callers always make progress.
*/
function getChunkEnd(str, startIndex, maxBytes) {
let index = startIndex;
let bytes = 0;
while (index < str.length) {
const codePoint = str.codePointAt(index);
const charLength = codePoint > 65535 ? 2 : 1;
// UTF-8 width from the code point. Encoding one character at a time through
// TextEncoder gives the same answer but dominates sync time on large stores.
// A lone surrogate lands in the 3-byte branch, matching the U+FFFD that
// TextEncoder would substitute for it.
const charBytes = codePoint < 128 ? 1 : codePoint < 2048 ? 2 : codePoint < 65536 ? 3 : 4;
if (bytes + charBytes > maxBytes && index > startIndex) {
break;
}
index += charLength;
bytes += charBytes;
if (bytes >= maxBytes) {
break;
}
}
return {
endIndex: index,
bytes
};
}
/**
* Split a string into parts each at most `maxBytes` UTF-8 bytes, never splitting
* a Unicode code point across parts.
*
* The store is serialized with devalue and written to disk as UTF-8. Splitting
* on UTF-16 code-unit boundaries (e.g. `String.prototype.slice`) can cut a
* surrogate pair in half; encoding a lone surrogate to UTF-8 substitutes U+FFFD,
* so concatenating the re-read parts would corrupt any astral-plane character
* (emoji, some CJK, etc.). `getChunkEnd()` advances by Unicode code point, so
* `str.slice` is only ever called on code-point boundaries and the parts always
* rejoin to the exact original string.
*/
export function chunkString(str, maxBytes) {
const chunks = [];
let startIndex = 0;
while (startIndex < str.length) {
const { endIndex } = getChunkEnd(str, startIndex, maxBytes);
chunks.push(str.slice(startIndex, endIndex));
startIndex = endIndex;
}
return chunks;
}
/**
* Atomically write `data` to `file`.
*
* The data is written to a temporary file and then renamed into place to avoid
* partial reads. If the file already contains identical data, the write is
* skipped. Callers are responsible for serializing concurrent writes to the
* same file.
*
* Returns `true` if the file was written, or `false` if the write was skipped.
*/
export async function writeFileAtomic(file, data) {
const tempFile = file instanceof URL ? new URL(`${file.href}.tmp`) : `${file}.tmp`;
const oldData = await fs.readFile(file, "utf-8").catch(() => "");
if (oldData === data) {
// If the data hasn't changed, we can skip the write.
return false;
}
// Write to a temporary file first and then move it to prevent partial reads.
await fs.writeFile(tempFile, data);
await fs.rename(tempFile, file);
return true;
}
/**
* A {@link DataStoreWriter} that serializes the whole store to a single file.
*/
export class FileWriter {
#file;
constructor(file) {
this.#file = file;
}
get target() {
return this.#file;
}
async write(collections) {
return await writeFileAtomic(this.#file, serializeDataStore(collections));
}
}
/**
* A {@link DataStoreWriter} that splits the store across many content-addressed
* files inside a directory, described by a manifest.
*
* Entries are serialized independently into parts no larger than a fixed byte
* size, so writing a collection does not require a collection-wide serialized
* string. Each part file is named by the xxhash of its contents, so unchanged
* parts keep the same name across builds and their writes are skipped, and two
* identical parts are naturally deduplicated. The manifest is written last as
* the commit point, and stale files are pruned afterwards. This is the inverse
* of {@link import('./data-store.js').ImmutableDataStore.manifestToMap}.
*/
export class ChunkedWriter {
#dir;
#manifestFile;
#chunkSize;
#hasher;
#writtenFiles = new Set();
constructor(dir, chunkSize) {
this.#dir = dir;
this.#manifestFile = new URL(`./${DATA_STORE_MANIFEST_FILE}`, dir);
this.#chunkSize = chunkSize;
}
get target() {
return this.#manifestFile;
}
async write(collections) {
if (!this.#hasher) {
this.#hasher = await xxhash();
}
this.#writtenFiles = new Set();
const manifest = {};
for (const [collectionName, entries] of sortCollections(collections)) {
manifest[collectionName] = await this.#writeCollection(entries);
}
// The manifest is the commit point: every part it references already
// exists on disk, so a reader never sees a dangling reference. Parts are
// content-addressed, so an unchanged manifest means unchanged data.
const didWrite = await writeFileAtomic(this.#manifestFile, JSON.stringify(manifest));
this.#writtenFiles.add(DATA_STORE_MANIFEST_FILE);
// Prune files left behind by previous snapshots.
emptyDir(this.#dir, this.#writtenFiles);
return didWrite;
}
async #writeCollection(entries) {
const parts = [];
let chunk = "";
let chunkBytes = 0;
const writeChunk = async () => {
const fileName = `${this.#hasher.h64ToString(chunk)}.txt`;
await writeFileAtomic(new URL(`./${fileName}`, this.#dir), chunk);
parts.push(fileName);
this.#writtenFiles.add(fileName);
chunk = "";
chunkBytes = 0;
};
for (const [id, entry] of entries) {
const serialized = `${devalue.stringify([id, entry])}\n`;
let startIndex = 0;
while (startIndex < serialized.length) {
const { endIndex, bytes } = getChunkEnd(serialized, startIndex, this.#chunkSize - chunkBytes);
chunk += serialized.slice(startIndex, endIndex);
chunkBytes += bytes;
startIndex = endIndex;
if (chunkBytes >= this.#chunkSize) {
await writeChunk();
}
}
}
if (chunk) {
await writeChunk();
}
return parts;
}
}