@unikvs/http
Learn how to read and write with @unikvs/http, the generic HTTP storage for UniKVS, using only fetch.
Overview
@unikvs/http is a storage plugin that uses any HTTP endpoint as KV storage. It reads and writes with only fetch.
- Runs on minimal runtimes such as Edge, Workers, and Lambda. It uses no runtime-specific APIs.
- Handles byte sequences only.
writeaccepts aUint8Array<ArrayBuffer>, andreadreturns aUint8Array<ArrayBuffer>. - Maps keys under
baseUrl. Auth headers and tokens can be attached. - Suitable for sharing between microservices and for saving through a gateway.
Install it as follows.
npm install @unikvs/httppnpm add @unikvs/httpyarn add @unikvs/httpbun add @unikvs/httpnub add @unikvs/httpaube add @unikvs/httpThe main dependency is @unikvs/core.
Usage
The central class is Http.
import { Http } from "@unikvs/http";
public constructor(baseUrl: string, options?: HttpOptions)
The replacement type for fetch is the following interface.
export interface IFetch {
(request: Request): Promise<Response>;
}
baseUrl is the base location for storage. The normalization rules are as follows.
- Only absolute URLs are accepted. Relative URLs are rejected.
- URLs with a query or fragment are rejected.
- A trailing
/is removed. - Inputs that become empty after removal, such as
/alone, are rejected. - Empty strings and strings with leading or trailing whitespace are rejected.
options is optional. The arguments are as follows.
| Argument | Type | Required | Description |
|---|---|---|---|
baseUrl |
string |
Yes | The base location for storage. Must be an absolute URL without a query or fragment. |
options |
HttpOptions |
No | Behavioral settings for the storage. Behaves like an empty object when omitted. |
options.keyPrefix |
string |
No | The prefix prepended to every key. Defaults to "unikvs:". Limits what clear() deletes. |
options.allowRepair |
boolean |
No | Whether to allow repair writes. Defaults to false. When false, repair write and getWritable calls fail with RepairNotAllowedError. |
options.allowClearWithoutPrefix |
boolean |
No | Whether to allow clear() with an empty keyPrefix. Defaults to false. |
options.fetch |
IFetch |
No | The fetch implementation used for sending. Defaults to globalThis.fetch. It is called with a wrapped Request. |
options.headers |
Record<string, string> |
No | The default request headers. Defaults to {}. Values must be strings only. |
options.token |
string |
No | The default bearer token. Defaults to undefined. Only non-empty strings are accepted. |
Request headers are resolved with three layers of precedence.
vars["@unikvs/http:headers"]andvars["@unikvs/http:token"]vars["@unikvs/fetch:headers"]andvars["@unikvs/fetch:token"]- The constructor
headersandtoken
When a token is present and Authorization is missing, Bearer ${token} is added automatically. An explicit Authorization header takes precedence. baseUrl, keyPrefix, and fetch cannot be overridden with vars.
The main members are as follows.
nameis"Http".isOpenis alwaystrue.open()andclose()only check for interruption. They create no connection.writesaves withPUT.readfetches withGET.existschecks withHEAD.deleteremoves withDELETE.
import { Http } from "@unikvs/http";
const storage = new Http("https://kv.example.com/store");
const signal = AbortSignal.timeout(5_000);
const vars = {};
await storage.open({ signal });
await storage.write({
key: "hello.bin",
data: new TextEncoder().encode("hello"),
signal,
vars,
});
const exists = await storage.exists({ key: "hello.bin", signal, vars });
if (exists) {
const data = await storage.read({ key: "hello.bin", signal, vars });
console.log(new TextDecoder().decode(data));
}
await storage.delete({ key: "hello.bin", signal, vars });
await storage.close({ signal });
Keys
Keys map to a single segment directly under baseUrl.
https://kv.example.com/store/unikvs%3Ahello%2Ebin
- The mapping is
`${baseUrl}/${encoded}`, whereencodedis the encoded form of`${keyPrefix}${key}`. .is converted to%2E. Keys containing dots still fit in a single segment.clear()only usesDELETE ?prefix=. An example follows.
DELETE https://kv.example.com/store/?prefix=unikvs%3A
Server
This is the contract the server side provides. The client sends the following requests directly under baseUrl.
PUT https://kv.example.com/store/unikvs%3Ahello%2Ebin
GET https://kv.example.com/store/unikvs%3Ahello%2Ebin
HEAD https://kv.example.com/store/unikvs%3Ahello%2Ebin
DELETE https://kv.example.com/store/unikvs%3Ahello%2Ebin
DELETE https://kv.example.com/store/?prefix=unikvs%3A
- All paths are directly under
baseUrl. The key part is encoded as a single segment..is converted to%2E. - Implement the server assuming request headers arrive as-is. When
Authorization: Bearer ...is present, validate it. - Status codes map as follows.
| Request | Success | Missing | Unsupported / Failure |
|---|---|---|---|
PUT /{encoded} |
2xx |
— | Any non-2xx status is treated as a save failure. |
GET /{encoded} |
2xx with a byte sequence. |
404, 410 |
Any status other than 2xx, 404, or 410 is treated as a fetch failure. |
HEAD /{encoded} |
2xx |
404, 410 |
Only on 405 or 501, retry the check with GET. |
DELETE /{encoded} |
2xx |
404 and 410 are also treated as success. |
Any status other than 2xx, 404, or 410 is treated as a delete failure. |
DELETE /?prefix={encodedPrefix} |
2xx |
— | 404, 405, and 501 are treated as unsupported. Any status other than 2xx, 404, 405, or 501 is treated as a delete failure and becomes HttpResponseError. |
PUT /{encoded}stores a value. It is sent in bulk onwriteand when a writable stream closes. The byte sequence arrives withContent-Type: application/octet-stream. Return2xxon success.GET /{encoded}reads a value. On success, return the byte sequence with2xxandapplication/octet-stream. Return404or410for missing keys.HEAD /{encoded}checks existence. Return404or410for missing keys. It may be unsupported, in which case return405or501. The client retries only in that case with aGETcarryingRange: bytes=0-0, so thatGETshould be supported.DELETE /{encoded}deletes a value. Implement it idempotently. Since404and410for missing keys are treated as success, return them as-is for already deleted keys.DELETE /?prefix={encodedPrefix}is for bulk deletion only. Delete only data matching the prefix. When unsupported, return one of404,405, or501.
OpenAPI definition (YAML)
openapi: 3.0.3
info:
title: UniKVS HTTP Storage API
version: 1.0.0
description: 'Contract for requests that `@unikvs/http` sends directly under baseUrl. Forward request headers as-is, and validate Authorization: Bearer when present.'
servers:
- url: '{baseUrl}'
variables:
baseUrl:
default: https://kv.example.com/store
description: Base location for storage. An absolute URL without a trailing /.
paths:
/{encoded}:
parameters:
- $ref: '#/components/parameters/Encoded'
put:
summary: Store a value
operationId: putValue
security:
- bearer: []
- {}
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
description: The byte sequence to store.
responses:
'2XX':
description: Stored successfully.
'5XX':
description: Save failed. The client treats it as HttpResponseError.
default:
description: Any non-2xx status is a save failure. The client treats it as HttpResponseError.
get:
summary: Read a value
operationId: getValue
security:
- bearer: []
- {}
parameters:
- name: Range
in: header
required: false
schema:
type: string
description: Sent as bytes=0-0 when used as an existence-check fallback. Optional.
responses:
'2XX':
description: Retrieved successfully.
content:
application/octet-stream:
schema:
type: string
format: binary
description: The stored byte sequence.
'404':
description: Not found. The client treats it as missing.
'410':
description: Not found. The client treats it as missing.
'5XX':
description: Fetch failed. The client treats it as HttpResponseError.
default:
description: Any status other than 2xx, 404, or 410 is a fetch failure. The client treats it as HttpResponseError.
head:
summary: Check existence
operationId: hasValue
security:
- bearer: []
- {}
responses:
'2XX':
description: Exists.
'404':
description: Does not exist.
'410':
description: Does not exist.
'405':
description: Not supported. The client retries with a Range GET.
'501':
description: Not supported. The client retries with a Range GET.
'5XX':
description: Existence check failed. The client treats it as HttpResponseError.
default:
description: Any non-2xx, non-404, non-410 status other than 405 or 501 is a check failure. The client treats it as HttpResponseError.
delete:
summary: Delete a value
operationId: deleteValue
security:
- bearer: []
- {}
responses:
'2XX':
description: Deleted successfully. Idempotent.
'404':
description: Treated as already deleted and successful. Idempotent.
'410':
description: Treated as already deleted and successful. Idempotent.
'5XX':
description: Delete failed. The client treats it as HttpResponseError.
default:
description: Any status other than 2xx, 404, or 410 is a delete failure. The client treats it as HttpResponseError.
/:
delete:
summary: Delete values matching a prefix
operationId: clearByPrefix
security:
- bearer: []
- {}
parameters:
- $ref: '#/components/parameters/Prefix'
responses:
'2XX':
description: Bulk delete succeeded.
'404':
description: Not supported. The client treats it as ClearNotSupported.
'405':
description: Not supported. The client treats it as ClearNotSupported.
'501':
description: Not supported. The client treats it as ClearNotSupported.
'5XX':
description: Bulk delete failed. The client treats it as HttpResponseError.
default:
description: Any status other than 2xx, 404, 405, or 501 is a bulk delete failure. The client treats it as HttpResponseError.
components:
parameters:
Encoded:
name: encoded
in: path
required: true
schema:
type: string
description: A single segment encoding keyPrefix concatenated with the key. . is converted to %2E.
Prefix:
name: prefix
in: query
required: true
schema:
type: string
description: The encoded keyPrefix. Only matching prefixes are deleted.
securitySchemes:
bearer:
type: http
scheme: bearer
description: 'Sent as Authorization: Bearer. Optional. An explicit Authorization header takes precedence.'Streams
Both readable and writable streams are supported.
Obtain a writable stream with the following method.
public getWritable(
args: Pick<IStorage.GetWritableArgs, "key" | "signal" | "vars">,
): WritableStream<Uint8Array<ArrayBuffer>>
Chunks are buffered, then sent in bulk with PUT on close. On abort, buffered chunks are discarded. No streaming delivery to the server is required.
Obtain a readable stream with the following method.
public async getReadable(
args: Pick<IStorage.GetReadableArgs, "key" | "signal" | "vars">,
): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>
The response body is delegated as-is. Responses without a body return an empty stream.
Notes
- Runtime-independent. It works wherever
fetch,Request, andResponseare available. - No built-in timeout. Timeouts are delegated to
AbortSignal. exists()falls back toGETwhenHEADis unsupported. It falls back only on405or501.readfails for missing keys.404and410are treated as missing.deleteis idempotent. Missing keys are treated as success.clear()with an emptykeyPrefixis rejected before sending. PassallowClearWithoutPrefix: trueto allow it.- An unsupported
clear()becomesClearNotSupportedError. A404may also mean an incorrectbaseUrl. - Interruption is forwarded as-is. It is not wrapped in
HttpNetworkError. Decide whether to retry by whether interruption caused the failure, not by the type.
Errors
| Error | Condition |
|---|---|
KeyNotFoundError |
Thrown when reading a missing key with read or getReadable. A shared error from @unikvs/core. |
InvalidBaseUrlError |
Thrown when baseUrl is not an absolute URL, has a query or fragment, or is empty or padded with whitespace. |
InvalidHeadersError |
Thrown when headers contain a non-string value. |
InvalidTokenError |
Thrown when the token is an empty string or not a string. |
InvalidKeyError |
Thrown when the key cannot be encoded as a URL path segment. |
ClearWithoutPrefixNotAllowedError |
Thrown when clear() is called with an empty keyPrefix without permission. |
ClearNotSupportedError |
Thrown when the server does not support DELETE ?prefix=. |
HttpNetworkError |
Thrown when sending itself fails. |
HttpResponseError |
Thrown when the response has an abnormal status. |
InvalidChunkTypeError |
Thrown when a non-Uint8Array value is written to getWritable. |
import { Http, KeyNotFoundError } from "@unikvs/http";
const storage = new Http("https://kv.example.com/store");
const signal = AbortSignal.timeout(5_000);
const vars = {};
try {
await storage.open({ signal });
await storage.read({ key: "missing.bin", signal, vars });
} catch (error) {
if (error instanceof KeyNotFoundError) {
console.error(`Key ${error.meta.key} was not found`);
} else {
throw error;
}
} finally {
await storage.close({ signal });
}
Examples
A minimal working example using the default globalThis.fetch.
import { Http } from "@unikvs/http";
const storage = new Http("https://kv.example.com/store");
const signal = AbortSignal.timeout(5_000);
const vars = {};
await storage.open({ signal });
const key = "greeting.bin";
await storage.write({
key,
data: new TextEncoder().encode("hello, Http"),
signal,
vars,
});
if (await storage.exists({ key, signal, vars })) {
const data = await storage.read({ key, signal, vars });
console.log(new TextDecoder().decode(data));
}
await storage.delete({ key, signal, vars });
await storage.clear({ signal, vars });
await storage.close({ signal });A minimal working example injecting a custom fetch implementation. It receives a Request and returns a Response.
import { Http, type IFetch } from "@unikvs/http";
const fetchImpl: IFetch = async (request: Request) => {
return globalThis.fetch(request);
};
const storage = new Http("https://kv.example.com/store", {
fetch: fetchImpl,
headers: { "X-App": "myapp" },
token: "secret",
});
const signal = AbortSignal.timeout(5_000);
const vars = {};
await storage.open({ signal });
const key = "greeting.bin";
await storage.write({
key,
data: new TextEncoder().encode("hello, Http"),
signal,
vars,
});
if (await storage.exists({ key, signal, vars })) {
const data = await storage.read({ key, signal, vars });
console.log(new TextDecoder().decode(data));
}
await storage.delete({ key, signal, vars });
await storage.clear({ signal, vars });
await storage.close({ signal });