import { METADATA_HEADER_INTERNAL, SIGNED_URL_ACCEPT_HEADER, decodeMetadata, encodeMetadata, isNodeError } from "./chunk-XR3MUBBK.js"; // src/server.ts import { createHmac } from "node:crypto"; import { createReadStream, createWriteStream, promises as fs } from "node:fs"; import http from "node:http"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { platform } from "node:process"; import stream from "node:stream"; import { promisify } from "node:util"; var API_URL_PATH = /\/api\/v1\/blobs\/(?[^/]+)\/(?[^/]+)\/?(?[^?]*)/; var LEGACY_API_URL_PATH = /\/api\/v1\/sites\/(?[^/]+)\/blobs\/?(?[^?]*)/; var LEGACY_DEFAULT_STORE = "production"; var REGION_PREFIX = "region:"; var Operation = /* @__PURE__ */ ((Operation2) => { Operation2["DELETE"] = "delete"; Operation2["GET"] = "get"; Operation2["GET_METADATA"] = "getMetadata"; Operation2["LIST"] = "list"; Operation2["SET"] = "set"; return Operation2; })(Operation || {}); var pipeline = promisify(stream.pipeline); var BlobsServer = class _BlobsServer { constructor({ debug, directory, logger, onRequest, port, token }) { this.address = ""; this.debug = debug === true; this.directory = directory; this.logger = logger ?? console.log; this.onRequest = onRequest; this.port = port || 0; this.token = token; this.tokenHash = createHmac("sha256", Math.random.toString()).update(token ?? Math.random.toString()).digest("hex"); } dispatchOnRequestEvent(type, url) { if (!this.onRequest) { return; } const urlPath = url instanceof URL ? url.pathname + url.search : url; this.onRequest({ type, url: urlPath }); } logDebug(...message) { if (!this.debug) { return; } this.logger("[Netlify Blobs server]", ...message); } async delete(req, res) { const apiMatch = this.parseAPIRequest(req); if (apiMatch?.useSignedURL) { return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); } const url = new URL(apiMatch?.url ?? req.url ?? "", this.address); const { dataPath, key, metadataPath } = this.getLocalPaths(url); if (!dataPath || !key) { return this.sendResponse(req, res, 400); } try { await fs.rm(metadataPath, { force: true, recursive: true }); } catch { } try { await fs.rm(dataPath, { force: true, recursive: true }); } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") { return this.sendResponse(req, res, 500); } } return this.sendResponse(req, res, 204); } async get(req, res) { const apiMatch = this.parseAPIRequest(req); const url = apiMatch?.url ?? new URL(req.url ?? "", this.address); if (apiMatch?.key && apiMatch?.useSignedURL) { return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); } const { dataPath, key, metadataPath, rootPath } = this.getLocalPaths(apiMatch?.url ?? url); if (!rootPath) { return this.sendResponse(req, res, 400); } if (!dataPath || !metadataPath) { return this.listStores(req, res, rootPath, url.searchParams.get("prefix") ?? ""); } if (!key) { return this.listBlobs({ dataPath, metadataPath, rootPath, req, res, url }); } this.dispatchOnRequestEvent("get" /* GET */, url); const headers = {}; try { const rawData = await fs.readFile(metadataPath, "utf8"); const metadata = JSON.parse(rawData); const encodedMetadata = encodeMetadata(metadata); if (encodedMetadata) { headers[METADATA_HEADER_INTERNAL] = encodedMetadata; } } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") { this.logDebug("Could not read metadata file:", error); } } for (const name in headers) { res.setHeader(name, headers[name]); } const stream2 = createReadStream(dataPath); stream2.on("error", (error) => { if (error.code === "EISDIR" || error.code === "ENOENT") { return this.sendResponse(req, res, 404); } return this.sendResponse(req, res, 500); }); stream2.pipe(res); } async head(req, res) { const url = this.parseAPIRequest(req)?.url ?? new URL(req.url ?? "", this.address); const { dataPath, key, metadataPath } = this.getLocalPaths(url); if (!dataPath || !metadataPath || !key) { return this.sendResponse(req, res, 400); } try { const rawData = await fs.readFile(metadataPath, "utf8"); const metadata = JSON.parse(rawData); const encodedMetadata = encodeMetadata(metadata); if (encodedMetadata) { res.setHeader(METADATA_HEADER_INTERNAL, encodedMetadata); } } catch (error) { if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ISDIR")) { return this.sendResponse(req, res, 404); } this.logDebug("Could not read metadata file:", error); return this.sendResponse(req, res, 500); } res.end(); } async listBlobs(options) { const { dataPath, rootPath, req, res, url } = options; const directories = url.searchParams.get("directories") === "true"; const prefix = url.searchParams.get("prefix") ?? ""; const result = { blobs: [], directories: [] }; this.dispatchOnRequestEvent("list" /* LIST */, url); try { await _BlobsServer.walk({ directories, path: dataPath, prefix, rootPath, result }); } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") { this.logDebug("Could not perform list:", error); return this.sendResponse(req, res, 500); } } res.setHeader("content-type", "application/json"); return this.sendResponse(req, res, 200, JSON.stringify(result)); } async listStores(req, res, rootPath, prefix) { try { const allStores = await fs.readdir(rootPath); const filteredStores = allStores.map((store) => platform === "win32" ? decodeURIComponent(store) : store).filter((store) => store.startsWith(prefix)); return this.sendResponse(req, res, 200, JSON.stringify({ stores: filteredStores })); } catch (error) { this.logDebug("Could not list stores:", error); return this.sendResponse(req, res, 500); } } async put(req, res) { const apiMatch = this.parseAPIRequest(req); if (apiMatch) { return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); } const url = new URL(req.url ?? "", this.address); const { dataPath, key, metadataPath } = this.getLocalPaths(url); if (!dataPath || !key || !metadataPath) { return this.sendResponse(req, res, 400); } const metadataHeader = req.headers[METADATA_HEADER_INTERNAL]; const metadata = decodeMetadata(Array.isArray(metadataHeader) ? metadataHeader[0] : metadataHeader ?? null); try { const tempDirectory = await fs.mkdtemp(join(tmpdir(), "netlify-blobs")); const relativeDataPath = relative(this.directory, dataPath); const tempDataPath = join(tempDirectory, relativeDataPath); await fs.mkdir(dirname(tempDataPath), { recursive: true }); await pipeline(req, createWriteStream(tempDataPath)); await fs.mkdir(dirname(dataPath), { recursive: true }); await fs.copyFile(tempDataPath, dataPath); await fs.rm(tempDirectory, { force: true, recursive: true }); await fs.mkdir(dirname(metadataPath), { recursive: true }); await fs.writeFile(metadataPath, JSON.stringify(metadata)); } catch (error) { this.logDebug("Error when writing data:", error); return this.sendResponse(req, res, 500); } return this.sendResponse(req, res, 200); } /** * Parses the URL and returns the filesystem paths where entries and metadata * should be stored. */ getLocalPaths(url) { if (!url) { return {}; } let parts = url.pathname.split("/").slice(1); if (parts[0].startsWith(REGION_PREFIX)) { parts = parts.slice(1); } const [siteID, rawStoreName, ...key] = parts; if (!siteID) { return {}; } const rootPath = resolve(this.directory, "entries", siteID); if (!rawStoreName) { return { rootPath }; } const storeName = platform === "win32" ? encodeURIComponent(rawStoreName) : rawStoreName; const storePath = resolve(rootPath, storeName); const dataPath = resolve(storePath, ...key); const metadataPath = resolve(this.directory, "metadata", siteID, storeName, ...key); return { dataPath, key: key.join("/"), metadataPath, rootPath: storePath }; } handleRequest(req, res) { if (!req.url || !this.validateAccess(req)) { return this.sendResponse(req, res, 403); } switch (req.method?.toLowerCase()) { case "delete" /* DELETE */: { this.dispatchOnRequestEvent("delete" /* DELETE */, req.url); return this.delete(req, res); } case "get" /* GET */: { return this.get(req, res); } case "put" /* PUT */: { this.dispatchOnRequestEvent("set" /* SET */, req.url); return this.put(req, res); } case "head" /* HEAD */: { this.dispatchOnRequestEvent("getMetadata" /* GET_METADATA */, req.url); return this.head(req, res); } default: return this.sendResponse(req, res, 405); } } /** * Tries to parse a URL as being an API request and returns the different * components, such as the store name, site ID, key, and signed URL. */ parseAPIRequest(req) { if (!req.url) { return null; } const apiURLMatch = req.url.match(API_URL_PATH); if (apiURLMatch) { const key = apiURLMatch.groups?.key; const siteID = apiURLMatch.groups?.site_id; const storeName = apiURLMatch.groups?.store_name; const urlPath = [siteID, storeName, key].filter(Boolean); const url = new URL(`/${urlPath.join("/")}?signature=${this.tokenHash}`, this.address); return { key, siteID, storeName, url, useSignedURL: req.headers.accept === SIGNED_URL_ACCEPT_HEADER }; } const legacyAPIURLMatch = req.url.match(LEGACY_API_URL_PATH); if (legacyAPIURLMatch) { const fullURL = new URL(req.url, this.address); const storeName = fullURL.searchParams.get("context") ?? LEGACY_DEFAULT_STORE; const key = legacyAPIURLMatch.groups?.key; const siteID = legacyAPIURLMatch.groups?.site_id; const urlPath = [siteID, storeName, key].filter(Boolean); const url = new URL(`/${urlPath.join("/")}?signature=${this.tokenHash}`, this.address); return { key, siteID, storeName, url, useSignedURL: true }; } return null; } sendResponse(req, res, status, body) { this.logDebug(`${req.method} ${req.url} ${status}`); res.writeHead(status); res.end(body); } async start() { await fs.mkdir(this.directory, { recursive: true }); const server = http.createServer((req, res) => this.handleRequest(req, res)); this.server = server; return new Promise((resolve2, reject) => { server.listen(this.port, () => { const address = server.address(); if (!address || typeof address === "string") { return reject(new Error("Server cannot be started on a pipe or Unix socket")); } this.address = `http://localhost:${address.port}`; resolve2(address); }); }); } async stop() { if (!this.server) { return; } await new Promise((resolve2, reject) => { this.server?.close((error) => { if (error) { return reject(error); } resolve2(null); }); }); } validateAccess(req) { if (!this.token) { return true; } const { authorization = "" } = req.headers; if (authorization.toLowerCase().startsWith("bearer ") && authorization.slice("bearer ".length) === this.token) { return true; } if (!req.url) { return false; } const url = new URL(req.url, this.address); const signature = url.searchParams.get("signature"); if (signature === this.tokenHash) { return true; } return false; } /** * Traverses a path and collects both blobs and directories into a `result` * object, taking into account the `directories` and `prefix` parameters. */ static async walk(options) { const { directories, path, prefix, result, rootPath } = options; const entries = await fs.readdir(path); for (const entry of entries) { const entryPath = join(path, entry); const stat = await fs.stat(entryPath); let key = relative(rootPath, entryPath); if (sep !== "/") { key = key.split(sep).join("/"); } const mask = key.slice(0, prefix.length); const isMatch = prefix.startsWith(mask); if (!isMatch) { continue; } if (!stat.isDirectory()) { const etag = Math.random().toString().slice(2); result.blobs?.push({ etag, key, last_modified: stat.mtime.toISOString(), size: stat.size }); continue; } if (directories && key.startsWith(prefix)) { result.directories?.push(key); continue; } await _BlobsServer.walk({ directories, path: entryPath, prefix, rootPath, result }); } } }; export { BlobsServer, Operation };