UNPKG

@tai-kun/surrealdb

Version:

The SurrealDB SDK for JavaScript

315 lines (265 loc) 7.52 kB
import { type CloseArgs, type ConnectArgs, EngineAbc, type EngineAbcConfig, processQueryRequest, type RpcArgs, } from "@tai-kun/surrealdb/engine"; import { ConnectionUnavailableError, MissingNamespaceError, ServerResponseError, SurrealTypeError, unreachable, } from "@tai-kun/surrealdb/errors"; import { cloneSync } from "@tai-kun/surrealdb/formatter"; import type { BidirectionalRpcResponse, IdLessRpcResponse, RpcParams, RpcQueryRequest, RpcResult, } from "@tai-kun/surrealdb/types"; import { isBrowser, isRpcResponse, throwIfAborted, } from "@tai-kun/surrealdb/utils"; export interface HttpFetcherRequestInit { method: "POST"; headers: { "Content-Type": string; "Surreal-DB"?: string; "Surreal-NS"?: string; Accept: string; Authorization?: `Bearer ${string}`; }; body: string | Uint8Array; signal: AbortSignal; } export type HttpFetcher = ( input: string, init: HttpFetcherRequestInit, ) => PromiseLike<Response>; export interface HttpEngineConfig extends EngineAbcConfig { readonly fetch?: HttpFetcher | undefined; } export default class HttpEngine extends EngineAbc { readonly name = "http"; protected vars: Record<string, unknown> = {}; protected fetch: HttpFetcher; constructor(config: HttpEngineConfig) { super(config); this.fetch = config.fetch || (isBrowser() ? window.fetch.bind(window) : fetch); } async connect({ endpoint, signal }: ConnectArgs): Promise<void> { throwIfAborted(signal); const conn = this.getConnectionInfo(); if (conn.state === "open") { return; } if (conn.state !== "closed") { unreachable(conn as never); } await this.transition( { state: "connecting", endpoint, }, () => "closed", ); await this.transition( { state: "open", endpoint, }, () => "closed", ); } async close({ signal }: CloseArgs): Promise<void> { throwIfAborted(signal); const conn = this.getConnectionInfo(); if (conn.state === "closed") { return; } if (conn.state !== "open") { unreachable(conn as never); } this.vars = {}; await this.transition( { state: "closing", endpoint: conn.endpoint, }, () => ({ state: "closing", endpoint: conn.endpoint, }), ); await this.transition("closed", () => "closed"); } async rpc({ request, signal }: RpcArgs): Promise<IdLessRpcResponse> { // 接続情報のスナップショットを取得します。 // 以降、接続情報を参照する際はこれを使用します。 const conn = this.getConnectionInfo(); if (conn.state !== "open") { throw new ConnectionUnavailableError({ cause: "The connection is not established via the .connect() method.", }); } switch (request.method) { case "use": { let { namespace, database } = conn; const [ns, db] = request.params; if (ns !== undefined) { namespace = ns; } if (db !== undefined) { database = db; } if (namespace === null && database !== null) { throw new MissingNamespaceError(database); } this.namespace = namespace; this.database = database; return { result: undefined, }; } case "let": { const [name, value] = request.params; this.vars[name] = this.fmt.toEncoded?.(value) // WebSocket エンジンとの挙動を合わせるためにパラメーターを不変にする。 ?? cloneSync(this.fmt, value); return { result: undefined, }; } case "unset": { const [name] = request.params; delete this.vars[name]; return { result: undefined, }; } case "query": { const req = processQueryRequest(request); req.params[1] = Object.assign({}, this.vars, req.params[1]); request = req as RpcQueryRequest; break; } } if (conn.namespace === null && conn.database !== null) { throw new MissingNamespaceError(conn.database); } const body: unknown = this.fmt.encodeSync(request); if (typeof body !== "string" && !(body instanceof Uint8Array)) { throw new SurrealTypeError(["String", "Uint8Array"], body); } const headers: HttpFetcherRequestInit["headers"] = { Accept: this.fmt.contentType, "Content-Type": this.fmt.contentType, }; if (conn.namespace != null) { headers["Surreal-NS"] = conn.namespace; } if (conn.database != null) { headers["Surreal-DB"] = conn.database; } if (conn.token) { headers["Authorization"] = `Bearer ${conn.token}`; } const resp: unknown = await this.fetch(conn.endpoint.href, { body, signal, method: "POST", headers, }); const cause = { method: request.method, // TODO(tai-kun): params には機微情報が含まれている可能性があるので、method のみにしておく? params: request.params, endpoint: conn.endpoint.href, database: conn.database, namespace: conn.namespace, }; if (!(resp instanceof Response) || resp.body === null) { throw new ServerResponseError( "Expected `Response` contains a non-null body.", { cause: Object.assign(cause, { response: resp, }), }, ); } if (resp.status !== 200) { const message = await resp.text(); throw new ServerResponseError(message, { cause: Object.assign(cause, { status: resp.status, }), }); } // throwIfAborted(signal); let rpcResp: unknown; if (this.fmt.decodeStream && this.fmt.decodingStrategy) { const length = Number(resp.headers.get("content-length")); if ( length === length && length > 0 && this.fmt.decodingStrategy({ name: "fetch", length }) === "stream" ) { rpcResp = await this.fmt.decodeStream(resp.body, signal); } else { rpcResp = this.fmt.decodeSync(await resp.arrayBuffer()); } } else { rpcResp = this.fmt.decodeSync(await resp.arrayBuffer()); } if (!isRpcResponse(rpcResp) || "id" in rpcResp) { throw new ServerResponseError("Expected id-less rpc response.", { cause: Object.assign(cause, { response: rpcResp, }), }); } if ("result" in rpcResp) { const rpc = { method: request.method, params: request.params, result: rpcResp.result, } as { [M in (typeof request)["method"]]: { method: M; params: RpcParams<M>; result: RpcResult<M>; }; }[(typeof request)["method"]]; switch (rpc.method) { case "signin": case "signup": this.token = rpc.result; break; case "authenticate": [this.token] = rpc.params; break; case "invalidate": this.token = null; break; } } // 双方向通信のレスポンスに擬態する。 const id: BidirectionalRpcResponse["id"] = `${request.method}_0`; const hooks = this.ee.emit(`rpc_${id}`, { id, ...rpcResp, }); if (hooks) { await Promise.all(hooks); } return rpcResp; } }