Files
2026-05-27 23:47:53 +01:00

4.4 KiB

nodeFileFetch — Node file:// adapter

(no submodules)

Purpose

nodeFileFetch is a server-tier FetchLike that closes a real gap in Node's global fetch: undici (the engine backing globalThis.fetch in every Node release through 24) refuses file: URLs outright. Consumers that point bundleUriPlugin at a local-disk rootUrl on Node therefore cannot use the default plugin wiring — the first get rejects with a transport error. This adapter is the supported fix.

The function is exposed at the dedicated ./node-fetch subpath so the package's default export stays universal-pure (no node:* imports from src/index.ts). Node consumers opt into the adapter with one explicit import, browser/Bun/edge consumers continue to use the default globalThis.fetch wiring untouched.

Features

  • WHATWG fetch shape — assignable to @suaveplan/types.FetchLike and to the plugin's options.fetch slot with no cast.
  • file: URL → node:fs/promises.readFile round trip with a real Response (status 200, content-length, last-modified).
  • HEAD short-circuit returns headers only (no buffer read).
  • Range: bytes=<start>-<end> honoured — returns 206 Partial Content with content-range. Empty end (bytes=10-) is interpreted as "through end of file", matching RFC 7233. Out-of-range requests return 416 Range Not Satisfiable with content-range: bytes */<size>.
  • init.signal threaded into fs.readFile({ signal }). A pre-aborted signal short-circuits the read and rejects with an AbortError (or the signal's reason if provided) so the plugin's abort pipeline can rewrap it as BUNDLE_URI.ABORTED.
  • Non-file: URLs delegate to globalThis.fetch, so a single adapter instance handles both local-disk and CDN roots without branching at the call site.
  • Missing path or non-file inode (directory, symlink loop) returns 404 Not Found, matching the rest of the plugin's NOT_FOUND mapping.

Usage: Basic

import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
import { nodeFileFetch } from "@suaveplan/storage-uri-plugin-bundle/node-fetch";

const plugin = bundleUriPlugin({
    packageSlug: "audio-ir-room-plugin-club",
    rootUrl: new URL("./assets/", import.meta.url),
    fetch: nodeFileFetch,
});

const bytes = await plugin.adapter.get("club.wav");

Usage: Advanced

import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
import { nodeFileFetch } from "@suaveplan/storage-uri-plugin-bundle/node-fetch";

// Range read — plugin issues "Range: bytes=10-19", adapter answers 206
// with the 10-byte slice, plugin returns it verbatim.
const plugin = bundleUriPlugin({
    packageSlug: "x",
    rootUrl: new URL("file:///pkgs/x/assets/"),
    fetch: nodeFileFetch,
});

const controller = new AbortController();
const slice = await plugin.adapter.getPartial(
    "club.wav",
    10,
    10,
    controller.signal
);
// controller.abort() before resolution → BUNDLE_URI.ABORTED.

API Reference

  • nodeFileFetch(input, init?): Promise<Response> — FetchLike. For file: URLs reads the path through node:fs/promises; for any other scheme delegates to globalThis.fetch.

Implementation Notes

The adapter never throws for HTTP-shaped problems — missing files become 404, range overflows become 416, etc. — because the calling plugin already owns the status → BUNDLE_URI.* mapping and expects a real Response shape. The one exception is AbortSignal: a pre-aborted signal throws synchronously (before the stat round trip) so the plugin's ensureNotAborted upstream check is symmetric with the adapter's downstream check. Mid-flight aborts propagate as the native AbortError that fs.readFile({ signal }) raises; the plugin's wrapAbortError catches that and rewrites it to BUNDLE_URI.ABORTED.

Range parsing tolerates both bytes=10-19 (closed interval) and bytes=10- (open through EOF) per RFC 7233 §2.1. End values past EOF are clamped to size-1 so a caller asking for bytes=10- against a 20-byte file gets a 10-byte slice + content-range: bytes 10-19/20. A single regex match runs per call — no allocation past the Headers construction the calling code already paid for.

node:fs/promises is the only node:* import. The module sits at the ./node-fetch subpath so universal consumers never pull it through the default barrel; tree-shaking and tier checks both stay green.