@unikvs/http
UniKVS の HTTP 汎用ストレージ。fetch だけで読み書きする方法を説明します。
概要
@unikvs/http は、任意の HTTP エンドポイントを KV として使うストレージプラグインです。fetch だけで読み書きします。
- Edge や Workers、Lambda などの最小ランタイムで動きます。ランタイム固有の API は使いません。
- 扱うデータはバイト列専用です。
writeはUint8Array<ArrayBuffer>を受け付けます。readはUint8Array<ArrayBuffer>を返します。 baseUrl配下にキーを対応付けます。認証ヘッダーやトークンを付与できます。- マイクロサービス間の共有や、ゲートウェイ経由の保存に適しています。
インストールは次のように行います。
npm install @unikvs/httppnpm add @unikvs/httpyarn add @unikvs/httpbun add @unikvs/httpnub add @unikvs/httpaube add @unikvs/http主な依存は @unikvs/core です。
使い方
中心は Http クラスです。
import { Http } from "@unikvs/http";
public constructor(baseUrl: string, options?: HttpOptions)
fetch の差し替え型は次のインターフェースです。
export interface IFetch {
(request: Request): Promise<Response>;
}
baseUrl は保存先の起点です。正規化規則は次のとおりです。
- 絶対 URL のみを受け付けます。相対 URL は拒否します。
- クエリーやフラグメント付きは拒否します。
- 末尾の
/は除去します。 /のみなど除去後に空になる入力は拒否します。- 空文字や前後空白付きは拒否します。
options は省略可能です。項目は次のとおりです。
| 項目 | 型 | 既定値 | 説明 |
|---|---|---|---|
keyPrefix |
string |
"unikvs:" |
すべてのキーに付ける接頭辞です。clear() の削除範囲を限定します。 |
allowRepair |
boolean |
false |
書き戻しによる書き込みを許可するかどうかです。false では書き戻しの write・getWritable が RepairNotAllowedError で失敗します。 |
allowClearWithoutPrefix |
boolean |
false |
空の keyPrefix での clear() を許可するかどうかです。 |
fetch |
IFetch |
globalThis.fetch |
送信用の fetch 実装です。Request を包んで呼びます。未指定時は globalThis.fetch を使います。 |
headers |
Record<string, string> |
{} |
既定の送信ヘッダーです。値は文字列のみです。 |
token |
string |
undefined |
既定のベアラートークンです。空でない文字列のみです。 |
送信ヘッダーは三層の優先度で決まります。
vars["@unikvs/http:headers"]とvars["@unikvs/http:token"]vars["@unikvs/fetch:headers"]とvars["@unikvs/fetch:token"]- コンストラクターの
headersとtoken
トークンがある場合、Authorization がなければ Bearer ${token} を自動付与します。明示の Authorization が優先します。baseUrl・keyPrefix・fetch は vars で上書きできません。
主なメンバーは次のとおりです。
nameは"Http"です。isOpenは常にtrueです。open()・close()は中断の検証のみ行います。接続は作りません。writeはPUTで保存します。readはGETで取得します。existsはHEADで確認します。deleteはDELETEで削除します。
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 });
キーの配置
キーは baseUrl 直下の単一セグメントに対応します。
https://kv.example.com/store/unikvs%3Ahello%2Ebin
- 対応は
`${baseUrl}/${encoded}`です。encodedは`${keyPrefix}${key}`を符号化した値です。 .は%2Eに変換します。ドット付きキーも単一セグメントに収まります。clear()はDELETE ?prefix=のみ使います。次の一例です。
DELETE https://kv.example.com/store/?prefix=unikvs%3A
サーバー実装
サーバー側が用意する契約です。クライアントは 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
- パスはすべて
baseUrl直下です。キー部は単一セグメントに符号化します。.は%2Eに変換されます。 - 送信ヘッダーはそのまま届く前提で実装します。
Authorization: Bearer ...がある場合は検証してください。 - 状態コードの対応は次のとおりです。
| 要求 | 成功 | 未存在 | 未対応・失敗 |
|---|---|---|---|
PUT /{encoded} |
2xx |
— | 2xx 以外は保存失敗として扱います。 |
GET /{encoded} |
2xx でバイト列を返します。 |
404・410 |
2xx・404・410 以外は取得失敗として扱います。 |
HEAD /{encoded} |
2xx |
404・410 |
405・501 の場合のみ GET で確認し直します。 |
DELETE /{encoded} |
2xx |
404・410 も成功として扱います。 |
2xx・404・410 以外は削除失敗として扱います。 |
DELETE /?prefix={encodedPrefix} |
2xx |
— | 404・405・501 は未対応として扱います。2xx・404・405・501 以外は削除失敗として扱い、HttpResponseError になります。 |
PUT /{encoded}は保存です。writeと書き込みストリームの確定時に一括で送ります。Content-Type: application/octet-streamでバイト列が届きます。成功は2xxを返します。GET /{encoded}は取得です。成功は2xxでapplication/octet-streamのバイト列を返します。存在しないキーは404または410を返します。HEAD /{encoded}は存在確認です。存在しないキーは404または410を返します。未対応でもかまいませんが、その場合は405または501を返します。クライアントはその場合のみRange: bytes=0-0付きのGETで確認し直すため、そのGETに応答できることが望ましいです。DELETE /{encoded}は削除です。冪等に実装します。存在しないキーの404・410も成功として扱うため、削除済みにはそのまま返します。DELETE /?prefix={encodedPrefix}は一括削除専用です。接頭辞に一致するデータだけを削除します。対応しない場合は404・405・501のいずれかを返します。
OpenAPI 定義 (YAML)
openapi: 3.0.3
info:
title: UniKVS HTTP Storage API
version: 1.0.0
description: '@unikvs/http が baseUrl 直下に送る要求の契約。送信ヘッダーは透過し、Authorization: Bearer がある場合は検証する。'
servers:
- url: '{baseUrl}'
variables:
baseUrl:
default: https://kv.example.com/store
description: 保存先の起点。末尾の / を除いた絶対 URL。
paths:
/{encoded}:
parameters:
- $ref: '#/components/parameters/Encoded'
put:
summary: 値を保存する
operationId: putValue
security:
- bearer: []
- {}
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
description: 保存するバイト列。
responses:
'2XX':
description: 保存成功。
'5XX':
description: 保存失敗。クライアントは HttpResponseError として扱う。
default:
description: 2xx 以外は保存失敗。クライアントは HttpResponseError として扱う。
get:
summary: 値を読む
operationId: getValue
security:
- bearer: []
- {}
parameters:
- name: Range
in: header
required: false
schema:
type: string
description: 存在確認の代替時に bytes=0-0 で送る。任意。
responses:
'2XX':
description: 取得成功。
content:
application/octet-stream:
schema:
type: string
format: binary
description: 保存されたバイト列。
'404':
description: 未存在。クライアントは未存在として扱う。
'410':
description: 未存在。クライアントは未存在として扱う。
'5XX':
description: 取得失敗。クライアントは HttpResponseError として扱う。
default:
description: 2xx・404・410 以外は取得失敗。クライアントは HttpResponseError として扱う。
head:
summary: 存在を確認する
operationId: hasValue
security:
- bearer: []
- {}
responses:
'2XX':
description: 存在する。
'404':
description: 存在しない。
'410':
description: 存在しない。
'405':
description: 未対応。クライアントは Range 付き GET で確認し直す。
'501':
description: 未対応。クライアントは Range 付き GET で確認し直す。
'5XX':
description: 確認失敗。クライアントは HttpResponseError として扱う。
default:
description: 405・501 以外の非 2xx・非 404・非 410 は確認失敗。クライアントは HttpResponseError として扱う。
delete:
summary: 値を消す
operationId: deleteValue
security:
- bearer: []
- {}
responses:
'2XX':
description: 削除成功。冪等。
'404':
description: 削除済みとして成功。冪等。
'410':
description: 削除済みとして成功。冪等。
'5XX':
description: 削除失敗。クライアントは HttpResponseError として扱う。
default:
description: 2xx・404・410 以外は削除失敗。クライアントは HttpResponseError として扱う。
/:
delete:
summary: 接頭辞に一致する値をまとめて消す
operationId: clearByPrefix
security:
- bearer: []
- {}
parameters:
- $ref: '#/components/parameters/Prefix'
responses:
'2XX':
description: 一括削除成功。
'404':
description: 未対応。クライアントは ClearNotSupported として扱う。
'405':
description: 未対応。クライアントは ClearNotSupported として扱う。
'501':
description: 未対応。クライアントは ClearNotSupported として扱う。
'5XX':
description: 一括削除失敗。クライアントは HttpResponseError として扱う。
default:
description: 2xx・404・405・501 以外は一括削除失敗。クライアントは HttpResponseError として扱う。
components:
parameters:
Encoded:
name: encoded
in: path
required: true
schema:
type: string
description: keyPrefix とキーを連結して符号化した単一セグメント。. は %2E に変換される。
Prefix:
name: prefix
in: query
required: true
schema:
type: string
description: keyPrefix を符号化した値。一致する接頭辞だけを削除する。
securitySchemes:
bearer:
type: http
scheme: bearer
description: 'Authorization: Bearer で送る。任意。明示の Authorization が優先される。'ストリーム
読み書きどちらのストリームにも対応しています。
書き込み用は次のメソッドで取得します。
public getWritable(
args: Pick<IStorage.GetWritableArgs, "key" | "signal" | "vars">,
): WritableStream<Uint8Array<ArrayBuffer>>
チャンクを蓄積し、close 時に一括で PUT します。abort では蓄積分を破棄します。サーバー側にストリーミング送信を求めません。
読み取り用は次のメソッドで取得します。
public async getReadable(
args: Pick<IStorage.GetReadableArgs, "key" | "signal" | "vars">,
): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>
応答のボディをそのまま委譲します。ボディなし応答では空ストリームを返します。
注意点
- ランタイム非依存です。
fetch・Request・Responseがあれば動きます。 - タイムアウトは備えていません。
AbortSignalに一任します。 exists()はHEAD未対応時にGETで代替します。405・501の場合のみ代替します。readは存在しないキーで失敗します。404・410を未存在とみなします。deleteは冪等です。存在しないキーでも成功扱いにします。clear()は空keyPrefixのままでは送信前に拒否します。許可にはallowClearWithoutPrefix: trueが必要です。clear()未対応時はClearNotSupportedErrorになります。404の場合はbaseUrlの誤りの可能性もあります。- 中断はそのまま伝えます。
HttpNetworkErrorには包みません。再試行の要否は型ではなく、中断が原因かどうかで判定してください。
エラー
| エラー | 発生条件 |
|---|---|
KeyNotFoundError |
存在しないキーを read または getReadable で読もうとした場合に発生します。@unikvs/core の共通エラーです。 |
InvalidBaseUrlError |
baseUrl が絶対 URL でない場合や、クエリー・フラグメント付き、空文字・前後空白などの場合に発生します。 |
InvalidHeadersError |
ヘッダーに文字列以外を含む場合に発生します。 |
InvalidTokenError |
トークンが空文字や文字列以外の場合に発生します。 |
InvalidKeyError |
キーを URL パスセグメントに符号化できない場合に発生します。 |
ClearWithoutPrefixNotAllowedError |
空の keyPrefix のまま許可なく clear() を呼んだ場合に発生します。 |
ClearNotSupportedError |
サーバーが DELETE ?prefix= に対応しない場合に発生します。 |
HttpNetworkError |
送信自体に失敗した場合に発生します。 |
HttpResponseError |
応答が異常ステータスの場合に発生します。 |
InvalidChunkTypeError |
getWritable に Uint8Array 以外を書いた場合に発生します。 |
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(`キー ${error.meta.key} が見つかりません`);
} else {
throw error;
}
} finally {
await storage.close({ signal });
}
使用例
既定の 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 });独自の fetch 実装を注入する最小完動例です。Request を受けて 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 });