├── .github └── workflows │ ├── ci.yaml │ └── pages.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── jwt_auth.mjs └── readme_example.js ├── jest.config.js ├── package-cjs.json ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── client.ts │ └── libsql_url.ts ├── batch.ts ├── byte_queue.ts ├── client.ts ├── cursor.ts ├── describe.ts ├── encoding │ ├── index.ts │ ├── json │ │ ├── decode.ts │ │ └── encode.ts │ └── protobuf │ │ ├── decode.ts │ │ ├── encode.ts │ │ └── util.ts ├── errors.ts ├── http │ ├── client.ts │ ├── cursor.ts │ ├── json_decode.ts │ ├── json_encode.ts │ ├── proto.ts │ ├── protobuf_decode.ts │ ├── protobuf_encode.ts │ └── stream.ts ├── id_alloc.ts ├── index.ts ├── libsql_url.ts ├── queue.ts ├── queue_microtask.ts ├── result.ts ├── shared │ ├── json_decode.ts │ ├── json_encode.ts │ ├── proto.ts │ ├── protobuf_decode.ts │ └── protobuf_encode.ts ├── sql.ts ├── stmt.ts ├── stream.ts ├── util.ts ├── value.ts └── ws │ ├── client.ts │ ├── cursor.ts │ ├── json_decode.ts │ ├── json_encode.ts │ ├── proto.ts │ ├── protobuf_decode.ts │ ├── protobuf_encode.ts │ └── stream.ts ├── tsconfig.base.json ├── tsconfig.build-cjs.json ├── tsconfig.build-esm.json ├── tsconfig.json └── typedoc.json /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | push: 4 | branches: 5 | pull_request: 6 | 7 | jobs: 8 | "node-test": 9 | name: "Build and test on Node.js" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: "Checkout this repo" 13 | uses: actions/checkout@v3 14 | - name: "Setup Node.js" 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: "18.x" 18 | cache: "npm" 19 | - name: "Install npm dependencies" 20 | run: "npm ci" 21 | 22 | - name: "Checkout hrana-test-server" 23 | uses: actions/checkout@v3 24 | with: 25 | repository: "libsql/hrana-test-server" 26 | path: "hrana-test-server" 27 | - name: "Setup Python" 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: "3.10" 31 | cache: "pip" 32 | - name: "Install pip dependencies" 33 | run: "pip install -r hrana-test-server/requirements.txt" 34 | 35 | - name: "Build" 36 | run: "npm run build" 37 | - name: "Test version 1 over WebSocket" 38 | run: "python hrana-test-server/server_v1.py npm test" 39 | env: {"VERSION": "1", "URL": "ws://localhost:8080"} 40 | - name: "Test version 2 over WebSocket" 41 | run: "python hrana-test-server/server_v2.py npm test" 42 | env: {"VERSION": "2", "URL": "ws://localhost:8080"} 43 | - name: "Test version 2 over HTTP" 44 | run: "python hrana-test-server/server_v2.py npm test" 45 | env: {"VERSION": "2", "URL": "http://localhost:8080"} 46 | - name: "Test version 3 over WebSocket and JSON" 47 | run: "python hrana-test-server/server_v3.py npm test" 48 | env: {"VERSION": "3", "URL": "ws://localhost:8080", "ENCODING": "json"} 49 | - name: "Test version 3 over WebSocket and Protobuf" 50 | run: "python hrana-test-server/server_v3.py npm test" 51 | env: {"VERSION": "3", "URL": "ws://localhost:8080", "ENCODING": "protobuf"} 52 | - name: "Test version 3 over HTTP and JSON" 53 | run: "python hrana-test-server/server_v3.py npm test" 54 | env: {"VERSION": "3", "URL": "http://localhost:8080", "ENCODING": "json"} 55 | - name: "Test version 3 over HTTP and Protobuf" 56 | run: "python hrana-test-server/server_v3.py npm test" 57 | env: {"VERSION": "3", "URL": "http://localhost:8080", "ENCODING": "protobuf"} 58 | - name: "Example" 59 | run: "python hrana-test-server/server_v3.py node examples/readme_example.js" 60 | -------------------------------------------------------------------------------- /.github/workflows/pages.yaml: -------------------------------------------------------------------------------- 1 | name: "GitHub Pages" 2 | on: 3 | push: 4 | branches: ["main"] 5 | 6 | jobs: 7 | "build": 8 | name: "Build the docs" 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: "Checkout this repo" 12 | uses: actions/checkout@v3 13 | - name: "Setup Node.js" 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: "${{ matrix.node-version }}" 17 | cache: "npm" 18 | - name: "Install npm dependencies" 19 | run: "npm ci" 20 | - name: "Build" 21 | run: "npm run typedoc" 22 | - name: "Upload GitHub Pages artifact" 23 | uses: actions/upload-pages-artifact@v1 24 | with: 25 | path: "./docs" 26 | 27 | "deploy": 28 | name: "Deploy the docs to GitHub Pages" 29 | needs: "build" 30 | permissions: 31 | pages: write 32 | id-token: write 33 | 34 | environment: 35 | name: github-pages 36 | url: ${{ steps.deployment.outputs.page_url }} 37 | 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: "Deploy to GitHub Pages" 41 | id: deployment 42 | uses: actions/deploy-pages@v1 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib-esm 3 | /lib-cjs 4 | /docs 5 | *.tsbuildinfo 6 | Session.vim 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.8.0 -- 2024-09-16 4 | 5 | - Replace isomorphic-fetch dependency with cross-fetch 6 | 7 | ## 0.7.0 -- 2024-09-16 8 | 9 | - Upgrade isomorphic-fetch to v0.3.0 which do not override `fetch` agent and do not have issue with headers in cloud providers (see https://github.com/libsql/isomorphic-ts/pull/17) 10 | 11 | ## 0.6.3 -- 2024-08-25 12 | 13 | - Make sure fetch response body is read or cancelled during flush, which fixes random networking errors observed by users. 14 | 15 | ## 0.6.2 -- 2024-06-03 16 | 17 | - Make row properties writable to match the behavior of the SQLite client. 18 | 19 | ## 0.6.1 -- 2024-06-03 20 | 21 | - Remove confusing 404 error message. 22 | 23 | ## 0.6.0 -- 2024-03-28 24 | 25 | - Update `isomorphic-fetch` dependency for built-in Node fetch(). This package now requires Node 18 or later. 26 | 27 | ## 0.5.2 -- 2023-09-11 28 | 29 | - Switch to use Hrana 2 by default to let Hrana 3 cook a bit longer. 30 | 31 | ## 0.5.1 -- 2023-09-11 32 | 33 | - Update `isomorphic-{fetch, ws}` dependencies for Bun support. 34 | 35 | ## 0.5.0 -- 2023-07-29 36 | 37 | - **Added support for Hrana 3**, which included some API changes: 38 | - Added variant `3` to the `ProtocolVersion` type 39 | - Added `BatchCond.isAutocommit()` 40 | - Added `Stream.getAutocommit()` 41 | - Added parameter `useCursor` to `Stream.batch()` 42 | - **Changed meaning of `Stream.close()`**, which now closes the stream immediately 43 | - Added `Stream.closeGracefully()` 44 | - Changed type of `StmtResult.lastInsertRowid` to bigint 45 | - Changed `BatchCond.and()` and `BatchCond.or()` to pass the `Batch` object 46 | - Added `Stream.client()` 47 | - Added `MisuseError` and `InternalError` 48 | - Added reexport of `WebSocket` from `@libsql/isomorphic-ws` 49 | - Added reexports of `fetch`, `Request`, `Response` and other types from `@libsql/isomorphic-fetch` 50 | - Dropped workarounds for broken WebSocket support in Miniflare 2 51 | 52 | ## 0.4.4 -- 2023-08-15 53 | 54 | - Pass a `string` instead of `URL` to the `Request` constructor 55 | 56 | ## 0.4.3 -- 2023-07-18 57 | 58 | - Added `customFetch` argument to `openHttp()` to override the `fetch()` function 59 | 60 | ## 0.4.2 -- 2023-06-22 61 | 62 | - Added `IntMode`, `Client.intMode` and `Stream.intMode` 63 | 64 | ## 0.4.1 -- 2023-06-12 65 | 66 | - Fixed environments that don't support `queueMicrotask()` by implementing a ponyfill [libsql-client-ts#47](https://github.com/libsql/libsql-client-ts/issues/47) 67 | 68 | ## 0.4.0 -- 2023-06-07 69 | 70 | - **Added support for Hrana over HTTP**, which included some API changes: 71 | - Removed `open()`, replaced with `openHttp()` and `openWs()` 72 | - Added `SqlOwner` interface for the `storeSql()` method, which is implemented by `WsClient` and `HttpStream` 73 | - Added HTTP `status` to `HttpServerError` 74 | - Changed `parseLibsqlUrl()` to support both WebSocket and HTTP URLs 75 | - Changed `Value` type to include `bigint` 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2023 the sqld authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hrana client for TypeScript 2 | 3 | **[API docs][docs] | [Github][github] | [npm][npm]** 4 | 5 | [docs]: https://libsql.org/hrana-client-ts/ 6 | [github]: https://github.com/libsql/hrana-client-ts/ 7 | [npm]: https://www.npmjs.com/package/@libsql/hrana-client 8 | 9 | This package implements a Hrana client for TypeScript. Hrana is the protocol for connecting to sqld using WebSocket or HTTP. 10 | 11 | > This package is intended mostly for internal use. Consider using the [`@libsql/client`][libsql-client] package, which will use Hrana automatically. 12 | 13 | [libsql-client]: https://www.npmjs.com/package/@libsql/client 14 | 15 | ## Usage 16 | 17 | ```typescript 18 | import * as hrana from "@libsql/hrana-client"; 19 | 20 | // Open a `hrana.Client`, which works like a connection pool in standard SQL 21 | // databases. 22 | const url = process.env.URL ?? "ws://localhost:8080"; // Address of the sqld server 23 | const jwt = process.env.JWT; // JWT token for authentication 24 | // Here we are using Hrana over WebSocket: 25 | const client = hrana.openWs(url, jwt); 26 | // But we can also use Hrana over HTTP: 27 | // const client = hrana.openHttp(url, jwt); 28 | 29 | // Open a `hrana.Stream`, which is an interactive SQL stream. This corresponds 30 | // to a "connection" from other SQL databases 31 | const stream = client.openStream(); 32 | 33 | // Fetch all rows returned by a SQL statement 34 | const books = await stream.query("SELECT title, year FROM book WHERE author = 'Jane Austen'"); 35 | // The rows are returned in an Array... 36 | for (const book of books.rows) { 37 | // every returned row works as an array (`book[1]`) and as an object (`book.year`) 38 | console.log(`${book.title} from ${book.year}`); 39 | } 40 | 41 | // Fetch a single row 42 | const book = await stream.queryRow("SELECT title, MIN(year) FROM book"); 43 | if (book.row !== undefined) { 44 | console.log(`The oldest book is ${book.row.title} from year ${book.row[1]}`); 45 | } 46 | 47 | // Fetch a single value, using a bound parameter 48 | const year = await stream.queryValue(["SELECT MAX(year) FROM book WHERE author = ?", ["Jane Austen"]]); 49 | if (year.value !== undefined) { 50 | console.log(`Last book from Jane Austen was published in ${year.value}`); 51 | } 52 | 53 | // Execute a statement that does not return any rows 54 | const res = await stream.run(["DELETE FROM book WHERE author = ?", ["J. K. Rowling"]]) 55 | console.log(`${res.affectedRowCount} books have been cancelled`); 56 | 57 | // When you are done, remember to close the client 58 | client.close(); 59 | ``` 60 | -------------------------------------------------------------------------------- /examples/jwt_auth.mjs: -------------------------------------------------------------------------------- 1 | import * as hrana from "@libsql/hrana-client"; 2 | 3 | const client = hrana.openWs(process.env.URL ?? "ws://localhost:8080", process.env.JWT); 4 | const stream = client.openStream(); 5 | console.log(await stream.queryValue("SELECT 1")); 6 | client.close(); 7 | -------------------------------------------------------------------------------- /examples/readme_example.js: -------------------------------------------------------------------------------- 1 | import * as hrana from "@libsql/hrana-client"; 2 | 3 | // Open a `hrana.Client`, which works like a connection pool in standard SQL 4 | // databases. 5 | const url = process.env.URL ?? "ws://localhost:8080"; // Address of the sqld server 6 | const jwt = process.env.JWT; // JWT token for authentication 7 | // Here we are using Hrana over WebSockets: 8 | const client = hrana.openWs(url, jwt, 3); 9 | // But we can also use Hrana over HTTP: 10 | // const client = hrana.openHttp(url, jwt, undefined, 3); 11 | 12 | // Open a `hrana.Stream`, which is an interactive SQL stream. This corresponds 13 | // to a "connection" from other SQL databases 14 | const stream = client.openStream(); 15 | 16 | await stream.run("DROP TABLE IF EXISTS book"); 17 | await stream.run(`CREATE TABLE book ( 18 | id INTEGER PRIMARY KEY NOT NULL, 19 | author TEXT NOT NULL, 20 | title TEXT NOT NULL, 21 | year INTEGER NOT NULL 22 | )`); 23 | await stream.run(`INSERT INTO book (author, title, year) VALUES 24 | ('Jane Austen', 'Sense and Sensibility', 1811), 25 | ('Jane Austen', 'Pride and Prejudice', 1813), 26 | ('Jane Austen', 'Mansfield Park', 1814), 27 | ('Jane Austen', 'Emma', 1815), 28 | ('Jane Austen', 'Persuasion', 1818), 29 | ('Jane Austen', 'Lady Susan', 1871), 30 | ('Daniel Defoe', 'Robinson Crusoe', 1719), 31 | ('Daniel Defoe', 'A Journal of the Plague Year', 1722), 32 | ('J. K. Rowling', 'Harry Potter and the Philosopher''s Stone', 1997), 33 | ('J. K. Rowling', 'The Casual Vacancy', 2012), 34 | ('J. K. Rowling', 'The Ickabog', 2020) 35 | `); 36 | 37 | // Fetch all rows returned by a SQL statement 38 | const books = await stream.query("SELECT title, year FROM book WHERE author = 'Jane Austen'"); 39 | // The rows are returned in an Array... 40 | for (const book of books.rows) { 41 | // every returned row works as an array (`book[1]`) and as an object (`book.year`) 42 | console.log(`${book.title} from ${book.year}`); 43 | } 44 | 45 | // Fetch a single row 46 | const book = await stream.queryRow("SELECT title, MIN(year) FROM book"); 47 | if (book.row !== undefined) { 48 | console.log(`The oldest book is ${book.row.title} from year ${book.row[1]}`); 49 | } 50 | 51 | // Fetch a single value, using a bound parameter 52 | const year = await stream.queryValue(["SELECT MAX(year) FROM book WHERE author = ?", ["Jane Austen"]]); 53 | if (year.value !== undefined) { 54 | console.log(`Last book from Jane Austen was published in ${year.value}`); 55 | } 56 | 57 | // Execute a statement that does not return any rows 58 | const res = await stream.run(["DELETE FROM book WHERE author = ?", ["J. K. Rowling"]]) 59 | console.log(`${res.affectedRowCount} books have been cancelled`); 60 | 61 | // When you are done, remember to close the client 62 | client.close(); 63 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: "ts-jest/presets/default-esm", 3 | moduleNameMapper: { 4 | '^(\\.{1,2}/.*)\\.js$': '$1', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /package-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@libsql/hrana-client", 3 | "version": "0.8.0", 4 | "keywords": [ 5 | "hrana", 6 | "libsql", 7 | "sqld", 8 | "database" 9 | ], 10 | "description": "Hrana client for connecting to sqld over HTTP or WebSocket", 11 | "repository": { 12 | "type": "git", 13 | "url": "github:libsql/hrana-client-ts" 14 | }, 15 | "homepage": "https://github.com/libsql/hrana-client-ts", 16 | "authors": [ 17 | "Jan Špaček " 18 | ], 19 | "license": "MIT", 20 | "type": "module", 21 | "main": "lib-cjs/index.js", 22 | "types": "lib-esm/index.d.ts", 23 | "exports": { 24 | ".": { 25 | "types": "./lib-esm/index.d.ts", 26 | "import": "./lib-esm/index.js", 27 | "require": "./lib-cjs/index.js" 28 | } 29 | }, 30 | "files": [ 31 | "lib-cjs/**", 32 | "lib-esm/**" 33 | ], 34 | "scripts": { 35 | "clean": "rm -rf ./lib-cjs ./lib-esm ./*.tsbuildinfo", 36 | "prepublishOnly": "npm run clean-build", 37 | "prebuild": "rm -rf ./lib-cjs ./lib-esm", 38 | "build": "npm run build:cjs && npm run build:esm", 39 | "build:cjs": "tsc -p tsconfig.build-cjs.json", 40 | "build:esm": "tsc -p tsconfig.build-esm.json", 41 | "postbuild": "cp package-cjs.json ./lib-cjs/package.json", 42 | "clean-build": "npm run clean && npm run build", 43 | "typecheck": "tsc --noEmit", 44 | "test": "jest --runInBand", 45 | "typedoc": "rm -rf ./docs && typedoc" 46 | }, 47 | "dependencies": { 48 | "@libsql/isomorphic-ws": "^0.1.5", 49 | "cross-fetch": "^4.0.0", 50 | "js-base64": "^3.7.5", 51 | "node-fetch": "^3.3.2" 52 | }, 53 | "devDependencies": { 54 | "@types/jest": "^29", 55 | "jest": "^29.6.2", 56 | "ts-jest": "^29.1.1", 57 | "typedoc": "^0.24.8", 58 | "typescript": "^5.1.6" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/__tests__/libsql_url.ts: -------------------------------------------------------------------------------- 1 | import * as hrana from ".."; 2 | 3 | describe("parseLibsqlUrl()", () => { 4 | function expectParse(url: string, parsed: Partial) { 5 | parsed.hranaWsUrl ??= undefined; 6 | parsed.hranaHttpUrl ??= undefined; 7 | parsed.authToken ??= undefined; 8 | expect(hrana.parseLibsqlUrl(url)).toStrictEqual(parsed); 9 | } 10 | 11 | function expectParseError(url: string, message: RegExp) { 12 | expect(() => hrana.parseLibsqlUrl(url)).toThrow(message); 13 | } 14 | 15 | test("ws/wss URL", () => { 16 | expectParse("ws://localhost", {hranaWsUrl: "ws://localhost"}); 17 | expectParse("ws://localhost:8080", {hranaWsUrl: "ws://localhost:8080"}); 18 | expectParse("ws://127.0.0.1:8080", {hranaWsUrl: "ws://127.0.0.1:8080"}); 19 | expectParse("ws://[2001:db8::1]:8080", {hranaWsUrl: "ws://[2001:db8::1]:8080"}); 20 | expectParse("ws://localhost/some/path", {hranaWsUrl: "ws://localhost/some/path"}); 21 | expectParse("wss://localhost", {hranaWsUrl: "wss://localhost"}); 22 | }); 23 | 24 | test("http/https URL", () => { 25 | expectParse("http://localhost", {hranaHttpUrl: "http://localhost"}); 26 | expectParse("http://localhost/some/path", {hranaHttpUrl: "http://localhost/some/path"}); 27 | expectParse("https://localhost", {hranaHttpUrl: "https://localhost"}); 28 | }); 29 | 30 | test("libsql URL", () => { 31 | expectParse("libsql://localhost", { 32 | hranaWsUrl: "wss://localhost", 33 | hranaHttpUrl: "https://localhost", 34 | }); 35 | expectParse("libsql://localhost:8080", { 36 | hranaWsUrl: "wss://localhost:8080", 37 | hranaHttpUrl: "https://localhost:8080", 38 | }); 39 | expectParse("libsql://localhost/some/path", { 40 | hranaWsUrl: "wss://localhost/some/path", 41 | hranaHttpUrl: "https://localhost/some/path", 42 | }); 43 | }); 44 | 45 | test("tls disabled", () => { 46 | expectParse("ws://localhost?tls=0", {hranaWsUrl: "ws://localhost"}); 47 | expectParse("http://localhost?tls=0", {hranaHttpUrl: "http://localhost"}); 48 | expectParseError("wss://localhost?tls=0", /tls=0/); 49 | expectParseError("https://localhost?tls=0", /tls=0/); 50 | expectParse("libsql://localhost:8080?tls=0", { 51 | hranaWsUrl: "ws://localhost:8080", 52 | hranaHttpUrl: "http://localhost:8080", 53 | }); 54 | expectParseError("libsql://localhost?tls=0", /tls=0.* explicit port/); 55 | }); 56 | 57 | test("tls enabled", () => { 58 | expectParse("wss://localhost?tls=1", {hranaWsUrl: "wss://localhost"}); 59 | expectParse("https://localhost?tls=1", {hranaHttpUrl: "https://localhost"}); 60 | expectParseError("ws://localhost?tls=1", /tls=1/); 61 | expectParseError("http://localhost?tls=1", /tls=1/); 62 | expectParse("libsql://localhost?tls=1", { 63 | hranaWsUrl: "wss://localhost", 64 | hranaHttpUrl: "https://localhost", 65 | }); 66 | }); 67 | 68 | test("invalid value for tls", () => { 69 | expectParseError("ws://localhost?tls=yes", /"tls"/); 70 | }); 71 | 72 | test("authToken in query params", () => { 73 | expectParse("wss://localhost?authToken=foobar", { 74 | hranaWsUrl: "wss://localhost", 75 | authToken: "foobar", 76 | }); 77 | }); 78 | 79 | test("unknown query param", () => { 80 | expectParseError("ws://localhost?foo", /"foo"/); 81 | }); 82 | 83 | test("unknown scheme", () => { 84 | expectParseError("spam://localhost", /"spam:"/); 85 | }); 86 | 87 | test("basic auth", () => { 88 | expectParseError("ws://alice@localhost", /Basic/); 89 | expectParseError("ws://alice:password@localhost", /Basic/); 90 | }); 91 | 92 | test("fragment", () => { 93 | expectParseError("ws://localhost#eggs", /fragments/); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/batch.ts: -------------------------------------------------------------------------------- 1 | import { ProtoError, MisuseError } from "./errors.js"; 2 | import { IdAlloc } from "./id_alloc.js"; 3 | import type { RowsResult, RowResult, ValueResult, StmtResult } from "./result.js"; 4 | import { 5 | stmtResultFromProto, rowsResultFromProto, 6 | rowResultFromProto, valueResultFromProto, 7 | errorFromProto, 8 | } from "./result.js"; 9 | import type * as proto from "./shared/proto.js"; 10 | import type { InStmt } from "./stmt.js"; 11 | import { stmtToProto } from "./stmt.js"; 12 | import { Stream } from "./stream.js"; 13 | import { impossible } from "./util.js"; 14 | import type { Value, InValue, IntMode } from "./value.js"; 15 | import { valueToProto, valueFromProto } from "./value.js"; 16 | 17 | /** A builder for creating a batch and executing it on the server. */ 18 | export class Batch { 19 | /** @private */ 20 | _stream: Stream; 21 | #useCursor: boolean; 22 | /** @private */ 23 | _steps: Array; 24 | #executed: boolean; 25 | 26 | /** @private */ 27 | constructor(stream: Stream, useCursor: boolean) { 28 | this._stream = stream; 29 | this.#useCursor = useCursor; 30 | this._steps = []; 31 | this.#executed = false; 32 | } 33 | 34 | /** Return a builder for adding a step to the batch. */ 35 | step(): BatchStep { 36 | return new BatchStep(this); 37 | } 38 | 39 | /** Execute the batch. */ 40 | execute(): Promise { 41 | if (this.#executed) { 42 | throw new MisuseError("This batch has already been executed"); 43 | } 44 | this.#executed = true; 45 | 46 | const batch: proto.Batch = { 47 | steps: this._steps.map((step) => step.proto), 48 | }; 49 | 50 | if (this.#useCursor) { 51 | return executeCursor(this._stream, this._steps, batch); 52 | } else { 53 | return executeRegular(this._stream, this._steps, batch); 54 | } 55 | } 56 | } 57 | 58 | interface BatchStepState { 59 | proto: proto.BatchStep; 60 | callback(stepResult: proto.StmtResult | undefined, stepError: proto.Error | undefined): void; 61 | } 62 | 63 | function executeRegular( 64 | stream: Stream, 65 | steps: Array, 66 | batch: proto.Batch, 67 | ): Promise { 68 | return stream._batch(batch).then((result) => { 69 | for (let step = 0; step < steps.length; ++step) { 70 | const stepResult = result.stepResults.get(step); 71 | const stepError = result.stepErrors.get(step); 72 | steps[step].callback(stepResult, stepError); 73 | } 74 | }); 75 | } 76 | 77 | async function executeCursor( 78 | stream: Stream, 79 | steps: Array, 80 | batch: proto.Batch, 81 | ): Promise { 82 | const cursor = await stream._openCursor(batch); 83 | try { 84 | let nextStep = 0; 85 | let beginEntry: proto.StepBeginEntry | undefined = undefined; 86 | let rows: Array> = []; 87 | 88 | for (;;) { 89 | const entry = await cursor.next(); 90 | if (entry === undefined) { 91 | break; 92 | } 93 | 94 | if (entry.type === "step_begin") { 95 | if (entry.step < nextStep || entry.step >= steps.length) { 96 | throw new ProtoError("Server produced StepBeginEntry for unexpected step"); 97 | } else if (beginEntry !== undefined) { 98 | throw new ProtoError("Server produced StepBeginEntry before terminating previous step"); 99 | } 100 | 101 | for (let step = nextStep; step < entry.step; ++step) { 102 | steps[step].callback(undefined, undefined); 103 | } 104 | nextStep = entry.step + 1; 105 | beginEntry = entry; 106 | rows = []; 107 | } else if (entry.type === "step_end") { 108 | if (beginEntry === undefined) { 109 | throw new ProtoError("Server produced StepEndEntry but no step is active"); 110 | } 111 | 112 | const stmtResult = { 113 | cols: beginEntry.cols, 114 | rows, 115 | affectedRowCount: entry.affectedRowCount, 116 | lastInsertRowid: entry.lastInsertRowid, 117 | }; 118 | steps[beginEntry.step].callback(stmtResult, undefined); 119 | beginEntry = undefined; 120 | rows = []; 121 | } else if (entry.type === "step_error") { 122 | if (beginEntry === undefined) { 123 | if (entry.step >= steps.length) { 124 | throw new ProtoError("Server produced StepErrorEntry for unexpected step"); 125 | } 126 | for (let step = nextStep; step < entry.step; ++step) { 127 | steps[step].callback(undefined, undefined); 128 | } 129 | } else { 130 | if (entry.step !== beginEntry.step) { 131 | throw new ProtoError("Server produced StepErrorEntry for unexpected step"); 132 | } 133 | beginEntry = undefined; 134 | rows = []; 135 | } 136 | steps[entry.step].callback(undefined, entry.error); 137 | nextStep = entry.step + 1; 138 | } else if (entry.type === "row") { 139 | if (beginEntry === undefined) { 140 | throw new ProtoError("Server produced RowEntry but no step is active"); 141 | } 142 | rows.push(entry.row); 143 | } else if (entry.type === "error") { 144 | throw errorFromProto(entry.error); 145 | } else if (entry.type === "none") { 146 | throw new ProtoError("Server produced unrecognized CursorEntry"); 147 | } else { 148 | throw impossible(entry, "Impossible CursorEntry"); 149 | } 150 | } 151 | 152 | if (beginEntry !== undefined) { 153 | throw new ProtoError("Server closed Cursor before terminating active step"); 154 | } 155 | for (let step = nextStep; step < steps.length; ++step) { 156 | steps[step].callback(undefined, undefined); 157 | } 158 | } finally { 159 | cursor.close(); 160 | } 161 | } 162 | 163 | /** A builder for adding a step to the batch. */ 164 | export class BatchStep { 165 | /** @private */ 166 | _batch: Batch; 167 | #conds: Array; 168 | /** @private */ 169 | _index: number | undefined; 170 | 171 | /** @private */ 172 | constructor(batch: Batch) { 173 | this._batch = batch; 174 | this.#conds = []; 175 | this._index = undefined; 176 | } 177 | 178 | /** Add the condition that needs to be satisfied to execute the statement. If you use this method multiple 179 | * times, we join the conditions with a logical AND. */ 180 | condition(cond: BatchCond): this { 181 | this.#conds.push(cond._proto); 182 | return this; 183 | } 184 | 185 | /** Add a statement that returns rows. */ 186 | query(stmt: InStmt): Promise { 187 | return this.#add(stmt, true, rowsResultFromProto); 188 | } 189 | 190 | /** Add a statement that returns at most a single row. */ 191 | queryRow(stmt: InStmt): Promise { 192 | return this.#add(stmt, true, rowResultFromProto); 193 | } 194 | 195 | /** Add a statement that returns at most a single value. */ 196 | queryValue(stmt: InStmt): Promise { 197 | return this.#add(stmt, true, valueResultFromProto); 198 | } 199 | 200 | /** Add a statement without returning rows. */ 201 | run(stmt: InStmt): Promise { 202 | return this.#add(stmt, false, stmtResultFromProto); 203 | } 204 | 205 | #add( 206 | inStmt: InStmt, 207 | wantRows: boolean, 208 | fromProto: (result: proto.StmtResult, intMode: IntMode) => T, 209 | ): Promise { 210 | if (this._index !== undefined) { 211 | throw new MisuseError("This BatchStep has already been added to the batch"); 212 | } 213 | 214 | const stmt = stmtToProto(this._batch._stream._sqlOwner(), inStmt, wantRows); 215 | 216 | let condition: proto.BatchCond | undefined; 217 | if (this.#conds.length === 0) { 218 | condition = undefined; 219 | } else if (this.#conds.length === 1) { 220 | condition = this.#conds[0]; 221 | } else { 222 | condition = {type: "and", conds: this.#conds.slice()}; 223 | } 224 | 225 | const proto: proto.BatchStep = {stmt, condition}; 226 | 227 | return new Promise((outputCallback, errorCallback) => { 228 | const callback = ( 229 | stepResult: proto.StmtResult | undefined, 230 | stepError: proto.Error | undefined, 231 | ): void => { 232 | if (stepResult !== undefined && stepError !== undefined) { 233 | errorCallback(new ProtoError("Server returned both result and error")); 234 | } else if (stepError !== undefined) { 235 | errorCallback(errorFromProto(stepError)); 236 | } else if (stepResult !== undefined) { 237 | outputCallback(fromProto(stepResult, this._batch._stream.intMode)); 238 | } else { 239 | outputCallback(undefined); 240 | } 241 | }; 242 | 243 | this._index = this._batch._steps.length; 244 | this._batch._steps.push({proto, callback}); 245 | }); 246 | } 247 | } 248 | 249 | export class BatchCond { 250 | /** @private */ 251 | _batch: Batch; 252 | /** @private */ 253 | _proto: proto.BatchCond; 254 | 255 | /** @private */ 256 | constructor(batch: Batch, proto: proto.BatchCond) { 257 | this._batch = batch; 258 | this._proto = proto; 259 | } 260 | 261 | /** Create a condition that evaluates to true when the given step executes successfully. 262 | * 263 | * If the given step fails error or is skipped because its condition evaluated to false, this 264 | * condition evaluates to false. 265 | */ 266 | static ok(step: BatchStep): BatchCond { 267 | return new BatchCond(step._batch, {type: "ok", step: stepIndex(step)}); 268 | } 269 | 270 | /** Create a condition that evaluates to true when the given step fails. 271 | * 272 | * If the given step succeeds or is skipped because its condition evaluated to false, this condition 273 | * evaluates to false. 274 | */ 275 | static error(step: BatchStep): BatchCond { 276 | return new BatchCond(step._batch, {type: "error", step: stepIndex(step)}); 277 | } 278 | 279 | /** Create a condition that is a logical negation of another condition. 280 | */ 281 | static not(cond: BatchCond): BatchCond { 282 | return new BatchCond(cond._batch, {type: "not", cond: cond._proto}); 283 | } 284 | 285 | /** Create a condition that is a logical AND of other conditions. 286 | */ 287 | static and(batch: Batch, conds: Array): BatchCond { 288 | for (const cond of conds) { 289 | checkCondBatch(batch, cond); 290 | } 291 | return new BatchCond(batch, {type: "and", conds: conds.map(e => e._proto)}); 292 | } 293 | 294 | /** Create a condition that is a logical OR of other conditions. 295 | */ 296 | static or(batch: Batch, conds: Array): BatchCond { 297 | for (const cond of conds) { 298 | checkCondBatch(batch, cond); 299 | } 300 | return new BatchCond(batch, {type: "or", conds: conds.map(e => e._proto)}); 301 | } 302 | 303 | /** Create a condition that evaluates to true when the SQL connection is in autocommit mode (not inside an 304 | * explicit transaction). This requires protocol version 3 or higher. 305 | */ 306 | static isAutocommit(batch: Batch): BatchCond { 307 | batch._stream.client()._ensureVersion(3, "BatchCond.isAutocommit()"); 308 | return new BatchCond(batch, {type: "is_autocommit"}); 309 | } 310 | } 311 | 312 | function stepIndex(step: BatchStep): number { 313 | if (step._index === undefined) { 314 | throw new MisuseError("Cannot add a condition referencing a step that has not been added to the batch"); 315 | } 316 | return step._index; 317 | } 318 | 319 | function checkCondBatch(expectedBatch: Batch, cond: BatchCond): void { 320 | if (cond._batch !== expectedBatch) { 321 | throw new MisuseError("Cannot mix BatchCond objects for different Batch objects"); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/byte_queue.ts: -------------------------------------------------------------------------------- 1 | export class ByteQueue { 2 | #array: Uint8Array; 3 | #shiftPos: number; 4 | #pushPos: number; 5 | 6 | constructor(initialCap: number) { 7 | this.#array = new Uint8Array(new ArrayBuffer(initialCap)); 8 | this.#shiftPos = 0; 9 | this.#pushPos = 0; 10 | } 11 | 12 | get length(): number { 13 | return this.#pushPos - this.#shiftPos; 14 | } 15 | 16 | data(): Uint8Array { 17 | return this.#array.slice(this.#shiftPos, this.#pushPos); 18 | } 19 | 20 | push(chunk: Uint8Array): void { 21 | this.#ensurePush(chunk.byteLength); 22 | this.#array.set(chunk, this.#pushPos); 23 | this.#pushPos += chunk.byteLength; 24 | } 25 | 26 | #ensurePush(pushLength: number): void { 27 | if (this.#pushPos + pushLength <= this.#array.byteLength) { 28 | return; 29 | } 30 | 31 | const filledLength = this.#pushPos - this.#shiftPos; 32 | if ( 33 | filledLength + pushLength <= this.#array.byteLength && 34 | 2*this.#pushPos >= this.#array.byteLength 35 | ) { 36 | this.#array.copyWithin(0, this.#shiftPos, this.#pushPos); 37 | } else { 38 | let newCap = this.#array.byteLength; 39 | do { 40 | newCap *= 2; 41 | } while (filledLength + pushLength > newCap); 42 | 43 | const newArray = new Uint8Array(new ArrayBuffer(newCap)); 44 | newArray.set(this.#array.slice(this.#shiftPos, this.#pushPos), 0); 45 | this.#array = newArray; 46 | } 47 | 48 | this.#pushPos = filledLength; 49 | this.#shiftPos = 0; 50 | } 51 | 52 | shift(length: number): void { 53 | this.#shiftPos += length; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import type { Stream } from "./stream.js"; 2 | import type { IntMode } from "./value.js"; 3 | 4 | export type ProtocolVersion = 1 | 2 | 3; 5 | export type ProtocolEncoding = "json" | "protobuf"; 6 | 7 | /** A client for the Hrana protocol (a "database connection pool"). */ 8 | export abstract class Client { 9 | /** @private */ 10 | constructor() { 11 | this.intMode = "number"; 12 | } 13 | 14 | /** Get the protocol version negotiated with the server. */ 15 | abstract getVersion(): Promise; 16 | 17 | // Make sure that the negotiated version is at least `minVersion`. 18 | /** @private */ 19 | abstract _ensureVersion(minVersion: ProtocolVersion, feature: string): void; 20 | 21 | /** Open a {@link Stream}, a stream for executing SQL statements. */ 22 | abstract openStream(): Stream; 23 | 24 | /** Immediately close the client. 25 | * 26 | * This closes the client immediately, aborting any pending operations. 27 | */ 28 | abstract close(): void; 29 | 30 | /** True if the client is closed. */ 31 | abstract get closed(): boolean; 32 | 33 | /** Representation of integers returned from the database. See {@link IntMode}. 34 | * 35 | * This value is inherited by {@link Stream} objects created with {@link openStream}, but you can 36 | * override the integer mode for every stream by setting {@link Stream.intMode} on the stream. 37 | */ 38 | intMode: IntMode; 39 | } 40 | -------------------------------------------------------------------------------- /src/cursor.ts: -------------------------------------------------------------------------------- 1 | import type * as proto from "./shared/proto.js"; 2 | 3 | export abstract class Cursor { 4 | /** Fetch the next entry from the cursor. */ 5 | abstract next(): Promise; 6 | 7 | /** Close the cursor. */ 8 | abstract close(): void; 9 | 10 | /** True if the cursor is closed. */ 11 | abstract get closed(): boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/describe.ts: -------------------------------------------------------------------------------- 1 | import type * as proto from "./shared/proto.js"; 2 | 3 | export interface DescribeResult { 4 | paramNames: Array; 5 | columns: Array; 6 | isExplain: boolean; 7 | isReadonly: boolean; 8 | } 9 | 10 | export interface DescribeColumn { 11 | name: string; 12 | decltype: string | undefined; 13 | } 14 | 15 | export function describeResultFromProto(result: proto.DescribeResult): DescribeResult { 16 | return { 17 | paramNames: result.params.map((p) => p.name), 18 | columns: result.cols, 19 | isExplain: result.isExplain, 20 | isReadonly: result.isReadonly, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/encoding/index.ts: -------------------------------------------------------------------------------- 1 | export { readJsonObject } from "./json/decode.js"; 2 | export { writeJsonObject } from "./json/encode.js"; 3 | export { readProtobufMessage } from "./protobuf/decode.js"; 4 | export { writeProtobufMessage } from "./protobuf/encode.js"; 5 | -------------------------------------------------------------------------------- /src/encoding/json/decode.ts: -------------------------------------------------------------------------------- 1 | import { ProtoError } from "../../errors.js"; 2 | 3 | export type Value = Obj | Array | string | number | true | false | null; 4 | export type Obj = {[key: string]: Value | undefined}; 5 | 6 | export type ObjectFun = (obj: Obj) => T; 7 | 8 | export function string(value: Value | undefined): string { 9 | if (typeof value === "string") { 10 | return value; 11 | } 12 | throw typeError(value, "string"); 13 | } 14 | 15 | export function stringOpt(value: Value | undefined): string | undefined { 16 | if (value === null || value === undefined) { 17 | return undefined; 18 | } else if (typeof value === "string") { 19 | return value; 20 | } 21 | throw typeError(value, "string or null"); 22 | } 23 | 24 | export function number(value: Value | undefined): number { 25 | if (typeof value === "number") { 26 | return value; 27 | } 28 | throw typeError(value, "number"); 29 | } 30 | 31 | export function boolean(value: Value | undefined): boolean { 32 | if (typeof value === "boolean") { 33 | return value; 34 | } 35 | throw typeError(value, "boolean"); 36 | } 37 | 38 | export function array(value: Value | undefined): Array { 39 | if (Array.isArray(value)) { 40 | return value; 41 | } 42 | throw typeError(value, "array"); 43 | } 44 | 45 | export function object(value: Value | undefined): Obj { 46 | if (value !== null && typeof value === "object" && !Array.isArray(value)) { 47 | return value; 48 | } 49 | throw typeError(value, "object"); 50 | } 51 | 52 | export function arrayObjectsMap(value: Value | undefined, fun: ObjectFun): Array { 53 | return array(value).map((elemValue) => fun(object(elemValue))); 54 | } 55 | 56 | function typeError(value: Value | undefined, expected: string): Error { 57 | if (value === undefined) { 58 | return new ProtoError(`Expected ${expected}, but the property was missing`); 59 | } 60 | 61 | let received: string = typeof value; 62 | if (value === null) { 63 | received = "null"; 64 | } else if (Array.isArray(value)) { 65 | received = "array"; 66 | } 67 | return new ProtoError(`Expected ${expected}, received ${received}`); 68 | } 69 | 70 | export function readJsonObject(value: unknown, fun: ObjectFun): T { 71 | return fun(object(value as Value)); 72 | } 73 | -------------------------------------------------------------------------------- /src/encoding/json/encode.ts: -------------------------------------------------------------------------------- 1 | export type ObjectFun = (w: ObjectWriter, value: T) => void; 2 | 3 | export class ObjectWriter { 4 | #output: Array; 5 | #isFirst: boolean; 6 | 7 | constructor(output: Array) { 8 | this.#output = output; 9 | this.#isFirst = false; 10 | } 11 | 12 | begin(): void { 13 | this.#output.push('{'); 14 | this.#isFirst = true; 15 | } 16 | 17 | end(): void { 18 | this.#output.push('}'); 19 | this.#isFirst = false; 20 | } 21 | 22 | #key(name: string): void { 23 | if (this.#isFirst) { 24 | this.#output.push('"'); 25 | this.#isFirst = false; 26 | } else { 27 | this.#output.push(',"'); 28 | } 29 | this.#output.push(name); 30 | this.#output.push('":'); 31 | } 32 | 33 | string(name: string, value: string): void { 34 | this.#key(name); 35 | this.#output.push(JSON.stringify(value)); 36 | } 37 | 38 | stringRaw(name: string, value: string): void { 39 | this.#key(name); 40 | this.#output.push('"'); 41 | this.#output.push(value); 42 | this.#output.push('"'); 43 | } 44 | 45 | number(name: string, value: number): void { 46 | this.#key(name); 47 | this.#output.push(""+value); 48 | } 49 | 50 | boolean(name: string, value: boolean): void { 51 | this.#key(name); 52 | this.#output.push(value ? "true" : "false"); 53 | } 54 | 55 | object(name: string, value: T, valueFun: ObjectFun): void { 56 | this.#key(name); 57 | 58 | this.begin(); 59 | valueFun(this, value); 60 | this.end(); 61 | } 62 | 63 | arrayObjects(name: string, values: Array, valueFun: ObjectFun): void { 64 | this.#key(name); 65 | this.#output.push('['); 66 | 67 | for (let i = 0; i < values.length; ++i) { 68 | if (i !== 0) { 69 | this.#output.push(','); 70 | } 71 | this.begin(); 72 | valueFun(this, values[i]); 73 | this.end(); 74 | } 75 | 76 | this.#output.push(']'); 77 | } 78 | } 79 | 80 | export function writeJsonObject(value: T, fun: ObjectFun): string { 81 | const output: Array = []; 82 | const writer = new ObjectWriter(output); 83 | writer.begin(); 84 | fun(writer, value); 85 | writer.end(); 86 | return output.join(""); 87 | } 88 | -------------------------------------------------------------------------------- /src/encoding/protobuf/decode.ts: -------------------------------------------------------------------------------- 1 | import { ProtoError } from "../../errors.js"; 2 | import { VARINT, FIXED_64, LENGTH_DELIMITED, FIXED_32 } from "./util.js"; 3 | 4 | export interface MessageDef { 5 | default(): T; 6 | [tag: number]: (r: FieldReader, msg: T) => T | void; 7 | } 8 | 9 | class MessageReader { 10 | #array: Uint8Array; 11 | #view: DataView; 12 | #pos: number; 13 | 14 | constructor(array: Uint8Array) { 15 | this.#array = array; 16 | this.#view = new DataView(array.buffer, array.byteOffset, array.byteLength); 17 | this.#pos = 0; 18 | } 19 | 20 | varint(): number { 21 | let value = 0; 22 | for (let shift = 0; ; shift += 7) { 23 | const byte = this.#array[this.#pos++]; 24 | value |= (byte & 0x7f) << shift; 25 | if (!(byte & 0x80)) { 26 | break; 27 | } 28 | } 29 | return value; 30 | } 31 | 32 | varintBig(): bigint { 33 | let value = 0n; 34 | for (let shift = 0n; ; shift += 7n) { 35 | const byte = this.#array[this.#pos++]; 36 | value |= BigInt(byte & 0x7f) << shift; 37 | if (!(byte & 0x80)) { 38 | break; 39 | } 40 | } 41 | return value; 42 | } 43 | 44 | bytes(length: number): Uint8Array { 45 | const array = new Uint8Array( 46 | this.#array.buffer, 47 | this.#array.byteOffset + this.#pos, 48 | length, 49 | ) 50 | this.#pos += length; 51 | return array; 52 | } 53 | 54 | double(): number { 55 | const value = this.#view.getFloat64(this.#pos, true); 56 | this.#pos += 8; 57 | return value; 58 | } 59 | 60 | skipVarint(): void { 61 | for (;;) { 62 | const byte = this.#array[this.#pos++]; 63 | if (!(byte & 0x80)) { 64 | break; 65 | } 66 | } 67 | } 68 | 69 | skip(count: number): void { 70 | this.#pos += count; 71 | } 72 | 73 | eof(): boolean { 74 | return this.#pos >= this.#array.byteLength; 75 | } 76 | } 77 | 78 | export class FieldReader { 79 | #reader: MessageReader; 80 | #wireType: number; 81 | 82 | constructor(reader: MessageReader) { 83 | this.#reader = reader; 84 | this.#wireType = -1; 85 | } 86 | 87 | setup(wireType: number): void { 88 | this.#wireType = wireType; 89 | } 90 | 91 | #expect(expectedWireType: number): void { 92 | if (this.#wireType !== expectedWireType) { 93 | throw new ProtoError(`Expected wire type ${expectedWireType}, got ${this.#wireType}`); 94 | } 95 | this.#wireType = -1; 96 | } 97 | 98 | bytes(): Uint8Array { 99 | this.#expect(LENGTH_DELIMITED); 100 | const length = this.#reader.varint(); 101 | return this.#reader.bytes(length); 102 | } 103 | 104 | string(): string { 105 | return new TextDecoder().decode(this.bytes()); 106 | } 107 | 108 | message(def: MessageDef): T { 109 | return readProtobufMessage(this.bytes(), def); 110 | } 111 | 112 | int32(): number { 113 | this.#expect(VARINT); 114 | return this.#reader.varint(); 115 | } 116 | 117 | uint32(): number { 118 | return this.int32(); 119 | } 120 | 121 | bool(): boolean { 122 | return this.int32() !== 0; 123 | } 124 | 125 | uint64(): bigint { 126 | this.#expect(VARINT); 127 | return this.#reader.varintBig(); 128 | } 129 | 130 | sint64(): bigint { 131 | const value = this.uint64(); 132 | return (value >> 1n) ^ (-(value & 1n)); 133 | } 134 | 135 | double(): number { 136 | this.#expect(FIXED_64); 137 | return this.#reader.double(); 138 | } 139 | 140 | maybeSkip(): void { 141 | if (this.#wireType < 0) { 142 | return; 143 | } else if (this.#wireType === VARINT) { 144 | this.#reader.skipVarint(); 145 | } else if (this.#wireType === FIXED_64) { 146 | this.#reader.skip(8); 147 | } else if (this.#wireType === LENGTH_DELIMITED) { 148 | const length = this.#reader.varint(); 149 | this.#reader.skip(length); 150 | } else if (this.#wireType === FIXED_32) { 151 | this.#reader.skip(4); 152 | } else { 153 | throw new ProtoError(`Unexpected wire type ${this.#wireType}`); 154 | } 155 | this.#wireType = -1; 156 | } 157 | } 158 | 159 | export function readProtobufMessage(data: Uint8Array, def: MessageDef): T { 160 | const msgReader = new MessageReader(data); 161 | const fieldReader = new FieldReader(msgReader); 162 | 163 | let value = def.default(); 164 | while (!msgReader.eof()) { 165 | const key = msgReader.varint(); 166 | const tag = key >> 3; 167 | const wireType = key & 0x7; 168 | 169 | fieldReader.setup(wireType); 170 | const tagFun = def[tag]; 171 | if (tagFun !== undefined) { 172 | const returnedValue = tagFun(fieldReader, value); 173 | if (returnedValue !== undefined) { 174 | value = returnedValue; 175 | } 176 | } 177 | fieldReader.maybeSkip(); 178 | } 179 | return value; 180 | } 181 | -------------------------------------------------------------------------------- /src/encoding/protobuf/encode.ts: -------------------------------------------------------------------------------- 1 | import type { WireType } from "./util.js"; 2 | import { VARINT, FIXED_64, LENGTH_DELIMITED } from "./util.js"; 3 | 4 | export type MessageFun = (w: MessageWriter, msg: T) => void; 5 | 6 | export class MessageWriter { 7 | #buf: ArrayBuffer; 8 | #array: Uint8Array; 9 | #view: DataView; 10 | #pos: number; 11 | 12 | constructor() { 13 | this.#buf = new ArrayBuffer(256); 14 | this.#array = new Uint8Array(this.#buf); 15 | this.#view = new DataView(this.#buf); 16 | this.#pos = 0; 17 | } 18 | 19 | #ensure(extra: number) { 20 | if (this.#pos + extra <= this.#buf.byteLength) { 21 | return; 22 | } 23 | 24 | let newCap = this.#buf.byteLength; 25 | while (newCap < this.#pos + extra) { 26 | newCap *= 2; 27 | } 28 | 29 | const newBuf = new ArrayBuffer(newCap); 30 | const newArray = new Uint8Array(newBuf); 31 | const newView = new DataView(newBuf); 32 | newArray.set(new Uint8Array(this.#buf, 0, this.#pos)); 33 | 34 | this.#buf = newBuf; 35 | this.#array = newArray; 36 | this.#view = newView; 37 | } 38 | 39 | #varint(value: number): void { 40 | this.#ensure(5); 41 | 42 | value = 0|value; 43 | do { 44 | let byte = value & 0x7f; 45 | value >>>= 7; 46 | byte |= (value ? 0x80 : 0); 47 | this.#array[this.#pos++] = byte; 48 | } while (value); 49 | } 50 | 51 | #varintBig(value: bigint): void { 52 | this.#ensure(10); 53 | 54 | value = value & 0xffffffffffffffffn; 55 | do { 56 | let byte = Number(value & 0x7fn); 57 | value >>= 7n; 58 | byte |= (value ? 0x80 : 0); 59 | this.#array[this.#pos++] = byte; 60 | } while (value); 61 | } 62 | 63 | #tag(tag: number, wireType: WireType): void { 64 | this.#varint((tag << 3) | wireType); 65 | } 66 | 67 | bytes(tag: number, value: Uint8Array): void { 68 | this.#tag(tag, LENGTH_DELIMITED); 69 | this.#varint(value.byteLength); 70 | this.#ensure(value.byteLength); 71 | this.#array.set(value, this.#pos); 72 | this.#pos += value.byteLength; 73 | } 74 | 75 | string(tag: number, value: string): void { 76 | this.bytes(tag, new TextEncoder().encode(value)); 77 | } 78 | 79 | message(tag: number, value: T, fun: MessageFun): void { 80 | const writer = new MessageWriter(); 81 | fun(writer, value); 82 | this.bytes(tag, writer.data()); 83 | } 84 | 85 | int32(tag: number, value: number): void { 86 | this.#tag(tag, VARINT); 87 | this.#varint(value); 88 | } 89 | 90 | uint32(tag: number, value: number): void { 91 | this.int32(tag, value); 92 | } 93 | 94 | bool(tag: number, value: boolean): void { 95 | this.int32(tag, value ? 1 : 0); 96 | } 97 | 98 | sint64(tag: number, value: bigint): void { 99 | this.#tag(tag, VARINT); 100 | this.#varintBig((value << 1n) ^ (value >> 63n)); 101 | } 102 | 103 | double(tag: number, value: number): void { 104 | this.#tag(tag, FIXED_64); 105 | this.#ensure(8); 106 | this.#view.setFloat64(this.#pos, value, true); 107 | this.#pos += 8; 108 | } 109 | 110 | data(): Uint8Array { 111 | return new Uint8Array(this.#buf, 0, this.#pos); 112 | } 113 | } 114 | 115 | export function writeProtobufMessage(value: T, fun: MessageFun): Uint8Array { 116 | const w = new MessageWriter(); 117 | fun(w, value); 118 | return w.data(); 119 | } 120 | -------------------------------------------------------------------------------- /src/encoding/protobuf/util.ts: -------------------------------------------------------------------------------- 1 | export type WireType = 0 | 1 | 2 | 3 | 4 | 5; 2 | export const VARINT: WireType = 0; 3 | export const FIXED_64: WireType = 1; 4 | export const LENGTH_DELIMITED: WireType = 2; 5 | export const GROUP_START: WireType = 3; 6 | export const GROUP_END: WireType = 4; 7 | export const FIXED_32: WireType = 5; 8 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import type * as proto from "./shared/proto.js"; 2 | 3 | /** Generic error produced by the Hrana client. */ 4 | export class ClientError extends Error { 5 | /** @private */ 6 | constructor(message: string) { 7 | super(message); 8 | this.name = "ClientError"; 9 | } 10 | } 11 | 12 | /** Error thrown when the server violates the protocol. */ 13 | export class ProtoError extends ClientError { 14 | /** @private */ 15 | constructor(message: string) { 16 | super(message); 17 | this.name = "ProtoError"; 18 | } 19 | } 20 | 21 | /** Error thrown when the server returns an error response. */ 22 | export class ResponseError extends ClientError { 23 | code: string | undefined; 24 | /** @internal */ 25 | proto: proto.Error; 26 | 27 | /** @private */ 28 | constructor(message: string, protoError: proto.Error) { 29 | super(message); 30 | this.name = "ResponseError"; 31 | this.code = protoError.code; 32 | this.proto = protoError; 33 | this.stack = undefined; 34 | } 35 | } 36 | 37 | /** Error thrown when the client or stream is closed. */ 38 | export class ClosedError extends ClientError { 39 | /** @private */ 40 | constructor(message: string, cause: Error | undefined) { 41 | if (cause !== undefined) { 42 | super(`${message}: ${cause}`); 43 | this.cause = cause; 44 | } else { 45 | super(message); 46 | } 47 | this.name = "ClosedError"; 48 | } 49 | } 50 | 51 | /** Error thrown when the environment does not seem to support WebSockets. */ 52 | export class WebSocketUnsupportedError extends ClientError { 53 | /** @private */ 54 | constructor(message: string) { 55 | super(message); 56 | this.name = "WebSocketUnsupportedError"; 57 | } 58 | } 59 | 60 | /** Error thrown when we encounter a WebSocket error. */ 61 | export class WebSocketError extends ClientError { 62 | /** @private */ 63 | constructor(message: string) { 64 | super(message); 65 | this.name = "WebSocketError"; 66 | } 67 | } 68 | 69 | /** Error thrown when the HTTP server returns an error response. */ 70 | export class HttpServerError extends ClientError { 71 | status: number; 72 | 73 | /** @private */ 74 | constructor(message: string, status: number) { 75 | super(message); 76 | this.status = status; 77 | this.name = "HttpServerError"; 78 | } 79 | } 80 | 81 | /** Error thrown when a libsql URL is not valid. */ 82 | export class LibsqlUrlParseError extends ClientError { 83 | /** @private */ 84 | constructor(message: string) { 85 | super(message); 86 | this.name = "LibsqlUrlParseError"; 87 | } 88 | } 89 | 90 | /** Error thrown when the protocol version is too low to support a feature. */ 91 | export class ProtocolVersionError extends ClientError { 92 | /** @private */ 93 | constructor(message: string) { 94 | super(message); 95 | this.name = "ProtocolVersionError"; 96 | } 97 | } 98 | 99 | /** Error thrown when an internal client error happens. */ 100 | export class InternalError extends ClientError { 101 | /** @private */ 102 | constructor(message: string) { 103 | super(message); 104 | this.name = "InternalError"; 105 | } 106 | } 107 | 108 | /** Error thrown when the API is misused. */ 109 | export class MisuseError extends ClientError { 110 | /** @private */ 111 | constructor(message: string) { 112 | super(message); 113 | this.name = "MisuseError"; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/http/client.ts: -------------------------------------------------------------------------------- 1 | import { fetch, Request } from "cross-fetch"; 2 | 3 | import type { ProtocolVersion, ProtocolEncoding } from "../client.js"; 4 | import { Client } from "../client.js"; 5 | import { ClientError, ClosedError, ProtocolVersionError } from "../errors.js"; 6 | 7 | import { HttpStream } from "./stream.js"; 8 | 9 | export type Endpoint = { 10 | versionPath: string, 11 | pipelinePath: string, 12 | cursorPath: string | undefined, 13 | version: ProtocolVersion, 14 | encoding: ProtocolEncoding, 15 | }; 16 | 17 | export const checkEndpoints: Array = [ 18 | { 19 | versionPath: "v3-protobuf", 20 | pipelinePath: "v3-protobuf/pipeline", 21 | cursorPath: "v3-protobuf/cursor", 22 | version: 3, 23 | encoding: "protobuf", 24 | }, 25 | /* 26 | { 27 | versionPath: "v3", 28 | pipelinePath: "v3/pipeline", 29 | cursorPath: "v3/cursor", 30 | version: 3, 31 | encoding: "json", 32 | }, 33 | */ 34 | ]; 35 | 36 | const fallbackEndpoint: Endpoint = { 37 | versionPath: "v2", 38 | pipelinePath: "v2/pipeline", 39 | cursorPath: undefined, 40 | version: 2, 41 | encoding: "json", 42 | }; 43 | 44 | /** A client for the Hrana protocol over HTTP. */ 45 | export class HttpClient extends Client { 46 | #url: URL; 47 | #jwt: string | undefined; 48 | #fetch: typeof fetch; 49 | 50 | #closed: Error | undefined; 51 | #streams: Set; 52 | 53 | /** @private */ 54 | _endpointPromise: Promise; 55 | /** @private */ 56 | _endpoint: Endpoint | undefined; 57 | 58 | /** @private */ 59 | constructor(url: URL, jwt: string | undefined, customFetch: unknown | undefined, protocolVersion: ProtocolVersion = 2) { 60 | super(); 61 | this.#url = url; 62 | this.#jwt = jwt; 63 | this.#fetch = (customFetch as typeof fetch) ?? fetch; 64 | 65 | this.#closed = undefined; 66 | this.#streams = new Set(); 67 | 68 | if (protocolVersion == 3) { 69 | this._endpointPromise = findEndpoint(this.#fetch, this.#url); 70 | this._endpointPromise.then( 71 | (endpoint) => this._endpoint = endpoint, 72 | (error) => this.#setClosed(error), 73 | ); 74 | } else { 75 | this._endpointPromise = Promise.resolve(fallbackEndpoint); 76 | this._endpointPromise.then( 77 | (endpoint) => this._endpoint = endpoint, 78 | (error) => this.#setClosed(error), 79 | ); 80 | } 81 | } 82 | 83 | /** Get the protocol version supported by the server. */ 84 | override async getVersion(): Promise { 85 | if (this._endpoint !== undefined) { 86 | return this._endpoint.version; 87 | } 88 | return (await this._endpointPromise).version; 89 | } 90 | 91 | // Make sure that the negotiated version is at least `minVersion`. 92 | /** @private */ 93 | override _ensureVersion(minVersion: ProtocolVersion, feature: string): void { 94 | if (minVersion <= fallbackEndpoint.version) { 95 | return; 96 | } else if (this._endpoint === undefined) { 97 | throw new ProtocolVersionError( 98 | `${feature} is supported only on protocol version ${minVersion} and higher, ` + 99 | "but the version supported by the HTTP server is not yet known. " + 100 | "Use Client.getVersion() to wait until the version is available.", 101 | ); 102 | } else if (this._endpoint.version < minVersion) { 103 | throw new ProtocolVersionError( 104 | `${feature} is supported only on protocol version ${minVersion} and higher, ` + 105 | `but the HTTP server only supports version ${this._endpoint.version}.`, 106 | ); 107 | } 108 | } 109 | 110 | /** Open a {@link HttpStream}, a stream for executing SQL statements. */ 111 | override openStream(): HttpStream { 112 | if (this.#closed !== undefined) { 113 | throw new ClosedError("Client is closed", this.#closed); 114 | } 115 | const stream = new HttpStream(this, this.#url, this.#jwt, this.#fetch); 116 | this.#streams.add(stream); 117 | return stream; 118 | } 119 | 120 | /** @private */ 121 | _streamClosed(stream: HttpStream): void { 122 | this.#streams.delete(stream); 123 | } 124 | 125 | /** Close the client and all its streams. */ 126 | override close(): void { 127 | this.#setClosed(new ClientError("Client was manually closed")); 128 | } 129 | 130 | /** True if the client is closed. */ 131 | override get closed(): boolean { 132 | return this.#closed !== undefined; 133 | } 134 | 135 | #setClosed(error: Error): void { 136 | if (this.#closed !== undefined) { 137 | return; 138 | } 139 | this.#closed = error; 140 | for (const stream of Array.from(this.#streams)) { 141 | stream._setClosed(new ClosedError("Client was closed", error)); 142 | } 143 | } 144 | } 145 | 146 | async function findEndpoint(customFetch: typeof fetch, clientUrl: URL): Promise { 147 | const fetch = customFetch; 148 | for (const endpoint of checkEndpoints) { 149 | const url = new URL(endpoint.versionPath, clientUrl); 150 | const request = new Request(url.toString(), {method: "GET"}); 151 | 152 | const response = await fetch(request); 153 | await response.arrayBuffer(); 154 | if (response.ok) { 155 | return endpoint; 156 | } 157 | } 158 | return fallbackEndpoint; 159 | } 160 | 161 | -------------------------------------------------------------------------------- /src/http/cursor.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "cross-fetch"; 2 | 3 | import { ByteQueue } from "../byte_queue.js"; 4 | import type { ProtocolEncoding } from "../client.js"; 5 | import { Cursor } from "../cursor.js"; 6 | import * as jsond from "../encoding/json/decode.js"; 7 | import * as protobufd from "../encoding/protobuf/decode.js"; 8 | import { ClientError, ClosedError, ProtoError, InternalError } from "../errors.js"; 9 | import { impossible } from "../util.js"; 10 | 11 | import type * as proto from "./proto.js"; 12 | import type { HttpStream } from "./stream.js"; 13 | 14 | import { CursorRespBody as json_CursorRespBody } from "./json_decode.js"; 15 | import { CursorRespBody as protobuf_CursorRespBody } from "./protobuf_decode.js"; 16 | import { CursorEntry as json_CursorEntry } from "../shared/json_decode.js"; 17 | import { CursorEntry as protobuf_CursorEntry } from "../shared/protobuf_decode.js"; 18 | 19 | export class HttpCursor extends Cursor { 20 | #stream: HttpStream; 21 | #encoding: ProtocolEncoding; 22 | 23 | #reader: any | undefined; 24 | #queue: ByteQueue; 25 | #closed: Error | undefined; 26 | #done: boolean; 27 | 28 | /** @private */ 29 | constructor(stream: HttpStream, encoding: ProtocolEncoding) { 30 | super(); 31 | this.#stream = stream; 32 | this.#encoding = encoding; 33 | 34 | this.#reader = undefined; 35 | this.#queue = new ByteQueue(16 * 1024); 36 | this.#closed = undefined; 37 | this.#done = false; 38 | } 39 | 40 | async open(response: Response): Promise { 41 | if (response.body === null) { 42 | throw new ProtoError("No response body for cursor request"); 43 | } 44 | 45 | // node-fetch do not fully support WebStream API, especially getReader() function 46 | // see https://github.com/node-fetch/node-fetch/issues/387 47 | // so, we are using async iterator which behaves similarly here instead 48 | this.#reader = (response.body as any)[Symbol.asyncIterator](); 49 | const respBody = await this.#nextItem(json_CursorRespBody, protobuf_CursorRespBody); 50 | if (respBody === undefined) { 51 | throw new ProtoError("Empty response to cursor request"); 52 | } 53 | return respBody; 54 | } 55 | 56 | /** Fetch the next entry from the cursor. */ 57 | override next(): Promise { 58 | return this.#nextItem(json_CursorEntry, protobuf_CursorEntry); 59 | } 60 | 61 | /** Close the cursor. */ 62 | override close(): void { 63 | this._setClosed(new ClientError("Cursor was manually closed")); 64 | } 65 | 66 | /** @private */ 67 | _setClosed(error: Error): void { 68 | if (this.#closed !== undefined) { 69 | return; 70 | } 71 | this.#closed = error; 72 | this.#stream._cursorClosed(this); 73 | 74 | if (this.#reader !== undefined) { 75 | this.#reader.return(); 76 | } 77 | } 78 | 79 | /** True if the cursor is closed. */ 80 | override get closed(): boolean { 81 | return this.#closed !== undefined; 82 | } 83 | 84 | async #nextItem(jsonFun: jsond.ObjectFun, protobufDef: protobufd.MessageDef): Promise { 85 | for (; ;) { 86 | if (this.#done) { 87 | return undefined; 88 | } else if (this.#closed !== undefined) { 89 | throw new ClosedError("Cursor is closed", this.#closed); 90 | } 91 | 92 | if (this.#encoding === "json") { 93 | const jsonData = this.#parseItemJson(); 94 | if (jsonData !== undefined) { 95 | const jsonText = new TextDecoder().decode(jsonData); 96 | const jsonValue = JSON.parse(jsonText); 97 | return jsond.readJsonObject(jsonValue, jsonFun); 98 | } 99 | } else if (this.#encoding === "protobuf") { 100 | const protobufData = this.#parseItemProtobuf(); 101 | if (protobufData !== undefined) { 102 | return protobufd.readProtobufMessage(protobufData, protobufDef); 103 | } 104 | } else { 105 | throw impossible(this.#encoding, "Impossible encoding"); 106 | } 107 | 108 | if (this.#reader === undefined) { 109 | throw new InternalError("Attempted to read from HTTP cursor before it was opened"); 110 | } 111 | 112 | const { value, done } = await this.#reader.next(); 113 | if (done && this.#queue.length === 0) { 114 | this.#done = true; 115 | } else if (done) { 116 | throw new ProtoError("Unexpected end of cursor stream"); 117 | } else { 118 | this.#queue.push(value); 119 | } 120 | } 121 | } 122 | 123 | #parseItemJson(): Uint8Array | undefined { 124 | const data = this.#queue.data(); 125 | const newlineByte = 10; 126 | const newlinePos = data.indexOf(newlineByte); 127 | if (newlinePos < 0) { 128 | return undefined; 129 | } 130 | 131 | const jsonData = data.slice(0, newlinePos); 132 | this.#queue.shift(newlinePos + 1); 133 | return jsonData; 134 | } 135 | 136 | #parseItemProtobuf(): Uint8Array | undefined { 137 | const data = this.#queue.data(); 138 | 139 | let varintValue = 0; 140 | let varintLength = 0; 141 | for (; ;) { 142 | if (varintLength >= data.byteLength) { 143 | return undefined; 144 | } 145 | const byte = data[varintLength]; 146 | varintValue |= (byte & 0x7f) << (7 * varintLength); 147 | varintLength += 1; 148 | if (!(byte & 0x80)) { 149 | break; 150 | } 151 | } 152 | 153 | if (data.byteLength < varintLength + varintValue) { 154 | return undefined; 155 | } 156 | 157 | const protobufData = data.slice(varintLength, varintLength + varintValue); 158 | this.#queue.shift(varintLength + varintValue); 159 | return protobufData; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/http/json_decode.ts: -------------------------------------------------------------------------------- 1 | import { ProtoError } from "../errors.js"; 2 | import * as d from "../encoding/json/decode.js"; 3 | import { Error, StmtResult, BatchResult, CursorEntry, DescribeResult } from "../shared/json_decode.js"; 4 | import * as proto from "./proto.js"; 5 | 6 | 7 | export function PipelineRespBody(obj: d.Obj): proto.PipelineRespBody { 8 | const baton = d.stringOpt(obj["baton"]); 9 | const baseUrl = d.stringOpt(obj["base_url"]); 10 | const results = d.arrayObjectsMap(obj["results"], StreamResult); 11 | return {baton, baseUrl, results}; 12 | } 13 | 14 | function StreamResult(obj: d.Obj): proto.StreamResult { 15 | const type = d.string(obj["type"]); 16 | if (type === "ok") { 17 | const response = StreamResponse(d.object(obj["response"])); 18 | return {type: "ok", response}; 19 | } else if (type === "error") { 20 | const error = Error(d.object(obj["error"])); 21 | return {type: "error", error}; 22 | } else { 23 | throw new ProtoError("Unexpected type of StreamResult"); 24 | } 25 | } 26 | 27 | function StreamResponse(obj: d.Obj): proto.StreamResponse { 28 | const type = d.string(obj["type"]); 29 | if (type === "close") { 30 | return {type: "close"}; 31 | } else if (type === "execute") { 32 | const result = StmtResult(d.object(obj["result"])); 33 | return {type: "execute", result}; 34 | } else if (type === "batch") { 35 | const result = BatchResult(d.object(obj["result"])); 36 | return {type: "batch", result}; 37 | } else if (type === "sequence") { 38 | return {type: "sequence"}; 39 | } else if (type === "describe") { 40 | const result = DescribeResult(d.object(obj["result"])); 41 | return {type: "describe", result}; 42 | } else if (type === "store_sql") { 43 | return {type: "store_sql"}; 44 | } else if (type === "close_sql") { 45 | return {type: "close_sql"}; 46 | } else if (type === "get_autocommit") { 47 | const isAutocommit = d.boolean(obj["is_autocommit"]); 48 | return {type: "get_autocommit", isAutocommit}; 49 | } else { 50 | throw new ProtoError("Unexpected type of StreamResponse"); 51 | } 52 | } 53 | 54 | 55 | export function CursorRespBody(obj: d.Obj): proto.CursorRespBody { 56 | const baton = d.stringOpt(obj["baton"]); 57 | const baseUrl = d.stringOpt(obj["base_url"]); 58 | return {baton, baseUrl}; 59 | } 60 | -------------------------------------------------------------------------------- /src/http/json_encode.ts: -------------------------------------------------------------------------------- 1 | import * as e from "../encoding/json/encode.js"; 2 | import { Stmt, Batch } from "../shared/json_encode.js"; 3 | import { impossible } from "../util.js"; 4 | import * as proto from "./proto.js"; 5 | 6 | export function PipelineReqBody(w: e.ObjectWriter, msg: proto.PipelineReqBody): void { 7 | if (msg.baton !== undefined) { w.string("baton", msg.baton); } 8 | w.arrayObjects("requests", msg.requests, StreamRequest); 9 | } 10 | 11 | function StreamRequest(w: e.ObjectWriter, msg: proto.StreamRequest): void { 12 | w.stringRaw("type", msg.type); 13 | if (msg.type === "close") { 14 | // do nothing 15 | } else if (msg.type === "execute") { 16 | w.object("stmt", msg.stmt, Stmt); 17 | } else if (msg.type === "batch") { 18 | w.object("batch", msg.batch, Batch); 19 | } else if (msg.type === "sequence") { 20 | if (msg.sql !== undefined) { w.string("sql", msg.sql); } 21 | if (msg.sqlId !== undefined) { w.number("sql_id", msg.sqlId); } 22 | } else if (msg.type === "describe") { 23 | if (msg.sql !== undefined) { w.string("sql", msg.sql); } 24 | if (msg.sqlId !== undefined) { w.number("sql_id", msg.sqlId); } 25 | } else if (msg.type === "store_sql") { 26 | w.number("sql_id", msg.sqlId); 27 | w.string("sql", msg.sql); 28 | } else if (msg.type === "close_sql") { 29 | w.number("sql_id", msg.sqlId); 30 | } else if (msg.type === "get_autocommit") { 31 | // do nothing 32 | } else { 33 | throw impossible(msg, "Impossible type of StreamRequest"); 34 | } 35 | } 36 | 37 | export function CursorReqBody(w: e.ObjectWriter, msg: proto.CursorReqBody): void { 38 | if (msg.baton !== undefined) { w.string("baton", msg.baton); } 39 | w.object("batch", msg.batch, Batch); 40 | } 41 | -------------------------------------------------------------------------------- /src/http/proto.ts: -------------------------------------------------------------------------------- 1 | // Types for the structures specific to Hrana over HTTP. 2 | 3 | export * from "../shared/proto.js"; 4 | import { int32, Error, Stmt, StmtResult, Batch, BatchResult, DescribeResult } from "../shared/proto.js"; 5 | 6 | // ## Execute requests on a stream 7 | 8 | export type PipelineReqBody = { 9 | baton: string | undefined, 10 | requests: Array, 11 | } 12 | 13 | export type PipelineRespBody = { 14 | baton: string | undefined, 15 | baseUrl: string | undefined, 16 | results: Array 17 | } 18 | 19 | export type StreamResult = 20 | | { type: "none" } 21 | | StreamResultOk 22 | | StreamResultError 23 | 24 | export type StreamResultOk = { 25 | type: "ok", 26 | response: StreamResponse, 27 | } 28 | 29 | export type StreamResultError = { 30 | type: "error", 31 | error: Error, 32 | } 33 | 34 | // ## Execute a batch using a cursor 35 | 36 | export type CursorReqBody = { 37 | baton: string | undefined, 38 | batch: Batch, 39 | } 40 | 41 | export type CursorRespBody = { 42 | baton: string | undefined, 43 | baseUrl: string | undefined, 44 | } 45 | 46 | // ## Requests 47 | 48 | export type StreamRequest = 49 | | CloseStreamReq 50 | | ExecuteStreamReq 51 | | BatchStreamReq 52 | | SequenceStreamReq 53 | | DescribeStreamReq 54 | | StoreSqlStreamReq 55 | | CloseSqlStreamReq 56 | | GetAutocommitStreamReq 57 | 58 | export type StreamResponse = 59 | | { type: "none" } 60 | | CloseStreamResp 61 | | ExecuteStreamResp 62 | | BatchStreamResp 63 | | SequenceStreamResp 64 | | DescribeStreamResp 65 | | StoreSqlStreamResp 66 | | CloseSqlStreamResp 67 | | GetAutocommitStreamResp 68 | 69 | // ### Close stream 70 | 71 | export type CloseStreamReq = { 72 | type: "close", 73 | } 74 | 75 | export type CloseStreamResp = { 76 | type: "close", 77 | } 78 | 79 | // ### Execute a statement 80 | 81 | export type ExecuteStreamReq = { 82 | type: "execute", 83 | stmt: Stmt, 84 | } 85 | 86 | export type ExecuteStreamResp = { 87 | type: "execute", 88 | result: StmtResult, 89 | } 90 | 91 | // ### Execute a batch 92 | 93 | export type BatchStreamReq = { 94 | type: "batch", 95 | batch: Batch, 96 | } 97 | 98 | export type BatchStreamResp = { 99 | type: "batch", 100 | result: BatchResult, 101 | } 102 | 103 | // ### Execute a sequence of SQL statements 104 | 105 | export type SequenceStreamReq = { 106 | type: "sequence", 107 | sql: string | undefined, 108 | sqlId: int32 | undefined, 109 | } 110 | 111 | export type SequenceStreamResp = { 112 | type: "sequence", 113 | } 114 | 115 | // ### Describe a statement 116 | 117 | export type DescribeStreamReq = { 118 | type: "describe", 119 | sql: string | undefined, 120 | sqlId: int32 | undefined, 121 | } 122 | 123 | export type DescribeStreamResp = { 124 | type: "describe", 125 | result: DescribeResult, 126 | } 127 | 128 | // ### Store an SQL text on the server 129 | 130 | export type StoreSqlStreamReq = { 131 | type: "store_sql", 132 | sqlId: int32, 133 | sql: string, 134 | } 135 | 136 | export type StoreSqlStreamResp = { 137 | type: "store_sql", 138 | } 139 | 140 | // ### Close a stored SQL text 141 | 142 | export type CloseSqlStreamReq = { 143 | type: "close_sql", 144 | sqlId: int32, 145 | } 146 | 147 | export type CloseSqlStreamResp = { 148 | type: "close_sql", 149 | } 150 | 151 | // ### Get the autocommit state 152 | 153 | export type GetAutocommitStreamReq = { 154 | type: "get_autocommit", 155 | } 156 | 157 | export type GetAutocommitStreamResp = { 158 | type: "get_autocommit", 159 | isAutocommit: boolean, 160 | } 161 | -------------------------------------------------------------------------------- /src/http/protobuf_decode.ts: -------------------------------------------------------------------------------- 1 | import * as d from "../encoding/protobuf/decode.js"; 2 | import { Error, StmtResult, BatchResult, CursorEntry, DescribeResult } from "../shared/protobuf_decode.js"; 3 | import * as proto from "./proto.js"; 4 | 5 | export const PipelineRespBody: d.MessageDef = { 6 | default() { return {baton: undefined, baseUrl: undefined, results: []} }, 7 | 1 (r, msg) { msg.baton = r.string() }, 8 | 2 (r, msg) { msg.baseUrl = r.string() }, 9 | 3 (r, msg) { msg.results.push(r.message(StreamResult)) }, 10 | }; 11 | 12 | const StreamResult: d.MessageDef = { 13 | default() { return {type: "none"} }, 14 | 1 (r) { return {type: "ok", response: r.message(StreamResponse)} }, 15 | 2 (r) { return {type: "error", error: r.message(Error)} }, 16 | }; 17 | 18 | const StreamResponse: d.MessageDef = { 19 | default() { return {type: "none"} }, 20 | 1 (r) { return {type: "close"} }, 21 | 2 (r) { return r.message(ExecuteStreamResp) }, 22 | 3 (r) { return r.message(BatchStreamResp) }, 23 | 4 (r) { return {type: "sequence"} }, 24 | 5 (r) { return r.message(DescribeStreamResp) }, 25 | 6 (r) { return {type: "store_sql"} }, 26 | 7 (r) { return {type: "close_sql"} }, 27 | 8 (r) { return r.message(GetAutocommitStreamResp) }, 28 | }; 29 | 30 | const ExecuteStreamResp: d.MessageDef = { 31 | default() { return {type: "execute", result: StmtResult.default()} }, 32 | 1 (r, msg) { msg.result = r.message(StmtResult) }, 33 | }; 34 | 35 | const BatchStreamResp: d.MessageDef = { 36 | default() { return {type: "batch", result: BatchResult.default()} }, 37 | 1 (r, msg) { msg.result = r.message(BatchResult) }, 38 | }; 39 | 40 | const DescribeStreamResp: d.MessageDef = { 41 | default() { return {type: "describe", result: DescribeResult.default()} }, 42 | 1 (r, msg) { msg.result = r.message(DescribeResult) }, 43 | }; 44 | 45 | const GetAutocommitStreamResp: d.MessageDef = { 46 | default() { return {type: "get_autocommit", isAutocommit: false} }, 47 | 1 (r, msg) { msg.isAutocommit = r.bool() }, 48 | }; 49 | 50 | export const CursorRespBody: d.MessageDef = { 51 | default() { return {baton: undefined, baseUrl: undefined} }, 52 | 1 (r, msg) { msg.baton = r.string() }, 53 | 2 (r, msg) { msg.baseUrl = r.string() }, 54 | }; 55 | 56 | -------------------------------------------------------------------------------- /src/http/protobuf_encode.ts: -------------------------------------------------------------------------------- 1 | import * as e from "../encoding/protobuf/encode.js"; 2 | import { Stmt, Batch } from "../shared/protobuf_encode.js"; 3 | import { impossible } from "../util.js"; 4 | import * as proto from "./proto.js"; 5 | 6 | export function PipelineReqBody(w: e.MessageWriter, msg: proto.PipelineReqBody): void { 7 | if (msg.baton !== undefined) { w.string(1, msg.baton); } 8 | for (const req of msg.requests) { w.message(2, req, StreamRequest); } 9 | } 10 | 11 | function StreamRequest(w: e.MessageWriter, msg: proto.StreamRequest): void { 12 | if (msg.type === "close") { 13 | w.message(1, msg, CloseStreamReq); 14 | } else if (msg.type === "execute") { 15 | w.message(2, msg, ExecuteStreamReq); 16 | } else if (msg.type === "batch") { 17 | w.message(3, msg, BatchStreamReq); 18 | } else if (msg.type === "sequence") { 19 | w.message(4, msg, SequenceStreamReq); 20 | } else if (msg.type === "describe") { 21 | w.message(5, msg, DescribeStreamReq); 22 | } else if (msg.type === "store_sql") { 23 | w.message(6, msg, StoreSqlStreamReq); 24 | } else if (msg.type === "close_sql") { 25 | w.message(7, msg, CloseSqlStreamReq); 26 | } else if (msg.type === "get_autocommit") { 27 | w.message(8, msg, GetAutocommitStreamReq); 28 | } else { 29 | throw impossible(msg, "Impossible type of StreamRequest"); 30 | } 31 | } 32 | 33 | function CloseStreamReq(_w: e.MessageWriter, _msg: proto.CloseStreamReq): void { 34 | } 35 | 36 | function ExecuteStreamReq(w: e.MessageWriter, msg: proto.ExecuteStreamReq): void { 37 | w.message(1, msg.stmt, Stmt); 38 | } 39 | 40 | function BatchStreamReq(w: e.MessageWriter, msg: proto.BatchStreamReq): void { 41 | w.message(1, msg.batch, Batch); 42 | } 43 | 44 | function SequenceStreamReq(w: e.MessageWriter, msg: proto.SequenceStreamReq): void { 45 | if (msg.sql !== undefined) { w.string(1, msg.sql); } 46 | if (msg.sqlId !== undefined) { w.int32(2, msg.sqlId); } 47 | } 48 | 49 | function DescribeStreamReq(w: e.MessageWriter, msg: proto.DescribeStreamReq): void { 50 | if (msg.sql !== undefined) { w.string(1, msg.sql); } 51 | if (msg.sqlId !== undefined) { w.int32(2, msg.sqlId); } 52 | } 53 | 54 | function StoreSqlStreamReq(w: e.MessageWriter, msg: proto.StoreSqlStreamReq): void { 55 | w.int32(1, msg.sqlId); 56 | w.string(2, msg.sql); 57 | } 58 | 59 | function CloseSqlStreamReq(w: e.MessageWriter, msg: proto.CloseSqlStreamReq): void { 60 | w.int32(1, msg.sqlId); 61 | } 62 | 63 | function GetAutocommitStreamReq(_w: e.MessageWriter, _msg: proto.GetAutocommitStreamReq): void { 64 | } 65 | 66 | export function CursorReqBody(w: e.MessageWriter, msg: proto.CursorReqBody): void { 67 | if (msg.baton !== undefined) { w.string(1, msg.baton); } 68 | w.message(2, msg.batch, Batch); 69 | } 70 | -------------------------------------------------------------------------------- /src/http/stream.ts: -------------------------------------------------------------------------------- 1 | import type { fetch } from "cross-fetch"; 2 | import { Request, Headers } from "cross-fetch"; 3 | 4 | import type { ProtocolEncoding } from "../client.js"; 5 | import type { Cursor } from "../cursor.js"; 6 | import type * as jsone from "../encoding/json/encode.js"; 7 | import type * as protobufe from "../encoding/protobuf/encode.js"; 8 | import { 9 | ClientError, HttpServerError, ProtocolVersionError, 10 | ProtoError, ClosedError, InternalError, 11 | } from "../errors.js"; 12 | import { 13 | readJsonObject, writeJsonObject, readProtobufMessage, writeProtobufMessage, 14 | } from "../encoding/index.js"; 15 | import { IdAlloc } from "../id_alloc.js"; 16 | import { Queue } from "../queue.js"; 17 | import { queueMicrotask } from "../queue_microtask.js"; 18 | import { errorFromProto } from "../result.js"; 19 | import type { SqlOwner, ProtoSql } from "../sql.js"; 20 | import { Sql } from "../sql.js"; 21 | import { Stream } from "../stream.js"; 22 | import { impossible } from "../util.js"; 23 | 24 | import type { HttpClient, Endpoint } from "./client.js"; 25 | import { HttpCursor } from "./cursor.js"; 26 | import type * as proto from "./proto.js"; 27 | 28 | import { PipelineReqBody as json_PipelineReqBody } from "./json_encode.js"; 29 | import { PipelineReqBody as protobuf_PipelineReqBody } from "./protobuf_encode.js"; 30 | import { CursorReqBody as json_CursorReqBody } from "./json_encode.js"; 31 | import { CursorReqBody as protobuf_CursorReqBody } from "./protobuf_encode.js"; 32 | import { PipelineRespBody as json_PipelineRespBody } from "./json_decode.js"; 33 | import { PipelineRespBody as protobuf_PipelineRespBody } from "./protobuf_decode.js"; 34 | 35 | type QueueEntry = PipelineEntry | CursorEntry; 36 | 37 | type PipelineEntry = { 38 | type: "pipeline", 39 | request: proto.StreamRequest, 40 | responseCallback: (_: proto.StreamResponse) => void, 41 | errorCallback: (_: Error) => void, 42 | } 43 | 44 | type CursorEntry = { 45 | type: "cursor", 46 | batch: proto.Batch, 47 | cursorCallback: (_: HttpCursor) => void, 48 | errorCallback: (_: Error) => void, 49 | } 50 | 51 | export class HttpStream extends Stream implements SqlOwner { 52 | #client: HttpClient; 53 | #baseUrl: string; 54 | #jwt: string | undefined; 55 | #fetch: typeof fetch; 56 | 57 | #baton: string | undefined; 58 | #queue: Queue; 59 | #flushing: boolean; 60 | #cursor: HttpCursor | undefined; 61 | #closing: boolean; 62 | #closeQueued: boolean; 63 | #closed: Error | undefined; 64 | 65 | #sqlIdAlloc: IdAlloc; 66 | 67 | /** @private */ 68 | constructor(client: HttpClient, baseUrl: URL, jwt: string | undefined, customFetch: typeof fetch) { 69 | super(client.intMode); 70 | this.#client = client; 71 | this.#baseUrl = baseUrl.toString(); 72 | this.#jwt = jwt; 73 | this.#fetch = customFetch; 74 | 75 | this.#baton = undefined; 76 | this.#queue = new Queue(); 77 | this.#flushing = false; 78 | this.#closing = false; 79 | this.#closeQueued = false; 80 | this.#closed = undefined; 81 | 82 | this.#sqlIdAlloc = new IdAlloc(); 83 | } 84 | 85 | /** Get the {@link HttpClient} object that this stream belongs to. */ 86 | override client(): HttpClient { 87 | return this.#client; 88 | } 89 | 90 | /** @private */ 91 | override _sqlOwner(): SqlOwner { 92 | return this; 93 | } 94 | 95 | /** Cache a SQL text on the server. */ 96 | storeSql(sql: string): Sql { 97 | const sqlId = this.#sqlIdAlloc.alloc(); 98 | this.#sendStreamRequest({type: "store_sql", sqlId, sql}).then( 99 | () => undefined, 100 | (error) => this._setClosed(error), 101 | ); 102 | return new Sql(this, sqlId); 103 | } 104 | 105 | /** @private */ 106 | _closeSql(sqlId: number): void { 107 | if (this.#closed !== undefined) { 108 | return; 109 | } 110 | 111 | this.#sendStreamRequest({type: "close_sql", sqlId}).then( 112 | () => this.#sqlIdAlloc.free(sqlId), 113 | (error) => this._setClosed(error), 114 | ); 115 | } 116 | 117 | /** @private */ 118 | override _execute(stmt: proto.Stmt): Promise { 119 | return this.#sendStreamRequest({type: "execute", stmt}).then((response) => { 120 | return (response as proto.ExecuteStreamResp).result; 121 | }); 122 | } 123 | 124 | /** @private */ 125 | override _batch(batch: proto.Batch): Promise { 126 | return this.#sendStreamRequest({type: "batch", batch}).then((response) => { 127 | return (response as proto.BatchStreamResp).result; 128 | }); 129 | } 130 | 131 | /** @private */ 132 | override _describe(protoSql: ProtoSql): Promise { 133 | return this.#sendStreamRequest({ 134 | type: "describe", 135 | sql: protoSql.sql, 136 | sqlId: protoSql.sqlId 137 | }).then((response) => { 138 | return (response as proto.DescribeStreamResp).result; 139 | }); 140 | } 141 | 142 | /** @private */ 143 | override _sequence(protoSql: ProtoSql): Promise { 144 | return this.#sendStreamRequest({ 145 | type: "sequence", 146 | sql: protoSql.sql, 147 | sqlId: protoSql.sqlId, 148 | }).then((_response) => { 149 | return undefined; 150 | }); 151 | } 152 | 153 | /** Check whether the SQL connection underlying this stream is in autocommit state (i.e., outside of an 154 | * explicit transaction). This requires protocol version 3 or higher. 155 | */ 156 | override getAutocommit(): Promise { 157 | this.#client._ensureVersion(3, "getAutocommit()"); 158 | return this.#sendStreamRequest({ 159 | type: "get_autocommit", 160 | }).then((response) => { 161 | return (response as proto.GetAutocommitStreamResp).isAutocommit; 162 | }); 163 | } 164 | 165 | #sendStreamRequest(request: proto.StreamRequest): Promise { 166 | return new Promise((responseCallback, errorCallback) => { 167 | this.#pushToQueue({type: "pipeline", request, responseCallback, errorCallback}); 168 | }); 169 | } 170 | 171 | /** @private */ 172 | override _openCursor(batch: proto.Batch): Promise { 173 | return new Promise((cursorCallback, errorCallback) => { 174 | this.#pushToQueue({type: "cursor", batch, cursorCallback, errorCallback}); 175 | }); 176 | } 177 | 178 | /** @private */ 179 | _cursorClosed(cursor: HttpCursor): void { 180 | if (cursor !== this.#cursor) { 181 | throw new InternalError("Cursor was closed, but it was not associated with the stream"); 182 | } 183 | this.#cursor = undefined; 184 | queueMicrotask(() => this.#flushQueue()); 185 | } 186 | 187 | /** Immediately close the stream. */ 188 | override close(): void { 189 | this._setClosed(new ClientError("Stream was manually closed")); 190 | } 191 | 192 | /** Gracefully close the stream. */ 193 | override closeGracefully(): void { 194 | this.#closing = true; 195 | queueMicrotask(() => this.#flushQueue()); 196 | } 197 | 198 | /** True if the stream is closed. */ 199 | override get closed(): boolean { 200 | return this.#closed !== undefined || this.#closing; 201 | } 202 | 203 | /** @private */ 204 | _setClosed(error: Error): void { 205 | if (this.#closed !== undefined) { 206 | return; 207 | } 208 | this.#closed = error; 209 | 210 | if (this.#cursor !== undefined) { 211 | this.#cursor._setClosed(error); 212 | } 213 | this.#client._streamClosed(this); 214 | 215 | for (;;) { 216 | const entry = this.#queue.shift(); 217 | if (entry !== undefined) { 218 | entry.errorCallback(error); 219 | } else { 220 | break; 221 | } 222 | } 223 | 224 | if ((this.#baton !== undefined || this.#flushing) && !this.#closeQueued) { 225 | this.#queue.push({ 226 | type: "pipeline", 227 | request: {type: "close"}, 228 | responseCallback: () => undefined, 229 | errorCallback: () => undefined, 230 | }); 231 | this.#closeQueued = true; 232 | queueMicrotask(() => this.#flushQueue()); 233 | } 234 | } 235 | 236 | #pushToQueue(entry: QueueEntry): void { 237 | if (this.#closed !== undefined) { 238 | throw new ClosedError("Stream is closed", this.#closed); 239 | } else if (this.#closing) { 240 | throw new ClosedError("Stream is closing", undefined); 241 | } else { 242 | this.#queue.push(entry); 243 | queueMicrotask(() => this.#flushQueue()); 244 | } 245 | } 246 | 247 | #flushQueue(): void { 248 | if (this.#flushing || this.#cursor !== undefined) { 249 | return; 250 | } 251 | 252 | if (this.#closing && this.#queue.length === 0) { 253 | this._setClosed(new ClientError("Stream was gracefully closed")); 254 | return; 255 | } 256 | 257 | const endpoint = this.#client._endpoint; 258 | if (endpoint === undefined) { 259 | this.#client._endpointPromise.then( 260 | () => this.#flushQueue(), 261 | (error) => this._setClosed(error), 262 | ); 263 | return; 264 | } 265 | 266 | const firstEntry = this.#queue.shift(); 267 | if (firstEntry === undefined) { 268 | return; 269 | } else if (firstEntry.type === "pipeline") { 270 | const pipeline: Array = [firstEntry]; 271 | for (;;) { 272 | const entry = this.#queue.first(); 273 | if (entry !== undefined && entry.type === "pipeline") { 274 | pipeline.push(entry); 275 | this.#queue.shift(); 276 | } else if (entry === undefined && this.#closing && !this.#closeQueued) { 277 | pipeline.push({ 278 | type: "pipeline", 279 | request: {type: "close"}, 280 | responseCallback: () => undefined, 281 | errorCallback: () => undefined, 282 | }); 283 | this.#closeQueued = true; 284 | break; 285 | } else { 286 | break; 287 | } 288 | } 289 | this.#flushPipeline(endpoint, pipeline); 290 | } else if (firstEntry.type === "cursor") { 291 | this.#flushCursor(endpoint, firstEntry); 292 | } else { 293 | throw impossible(firstEntry, "Impossible type of QueueEntry"); 294 | } 295 | } 296 | 297 | #flushPipeline(endpoint: Endpoint, pipeline: Array): void { 298 | this.#flush( 299 | () => this.#createPipelineRequest(pipeline, endpoint), 300 | (resp) => decodePipelineResponse(resp, endpoint.encoding), 301 | (respBody) => respBody.baton, 302 | (respBody) => respBody.baseUrl, 303 | (respBody) => handlePipelineResponse(pipeline, respBody), 304 | (error) => pipeline.forEach((entry) => entry.errorCallback(error)), 305 | ); 306 | } 307 | 308 | #flushCursor(endpoint: Endpoint, entry: CursorEntry): void { 309 | const cursor = new HttpCursor(this, endpoint.encoding); 310 | this.#cursor = cursor; 311 | this.#flush( 312 | () => this.#createCursorRequest(entry, endpoint), 313 | (resp) => cursor.open(resp), 314 | (respBody) => respBody.baton, 315 | (respBody) => respBody.baseUrl, 316 | (_respBody) => entry.cursorCallback(cursor), 317 | (error) => entry.errorCallback(error), 318 | ); 319 | } 320 | 321 | #flush( 322 | createRequest: () => Request, 323 | decodeResponse: (_: Response) => Promise, 324 | getBaton: (_: R) => string | undefined, 325 | getBaseUrl: (_: R) => string | undefined, 326 | handleResponse: (_: R) => void, 327 | handleError: (_: Error) => void, 328 | ): void { 329 | let promise; 330 | try { 331 | const request = createRequest(); 332 | const fetch = this.#fetch; 333 | promise = fetch(request); 334 | } catch (error) { 335 | promise = Promise.reject(error); 336 | } 337 | 338 | this.#flushing = true; 339 | promise.then((resp: Response): Promise => { 340 | if (!resp.ok) { 341 | return errorFromResponse(resp).then((error) => { 342 | throw error; 343 | }); 344 | } 345 | return decodeResponse(resp); 346 | }).then((r: R) => { 347 | this.#baton = getBaton(r); 348 | this.#baseUrl = getBaseUrl(r) ?? this.#baseUrl; 349 | handleResponse(r); 350 | }).catch((error: Error) => { 351 | this._setClosed(error); 352 | handleError(error); 353 | }).finally(() => { 354 | this.#flushing = false; 355 | this.#flushQueue(); 356 | }); 357 | } 358 | 359 | #createPipelineRequest(pipeline: Array, endpoint: Endpoint): Request { 360 | return this.#createRequest( 361 | new URL(endpoint.pipelinePath, this.#baseUrl), 362 | { 363 | baton: this.#baton, 364 | requests: pipeline.map((entry) => entry.request), 365 | }, 366 | endpoint.encoding, 367 | json_PipelineReqBody, 368 | protobuf_PipelineReqBody, 369 | ); 370 | } 371 | 372 | #createCursorRequest(entry: CursorEntry, endpoint: Endpoint): Request { 373 | if (endpoint.cursorPath === undefined) { 374 | throw new ProtocolVersionError( 375 | "Cursors are supported only on protocol version 3 and higher, " + 376 | `but the HTTP server only supports version ${endpoint.version}.`, 377 | ); 378 | } 379 | return this.#createRequest( 380 | new URL(endpoint.cursorPath, this.#baseUrl), 381 | { 382 | baton: this.#baton, 383 | batch: entry.batch, 384 | }, 385 | endpoint.encoding, 386 | json_CursorReqBody, 387 | protobuf_CursorReqBody, 388 | ); 389 | } 390 | 391 | #createRequest( 392 | url: URL, 393 | reqBody: T, 394 | encoding: ProtocolEncoding, 395 | jsonFun: jsone.ObjectFun, 396 | protobufFun: protobufe.MessageFun, 397 | ): Request { 398 | let bodyData: string | Uint8Array; 399 | let contentType: string; 400 | if (encoding === "json") { 401 | bodyData = writeJsonObject(reqBody, jsonFun); 402 | contentType = "application/json"; 403 | } else if (encoding === "protobuf") { 404 | bodyData = writeProtobufMessage(reqBody, protobufFun); 405 | contentType = "application/x-protobuf"; 406 | } else { 407 | throw impossible(encoding, "Impossible encoding"); 408 | } 409 | 410 | const headers = new Headers(); 411 | headers.set("content-type", contentType); 412 | if (this.#jwt !== undefined) { 413 | headers.set("authorization", `Bearer ${this.#jwt}`); 414 | } 415 | 416 | return new Request(url.toString(), {method: "POST", headers, body: bodyData}); 417 | } 418 | } 419 | 420 | function handlePipelineResponse(pipeline: Array, respBody: proto.PipelineRespBody): void { 421 | if (respBody.results.length !== pipeline.length) { 422 | throw new ProtoError("Server returned unexpected number of pipeline results"); 423 | } 424 | 425 | for (let i = 0; i < pipeline.length; ++i) { 426 | const result = respBody.results[i]; 427 | const entry = pipeline[i]; 428 | 429 | if (result.type === "ok") { 430 | if (result.response.type !== entry.request.type) { 431 | throw new ProtoError("Received unexpected type of response"); 432 | } 433 | entry.responseCallback(result.response); 434 | } else if (result.type === "error") { 435 | entry.errorCallback(errorFromProto(result.error)); 436 | } else if (result.type === "none") { 437 | throw new ProtoError("Received unrecognized type of StreamResult"); 438 | } else { 439 | throw impossible(result, "Received impossible type of StreamResult"); 440 | } 441 | } 442 | } 443 | 444 | async function decodePipelineResponse( 445 | resp: Response, 446 | encoding: ProtocolEncoding, 447 | ): Promise { 448 | if (encoding === "json") { 449 | const respJson = await resp.json(); 450 | return readJsonObject(respJson, json_PipelineRespBody); 451 | } 452 | 453 | if (encoding === "protobuf") { 454 | const respData = await resp.arrayBuffer(); 455 | return readProtobufMessage(new Uint8Array(respData), protobuf_PipelineRespBody); 456 | } 457 | 458 | await resp.body?.cancel(); 459 | throw impossible(encoding, "Impossible encoding"); 460 | } 461 | 462 | async function errorFromResponse(resp: Response): Promise { 463 | const respType = resp.headers.get("content-type") ?? "text/plain"; 464 | let message = `Server returned HTTP status ${resp.status}`; 465 | 466 | if (respType === "application/json") { 467 | const respBody = await resp.json(); 468 | if ("message" in respBody) { 469 | return errorFromProto(respBody as proto.Error); 470 | } 471 | return new HttpServerError(message, resp.status); 472 | } 473 | 474 | if (respType === "text/plain") { 475 | const respBody = (await resp.text()).trim(); 476 | if (respBody !== "") { 477 | message += `: ${respBody}`; 478 | } 479 | return new HttpServerError(message, resp.status); 480 | } 481 | 482 | await resp.body?.cancel(); 483 | return new HttpServerError(message, resp.status); 484 | } 485 | -------------------------------------------------------------------------------- /src/id_alloc.ts: -------------------------------------------------------------------------------- 1 | import { InternalError } from "./errors.js"; 2 | 3 | // An allocator of non-negative integer ids. 4 | // 5 | // This clever data structure has these "ideal" properties: 6 | // - It consumes memory proportional to the number of used ids (which is optimal). 7 | // - All operations are O(1) time. 8 | // - The allocated ids are small (with a slight modification, we could always provide the smallest possible 9 | // id). 10 | export class IdAlloc { 11 | // Set of all allocated ids 12 | #usedIds: Set; 13 | // Set of all free ids lower than `#usedIds.size` 14 | #freeIds: Set; 15 | 16 | constructor() { 17 | this.#usedIds = new Set(); 18 | this.#freeIds = new Set(); 19 | } 20 | 21 | // Returns an id that was free, and marks it as used. 22 | alloc(): number { 23 | // this "loop" is just a way to pick an arbitrary element from the `#freeIds` set 24 | for (const freeId of this.#freeIds) { 25 | this.#freeIds.delete(freeId); 26 | this.#usedIds.add(freeId); 27 | 28 | // maintain the invariant of `#freeIds` 29 | if (!this.#usedIds.has(this.#usedIds.size - 1)) { 30 | this.#freeIds.add(this.#usedIds.size - 1); 31 | } 32 | return freeId; 33 | } 34 | 35 | // the `#freeIds` set is empty, so there are no free ids lower than `#usedIds.size` 36 | // this means that `#usedIds` is a set that contains all numbers from 0 to `#usedIds.size - 1`, 37 | // so `#usedIds.size` is free 38 | const freeId = this.#usedIds.size; 39 | this.#usedIds.add(freeId); 40 | return freeId; 41 | } 42 | 43 | free(id: number) { 44 | if (!this.#usedIds.delete(id)) { 45 | throw new InternalError("Freeing an id that is not allocated"); 46 | } 47 | 48 | // maintain the invariant of `#freeIds` 49 | this.#freeIds.delete(this.#usedIds.size); 50 | if (id < this.#usedIds.size) { 51 | this.#freeIds.add(id); 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket } from "@libsql/isomorphic-ws"; 2 | 3 | import { subprotocolsV2, subprotocolsV3 } from "./ws/client.js"; 4 | import { WebSocketUnsupportedError } from "./errors.js"; 5 | 6 | import { HttpClient } from "./http/client.js"; 7 | import { WsClient } from "./ws/client.js"; 8 | import { ProtocolVersion } from "./client.js"; 9 | 10 | export { WebSocket } from "@libsql/isomorphic-ws"; 11 | export type { Response } from "cross-fetch"; 12 | export { fetch, Request, Headers } from "cross-fetch"; 13 | 14 | export type { ProtocolVersion, ProtocolEncoding } from "./client.js"; 15 | export { Client } from "./client.js"; 16 | export type { DescribeResult, DescribeColumn } from "./describe.js"; 17 | export * from "./errors.js"; 18 | export { Batch, BatchStep, BatchCond } from "./batch.js"; 19 | export type { ParsedLibsqlUrl } from "./libsql_url.js"; 20 | export { parseLibsqlUrl } from "./libsql_url.js"; 21 | export type { StmtResult, RowsResult, RowResult, ValueResult, Row } from "./result.js"; 22 | export type { InSql, SqlOwner } from "./sql.js"; 23 | export { Sql } from "./sql.js"; 24 | export type { InStmt, InStmtArgs } from "./stmt.js"; 25 | export { Stmt } from "./stmt.js"; 26 | export { Stream } from "./stream.js"; 27 | export type { Value, InValue, IntMode } from "./value.js"; 28 | 29 | export { HttpClient } from "./http/client.js"; 30 | export { HttpStream } from "./http/stream.js"; 31 | export { WsClient } from "./ws/client.js"; 32 | export { WsStream } from "./ws/stream.js"; 33 | 34 | /** Open a Hrana client over WebSocket connected to the given `url`. */ 35 | export function openWs(url: string | URL, jwt?: string, protocolVersion: ProtocolVersion = 2): WsClient { 36 | if (typeof WebSocket === "undefined") { 37 | throw new WebSocketUnsupportedError("WebSockets are not supported in this environment"); 38 | } 39 | var subprotocols = undefined; 40 | if (protocolVersion == 3) { 41 | subprotocols = Array.from(subprotocolsV3.keys()); 42 | } else { 43 | subprotocols = Array.from(subprotocolsV2.keys()); 44 | } 45 | const socket = new WebSocket(url, subprotocols); 46 | return new WsClient(socket, jwt); 47 | } 48 | 49 | /** Open a Hrana client over HTTP connected to the given `url`. 50 | * 51 | * If the `customFetch` argument is passed and not `undefined`, it is used in place of the `fetch` function 52 | * from `cross-fetch`. This function is always called with a `Request` object from 53 | * `cross-fetch`. 54 | */ 55 | export function openHttp(url: string | URL, jwt?: string, customFetch?: unknown | undefined, protocolVersion: ProtocolVersion = 2): HttpClient { 56 | return new HttpClient(url instanceof URL ? url : new URL(url), jwt, customFetch, protocolVersion); 57 | } 58 | -------------------------------------------------------------------------------- /src/libsql_url.ts: -------------------------------------------------------------------------------- 1 | import { LibsqlUrlParseError } from "./errors.js"; 2 | 3 | /** Result of parsing a libsql URL using {@link parseLibsqlUrl}. */ 4 | export interface ParsedLibsqlUrl { 5 | /** A WebSocket URL that can be used with {@link openWs} to open a {@link WsClient}. It is `undefined` 6 | * if the parsed URL specified HTTP explicitly. */ 7 | hranaWsUrl: string | undefined; 8 | /** A HTTP URL that can be used with {@link openHttp} to open a {@link HttpClient}. It is `undefined` 9 | * if the parsed URL specified WebSockets explicitly. */ 10 | hranaHttpUrl: string | undefined; 11 | /** The optional `authToken` query parameter that should be passed as `jwt` to 12 | * {@link openWs}/{@link openHttp}. */ 13 | authToken: string | undefined; 14 | }; 15 | 16 | /** Parses a URL compatible with the libsql client (`@libsql/client`). This URL may have the "libsql:" scheme 17 | * and may contain query parameters. */ 18 | export function parseLibsqlUrl(urlStr: string): ParsedLibsqlUrl { 19 | const url = new URL(urlStr); 20 | 21 | let authToken: string | undefined = undefined; 22 | let tls: boolean | undefined = undefined; 23 | for (const [key, value] of url.searchParams.entries()) { 24 | if (key === "authToken") { 25 | authToken = value; 26 | } else if (key === "tls") { 27 | if (value === "0") { 28 | tls = false; 29 | } else if (value === "1") { 30 | tls = true; 31 | } else { 32 | throw new LibsqlUrlParseError( 33 | `Unknown value for the "tls" query argument: ${JSON.stringify(value)}`, 34 | ); 35 | } 36 | } else { 37 | throw new LibsqlUrlParseError(`Unknown URL query argument ${JSON.stringify(key)}`); 38 | } 39 | } 40 | 41 | let hranaWsScheme: string | undefined; 42 | let hranaHttpScheme: string | undefined; 43 | 44 | if ((url.protocol === "http:" || url.protocol === "ws:") && (tls === true)) { 45 | throw new LibsqlUrlParseError( 46 | `A ${JSON.stringify(url.protocol)} URL cannot opt into TLS using ?tls=1`, 47 | ); 48 | } else if ((url.protocol === "https:" || url.protocol === "wss:") && (tls === false)) { 49 | throw new LibsqlUrlParseError( 50 | `A ${JSON.stringify(url.protocol)} URL cannot opt out of TLS using ?tls=0`, 51 | ); 52 | } 53 | 54 | if (url.protocol === "http:" || url.protocol === "https:") { 55 | hranaHttpScheme = url.protocol; 56 | } else if (url.protocol === "ws:" || url.protocol === "wss:") { 57 | hranaWsScheme = url.protocol; 58 | } else if (url.protocol === "libsql:") { 59 | if (tls === false) { 60 | if (!url.port) { 61 | throw new LibsqlUrlParseError(`A "libsql:" URL with ?tls=0 must specify an explicit port`); 62 | } 63 | hranaHttpScheme = "http:"; 64 | hranaWsScheme = "ws:"; 65 | } else { 66 | hranaHttpScheme = "https:"; 67 | hranaWsScheme = "wss:"; 68 | } 69 | } else { 70 | throw new LibsqlUrlParseError( 71 | `This client does not support ${JSON.stringify(url.protocol)} URLs. ` + 72 | 'Please use a "libsql:", "ws:", "wss:", "http:" or "https:" URL instead.' 73 | ); 74 | } 75 | 76 | if (url.username || url.password) { 77 | throw new LibsqlUrlParseError( 78 | "This client does not support HTTP Basic authentication with a username and password. " + 79 | 'You can authenticate using a token passed in the "authToken" URL query parameter.', 80 | ); 81 | } 82 | if (url.hash) { 83 | throw new LibsqlUrlParseError("URL fragments are not supported"); 84 | } 85 | 86 | let hranaPath = url.pathname; 87 | if (hranaPath === "/") { 88 | hranaPath = ""; 89 | } 90 | 91 | const hranaWsUrl = hranaWsScheme !== undefined 92 | ? `${hranaWsScheme}//${url.host}${hranaPath}` : undefined; 93 | const hranaHttpUrl = hranaHttpScheme !== undefined 94 | ? `${hranaHttpScheme}//${url.host}${hranaPath}` : undefined; 95 | return { hranaWsUrl, hranaHttpUrl, authToken }; 96 | } 97 | -------------------------------------------------------------------------------- /src/queue.ts: -------------------------------------------------------------------------------- 1 | export class Queue { 2 | #pushStack: Array; 3 | #shiftStack: Array; 4 | 5 | constructor() { 6 | this.#pushStack = []; 7 | this.#shiftStack = []; 8 | } 9 | 10 | get length(): number { 11 | return this.#pushStack.length + this.#shiftStack.length; 12 | } 13 | 14 | push(elem: T): void { 15 | this.#pushStack.push(elem); 16 | } 17 | 18 | shift(): T | undefined { 19 | if (this.#shiftStack.length === 0 && this.#pushStack.length > 0) { 20 | this.#shiftStack = this.#pushStack.reverse(); 21 | this.#pushStack = []; 22 | } 23 | return this.#shiftStack.pop(); 24 | } 25 | 26 | first(): T | undefined { 27 | return this.#shiftStack.length !== 0 28 | ? this.#shiftStack[this.#shiftStack.length - 1] 29 | : this.#pushStack[0]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/queue_microtask.ts: -------------------------------------------------------------------------------- 1 | // queueMicrotask() ponyfill 2 | // https://github.com/libsql/libsql-client-ts/issues/47 3 | let _queueMicrotask: (callback: () => void) => void; 4 | if (typeof queueMicrotask !== "undefined") { 5 | _queueMicrotask = queueMicrotask; 6 | } else { 7 | const resolved = Promise.resolve(); 8 | _queueMicrotask = (callback) => { 9 | resolved.then(callback); 10 | }; 11 | } 12 | 13 | export { _queueMicrotask as queueMicrotask }; 14 | -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | import { ClientError, ProtoError, ResponseError } from "./errors.js"; 2 | import type * as proto from "./shared/proto.js"; 3 | import type { Value, IntMode } from "./value.js"; 4 | import { valueFromProto } from "./value.js"; 5 | 6 | /** Result of executing a database statement. */ 7 | export interface StmtResult { 8 | /** Number of rows that were changed by the statement. This is meaningful only if the statement was an 9 | * INSERT, UPDATE or DELETE, and the value is otherwise undefined. */ 10 | affectedRowCount: number; 11 | /** The ROWID of the last successful insert into a rowid table. This is a 64-big signed integer. For other 12 | * statements than INSERTs into a rowid table, the value is not specified. */ 13 | lastInsertRowid: bigint | undefined; 14 | /** Names of columns in the result. */ 15 | columnNames: Array; 16 | /** Declared types of columns in the result. */ 17 | columnDecltypes: Array; 18 | } 19 | 20 | /** An array of rows returned by a database statement. */ 21 | export interface RowsResult extends StmtResult { 22 | /** The returned rows. */ 23 | rows: Array; 24 | } 25 | 26 | /** A single row returned by a database statement. */ 27 | export interface RowResult extends StmtResult { 28 | /** The returned row. If the query produced zero rows, this is `undefined`. */ 29 | row: Row | undefined, 30 | } 31 | 32 | /** A single value returned by a database statement. */ 33 | export interface ValueResult extends StmtResult { 34 | /** The returned value. If the query produced zero rows or zero columns, this is `undefined`. */ 35 | value: Value | undefined, 36 | } 37 | 38 | /** Row returned from the database. This is an Array-like object (it has `length` and can be indexed with a 39 | * number), and in addition, it has enumerable properties from the named columns. */ 40 | export interface Row { 41 | length: number; 42 | [index: number]: Value; 43 | [name: string]: Value; 44 | } 45 | 46 | export function stmtResultFromProto(result: proto.StmtResult): StmtResult { 47 | return { 48 | affectedRowCount: result.affectedRowCount, 49 | lastInsertRowid: result.lastInsertRowid, 50 | columnNames: result.cols.map(col => col.name), 51 | columnDecltypes: result.cols.map(col => col.decltype), 52 | }; 53 | } 54 | 55 | export function rowsResultFromProto(result: proto.StmtResult, intMode: IntMode): RowsResult { 56 | const stmtResult = stmtResultFromProto(result); 57 | const rows = result.rows.map(row => rowFromProto(stmtResult.columnNames, row, intMode)); 58 | return {...stmtResult, rows}; 59 | } 60 | 61 | export function rowResultFromProto(result: proto.StmtResult, intMode: IntMode): RowResult { 62 | const stmtResult = stmtResultFromProto(result); 63 | let row: Row | undefined; 64 | if (result.rows.length > 0) { 65 | row = rowFromProto(stmtResult.columnNames, result.rows[0], intMode); 66 | } 67 | return {...stmtResult, row}; 68 | } 69 | 70 | export function valueResultFromProto(result: proto.StmtResult, intMode: IntMode): ValueResult { 71 | const stmtResult = stmtResultFromProto(result); 72 | let value: Value | undefined; 73 | if (result.rows.length > 0 && stmtResult.columnNames.length > 0) { 74 | value = valueFromProto(result.rows[0][0], intMode); 75 | } 76 | return {...stmtResult, value}; 77 | } 78 | 79 | function rowFromProto( 80 | colNames: Array, 81 | values: Array, 82 | intMode: IntMode, 83 | ): Row { 84 | const row = {}; 85 | // make sure that the "length" property is not enumerable 86 | Object.defineProperty(row, "length", { value: values.length }); 87 | for (let i = 0; i < values.length; ++i) { 88 | const value = valueFromProto(values[i], intMode); 89 | Object.defineProperty(row, i, { value }); 90 | 91 | const colName = colNames[i]; 92 | if (colName !== undefined && !Object.hasOwn(row, colName)) { 93 | Object.defineProperty(row, colName, { value, enumerable: true, configurable: true, writable: true }); 94 | } 95 | } 96 | return row as Row; 97 | } 98 | 99 | export function errorFromProto(error: proto.Error): ResponseError { 100 | return new ResponseError(error.message, error); 101 | } 102 | -------------------------------------------------------------------------------- /src/shared/json_decode.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from "js-base64"; 2 | 3 | import { ProtoError } from "../errors.js"; 4 | import * as d from "../encoding/json/decode.js"; 5 | import * as proto from "./proto.js"; 6 | 7 | export function Error(obj: d.Obj): proto.Error { 8 | const message = d.string(obj["message"]); 9 | const code = d.stringOpt(obj["code"]); 10 | return {message, code}; 11 | } 12 | 13 | 14 | 15 | export function StmtResult(obj: d.Obj): proto.StmtResult { 16 | const cols = d.arrayObjectsMap(obj["cols"], Col); 17 | const rows = d.array(obj["rows"]).map((rowObj) => d.arrayObjectsMap(rowObj, Value)); 18 | const affectedRowCount = d.number(obj["affected_row_count"]); 19 | const lastInsertRowidStr = d.stringOpt(obj["last_insert_rowid"]); 20 | const lastInsertRowid = lastInsertRowidStr !== undefined 21 | ? BigInt(lastInsertRowidStr) : undefined; 22 | return {cols, rows, affectedRowCount, lastInsertRowid}; 23 | } 24 | 25 | function Col(obj: d.Obj): proto.Col { 26 | const name = d.stringOpt(obj["name"]); 27 | const decltype = d.stringOpt(obj["decltype"]); 28 | return {name, decltype}; 29 | } 30 | 31 | 32 | export function BatchResult(obj: d.Obj): proto.BatchResult { 33 | const stepResults = new Map(); 34 | d.array(obj["step_results"]).forEach((value, i) => { 35 | if (value !== null) { 36 | stepResults.set(i, StmtResult(d.object(value))); 37 | } 38 | }); 39 | 40 | const stepErrors = new Map(); 41 | d.array(obj["step_errors"]).forEach((value, i) => { 42 | if (value !== null) { 43 | stepErrors.set(i, Error(d.object(value))); 44 | } 45 | }); 46 | 47 | return {stepResults, stepErrors}; 48 | } 49 | 50 | 51 | export function CursorEntry(obj: d.Obj): proto.CursorEntry { 52 | const type = d.string(obj["type"]); 53 | if (type === "step_begin") { 54 | const step = d.number(obj["step"]); 55 | const cols = d.arrayObjectsMap(obj["cols"], Col); 56 | return {type: "step_begin", step, cols}; 57 | } else if (type === "step_end") { 58 | const affectedRowCount = d.number(obj["affected_row_count"]); 59 | const lastInsertRowidStr = d.stringOpt(obj["last_insert_rowid"]); 60 | const lastInsertRowid = lastInsertRowidStr !== undefined 61 | ? BigInt(lastInsertRowidStr) : undefined; 62 | return {type: "step_end", affectedRowCount, lastInsertRowid}; 63 | } else if (type === "step_error") { 64 | const step = d.number(obj["step"]); 65 | const error = Error(d.object(obj["error"])); 66 | return {type: "step_error", step, error}; 67 | } else if (type === "row") { 68 | const row = d.arrayObjectsMap(obj["row"], Value); 69 | return {type: "row", row}; 70 | } else if (type === "error") { 71 | const error = Error(d.object(obj["error"])); 72 | return {type: "error", error}; 73 | } else { 74 | throw new ProtoError("Unexpected type of CursorEntry"); 75 | } 76 | } 77 | 78 | 79 | 80 | export function DescribeResult(obj: d.Obj): proto.DescribeResult { 81 | const params = d.arrayObjectsMap(obj["params"], DescribeParam); 82 | const cols = d.arrayObjectsMap(obj["cols"], DescribeCol); 83 | const isExplain = d.boolean(obj["is_explain"]); 84 | const isReadonly = d.boolean(obj["is_readonly"]); 85 | return {params, cols, isExplain, isReadonly}; 86 | } 87 | 88 | function DescribeParam(obj: d.Obj): proto.DescribeParam { 89 | const name = d.stringOpt(obj["name"]); 90 | return {name}; 91 | } 92 | 93 | function DescribeCol(obj: d.Obj): proto.DescribeCol { 94 | const name = d.string(obj["name"]); 95 | const decltype = d.stringOpt(obj["decltype"]); 96 | return {name, decltype}; 97 | } 98 | 99 | 100 | 101 | export function Value(obj: d.Obj): proto.Value { 102 | const type = d.string(obj["type"]); 103 | if (type === "null") { 104 | return null; 105 | } else if (type === "integer") { 106 | const value = d.string(obj["value"]); 107 | return BigInt(value); 108 | } else if (type === "float") { 109 | return d.number(obj["value"]); 110 | } else if (type === "text") { 111 | return d.string(obj["value"]); 112 | } else if (type === "blob") { 113 | return Base64.toUint8Array(d.string(obj["base64"])); 114 | } else { 115 | throw new ProtoError("Unexpected type of Value"); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/shared/json_encode.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from "js-base64"; 2 | 3 | import * as e from "../encoding/json/encode.js"; 4 | import { impossible } from "../util.js"; 5 | 6 | import * as proto from "./proto.js"; 7 | 8 | export function Stmt(w: e.ObjectWriter, msg: proto.Stmt): void { 9 | if (msg.sql !== undefined) { w.string("sql", msg.sql); } 10 | if (msg.sqlId !== undefined) { w.number("sql_id", msg.sqlId); } 11 | w.arrayObjects("args", msg.args, Value); 12 | w.arrayObjects("named_args", msg.namedArgs, NamedArg); 13 | w.boolean("want_rows", msg.wantRows); 14 | } 15 | 16 | function NamedArg(w: e.ObjectWriter, msg: proto.NamedArg): void { 17 | w.string("name", msg.name); 18 | w.object("value", msg.value, Value); 19 | } 20 | 21 | export function Batch(w: e.ObjectWriter, msg: proto.Batch): void { 22 | w.arrayObjects("steps", msg.steps, BatchStep); 23 | } 24 | 25 | function BatchStep(w: e.ObjectWriter, msg: proto.BatchStep): void { 26 | if (msg.condition !== undefined) { w.object("condition", msg.condition, BatchCond); } 27 | w.object("stmt", msg.stmt, Stmt); 28 | } 29 | 30 | function BatchCond(w: e.ObjectWriter, msg: proto.BatchCond): void { 31 | w.stringRaw("type", msg.type); 32 | if (msg.type === "ok" || msg.type === "error") { 33 | w.number("step", msg.step); 34 | } else if (msg.type === "not") { 35 | w.object("cond", msg.cond, BatchCond); 36 | } else if (msg.type === "and" || msg.type === "or") { 37 | w.arrayObjects("conds", msg.conds, BatchCond); 38 | } else if (msg.type === "is_autocommit") { 39 | // do nothing 40 | } else { 41 | throw impossible(msg, "Impossible type of BatchCond"); 42 | } 43 | } 44 | 45 | function Value(w: e.ObjectWriter, msg: proto.Value): void { 46 | if (msg === null) { 47 | w.stringRaw("type", "null"); 48 | } else if (typeof msg === "bigint") { 49 | w.stringRaw("type", "integer"); 50 | w.stringRaw("value", ""+msg); 51 | } else if (typeof msg === "number") { 52 | w.stringRaw("type", "float"); 53 | w.number("value", msg); 54 | } else if (typeof msg === "string") { 55 | w.stringRaw("type", "text"); 56 | w.string("value", msg); 57 | } else if (msg instanceof Uint8Array) { 58 | w.stringRaw("type", "blob"); 59 | w.stringRaw("base64", Base64.fromUint8Array(msg)); 60 | } else if (msg === undefined) { 61 | // do nothing 62 | } else { 63 | throw impossible(msg, "Impossible type of Value"); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/shared/proto.ts: -------------------------------------------------------------------------------- 1 | // Types for the protocol structures that are shared for WebSocket and HTTP 2 | 3 | export type int32 = number; 4 | export type uint32 = number; 5 | 6 | // Errors 7 | 8 | export type Error = { 9 | message: string, 10 | code: string | undefined, 11 | } 12 | 13 | // Statements 14 | 15 | export type Stmt = { 16 | sql: string | undefined, 17 | sqlId: int32 | undefined, 18 | args: Array, 19 | namedArgs: Array, 20 | wantRows: boolean, 21 | } 22 | 23 | export type NamedArg = { 24 | name: string, 25 | value: Value, 26 | } 27 | 28 | export type StmtResult = { 29 | cols: Array, 30 | rows: Array>, 31 | affectedRowCount: number, 32 | lastInsertRowid: bigint | undefined, 33 | } 34 | 35 | export type Col = { 36 | name: string | undefined, 37 | decltype: string | undefined, 38 | } 39 | 40 | // Batches 41 | 42 | export type Batch = { 43 | steps: Array, 44 | } 45 | 46 | export type BatchStep = { 47 | condition: BatchCond | undefined, 48 | stmt: Stmt, 49 | } 50 | 51 | export type BatchCond = 52 | | { type: "ok", step: uint32 } 53 | | { type: "error", step: uint32 } 54 | | { type: "not", cond: BatchCond } 55 | | { type: "and", conds: Array } 56 | | { type: "or", conds: Array } 57 | | { type: "is_autocommit" } 58 | 59 | export type BatchResult = { 60 | stepResults: Map, 61 | stepErrors: Map, 62 | } 63 | 64 | // Cursor entries 65 | 66 | export type CursorEntry = 67 | | { type: "none" } 68 | | StepBeginEntry 69 | | StepEndEntry 70 | | StepErrorEntry 71 | | RowEntry 72 | | ErrorEntry 73 | 74 | export type StepBeginEntry = { 75 | type: "step_begin", 76 | step: uint32, 77 | cols: Array, 78 | } 79 | 80 | export type StepEndEntry = { 81 | type: "step_end", 82 | affectedRowCount: number, 83 | lastInsertRowid: bigint | undefined, 84 | } 85 | 86 | export type StepErrorEntry = { 87 | type: "step_error", 88 | step: uint32, 89 | error: Error, 90 | } 91 | 92 | export type RowEntry = { 93 | type: "row", 94 | row: Array, 95 | } 96 | 97 | export type ErrorEntry = { 98 | type: "error", 99 | error: Error, 100 | } 101 | 102 | // Describe 103 | 104 | export type DescribeResult = { 105 | params: Array, 106 | cols: Array, 107 | isExplain: boolean, 108 | isReadonly: boolean, 109 | } 110 | 111 | export type DescribeParam = { 112 | name: string | undefined, 113 | } 114 | 115 | export type DescribeCol = { 116 | name: string, 117 | decltype: string | undefined, 118 | } 119 | 120 | // Values 121 | 122 | // NOTE: contrary to other enum structures, we don't wrap every `Value` in an 123 | // object with `type` property, because there might be a lot of `Value` 124 | // instances and we don't want to create an unnecessary object for each one 125 | export type Value = 126 | | undefined 127 | | null 128 | | bigint 129 | | number 130 | | string 131 | | Uint8Array 132 | -------------------------------------------------------------------------------- /src/shared/protobuf_decode.ts: -------------------------------------------------------------------------------- 1 | import * as d from "../encoding/protobuf/decode.js"; 2 | import { ProtoError } from "../errors.js"; 3 | 4 | import * as proto from "./proto.js"; 5 | 6 | export const Error: d.MessageDef = { 7 | default() { return {message: "", code: undefined}; }, 8 | 1 (r, msg) { msg.message = r.string(); }, 9 | 2 (r, msg) { msg.code = r.string(); }, 10 | }; 11 | 12 | 13 | 14 | export const StmtResult: d.MessageDef = { 15 | default() { 16 | return { 17 | cols: [], 18 | rows: [], 19 | affectedRowCount: 0, 20 | lastInsertRowid: undefined, 21 | }; 22 | }, 23 | 1 (r, msg) { msg.cols.push(r.message(Col)) }, 24 | 2 (r, msg) { msg.rows.push(r.message(Row)) }, 25 | 3 (r, msg) { msg.affectedRowCount = Number(r.uint64()) }, 26 | 4 (r, msg) { msg.lastInsertRowid = r.sint64() }, 27 | }; 28 | 29 | const Col: d.MessageDef = { 30 | default() { return {name: undefined, decltype: undefined} }, 31 | 1 (r, msg) { msg.name = r.string() }, 32 | 2 (r, msg) { msg.decltype = r.string() }, 33 | }; 34 | 35 | const Row: d.MessageDef> = { 36 | default() { return [] }, 37 | 1 (r, msg) { msg.push(r.message(Value)); }, 38 | }; 39 | 40 | 41 | 42 | export const BatchResult: d.MessageDef = { 43 | default() { return {stepResults: new Map(), stepErrors: new Map()} }, 44 | 1 (r, msg) { 45 | const [key, value] = r.message(BatchResultStepResult); 46 | msg.stepResults.set(key, value); 47 | }, 48 | 2 (r, msg) { 49 | const [key, value] = r.message(BatchResultStepError); 50 | msg.stepErrors.set(key, value); 51 | }, 52 | }; 53 | 54 | const BatchResultStepResult: d.MessageDef<[number, proto.StmtResult]> = { 55 | default() { return [0, StmtResult.default()] }, 56 | 1 (r, msg) { msg[0] = r.uint32() }, 57 | 2 (r, msg) { msg[1] = r.message(StmtResult) }, 58 | }; 59 | 60 | const BatchResultStepError: d.MessageDef<[number, proto.Error]> = { 61 | default() { return [0, Error.default()] }, 62 | 1 (r, msg) { msg[0] = r.uint32() }, 63 | 2 (r, msg) { msg[1] = r.message(Error) }, 64 | }; 65 | 66 | 67 | 68 | export const CursorEntry: d.MessageDef = { 69 | default() { return {type: "none"} }, 70 | 1 (r) { return r.message(StepBeginEntry) }, 71 | 2 (r) { return r.message(StepEndEntry) }, 72 | 3 (r) { return r.message(StepErrorEntry) }, 73 | 4 (r) { return {type: "row", row: r.message(Row)} }, 74 | 5 (r) { return {type: "error", error: r.message(Error)} }, 75 | }; 76 | 77 | const StepBeginEntry: d.MessageDef = { 78 | default() { return {type: "step_begin", step: 0, cols: []} }, 79 | 1 (r, msg) { msg.step = r.uint32() }, 80 | 2 (r, msg) { msg.cols.push(r.message(Col)) }, 81 | }; 82 | 83 | const StepEndEntry: d.MessageDef = { 84 | default() { 85 | return { 86 | type: "step_end", 87 | affectedRowCount: 0, 88 | lastInsertRowid: undefined, 89 | } 90 | }, 91 | 1 (r, msg) { msg.affectedRowCount = r.uint32() }, 92 | 2 (r, msg) { msg.lastInsertRowid = r.uint64() }, 93 | }; 94 | 95 | const StepErrorEntry: d.MessageDef = { 96 | default() { 97 | return { 98 | type: "step_error", 99 | step: 0, 100 | error: Error.default(), 101 | } 102 | }, 103 | 1 (r, msg) { msg.step = r.uint32() }, 104 | 2 (r, msg) { msg.error = r.message(Error) }, 105 | }; 106 | 107 | 108 | 109 | export const DescribeResult: d.MessageDef = { 110 | default() { 111 | return { 112 | params: [], 113 | cols: [], 114 | isExplain: false, 115 | isReadonly: false, 116 | } 117 | }, 118 | 1 (r, msg) { msg.params.push(r.message(DescribeParam)) }, 119 | 2 (r, msg) { msg.cols.push(r.message(DescribeCol)) }, 120 | 3 (r, msg) { msg.isExplain = r.bool() }, 121 | 4 (r, msg) { msg.isReadonly = r.bool() }, 122 | }; 123 | 124 | const DescribeParam: d.MessageDef = { 125 | default() { return {name: undefined} }, 126 | 1 (r, msg) { msg.name = r.string() }, 127 | }; 128 | 129 | const DescribeCol: d.MessageDef = { 130 | default() { return {name: "", decltype: undefined} }, 131 | 1 (r, msg) { msg.name = r.string() }, 132 | 2 (r, msg) { msg.decltype = r.string() }, 133 | }; 134 | 135 | 136 | 137 | const Value: d.MessageDef = { 138 | default() { return undefined }, 139 | 1 (r) { return null }, 140 | 2 (r) { return r.sint64() }, 141 | 3 (r) { return r.double() }, 142 | 4 (r) { return r.string() }, 143 | 5 (r) { return r.bytes() }, 144 | }; 145 | -------------------------------------------------------------------------------- /src/shared/protobuf_encode.ts: -------------------------------------------------------------------------------- 1 | import * as e from "../encoding/protobuf/encode.js"; 2 | import { impossible } from "../util.js"; 3 | 4 | import * as proto from "./proto.js" 5 | 6 | export function Stmt(w: e.MessageWriter, msg: proto.Stmt): void { 7 | if (msg.sql !== undefined) { w.string(1, msg.sql); } 8 | if (msg.sqlId !== undefined) { w.int32(2, msg.sqlId); } 9 | for (const arg of msg.args) { w.message(3, arg, Value); } 10 | for (const arg of msg.namedArgs) { w.message(4, arg, NamedArg); } 11 | w.bool(5, msg.wantRows); 12 | } 13 | 14 | function NamedArg(w: e.MessageWriter, msg: proto.NamedArg): void { 15 | w.string(1, msg.name); 16 | w.message(2, msg.value, Value); 17 | } 18 | 19 | export function Batch(w: e.MessageWriter, msg: proto.Batch): void { 20 | for (const step of msg.steps) { w.message(1, step, BatchStep); } 21 | } 22 | 23 | function BatchStep(w: e.MessageWriter, msg: proto.BatchStep): void { 24 | if (msg.condition !== undefined) { w.message(1, msg.condition, BatchCond); } 25 | w.message(2, msg.stmt, Stmt); 26 | } 27 | 28 | function BatchCond(w: e.MessageWriter, msg: proto.BatchCond): void { 29 | if (msg.type === "ok") { 30 | w.uint32(1, msg.step); 31 | } else if (msg.type === "error") { 32 | w.uint32(2, msg.step); 33 | } else if (msg.type === "not") { 34 | w.message(3, msg.cond, BatchCond); 35 | } else if (msg.type === "and") { 36 | w.message(4, msg.conds, BatchCondList); 37 | } else if (msg.type === "or") { 38 | w.message(5, msg.conds, BatchCondList); 39 | } else if (msg.type === "is_autocommit") { 40 | w.message(6, undefined, Empty); 41 | } else { 42 | throw impossible(msg, "Impossible type of BatchCond"); 43 | } 44 | } 45 | 46 | function BatchCondList(w: e.MessageWriter, msg: Array): void { 47 | for (const cond of msg) { w.message(1, cond, BatchCond); } 48 | } 49 | 50 | function Value(w: e.MessageWriter, msg: proto.Value): void { 51 | if (msg === null) { 52 | w.message(1, undefined, Empty); 53 | } else if (typeof msg === "bigint") { 54 | w.sint64(2, msg); 55 | } else if (typeof msg === "number") { 56 | w.double(3, msg); 57 | } else if (typeof msg === "string") { 58 | w.string(4, msg); 59 | } else if (msg instanceof Uint8Array) { 60 | w.bytes(5, msg); 61 | } else if (msg === undefined) { 62 | // do nothing 63 | } else { 64 | throw impossible(msg, "Impossible type of Value"); 65 | } 66 | } 67 | 68 | function Empty(_w: e.MessageWriter, _msg: undefined): void { 69 | // do nothing 70 | } 71 | -------------------------------------------------------------------------------- /src/sql.ts: -------------------------------------------------------------------------------- 1 | import { ClientError, ClosedError, MisuseError } from "./errors.js"; 2 | 3 | /** A SQL text that you can send to the database. Either a string or a reference to SQL text that is cached on 4 | * the server. */ 5 | export type InSql = string | Sql; 6 | 7 | export interface SqlOwner { 8 | /** Cache a SQL text on the server. This requires protocol version 2 or higher. */ 9 | storeSql(sql: string): Sql; 10 | 11 | /** @private */ 12 | _closeSql(sqlId: number): void; 13 | } 14 | 15 | /** Text of an SQL statement cached on the server. */ 16 | export class Sql { 17 | #owner: SqlOwner; 18 | #sqlId: number; 19 | #closed: Error | undefined; 20 | 21 | /** @private */ 22 | constructor(owner: SqlOwner, sqlId: number) { 23 | this.#owner = owner; 24 | this.#sqlId = sqlId; 25 | this.#closed = undefined; 26 | } 27 | 28 | /** @private */ 29 | _getSqlId(owner: SqlOwner): number { 30 | if (this.#owner !== owner) { 31 | throw new MisuseError("Attempted to use SQL text opened with other object"); 32 | } else if (this.#closed !== undefined) { 33 | throw new ClosedError("SQL text is closed", this.#closed); 34 | } 35 | return this.#sqlId; 36 | } 37 | 38 | /** Remove the SQL text from the server, releasing resouces. */ 39 | close(): void { 40 | this._setClosed(new ClientError("SQL text was manually closed")); 41 | } 42 | 43 | /** @private */ 44 | _setClosed(error: Error): void { 45 | if (this.#closed === undefined) { 46 | this.#closed = error; 47 | this.#owner._closeSql(this.#sqlId); 48 | } 49 | } 50 | 51 | /** True if the SQL text is closed (removed from the server). */ 52 | get closed() { 53 | return this.#closed !== undefined; 54 | } 55 | } 56 | 57 | export type ProtoSql = { 58 | sql?: string, 59 | sqlId?: number, 60 | }; 61 | 62 | export function sqlToProto(owner: SqlOwner, sql: InSql): ProtoSql { 63 | if (sql instanceof Sql) { 64 | return {sqlId: sql._getSqlId(owner)}; 65 | } else { 66 | return {sql: ""+sql}; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/stmt.ts: -------------------------------------------------------------------------------- 1 | import type * as proto from "./shared/proto.js"; 2 | import type { InSql, SqlOwner } from "./sql.js"; 3 | import { sqlToProto } from "./sql.js"; 4 | import type { InValue } from "./value.js"; 5 | import { valueToProto } from "./value.js"; 6 | 7 | /** A statement that you can send to the database. Statements are represented by the {@link Stmt} class, but 8 | * as a shorthand, you can specify an SQL text without arguments, or a tuple with the SQL text and positional 9 | * or named arguments. 10 | */ 11 | export type InStmt = 12 | | Stmt 13 | | InSql 14 | | [InSql, InStmtArgs]; 15 | 16 | /** Arguments for a statement. Either an array that is bound to parameters by position, or an object with 17 | * values that are bound to parameters by name. */ 18 | export type InStmtArgs = Array | Record; 19 | 20 | /** A statement that can be evaluated by the database. Besides the SQL text, it also contains the positional 21 | * and named arguments. */ 22 | export class Stmt { 23 | /** The SQL statement text. */ 24 | sql: InSql; 25 | /** @private */ 26 | _args: Array; 27 | /** @private */ 28 | _namedArgs: Map; 29 | 30 | /** Initialize the statement with given SQL text. */ 31 | constructor(sql: InSql) { 32 | this.sql = sql; 33 | this._args = []; 34 | this._namedArgs = new Map(); 35 | } 36 | 37 | /** Binds positional parameters from the given `values`. All previous positional bindings are cleared. */ 38 | bindIndexes(values: Iterable): this { 39 | this._args.length = 0; 40 | for (const value of values) { 41 | this._args.push(valueToProto(value)); 42 | } 43 | return this; 44 | } 45 | 46 | /** Binds a parameter by a 1-based index. */ 47 | bindIndex(index: number, value: InValue): this { 48 | if (index !== (index|0) || index <= 0) { 49 | throw new RangeError("Index of a positional argument must be positive integer"); 50 | } 51 | 52 | while (this._args.length < index) { 53 | this._args.push(null); 54 | } 55 | this._args[index - 1] = valueToProto(value); 56 | 57 | return this; 58 | } 59 | 60 | /** Binds a parameter by name. */ 61 | bindName(name: string, value: InValue): this { 62 | this._namedArgs.set(name, valueToProto(value)); 63 | return this; 64 | } 65 | 66 | /** Clears all bindings. */ 67 | unbindAll(): this { 68 | this._args.length = 0; 69 | this._namedArgs.clear(); 70 | return this; 71 | } 72 | } 73 | 74 | export function stmtToProto( 75 | sqlOwner: SqlOwner, 76 | stmt: InStmt, 77 | wantRows: boolean, 78 | ): proto.Stmt { 79 | let inSql: InSql; 80 | let args: Array = []; 81 | let namedArgs: Array = []; 82 | if (stmt instanceof Stmt) { 83 | inSql = stmt.sql; 84 | args = stmt._args; 85 | for (const [name, value] of stmt._namedArgs.entries()) { 86 | namedArgs.push({name, value}); 87 | } 88 | } else if (Array.isArray(stmt)) { 89 | inSql = stmt[0]; 90 | if (Array.isArray(stmt[1])) { 91 | args = stmt[1].map((arg) => valueToProto(arg)); 92 | } else { 93 | namedArgs = Object.entries(stmt[1]).map(([name, value]) => { 94 | return {name, value: valueToProto(value)}; 95 | }); 96 | } 97 | } else { 98 | inSql = stmt; 99 | } 100 | 101 | const {sql, sqlId} = sqlToProto(sqlOwner, inSql); 102 | return {sql, sqlId, args, namedArgs, wantRows}; 103 | } 104 | 105 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | import { Batch } from "./batch.js"; 2 | import type { Client } from "./client.js"; 3 | import type { Cursor } from "./cursor.js"; 4 | import type { DescribeResult } from "./describe.js"; 5 | import { describeResultFromProto } from "./describe.js"; 6 | import type { RowsResult, RowResult, ValueResult, StmtResult } from "./result.js"; 7 | import { 8 | stmtResultFromProto, rowsResultFromProto, 9 | rowResultFromProto, valueResultFromProto, 10 | } from "./result.js"; 11 | import type * as proto from "./shared/proto.js"; 12 | import type { InSql, SqlOwner, ProtoSql } from "./sql.js"; 13 | import { sqlToProto } from "./sql.js"; 14 | import type { InStmt } from "./stmt.js"; 15 | import { stmtToProto } from "./stmt.js"; 16 | import type { IntMode } from "./value.js"; 17 | 18 | /** A stream for executing SQL statements (a "database connection"). */ 19 | export abstract class Stream { 20 | /** @private */ 21 | constructor(intMode: IntMode) { 22 | this.intMode = intMode; 23 | } 24 | 25 | /** Get the client object that this stream belongs to. */ 26 | abstract client(): Client; 27 | 28 | /** @private */ 29 | abstract _sqlOwner(): SqlOwner; 30 | /** @private */ 31 | abstract _execute(stmt: proto.Stmt): Promise; 32 | /** @private */ 33 | abstract _batch(batch: proto.Batch): Promise; 34 | /** @private */ 35 | abstract _describe(protoSql: ProtoSql): Promise; 36 | /** @private */ 37 | abstract _sequence(protoSql: ProtoSql): Promise; 38 | /** @private */ 39 | abstract _openCursor(batch: proto.Batch): Promise; 40 | 41 | /** Execute a statement and return rows. */ 42 | query(stmt: InStmt): Promise { 43 | return this.#execute(stmt, true, rowsResultFromProto); 44 | } 45 | 46 | /** Execute a statement and return at most a single row. */ 47 | queryRow(stmt: InStmt): Promise { 48 | return this.#execute(stmt, true, rowResultFromProto); 49 | } 50 | 51 | /** Execute a statement and return at most a single value. */ 52 | queryValue(stmt: InStmt): Promise { 53 | return this.#execute(stmt, true, valueResultFromProto); 54 | } 55 | 56 | /** Execute a statement without returning rows. */ 57 | run(stmt: InStmt): Promise { 58 | return this.#execute(stmt, false, stmtResultFromProto); 59 | } 60 | 61 | #execute( 62 | inStmt: InStmt, 63 | wantRows: boolean, 64 | fromProto: (result: proto.StmtResult, intMode: IntMode) => T, 65 | ): Promise { 66 | const stmt = stmtToProto(this._sqlOwner(), inStmt, wantRows); 67 | return this._execute(stmt).then((r) => fromProto(r, this.intMode)); 68 | } 69 | 70 | /** Return a builder for creating and executing a batch. 71 | * 72 | * If `useCursor` is true, the batch will be executed using a Hrana cursor, which will stream results from 73 | * the server to the client, which consumes less memory on the server. This requires protocol version 3 or 74 | * higher. 75 | */ 76 | batch(useCursor: boolean = false): Batch { 77 | return new Batch(this, useCursor); 78 | } 79 | 80 | /** Parse and analyze a statement. This requires protocol version 2 or higher. */ 81 | describe(inSql: InSql): Promise { 82 | const protoSql = sqlToProto(this._sqlOwner(), inSql); 83 | return this._describe(protoSql).then(describeResultFromProto); 84 | } 85 | 86 | /** Execute a sequence of statements separated by semicolons. This requires protocol version 2 or higher. 87 | * */ 88 | sequence(inSql: InSql): Promise { 89 | const protoSql = sqlToProto(this._sqlOwner(), inSql); 90 | return this._sequence(protoSql); 91 | } 92 | 93 | /** Check whether the SQL connection underlying this stream is in autocommit state (i.e., outside of an 94 | * explicit transaction). This requires protocol version 3 or higher. 95 | */ 96 | abstract getAutocommit(): Promise; 97 | 98 | /** Immediately close the stream. 99 | * 100 | * This closes the stream immediately, aborting any pending operations. 101 | */ 102 | abstract close(): void; 103 | 104 | /** Gracefully close the stream. 105 | * 106 | * After calling this method, you will not be able to start new operations, but existing operations will 107 | * complete. 108 | */ 109 | abstract closeGracefully(): void; 110 | 111 | /** True if the stream is closed or closing. 112 | * 113 | * If you call {@link closeGracefully}, this will become true immediately, even if the underlying stream 114 | * is not physically closed yet. 115 | */ 116 | abstract get closed(): boolean; 117 | 118 | /** Representation of integers returned from the database. See {@link IntMode}. 119 | * 120 | * This value affects the results of all operations on this stream. 121 | */ 122 | intMode: IntMode; 123 | } 124 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { InternalError } from "./errors.js"; 2 | 3 | export function impossible(value: never, message: string): Error { 4 | throw new InternalError(message); 5 | } 6 | -------------------------------------------------------------------------------- /src/value.ts: -------------------------------------------------------------------------------- 1 | import { ClientError, ProtoError, MisuseError } from "./errors.js"; 2 | import type * as proto from "./shared/proto.js"; 3 | import { impossible } from "./util.js"; 4 | 5 | /** JavaScript values that you can receive from the database in a statement result. */ 6 | export type Value = 7 | | null 8 | | string 9 | | number 10 | | bigint 11 | | ArrayBuffer 12 | 13 | /** JavaScript values that you can send to the database as an argument. */ 14 | export type InValue = 15 | | Value 16 | | boolean 17 | | Uint8Array 18 | | Date 19 | | RegExp 20 | | object 21 | 22 | /** Possible representations of SQLite integers in JavaScript: 23 | * 24 | * - `"number"` (default): returns SQLite integers as JavaScript `number`-s (double precision floats). 25 | * `number` cannot precisely represent integers larger than 2^53-1 in absolute value, so attempting to read 26 | * larger integers will throw a `RangeError`. 27 | * - `"bigint"`: returns SQLite integers as JavaScript `bigint`-s (arbitrary precision integers). Bigints can 28 | * precisely represent all SQLite integers. 29 | * - `"string"`: returns SQLite integers as strings. 30 | */ 31 | export type IntMode = "number" | "bigint" | "string"; 32 | 33 | export function valueToProto(value: InValue): proto.Value { 34 | if (value === null) { 35 | return null; 36 | } else if (typeof value === "string") { 37 | return value; 38 | } else if (typeof value === "number") { 39 | if (!Number.isFinite(value)) { 40 | throw new RangeError("Only finite numbers (not Infinity or NaN) can be passed as arguments"); 41 | } 42 | return value; 43 | } else if (typeof value === "bigint") { 44 | if (value < minInteger || value > maxInteger) { 45 | throw new RangeError( 46 | "This bigint value is too large to be represented as a 64-bit integer and passed as argument" 47 | ); 48 | } 49 | return value; 50 | } else if (typeof value === "boolean") { 51 | return value ? 1n : 0n; 52 | } else if (value instanceof ArrayBuffer) { 53 | return new Uint8Array(value); 54 | } else if (value instanceof Uint8Array) { 55 | return value; 56 | } else if (value instanceof Date) { 57 | return +value.valueOf(); 58 | } else if (typeof value === "object") { 59 | return ""+value.toString(); 60 | } else { 61 | throw new TypeError("Unsupported type of value"); 62 | } 63 | } 64 | 65 | const minInteger = -9223372036854775808n; 66 | const maxInteger = 9223372036854775807n; 67 | 68 | export function valueFromProto(value: proto.Value, intMode: IntMode): Value { 69 | if (value === null) { 70 | return null; 71 | } else if (typeof value === "number") { 72 | return value; 73 | } else if (typeof value === "string") { 74 | return value; 75 | } else if (typeof value === "bigint") { 76 | if (intMode === "number") { 77 | const num = Number(value); 78 | if (!Number.isSafeInteger(num)) { 79 | throw new RangeError( 80 | "Received integer which is too large to be safely represented as a JavaScript number" 81 | ); 82 | } 83 | return num; 84 | } else if (intMode === "bigint") { 85 | return value; 86 | } else if (intMode === "string") { 87 | return ""+value; 88 | } else { 89 | throw new MisuseError("Invalid value for IntMode"); 90 | } 91 | } else if (value instanceof Uint8Array) { 92 | // TODO: we need to copy data from `Uint8Array` to return an `ArrayBuffer`. Perhaps we should add a 93 | // `blobMode` parameter, similar to `intMode`, which would allow the user to choose between receiving 94 | // `ArrayBuffer` (default, convenient) and `Uint8Array` (zero copy)? 95 | return value.slice().buffer; 96 | } else if (value === undefined) { 97 | throw new ProtoError("Received unrecognized type of Value"); 98 | } else { 99 | throw impossible(value, "Impossible type of Value"); 100 | } 101 | } 102 | 103 | -------------------------------------------------------------------------------- /src/ws/client.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket } from "@libsql/isomorphic-ws"; 2 | 3 | import type { ProtocolVersion, ProtocolEncoding } from "../client.js"; 4 | import { Client } from "../client.js"; 5 | import { 6 | readJsonObject, writeJsonObject, readProtobufMessage, writeProtobufMessage, 7 | } from "../encoding/index.js"; 8 | import { 9 | ClientError, ProtoError, ClosedError, WebSocketError, ProtocolVersionError, 10 | InternalError, MisuseError, 11 | } from "../errors.js"; 12 | import { IdAlloc } from "../id_alloc.js"; 13 | import { errorFromProto } from "../result.js"; 14 | import { Sql, SqlOwner } from "../sql.js"; 15 | import { impossible } from "../util.js"; 16 | 17 | import type * as proto from "./proto.js"; 18 | import { WsStream } from "./stream.js"; 19 | 20 | import { ClientMsg as json_ClientMsg } from "./json_encode.js"; 21 | import { ClientMsg as protobuf_ClientMsg } from "./protobuf_encode.js"; 22 | import { ServerMsg as json_ServerMsg } from "./json_decode.js"; 23 | import { ServerMsg as protobuf_ServerMsg } from "./protobuf_decode.js"; 24 | 25 | export type Subprotocol = { 26 | version: ProtocolVersion, 27 | encoding: ProtocolEncoding, 28 | }; 29 | 30 | export const subprotocolsV2: Map = new Map([ 31 | ["hrana2", {version: 2, encoding: "json"}], 32 | ["hrana1", {version: 1, encoding: "json"}], 33 | ]); 34 | 35 | export const subprotocolsV3: Map = new Map([ 36 | ["hrana3-protobuf", {version: 3, encoding: "protobuf"}], 37 | ["hrana3", {version: 3, encoding: "json"}], 38 | ["hrana2", {version: 2, encoding: "json"}], 39 | ["hrana1", {version: 1, encoding: "json"}], 40 | ]); 41 | 42 | /** A client for the Hrana protocol over a WebSocket. */ 43 | export class WsClient extends Client implements SqlOwner { 44 | #socket: WebSocket; 45 | // List of callbacks that we queue until the socket transitions from the CONNECTING to the OPEN state. 46 | #openCallbacks: Array; 47 | // Have we already transitioned from CONNECTING to OPEN and fired the callbacks in #openCallbacks? 48 | #opened: boolean; 49 | // Stores the error that caused us to close the client (and the socket). If we are not closed, this is 50 | // `undefined`. 51 | #closed: Error | undefined; 52 | 53 | // Have we received a response to our "hello" from the server? 54 | #recvdHello: boolean; 55 | // Subprotocol negotiated with the server. It is only available after the socket transitions to the OPEN 56 | // state. 57 | #subprotocol: Subprotocol | undefined; 58 | // Has the `getVersion()` function been called? This is only used to validate that the API is used 59 | // correctly. 60 | #getVersionCalled: boolean; 61 | // A map from request id to the responses that we expect to receive from the server. 62 | #responseMap: Map; 63 | // An allocator of request ids. 64 | #requestIdAlloc: IdAlloc; 65 | 66 | // An allocator of stream ids. 67 | /** @private */ 68 | _streamIdAlloc: IdAlloc; 69 | // An allocator of cursor ids. 70 | /** @private */ 71 | _cursorIdAlloc: IdAlloc; 72 | // An allocator of SQL text ids. 73 | #sqlIdAlloc: IdAlloc; 74 | 75 | /** @private */ 76 | constructor(socket: WebSocket, jwt: string | undefined) { 77 | super(); 78 | 79 | this.#socket = socket; 80 | this.#openCallbacks = []; 81 | this.#opened = false; 82 | this.#closed = undefined; 83 | 84 | this.#recvdHello = false; 85 | this.#subprotocol = undefined; 86 | this.#getVersionCalled = false; 87 | this.#responseMap = new Map(); 88 | 89 | this.#requestIdAlloc = new IdAlloc(); 90 | this._streamIdAlloc = new IdAlloc(); 91 | this._cursorIdAlloc = new IdAlloc(); 92 | this.#sqlIdAlloc = new IdAlloc(); 93 | 94 | this.#socket.binaryType = "arraybuffer"; 95 | this.#socket.addEventListener("open", () => this.#onSocketOpen()); 96 | this.#socket.addEventListener("close", (event) => this.#onSocketClose(event)); 97 | this.#socket.addEventListener("error", (event) => this.#onSocketError(event)); 98 | this.#socket.addEventListener("message", (event) => this.#onSocketMessage(event)); 99 | 100 | this.#send({type: "hello", jwt}); 101 | } 102 | 103 | // Send (or enqueue to send) a message to the server. 104 | #send(msg: proto.ClientMsg): void { 105 | if (this.#closed !== undefined) { 106 | throw new InternalError("Trying to send a message on a closed client"); 107 | } 108 | 109 | if (this.#opened) { 110 | this.#sendToSocket(msg); 111 | } else { 112 | const openCallback = () => this.#sendToSocket(msg); 113 | const errorCallback = () => undefined; 114 | this.#openCallbacks.push({openCallback, errorCallback}); 115 | } 116 | } 117 | 118 | // The socket transitioned from CONNECTING to OPEN 119 | #onSocketOpen(): void { 120 | const protocol = this.#socket.protocol; 121 | if (protocol === undefined) { 122 | this.#setClosed(new ClientError( 123 | "The `WebSocket.protocol` property is undefined. This most likely means that the WebSocket " + 124 | "implementation provided by the environment is broken. If you are using Miniflare 2, " + 125 | "please update to Miniflare 3, which fixes this problem." 126 | )); 127 | return; 128 | } else if (protocol === "") { 129 | this.#subprotocol = {version: 1, encoding: "json"}; 130 | } else { 131 | this.#subprotocol = subprotocolsV3.get(protocol); 132 | if (this.#subprotocol === undefined) { 133 | this.#setClosed(new ProtoError( 134 | `Unrecognized WebSocket subprotocol: ${JSON.stringify(protocol)}`, 135 | )); 136 | return; 137 | } 138 | } 139 | 140 | for (const callbacks of this.#openCallbacks) { 141 | callbacks.openCallback(); 142 | } 143 | this.#openCallbacks.length = 0; 144 | this.#opened = true; 145 | } 146 | 147 | #sendToSocket(msg: proto.ClientMsg): void { 148 | const encoding = this.#subprotocol!.encoding; 149 | if (encoding === "json") { 150 | const jsonMsg = writeJsonObject(msg, json_ClientMsg); 151 | this.#socket.send(jsonMsg); 152 | } else if (encoding === "protobuf") { 153 | const protobufMsg = writeProtobufMessage(msg, protobuf_ClientMsg); 154 | this.#socket.send(protobufMsg); 155 | } else { 156 | throw impossible(encoding, "Impossible encoding"); 157 | } 158 | } 159 | 160 | /** Get the protocol version negotiated with the server, possibly waiting until the socket is open. */ 161 | override getVersion(): Promise { 162 | return new Promise((versionCallback, errorCallback) => { 163 | this.#getVersionCalled = true; 164 | if (this.#closed !== undefined) { 165 | errorCallback(this.#closed); 166 | } else if (!this.#opened) { 167 | const openCallback = () => versionCallback(this.#subprotocol!.version); 168 | this.#openCallbacks.push({openCallback, errorCallback}); 169 | } else { 170 | versionCallback(this.#subprotocol!.version); 171 | } 172 | }); 173 | } 174 | 175 | // Make sure that the negotiated version is at least `minVersion`. 176 | /** @private */ 177 | override _ensureVersion(minVersion: ProtocolVersion, feature: string): void { 178 | if (this.#subprotocol === undefined || !this.#getVersionCalled) { 179 | throw new ProtocolVersionError( 180 | `${feature} is supported only on protocol version ${minVersion} and higher, ` + 181 | "but the version supported by the WebSocket server is not yet known. " + 182 | "Use Client.getVersion() to wait until the version is available.", 183 | ); 184 | } else if (this.#subprotocol.version < minVersion) { 185 | throw new ProtocolVersionError( 186 | `${feature} is supported on protocol version ${minVersion} and higher, ` + 187 | `but the WebSocket server only supports version ${this.#subprotocol.version}` 188 | ); 189 | } 190 | } 191 | 192 | // Send a request to the server and invoke a callback when we get the response. 193 | /** @private */ 194 | _sendRequest(request: proto.Request, callbacks: ResponseCallbacks) { 195 | if (this.#closed !== undefined) { 196 | callbacks.errorCallback(new ClosedError("Client is closed", this.#closed)); 197 | return; 198 | } 199 | 200 | const requestId = this.#requestIdAlloc.alloc(); 201 | this.#responseMap.set(requestId, {...callbacks, type: request.type}); 202 | this.#send({type: "request", requestId, request}); 203 | } 204 | 205 | // The socket encountered an error. 206 | #onSocketError(event: Event | WebSocket.ErrorEvent): void { 207 | const eventMessage = (event as {message?: string}).message; 208 | const message = eventMessage ?? "WebSocket was closed due to an error"; 209 | this.#setClosed(new WebSocketError(message)); 210 | } 211 | 212 | // The socket was closed. 213 | #onSocketClose(event: WebSocket.CloseEvent): void { 214 | let message = `WebSocket was closed with code ${event.code}`; 215 | if (event.reason) { 216 | message += `: ${event.reason}`; 217 | } 218 | this.#setClosed(new WebSocketError(message)); 219 | } 220 | 221 | // Close the client with the given error. 222 | #setClosed(error: Error): void { 223 | if (this.#closed !== undefined) { 224 | return; 225 | } 226 | this.#closed = error; 227 | 228 | for (const callbacks of this.#openCallbacks) { 229 | callbacks.errorCallback(error); 230 | } 231 | this.#openCallbacks.length = 0; 232 | 233 | for (const [requestId, responseState] of this.#responseMap.entries()) { 234 | responseState.errorCallback(error); 235 | this.#requestIdAlloc.free(requestId); 236 | } 237 | this.#responseMap.clear(); 238 | 239 | this.#socket.close(); 240 | } 241 | 242 | // We received a message from the socket. 243 | #onSocketMessage(event: WebSocket.MessageEvent): void { 244 | if (this.#closed !== undefined) { 245 | return; 246 | } 247 | 248 | try { 249 | let msg: proto.ServerMsg; 250 | 251 | const encoding = this.#subprotocol!.encoding; 252 | if (encoding === "json") { 253 | if (typeof event.data !== "string") { 254 | this.#socket.close(3003, "Only text messages are accepted with JSON encoding"); 255 | this.#setClosed(new ProtoError( 256 | "Received non-text message from server with JSON encoding")) 257 | return; 258 | } 259 | msg = readJsonObject(JSON.parse(event.data), json_ServerMsg); 260 | } else if (encoding === "protobuf") { 261 | if (!(event.data instanceof ArrayBuffer)) { 262 | this.#socket.close(3003, "Only binary messages are accepted with Protobuf encoding"); 263 | this.#setClosed(new ProtoError( 264 | "Received non-binary message from server with Protobuf encoding")) 265 | return; 266 | } 267 | msg = readProtobufMessage(new Uint8Array(event.data), protobuf_ServerMsg); 268 | } else { 269 | throw impossible(encoding, "Impossible encoding"); 270 | } 271 | 272 | this.#handleMsg(msg); 273 | } catch (e) { 274 | this.#socket.close(3007, "Could not handle message"); 275 | this.#setClosed(e as Error); 276 | } 277 | } 278 | 279 | // Handle a message from the server. 280 | #handleMsg(msg: proto.ServerMsg): void { 281 | if (msg.type === "none") { 282 | throw new ProtoError("Received an unrecognized ServerMsg"); 283 | } else if (msg.type === "hello_ok" || msg.type === "hello_error") { 284 | if (this.#recvdHello) { 285 | throw new ProtoError("Received a duplicated hello response"); 286 | } 287 | this.#recvdHello = true; 288 | 289 | if (msg.type === "hello_error") { 290 | throw errorFromProto(msg.error); 291 | } 292 | return; 293 | } else if (!this.#recvdHello) { 294 | throw new ProtoError("Received a non-hello message before a hello response"); 295 | } 296 | 297 | if (msg.type === "response_ok") { 298 | const requestId = msg.requestId; 299 | const responseState = this.#responseMap.get(requestId); 300 | this.#responseMap.delete(requestId); 301 | 302 | if (responseState === undefined) { 303 | throw new ProtoError("Received unexpected OK response"); 304 | } 305 | this.#requestIdAlloc.free(requestId); 306 | 307 | try { 308 | if (responseState.type !== msg.response.type) { 309 | console.dir({responseState, msg}); 310 | throw new ProtoError("Received unexpected type of response"); 311 | } 312 | responseState.responseCallback(msg.response); 313 | } catch (e) { 314 | responseState.errorCallback(e as Error); 315 | throw e; 316 | } 317 | } else if (msg.type === "response_error") { 318 | const requestId = msg.requestId; 319 | const responseState = this.#responseMap.get(requestId); 320 | this.#responseMap.delete(requestId); 321 | 322 | if (responseState === undefined) { 323 | throw new ProtoError("Received unexpected error response"); 324 | } 325 | this.#requestIdAlloc.free(requestId); 326 | 327 | responseState.errorCallback(errorFromProto(msg.error)); 328 | } else { 329 | throw impossible(msg, "Impossible ServerMsg type"); 330 | } 331 | } 332 | 333 | /** Open a {@link WsStream}, a stream for executing SQL statements. */ 334 | override openStream(): WsStream { 335 | return WsStream.open(this); 336 | } 337 | 338 | /** Cache a SQL text on the server. This requires protocol version 2 or higher. */ 339 | storeSql(sql: string): Sql { 340 | this._ensureVersion(2, "storeSql()"); 341 | 342 | const sqlId = this.#sqlIdAlloc.alloc(); 343 | const sqlObj = new Sql(this, sqlId); 344 | 345 | const responseCallback = () => undefined; 346 | const errorCallback = (e: Error) => sqlObj._setClosed(e); 347 | 348 | const request: proto.StoreSqlReq = {type: "store_sql", sqlId, sql}; 349 | this._sendRequest(request, {responseCallback, errorCallback}); 350 | return sqlObj; 351 | } 352 | 353 | /** @private */ 354 | _closeSql(sqlId: number): void { 355 | if (this.#closed !== undefined) { 356 | return; 357 | } 358 | 359 | const responseCallback = () => this.#sqlIdAlloc.free(sqlId); 360 | const errorCallback = (e: Error) => this.#setClosed(e); 361 | const request: proto.CloseSqlReq = {type: "close_sql", sqlId}; 362 | this._sendRequest(request, {responseCallback, errorCallback}); 363 | } 364 | 365 | /** Close the client and the WebSocket. */ 366 | override close(): void { 367 | this.#setClosed(new ClientError("Client was manually closed")); 368 | } 369 | 370 | /** True if the client is closed. */ 371 | override get closed(): boolean { 372 | return this.#closed !== undefined; 373 | } 374 | } 375 | 376 | export interface OpenCallbacks { 377 | openCallback: () => void; 378 | errorCallback: (_: Error) => void; 379 | } 380 | 381 | export interface ResponseCallbacks { 382 | responseCallback: (_: proto.Response) => void; 383 | errorCallback: (_: Error) => void; 384 | } 385 | 386 | interface ResponseState extends ResponseCallbacks { 387 | type: string; 388 | } 389 | -------------------------------------------------------------------------------- /src/ws/cursor.ts: -------------------------------------------------------------------------------- 1 | import { ClientError, ClosedError } from "../errors.js"; 2 | import { Cursor } from "../cursor.js"; 3 | import { Queue } from "../queue.js"; 4 | 5 | import type { WsClient } from "./client.js"; 6 | import type * as proto from "./proto.js"; 7 | import type { WsStream } from "./stream.js"; 8 | 9 | const fetchChunkSize = 1000; 10 | const fetchQueueSize = 10; 11 | 12 | export class WsCursor extends Cursor { 13 | #client: WsClient; 14 | #stream: WsStream; 15 | #cursorId: number; 16 | 17 | #entryQueue: Queue; 18 | #fetchQueue: Queue>; 19 | #closed: Error | undefined; 20 | #done: boolean; 21 | 22 | /** @private */ 23 | constructor(client: WsClient, stream: WsStream, cursorId: number) { 24 | super(); 25 | this.#client = client; 26 | this.#stream = stream; 27 | this.#cursorId = cursorId; 28 | 29 | this.#entryQueue = new Queue(); 30 | this.#fetchQueue = new Queue(); 31 | this.#closed = undefined; 32 | this.#done = false; 33 | } 34 | 35 | /** Fetch the next entry from the cursor. */ 36 | override async next(): Promise { 37 | for (;;) { 38 | if (this.#closed !== undefined) { 39 | throw new ClosedError("Cursor is closed", this.#closed); 40 | } 41 | 42 | while (!this.#done && this.#fetchQueue.length < fetchQueueSize) { 43 | this.#fetchQueue.push(this.#fetch()); 44 | } 45 | 46 | const entry = this.#entryQueue.shift(); 47 | if (this.#done || entry !== undefined) { 48 | return entry; 49 | } 50 | 51 | // we assume that `Cursor.next()` is never called concurrently 52 | await this.#fetchQueue.shift()!.then((response) => { 53 | if (response === undefined) { 54 | return; 55 | } 56 | for (const entry of response.entries) { 57 | this.#entryQueue.push(entry); 58 | } 59 | this.#done ||= response.done; 60 | }); 61 | } 62 | } 63 | 64 | #fetch(): Promise { 65 | return this.#stream._sendCursorRequest(this, { 66 | type: "fetch_cursor", 67 | cursorId: this.#cursorId, 68 | maxCount: fetchChunkSize, 69 | }).then( 70 | (resp: proto.Response) => resp as proto.FetchCursorResp, 71 | (error) => { 72 | this._setClosed(error); 73 | return undefined; 74 | }, 75 | ); 76 | } 77 | 78 | /** @private */ 79 | _setClosed(error: Error): void { 80 | if (this.#closed !== undefined) { 81 | return; 82 | } 83 | this.#closed = error; 84 | 85 | this.#stream._sendCursorRequest(this, { 86 | type: "close_cursor", 87 | cursorId: this.#cursorId, 88 | }).catch(() => undefined); 89 | this.#stream._cursorClosed(this); 90 | } 91 | 92 | /** Close the cursor. */ 93 | override close(): void { 94 | this._setClosed(new ClientError("Cursor was manually closed")); 95 | } 96 | 97 | /** True if the cursor is closed. */ 98 | override get closed(): boolean { 99 | return this.#closed !== undefined; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ws/json_decode.ts: -------------------------------------------------------------------------------- 1 | import { ProtoError } from "../errors.js"; 2 | import * as d from "../encoding/json/decode.js"; 3 | import { Error, StmtResult, BatchResult, CursorEntry, DescribeResult } from "../shared/json_decode.js"; 4 | import * as proto from "./proto.js"; 5 | 6 | 7 | export function ServerMsg(obj: d.Obj): proto.ServerMsg { 8 | const type = d.string(obj["type"]); 9 | if (type === "hello_ok") { 10 | return {type: "hello_ok"}; 11 | } else if (type === "hello_error") { 12 | const error = Error(d.object(obj["error"])); 13 | return {type: "hello_error", error}; 14 | } else if (type === "response_ok") { 15 | const requestId = d.number(obj["request_id"]); 16 | const response = Response(d.object(obj["response"])); 17 | return {type: "response_ok", requestId, response}; 18 | } else if (type === "response_error") { 19 | const requestId = d.number(obj["request_id"]); 20 | const error = Error(d.object(obj["error"])); 21 | return {type: "response_error", requestId, error}; 22 | } else { 23 | throw new ProtoError("Unexpected type of ServerMsg"); 24 | } 25 | } 26 | 27 | function Response(obj: d.Obj): proto.Response { 28 | const type = d.string(obj["type"]); 29 | if (type === "open_stream") { 30 | return {type: "open_stream"}; 31 | } else if (type === "close_stream") { 32 | return {type: "close_stream"}; 33 | } else if (type === "execute") { 34 | const result = StmtResult(d.object(obj["result"])); 35 | return {type: "execute", result}; 36 | } else if (type === "batch") { 37 | const result = BatchResult(d.object(obj["result"])); 38 | return {type: "batch", result}; 39 | } else if (type === "open_cursor") { 40 | return {type: "open_cursor"}; 41 | } else if (type === "close_cursor") { 42 | return {type: "close_cursor"}; 43 | } else if (type === "fetch_cursor") { 44 | const entries = d.arrayObjectsMap(obj["entries"], CursorEntry); 45 | const done = d.boolean(obj["done"]); 46 | return {type: "fetch_cursor", entries, done}; 47 | } else if (type === "sequence") { 48 | return {type: "sequence"}; 49 | } else if (type === "describe") { 50 | const result = DescribeResult(d.object(obj["result"])); 51 | return {type: "describe", result}; 52 | } else if (type === "store_sql") { 53 | return {type: "store_sql"}; 54 | } else if (type === "close_sql") { 55 | return {type: "close_sql"}; 56 | } else if (type === "get_autocommit") { 57 | const isAutocommit = d.boolean(obj["is_autocommit"]); 58 | return {type: "get_autocommit", isAutocommit}; 59 | } else { 60 | throw new ProtoError("Unexpected type of Response"); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ws/json_encode.ts: -------------------------------------------------------------------------------- 1 | import * as e from "../encoding/json/encode.js"; 2 | import { Stmt, Batch } from "../shared/json_encode.js"; 3 | import { impossible } from "../util.js"; 4 | import * as proto from "./proto.js"; 5 | 6 | export function ClientMsg(w: e.ObjectWriter, msg: proto.ClientMsg): void { 7 | w.stringRaw("type", msg.type); 8 | if (msg.type === "hello") { 9 | if (msg.jwt !== undefined) { w.string("jwt", msg.jwt); } 10 | } else if (msg.type === "request") { 11 | w.number("request_id", msg.requestId); 12 | w.object("request", msg.request, Request); 13 | } else { 14 | throw impossible(msg, "Impossible type of ClientMsg"); 15 | } 16 | } 17 | 18 | function Request(w: e.ObjectWriter, msg: proto.Request): void { 19 | w.stringRaw("type", msg.type); 20 | if (msg.type === "open_stream") { 21 | w.number("stream_id", msg.streamId); 22 | } else if (msg.type === "close_stream") { 23 | w.number("stream_id", msg.streamId); 24 | } else if (msg.type === "execute") { 25 | w.number("stream_id", msg.streamId); 26 | w.object("stmt", msg.stmt, Stmt); 27 | } else if (msg.type === "batch") { 28 | w.number("stream_id", msg.streamId); 29 | w.object("batch", msg.batch, Batch); 30 | } else if (msg.type === "open_cursor") { 31 | w.number("stream_id", msg.streamId); 32 | w.number("cursor_id", msg.cursorId); 33 | w.object("batch", msg.batch, Batch); 34 | } else if (msg.type === "close_cursor") { 35 | w.number("cursor_id", msg.cursorId); 36 | } else if (msg.type === "fetch_cursor") { 37 | w.number("cursor_id", msg.cursorId); 38 | w.number("max_count", msg.maxCount); 39 | } else if (msg.type === "sequence") { 40 | w.number("stream_id", msg.streamId); 41 | if (msg.sql !== undefined) { w.string("sql", msg.sql); } 42 | if (msg.sqlId !== undefined) { w.number("sql_id", msg.sqlId); } 43 | } else if (msg.type === "describe") { 44 | w.number("stream_id", msg.streamId); 45 | if (msg.sql !== undefined) { w.string("sql", msg.sql); } 46 | if (msg.sqlId !== undefined) { w.number("sql_id", msg.sqlId); } 47 | } else if (msg.type === "store_sql") { 48 | w.number("sql_id", msg.sqlId); 49 | w.string("sql", msg.sql); 50 | } else if (msg.type === "close_sql") { 51 | w.number("sql_id", msg.sqlId); 52 | } else if (msg.type === "get_autocommit") { 53 | w.number("stream_id", msg.streamId); 54 | } else { 55 | throw impossible(msg, "Impossible type of Request"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ws/proto.ts: -------------------------------------------------------------------------------- 1 | // Types for the structures specific to Hrana over WebSockets. 2 | 3 | export * from "../shared/proto.js"; 4 | import { 5 | int32, uint32, Error, Stmt, StmtResult, 6 | Batch, BatchResult, CursorEntry, DescribeResult, 7 | } from "../shared/proto.js"; 8 | 9 | export type ClientMsg = 10 | | HelloMsg 11 | | RequestMsg 12 | 13 | export type ServerMsg = 14 | | { type: "none" } 15 | | HelloOkMsg 16 | | HelloErrorMsg 17 | | ResponseOkMsg 18 | | ResponseErrorMsg 19 | 20 | // Hello 21 | 22 | export type HelloMsg = { 23 | type: "hello", 24 | jwt: string | undefined, 25 | } 26 | 27 | export type HelloOkMsg = { 28 | type: "hello_ok", 29 | } 30 | 31 | export type HelloErrorMsg = { 32 | type: "hello_error", 33 | error: Error, 34 | } 35 | 36 | // Request/response 37 | 38 | export type RequestMsg = { 39 | type: "request", 40 | requestId: int32, 41 | request: Request, 42 | } 43 | 44 | export type ResponseOkMsg = { 45 | type: "response_ok", 46 | requestId: int32, 47 | response: Response, 48 | } 49 | 50 | export type ResponseErrorMsg = { 51 | type: "response_error", 52 | requestId: int32, 53 | error: Error, 54 | } 55 | 56 | // Requests 57 | 58 | export type Request = 59 | | OpenStreamReq 60 | | CloseStreamReq 61 | | ExecuteReq 62 | | BatchReq 63 | | OpenCursorReq 64 | | CloseCursorReq 65 | | FetchCursorReq 66 | | SequenceReq 67 | | DescribeReq 68 | | StoreSqlReq 69 | | CloseSqlReq 70 | | GetAutocommitReq 71 | 72 | export type Response = 73 | | { type: "none" } 74 | | OpenStreamResp 75 | | CloseStreamResp 76 | | ExecuteResp 77 | | BatchResp 78 | | OpenCursorResp 79 | | CloseCursorResp 80 | | FetchCursorResp 81 | | SequenceResp 82 | | DescribeResp 83 | | StoreSqlResp 84 | | CloseSqlResp 85 | | GetAutocommitResp 86 | 87 | // Open stream 88 | 89 | export type OpenStreamReq = { 90 | type: "open_stream", 91 | streamId: int32, 92 | } 93 | 94 | export type OpenStreamResp = { 95 | type: "open_stream", 96 | } 97 | 98 | // Close stream 99 | 100 | export type CloseStreamReq = { 101 | type: "close_stream", 102 | streamId: int32, 103 | } 104 | 105 | export type CloseStreamResp = { 106 | type: "close_stream", 107 | } 108 | 109 | // Execute a statement 110 | 111 | export type ExecuteReq = { 112 | type: "execute", 113 | streamId: int32, 114 | stmt: Stmt, 115 | } 116 | 117 | export type ExecuteResp = { 118 | type: "execute", 119 | result: StmtResult, 120 | } 121 | 122 | // Execute a batch 123 | 124 | export type BatchReq = { 125 | type: "batch", 126 | streamId: int32, 127 | batch: Batch, 128 | } 129 | 130 | export type BatchResp = { 131 | type: "batch", 132 | result: BatchResult, 133 | } 134 | 135 | // Open a cursor executing a batch 136 | 137 | export type OpenCursorReq = { 138 | type: "open_cursor", 139 | streamId: int32, 140 | cursorId: int32, 141 | batch: Batch, 142 | } 143 | 144 | export type OpenCursorResp = { 145 | type: "open_cursor", 146 | } 147 | 148 | // Close a cursor 149 | 150 | export type CloseCursorReq = { 151 | type: "close_cursor", 152 | cursorId: int32, 153 | } 154 | 155 | export type CloseCursorResp = { 156 | type: "close_cursor", 157 | } 158 | 159 | 160 | // Fetch entries from a cursor 161 | 162 | export type FetchCursorReq = { 163 | type: "fetch_cursor", 164 | cursorId: int32, 165 | maxCount: uint32, 166 | } 167 | 168 | export type FetchCursorResp = { 169 | type: "fetch_cursor", 170 | entries: Array, 171 | done: boolean, 172 | } 173 | 174 | // Describe a statement 175 | 176 | export type DescribeReq = { 177 | type: "describe", 178 | streamId: int32, 179 | sql: string | undefined, 180 | sqlId: int32 | undefined, 181 | } 182 | 183 | export type DescribeResp = { 184 | type: "describe", 185 | result: DescribeResult, 186 | } 187 | 188 | // Execute a sequence of SQL statements 189 | 190 | export type SequenceReq = { 191 | type: "sequence", 192 | streamId: int32, 193 | sql: string | undefined, 194 | sqlId: int32 | undefined, 195 | } 196 | 197 | export type SequenceResp = { 198 | type: "sequence", 199 | } 200 | 201 | // Store an SQL text on the server 202 | 203 | export type StoreSqlReq = { 204 | type: "store_sql", 205 | sqlId: int32, 206 | sql: string, 207 | } 208 | 209 | export type StoreSqlResp = { 210 | type: "store_sql", 211 | } 212 | 213 | // Close a stored SQL text 214 | 215 | export type CloseSqlReq = { 216 | type: "close_sql", 217 | sqlId: int32, 218 | } 219 | 220 | export type CloseSqlResp = { 221 | type: "close_sql", 222 | } 223 | 224 | // Get the autocommit state 225 | 226 | export type GetAutocommitReq = { 227 | type: "get_autocommit", 228 | streamId: int32, 229 | } 230 | 231 | export type GetAutocommitResp = { 232 | type: "get_autocommit", 233 | isAutocommit: boolean, 234 | } 235 | -------------------------------------------------------------------------------- /src/ws/protobuf_decode.ts: -------------------------------------------------------------------------------- 1 | import * as d from "../encoding/protobuf/decode.js"; 2 | import { Error, StmtResult, BatchResult, CursorEntry, DescribeResult } from "../shared/protobuf_decode.js"; 3 | import * as proto from "./proto.js"; 4 | 5 | export const ServerMsg: d.MessageDef = { 6 | default() { return {type: "none"} }, 7 | 1 (r) { return {type: "hello_ok"} }, 8 | 2 (r) { return r.message(HelloErrorMsg) }, 9 | 3 (r) { return r.message(ResponseOkMsg) }, 10 | 4 (r) { return r.message(ResponseErrorMsg) }, 11 | }; 12 | 13 | const HelloErrorMsg: d.MessageDef = { 14 | default() { return {type: "hello_error", error: Error.default()} }, 15 | 1 (r, msg) { msg.error = r.message(Error) }, 16 | }; 17 | 18 | const ResponseErrorMsg: d.MessageDef = { 19 | default() { return {type: "response_error", requestId: 0, error: Error.default()} }, 20 | 1 (r, msg) { msg.requestId = r.int32() }, 21 | 2 (r, msg) { msg.error = r.message(Error) }, 22 | }; 23 | 24 | const ResponseOkMsg: d.MessageDef = { 25 | default() { 26 | return { 27 | type: "response_ok", 28 | requestId: 0, 29 | response: {type: "none"}, 30 | } 31 | }, 32 | 1 (r, msg) { msg.requestId = r.int32() }, 33 | 2 (r, msg) { msg.response = {type: "open_stream"} }, 34 | 3 (r, msg) { msg.response = {type: "close_stream"} }, 35 | 4 (r, msg) { msg.response = r.message(ExecuteResp) }, 36 | 5 (r, msg) { msg.response = r.message(BatchResp) }, 37 | 6 (r, msg) { msg.response = {type: "open_cursor"} }, 38 | 7 (r, msg) { msg.response = {type: "close_cursor"} }, 39 | 8 (r, msg) { msg.response = r.message(FetchCursorResp) }, 40 | 9 (r, msg) { msg.response = {type: "sequence"} }, 41 | 10 (r, msg) { msg.response = r.message(DescribeResp) }, 42 | 11 (r, msg) { msg.response = {type: "store_sql"} }, 43 | 12 (r, msg) { msg.response = {type: "close_sql"} }, 44 | 13 (r, msg) { msg.response = r.message(GetAutocommitResp) }, 45 | }; 46 | 47 | const ExecuteResp: d.MessageDef = { 48 | default() { return {type: "execute", result: StmtResult.default()} }, 49 | 1 (r, msg) { msg.result = r.message(StmtResult) }, 50 | }; 51 | 52 | const BatchResp: d.MessageDef = { 53 | default() { return {type: "batch", result: BatchResult.default()} }, 54 | 1 (r, msg) { msg.result = r.message(BatchResult) }, 55 | }; 56 | 57 | const FetchCursorResp: d.MessageDef = { 58 | default() { return {type: "fetch_cursor", entries: [], done: false} }, 59 | 1 (r, msg) { msg.entries.push(r.message(CursorEntry)) }, 60 | 2 (r, msg) { msg.done = r.bool() }, 61 | }; 62 | 63 | const DescribeResp: d.MessageDef = { 64 | default() { return {type: "describe", result: DescribeResult.default()} }, 65 | 1 (r, msg) { msg.result = r.message(DescribeResult) }, 66 | }; 67 | 68 | const GetAutocommitResp: d.MessageDef = { 69 | default() { return {type: "get_autocommit", isAutocommit: false} }, 70 | 1 (r, msg) { msg.isAutocommit = r.bool() }, 71 | }; 72 | -------------------------------------------------------------------------------- /src/ws/protobuf_encode.ts: -------------------------------------------------------------------------------- 1 | import * as e from "../encoding/protobuf/encode.js"; 2 | import { Stmt, Batch } from "../shared/protobuf_encode.js"; 3 | import { impossible } from "../util.js"; 4 | import * as proto from "./proto.js"; 5 | 6 | export function ClientMsg(w: e.MessageWriter, msg: proto.ClientMsg): void { 7 | if (msg.type === "hello") { 8 | w.message(1, msg, HelloMsg); 9 | } else if (msg.type === "request") { 10 | w.message(2, msg, RequestMsg); 11 | } else { 12 | throw impossible(msg, "Impossible type of ClientMsg"); 13 | } 14 | } 15 | 16 | function HelloMsg(w: e.MessageWriter, msg: proto.HelloMsg): void { 17 | if (msg.jwt !== undefined) { w.string(1, msg.jwt); } 18 | } 19 | 20 | function RequestMsg(w: e.MessageWriter, msg: proto.RequestMsg): void { 21 | w.int32(1, msg.requestId); 22 | 23 | const request = msg.request; 24 | if (request.type === "open_stream") { 25 | w.message(2, request, OpenStreamReq); 26 | } else if (request.type === "close_stream") { 27 | w.message(3, request, CloseStreamReq); 28 | } else if (request.type === "execute") { 29 | w.message(4, request, ExecuteReq); 30 | } else if (request.type === "batch") { 31 | w.message(5, request, BatchReq); 32 | } else if (request.type === "open_cursor") { 33 | w.message(6, request, OpenCursorReq); 34 | } else if (request.type === "close_cursor") { 35 | w.message(7, request, CloseCursorReq); 36 | } else if (request.type === "fetch_cursor") { 37 | w.message(8, request, FetchCursorReq); 38 | } else if (request.type === "sequence") { 39 | w.message(9, request, SequenceReq); 40 | } else if (request.type === "describe") { 41 | w.message(10, request, DescribeReq); 42 | } else if (request.type === "store_sql") { 43 | w.message(11, request, StoreSqlReq); 44 | } else if (request.type === "close_sql") { 45 | w.message(12, request, CloseSqlReq); 46 | } else if (request.type === "get_autocommit") { 47 | w.message(13, request, GetAutocommitReq); 48 | } else { 49 | throw impossible(request, "Impossible type of Request"); 50 | } 51 | } 52 | 53 | function OpenStreamReq(w: e.MessageWriter, msg: proto.OpenStreamReq): void { 54 | w.int32(1, msg.streamId); 55 | } 56 | 57 | function CloseStreamReq(w: e.MessageWriter, msg: proto.CloseStreamReq): void { 58 | w.int32(1, msg.streamId); 59 | } 60 | 61 | function ExecuteReq(w: e.MessageWriter, msg: proto.ExecuteReq): void { 62 | w.int32(1, msg.streamId); 63 | w.message(2, msg.stmt, Stmt); 64 | } 65 | 66 | function BatchReq(w: e.MessageWriter, msg: proto.BatchReq): void { 67 | w.int32(1, msg.streamId); 68 | w.message(2, msg.batch, Batch); 69 | } 70 | 71 | function OpenCursorReq(w: e.MessageWriter, msg: proto.OpenCursorReq): void { 72 | w.int32(1, msg.streamId); 73 | w.int32(2, msg.cursorId); 74 | w.message(3, msg.batch, Batch); 75 | } 76 | 77 | function CloseCursorReq(w: e.MessageWriter, msg: proto.CloseCursorReq): void { 78 | w.int32(1, msg.cursorId); 79 | } 80 | 81 | function FetchCursorReq(w: e.MessageWriter, msg: proto.FetchCursorReq): void { 82 | w.int32(1, msg.cursorId); 83 | w.uint32(2, msg.maxCount); 84 | } 85 | 86 | function SequenceReq(w: e.MessageWriter, msg: proto.SequenceReq): void { 87 | w.int32(1, msg.streamId); 88 | if (msg.sql !== undefined) { w.string(2, msg.sql); } 89 | if (msg.sqlId !== undefined) { w.int32(3, msg.sqlId); } 90 | } 91 | 92 | function DescribeReq(w: e.MessageWriter, msg: proto.DescribeReq): void { 93 | w.int32(1, msg.streamId); 94 | if (msg.sql !== undefined) { w.string(2, msg.sql); } 95 | if (msg.sqlId !== undefined) { w.int32(3, msg.sqlId); } 96 | } 97 | 98 | function StoreSqlReq(w: e.MessageWriter, msg: proto.StoreSqlReq): void { 99 | w.int32(1, msg.sqlId); 100 | w.string(2, msg.sql); 101 | } 102 | 103 | function CloseSqlReq(w: e.MessageWriter, msg: proto.CloseSqlReq): void { 104 | w.int32(1, msg.sqlId); 105 | } 106 | 107 | function GetAutocommitReq(w: e.MessageWriter, msg: proto.GetAutocommitReq): void { 108 | w.int32(1, msg.streamId); 109 | } 110 | -------------------------------------------------------------------------------- /src/ws/stream.ts: -------------------------------------------------------------------------------- 1 | import { ClientError, ClosedError, InternalError } from "../errors.js"; 2 | import { Queue } from "../queue.js"; 3 | import type { SqlOwner, ProtoSql } from "../sql.js"; 4 | import { Stream } from "../stream.js"; 5 | 6 | import type { WsClient } from "./client.js"; 7 | import { WsCursor } from "./cursor.js"; 8 | import type * as proto from "./proto.js"; 9 | 10 | type QueueEntry = RequestEntry | CursorEntry; 11 | 12 | type RequestEntry = { 13 | type: "request", 14 | request: proto.Request, 15 | responseCallback: (_: proto.Response) => void; 16 | errorCallback: (_: Error) => void; 17 | } 18 | 19 | type CursorEntry = { 20 | type: "cursor", 21 | batch: proto.Batch, 22 | cursorCallback: (_: WsCursor) => void, 23 | errorCallback: (_: Error) => void, 24 | } 25 | 26 | export class WsStream extends Stream { 27 | #client: WsClient; 28 | #streamId: number; 29 | 30 | #queue: Queue; 31 | #cursor: WsCursor | undefined; 32 | #closing: boolean; 33 | #closed: Error | undefined; 34 | 35 | /** @private */ 36 | static open(client: WsClient): WsStream { 37 | const streamId = client._streamIdAlloc.alloc(); 38 | const stream = new WsStream(client, streamId); 39 | 40 | const responseCallback = () => undefined; 41 | const errorCallback = (e: Error) => stream.#setClosed(e); 42 | 43 | const request: proto.OpenStreamReq = {type: "open_stream", streamId}; 44 | client._sendRequest(request, {responseCallback, errorCallback}); 45 | return stream; 46 | } 47 | 48 | /** @private */ 49 | constructor(client: WsClient, streamId: number) { 50 | super(client.intMode); 51 | this.#client = client; 52 | this.#streamId = streamId; 53 | 54 | this.#queue = new Queue(); 55 | this.#cursor = undefined; 56 | this.#closing = false; 57 | this.#closed = undefined; 58 | } 59 | 60 | /** Get the {@link WsClient} object that this stream belongs to. */ 61 | override client(): WsClient { 62 | return this.#client; 63 | } 64 | 65 | /** @private */ 66 | override _sqlOwner(): SqlOwner { 67 | return this.#client; 68 | } 69 | 70 | /** @private */ 71 | override _execute(stmt: proto.Stmt): Promise { 72 | return this.#sendStreamRequest({ 73 | type: "execute", 74 | streamId: this.#streamId, 75 | stmt, 76 | }).then((response) => { 77 | return (response as proto.ExecuteResp).result; 78 | }); 79 | } 80 | 81 | /** @private */ 82 | override _batch(batch: proto.Batch): Promise { 83 | return this.#sendStreamRequest({ 84 | type: "batch", 85 | streamId: this.#streamId, 86 | batch, 87 | }).then((response) => { 88 | return (response as proto.BatchResp).result; 89 | }); 90 | } 91 | 92 | /** @private */ 93 | override _describe(protoSql: ProtoSql): Promise { 94 | this.#client._ensureVersion(2, "describe()"); 95 | return this.#sendStreamRequest({ 96 | type: "describe", 97 | streamId: this.#streamId, 98 | sql: protoSql.sql, 99 | sqlId: protoSql.sqlId, 100 | }).then((response) => { 101 | return (response as proto.DescribeResp).result; 102 | }); 103 | } 104 | 105 | /** @private */ 106 | override _sequence(protoSql: ProtoSql): Promise { 107 | this.#client._ensureVersion(2, "sequence()"); 108 | return this.#sendStreamRequest({ 109 | type: "sequence", 110 | streamId: this.#streamId, 111 | sql: protoSql.sql, 112 | sqlId: protoSql.sqlId, 113 | }).then((_response) => { 114 | return undefined; 115 | }); 116 | } 117 | 118 | /** Check whether the SQL connection underlying this stream is in autocommit state (i.e., outside of an 119 | * explicit transaction). This requires protocol version 3 or higher. 120 | */ 121 | override getAutocommit(): Promise { 122 | this.#client._ensureVersion(3, "getAutocommit()"); 123 | return this.#sendStreamRequest({ 124 | type: "get_autocommit", 125 | streamId: this.#streamId, 126 | }).then((response) => { 127 | return (response as proto.GetAutocommitResp).isAutocommit; 128 | }); 129 | } 130 | 131 | #sendStreamRequest(request: proto.Request): Promise { 132 | return new Promise((responseCallback, errorCallback) => { 133 | this.#pushToQueue({type: "request", request, responseCallback, errorCallback}); 134 | }); 135 | } 136 | 137 | /** @private */ 138 | override _openCursor(batch: proto.Batch): Promise { 139 | this.#client._ensureVersion(3, "cursor"); 140 | return new Promise((cursorCallback, errorCallback) => { 141 | this.#pushToQueue({type: "cursor", batch, cursorCallback, errorCallback}); 142 | }); 143 | } 144 | 145 | /** @private */ 146 | _sendCursorRequest(cursor: WsCursor, request: proto.Request): Promise { 147 | if (cursor !== this.#cursor) { 148 | throw new InternalError("Cursor not associated with the stream attempted to execute a request"); 149 | } 150 | return new Promise((responseCallback, errorCallback) => { 151 | if (this.#closed !== undefined) { 152 | errorCallback(new ClosedError("Stream is closed", this.#closed)); 153 | } else { 154 | this.#client._sendRequest(request, {responseCallback, errorCallback}); 155 | } 156 | }); 157 | } 158 | 159 | /** @private */ 160 | _cursorClosed(cursor: WsCursor): void { 161 | if (cursor !== this.#cursor) { 162 | throw new InternalError("Cursor was closed, but it was not associated with the stream"); 163 | } 164 | this.#cursor = undefined; 165 | this.#flushQueue(); 166 | } 167 | 168 | #pushToQueue(entry: QueueEntry): void { 169 | if (this.#closed !== undefined) { 170 | entry.errorCallback(new ClosedError("Stream is closed", this.#closed)); 171 | } else if (this.#closing) { 172 | entry.errorCallback(new ClosedError("Stream is closing", undefined)); 173 | } else { 174 | this.#queue.push(entry); 175 | this.#flushQueue(); 176 | } 177 | } 178 | 179 | #flushQueue(): void { 180 | for (;;) { 181 | const entry = this.#queue.first(); 182 | if (entry === undefined && this.#cursor === undefined && this.#closing) { 183 | this.#setClosed(new ClientError("Stream was gracefully closed")); 184 | break; 185 | } else if (entry?.type === "request" && this.#cursor === undefined) { 186 | const {request, responseCallback, errorCallback} = entry; 187 | this.#queue.shift(); 188 | 189 | this.#client._sendRequest(request, {responseCallback, errorCallback}); 190 | } else if (entry?.type === "cursor" && this.#cursor === undefined) { 191 | const {batch, cursorCallback} = entry; 192 | this.#queue.shift(); 193 | 194 | const cursorId = this.#client._cursorIdAlloc.alloc(); 195 | const cursor = new WsCursor(this.#client, this, cursorId); 196 | 197 | const request: proto.OpenCursorReq = { 198 | type: "open_cursor", 199 | streamId: this.#streamId, 200 | cursorId, 201 | batch, 202 | }; 203 | const responseCallback = () => undefined; 204 | const errorCallback = (e: Error) => cursor._setClosed(e); 205 | this.#client._sendRequest(request, {responseCallback, errorCallback}); 206 | 207 | this.#cursor = cursor; 208 | cursorCallback(cursor); 209 | } else { 210 | break; 211 | } 212 | } 213 | } 214 | 215 | #setClosed(error: Error): void { 216 | if (this.#closed !== undefined) { 217 | return; 218 | } 219 | this.#closed = error; 220 | 221 | if (this.#cursor !== undefined) { 222 | this.#cursor._setClosed(error); 223 | } 224 | 225 | for (;;) { 226 | const entry = this.#queue.shift(); 227 | if (entry !== undefined) { 228 | entry.errorCallback(error); 229 | } else { 230 | break; 231 | } 232 | } 233 | 234 | const request: proto.CloseStreamReq = {type: "close_stream", streamId: this.#streamId}; 235 | const responseCallback = () => this.#client._streamIdAlloc.free(this.#streamId); 236 | const errorCallback = () => undefined; 237 | this.#client._sendRequest(request, {responseCallback, errorCallback}); 238 | } 239 | 240 | /** Immediately close the stream. */ 241 | override close(): void { 242 | this.#setClosed(new ClientError("Stream was manually closed")); 243 | } 244 | 245 | /** Gracefully close the stream. */ 246 | override closeGracefully(): void { 247 | this.#closing = true; 248 | this.#flushQueue(); 249 | } 250 | 251 | /** True if the stream is closed or closing. */ 252 | override get closed(): boolean { 253 | return this.#closed !== undefined || this.#closing; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "lib": ["esnext"], 5 | "target": "esnext", 6 | 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | 10 | "rootDir": "src/", 11 | 12 | "strict": true 13 | }, 14 | "include": ["src/"], 15 | "exclude": ["**/__tests__"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.build-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": false, 6 | "outDir": "./lib-cjs/" 7 | } 8 | } 9 | 10 | -------------------------------------------------------------------------------- /tsconfig.build-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "declaration": true, 6 | "outDir": "./lib-esm/" 7 | } 8 | } 9 | 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "incremental": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "out": "docs", 4 | "excludePrivate": true, 5 | "excludeInternal": true, 6 | "visibilityFilters": { 7 | "inherited": true, 8 | "external": true 9 | }, 10 | "includeVersion": true 11 | } 12 | --------------------------------------------------------------------------------