From 3dd0a11bca079e75e7334efe84f3421dd9dba6cc Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:33:59 +0800 Subject: [PATCH 1/5] fix(credential-web): fix IndexedDB usage --- libraries/adb-credential-web/package.json | 6 +- libraries/adb-credential-web/src/manager.ts | 4 + .../src/storage/indexed-db/shared.spec.ts | 245 ++++++++++++++++++ .../src/storage/indexed-db/shared.ts | 128 ++++++++- .../src/storage/indexed-db/v1.ts | 27 +- .../src/storage/indexed-db/v2.ts | 59 +++-- .../src/storage/password.ts | 4 + .../src/storage/prf/storage.ts | 4 + .../adb-credential-web/src/storage/type.ts | 2 + .../adb-credential-web/tsconfig.test.json | 6 +- .../adb/src/daemon/auth/packet-processor.ts | 2 + libraries/struct/src/bipedal.spec.ts | 105 ++++++++ libraries/struct/src/bipedal.ts | 35 ++- libraries/struct/src/number.ts | 4 +- pnpm-lock.yaml | 18 ++ 15 files changed, 577 insertions(+), 72 deletions(-) create mode 100644 libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts create mode 100644 libraries/struct/src/bipedal.spec.ts diff --git a/libraries/adb-credential-web/package.json b/libraries/adb-credential-web/package.json index fb52b8b0e..63afa5d6b 100644 --- a/libraries/adb-credential-web/package.json +++ b/libraries/adb-credential-web/package.json @@ -27,7 +27,8 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test": "run-test" }, "dependencies": { "@yume-chan/adb": "workspace:*", @@ -36,8 +37,11 @@ "@yume-chan/struct": "workspace:*" }, "devDependencies": { + "@types/node": "^26.5.1", "@yume-chan/eslint-config": "workspace:^", + "@yume-chan/test-runner": "workspace:^", "@yume-chan/tsconfig": "workspace:^", + "fake-indexeddb": "^6.2.5", "prettier": "^3.9.6", "typescript": "^6.0.3" } diff --git a/libraries/adb-credential-web/src/manager.ts b/libraries/adb-credential-web/src/manager.ts index 64367b733..2c0b08447 100644 --- a/libraries/adb-credential-web/src/manager.ts +++ b/libraries/adb-credential-web/src/manager.ts @@ -79,4 +79,8 @@ export class AdbWebCryptoCredentialManager implements AdbCredentialManager { } } } + + close() { + return this.#storage.close?.(); + } } diff --git a/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts b/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts new file mode 100644 index 000000000..29c452076 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts @@ -0,0 +1,245 @@ +/* eslint-disable require-yield */ +import "fake-indexeddb/auto"; +import assert from "node:assert"; +import { describe, it } from "node:test"; + +import { createTransaction, openDatabase, waitRequest } from "./shared.js"; + +let id = 0; +function nextDatabaseName() { + id += 1; + return "database-" + id; +} + +describe("waitRequest", () => { + it("should resolve with the result of the request", async () => { + const request = indexedDB.open(nextDatabaseName(), 1); + const result = await waitRequest(request); + assert.strictEqual(result instanceof IDBDatabase, true); + result.close(); + }); + + it("should reject if the request fails", async () => { + const dbName = nextDatabaseName(); + + const db = await waitRequest(indexedDB.open(dbName, 2)); + db.close(); + + const request = indexedDB.open(dbName, 1); + await assert.rejects(waitRequest(request), /VersionError/); + }); +}); + +describe("openDatabase", () => { + it("should close the database when callback finishes", async () => { + let db: IDBDatabase; + await openDatabase( + nextDatabaseName(), + 1, + () => {}, + (db_) => { + db = db_; + return new Promise((resolve) => setTimeout(resolve, 2000)); + }, + ); + assert.throws(() => db.transaction("store"), /InvalidStateError/); + }); +}); + +describe("transaction", () => { + it("can't add requests asynchronously", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + const tx = db.transaction("store", "readwrite"); + const store = tx.objectStore("store"); + await waitRequest(store.put({ value: 1 })); + await new Promise(setImmediate); + assert.throws( + () => store.put({ value: 2 }), + /TransactionInactiveError/, + ); + db.close(); + }); +}); + +describe("createTransaction", () => { + it("should throw if callback throws", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await assert.rejects( + createTransaction(db, "store", function* () { + throw new Error("Test error"); + }), + /Test error/, + ); + db.close(); + }); + + it("should abort the transaction if callback throws", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await assert.rejects(() => + createTransaction( + db, + "store", + function* (_, store) { + yield store.add({ value: 1 }); + throw new Error("Test error"); + }, + { mode: "readwrite" }, + ), + ); + await createTransaction(db, "store", function* (_, store, waitRequest) { + // Verify the `.add` operation was not committed + const values = yield* waitRequest(store.getAll()); + assert.strictEqual(values.length, 0); + }); + db.close(); + }); + + it("should throw if callback throws after yielding", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await assert.rejects( + createTransaction(db, "store", function* (_, store) { + yield store.count(); + throw new Error("Test error after yielding"); + }), + /Test error after yielding/, + ); + db.close(); + }); + + it("yield should return request's result", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await createTransaction(db, "store", function* (_, store) { + const result = yield store.count(); + assert.strictEqual(result, 0); + }); + db.close(); + }); + + it("yield should throw if request fails", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { keyPath: "value" }); + }); + await assert.rejects(() => + createTransaction( + db, + "store", + function* (_, store) { + yield store.add({ value: 1 }); + try { + yield store.add({ value: 1 }); + assert.fail("Expected store.add to throw"); + } catch (e) { + assert.deepEqual((e as Error).name, "ConstraintError"); + throw e; + } + }, + { mode: "readwrite" }, + ), + ); + db.close(); + }); + + it("yield should return the value if it's not a request", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await createTransaction(db, "store", function* () { + const result = yield 42 as never; + assert.strictEqual(result, 42); + }); + db.close(); + }); + + it("waitRequest should return request's result", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await createTransaction(db, "store", function* (_, store, waitRequest) { + const result = yield* waitRequest(store.count()); + assert.strictEqual(result, 0); + }); + db.close(); + }); + + it("should allow sequential requests", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await createTransaction( + db, + "store", + function* (_, store) { + yield store.add({ value: 1 }); + yield store.add({ value: 2 }); + }, + { mode: "readwrite" }, + ); + await createTransaction(db, "store", function* (_, store, waitRequest) { + const values = yield* waitRequest( + store.getAll() as IDBRequest<{ value: number }[]>, + ); + assert.deepStrictEqual(values[0], { value: 1 }); + assert.deepStrictEqual(values[1], { value: 2 }); + }); + db.close(); + }); + + it("should continue if request error is handled", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { keyPath: "value" }); + }); + await createTransaction( + db, + "store", + function* (_, store) { + yield store.add({ value: 1 }); + try { + yield store.add({ value: 1 }); + } catch { + // ignore + } + yield store.add({ value: 2 }); + }, + { mode: "readwrite" }, + ); + await createTransaction(db, "store", function* (_, store, waitRequest) { + const values = yield* waitRequest( + store.getAll() as IDBRequest<{ value: number }[]>, + ); + assert.deepStrictEqual(values[0], { value: 1 }); + assert.deepStrictEqual(values[1], { value: 2 }); + }); + db.close(); + }); + + it("should ignore double transaction abortion", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { keyPath: "value" }); + }); + + await assert.rejects( + () => + createTransaction( + db, + "store", + function* (tx) { + tx.commit(); + throw new Error("Transaction aborted"); + }, + { mode: "readwrite" }, + ), + /Transaction aborted/, + ); + db.close(); + }); +}); diff --git a/libraries/adb-credential-web/src/storage/indexed-db/shared.ts b/libraries/adb-credential-web/src/storage/indexed-db/shared.ts index 74ddc9234..a8e359246 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/shared.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/shared.ts @@ -1,3 +1,12 @@ +/** + * Convert an IndexedDB request into a promise. + * + * Must not be used inside transactions. + * @param request The IndexedDB request to wait for. + * @returns + * A promise that resolves with the result of the request + * or rejects with the request's error. + */ export function waitRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onerror = () => { @@ -13,7 +22,19 @@ export async function openDatabase( name: string, version: number, onUpgrade: (db: IDBDatabase) => undefined, -): Promise { +): Promise; +export async function openDatabase( + name: string, + version: number, + onUpgrade: (db: IDBDatabase) => undefined, + callback: (db: IDBDatabase) => T, +): Promise; +export async function openDatabase( + name: string, + version: number, + onUpgrade: (db: IDBDatabase) => undefined, + callback?: (db: IDBDatabase) => T, +): Promise { const request = indexedDB.open(name, version); request.onupgradeneeded = () => { @@ -21,19 +42,88 @@ export async function openDatabase( onUpgrade(db); }; - return await waitRequest(request); + const db = await waitRequest(request); + + if (callback) { + try { + return await callback(db); + } finally { + db.close(); + } + } + + return db; +} + +function advance( + iterator: Generator, + { done, value }: IteratorResult, +) { + if (done) { + return Promise.resolve(value); + } + if (value instanceof IDBRequest) { + return new Promise((resolve, reject) => { + value.onsuccess = () => { + // The transaction is "active" only + // when handling a request's success or error events. + try { + resolve(advance(iterator, iterator.next(value.result))); + } catch (e) { + // Prevent automatic transaction abortion + // https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1 + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } + }; + value.onerror = (e) => { + try { + // Prevent automatic transaction abortion + // https://w3c.github.io/IndexedDB/#ref-for-canceled-flag%E2%91%A0 + e.preventDefault(); + + // Prevent event buddles to transaction's `onerror` + // https://w3c.github.io/IndexedDB/#ref-for-get-the-parent%E2%91%A1 + e.stopPropagation(); + + // Throw the error to the generator so it can handle it. + resolve(advance(iterator, iterator.throw(value.error))); + } catch (e) { + // Prevent automatic transaction abortion + // https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1 + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } + }; + }); + } + return advance(iterator, iterator.next(value)); +} + +function start(generator: Generator) { + return advance(generator, generator.next(undefined)); } export function createTransaction( database: IDBDatabase, storeName: string, - callback: (transaction: IDBTransaction) => T, + callback: ( + transaction: IDBTransaction, + store: IDBObjectStore, + waitRequest: ( + request: IDBRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Iterable, U, unknown>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Generator, T, unknown>, + options: IDBTransactionOptions & { mode: IDBTransactionMode } = { + mode: "readonly", + }, ): Promise { return new Promise((resolve, reject) => { - const transaction = database.transaction(storeName, "readwrite"); - transaction.onerror = () => { - reject(transaction.error!); - }; + const transaction = database.transaction(storeName, options.mode); let result!: T; transaction.oncomplete = () => { @@ -42,13 +132,10 @@ export function createTransaction( transaction.onabort = () => { reject(transaction.error ?? new Error("Transaction aborted")); }; + // `IDBTransaction`'s `error` event only receives bubbled events from its requests + // So it doesn't need to be listened to - try { - result = callback(transaction); - if (result instanceof Promise) { - throw new Error("callback must not be an async function"); - } - } catch (e) { + const handleError = (e: unknown) => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(e); @@ -57,6 +144,21 @@ export function createTransaction( } catch { // ignore } + }; + + try { + const iterator = callback( + transaction, + transaction.objectStore(storeName), + function* ( + request: IDBRequest, + ): Generator, U, U> { + return yield request; + }, + ); + start(iterator).then((value) => (result = value), handleError); + } catch (e) { + handleError(e); } }); } diff --git a/libraries/adb-credential-web/src/storage/indexed-db/v1.ts b/libraries/adb-credential-web/src/storage/indexed-db/v1.ts index 6f6276f7c..a8e6da5b8 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/v1.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/v1.ts @@ -1,4 +1,4 @@ -import { createTransaction, openDatabase, waitRequest } from "./shared.js"; +import { createTransaction, openDatabase } from "./shared.js"; export const DefaultDatabaseName = "Tango"; export const DefaultStoreName = "Authentication"; @@ -16,14 +16,19 @@ export async function getAllKeysV1() { return undefined; } - const db = await openDatabase(DefaultDatabaseName, Version1, () => {}); - - try { - return await createTransaction(db, DefaultStoreName, (tx) => { - const store = tx.objectStore(DefaultStoreName); - return waitRequest(store.getAll() as IDBRequest); - }); - } finally { - db.close(); - } + return await openDatabase( + DefaultDatabaseName, + Version1, + () => {}, + (db) => + createTransaction( + db, + DefaultStoreName, + function* (_, store, waitRequest) { + return yield* waitRequest( + store.getAll() as IDBRequest, + ); + }, + ), + ); } diff --git a/libraries/adb-credential-web/src/storage/indexed-db/v2.ts b/libraries/adb-credential-web/src/storage/indexed-db/v2.ts index 31d622528..7d24255b3 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/v2.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/v2.ts @@ -26,6 +26,8 @@ export class TangoIndexedDbStorage implements TangoKeyStorage { async #openDatabaseCore() { const v1Keys = await getAllKeysV1(); if (v1Keys) { + // V1 uses hardcoded database name, + // Delete the database for recreation await waitRequest(indexedDB.deleteDatabase(DefaultDatabaseName)); } @@ -55,41 +57,46 @@ export class TangoIndexedDbStorage implements TangoKeyStorage { ): Promise { const db = await this.#openDatabase(); - try { - await createTransaction(db, this.#storeName, (tx) => { - const store = tx.objectStore(this.#storeName); - store.add({ privateKey, name } satisfies TangoKey); - }); - } finally { - db.close(); - } + await createTransaction( + db, + this.#storeName, + function* (_, store) { + yield store.add({ privateKey, name } satisfies TangoKey); + }, + { mode: "readwrite" }, + ); } async *load(): AsyncGenerator { const db = await this.#openDatabase(); - try { - const keys = await createTransaction(db, this.#storeName, (tx) => { - const store = tx.objectStore(this.#storeName); - return waitRequest(store.getAll() as IDBRequest); - }); - - yield* keys; - } finally { - db.close(); - } + const keys = await createTransaction( + db, + this.#storeName, + function* (_, store, waitRequest) { + return yield* waitRequest( + store.getAll() as IDBRequest, + ); + }, + ); + + yield* keys; } async clear() { const db = await this.#openDatabase(); - try { - await createTransaction(db, this.#storeName, (tx) => { - const store = tx.objectStore(this.#storeName); - store.clear(); - }); - } finally { - db.close(); - } + await createTransaction( + db, + this.#storeName, + function* (_, store) { + yield store.clear(); + }, + { mode: "readwrite" }, + ); + } + + close() { + return this.#openDatabasePromise?.then((db) => void db.close()); } } diff --git a/libraries/adb-credential-web/src/storage/password.ts b/libraries/adb-credential-web/src/storage/password.ts index 45ea7c388..6f98ea419 100644 --- a/libraries/adb-credential-web/src/storage/password.ts +++ b/libraries/adb-credential-web/src/storage/password.ts @@ -233,6 +233,10 @@ export class TangoPasswordProtectedStorage implements TangoKeyStorage { } } } + + close() { + return this.#storage.close?.(); + } } export namespace TangoPasswordProtectedStorage { diff --git a/libraries/adb-credential-web/src/storage/prf/storage.ts b/libraries/adb-credential-web/src/storage/prf/storage.ts index 6f6e607b5..394c6af2f 100644 --- a/libraries/adb-credential-web/src/storage/prf/storage.ts +++ b/libraries/adb-credential-web/src/storage/prf/storage.ts @@ -188,4 +188,8 @@ export class TangoPrfStorage implements TangoKeyStorage { } } } + + close() { + return this.#storage.close?.(); + } } diff --git a/libraries/adb-credential-web/src/storage/type.ts b/libraries/adb-credential-web/src/storage/type.ts index 3b0987d55..5ba25e7f4 100644 --- a/libraries/adb-credential-web/src/storage/type.ts +++ b/libraries/adb-credential-web/src/storage/type.ts @@ -14,4 +14,6 @@ export interface TangoKeyStorage { load(): Iterable> | AsyncIterable>; + + close?(): MaybePromiseLike; } diff --git a/libraries/adb-credential-web/tsconfig.test.json b/libraries/adb-credential-web/tsconfig.test.json index e642aeaa4..70f0e0287 100644 --- a/libraries/adb-credential-web/tsconfig.test.json +++ b/libraries/adb-credential-web/tsconfig.test.json @@ -1,5 +1,9 @@ { "extends": "./tsconfig.build.json", - "compilerOptions": {}, + "compilerOptions": { + "types": [ + "node" + ] + }, "exclude": [] } diff --git a/libraries/adb/src/daemon/auth/packet-processor.ts b/libraries/adb/src/daemon/auth/packet-processor.ts index 8f73ad6e2..ac329821e 100644 --- a/libraries/adb/src/daemon/auth/packet-processor.ts +++ b/libraries/adb/src/daemon/auth/packet-processor.ts @@ -35,6 +35,8 @@ export interface AdbCredentialManager { * Each call to `iterateKeys` must return a different iterator that iterate through all stored keys. */ iterateKeys(): AdbKeyIterable; + + close?(): MaybePromiseLike; } export interface AdbKeyInfo { diff --git a/libraries/struct/src/bipedal.spec.ts b/libraries/struct/src/bipedal.spec.ts new file mode 100644 index 000000000..b2bcf2c1a --- /dev/null +++ b/libraries/struct/src/bipedal.spec.ts @@ -0,0 +1,105 @@ +/* eslint-disable require-yield */ +import assert from "node:assert"; +import { describe, it } from "node:test"; + +import { bipedal } from "./bipedal.js"; + +describe("bipedal", () => { + describe("without async", () => { + it("should return synchronously", () => { + const func = bipedal(function* () { + yield 42; + return 42; + }); + assert.strictEqual(func(), 42); + }); + + it("should reject synchronously", () => { + let error!: Error; + const func = bipedal(function* () { + error = new Error("test"); + yield error; + throw error; + }); + assert.throws(() => func(), error); + }); + + it("yield should return input value", () => { + const func = bipedal(function* () { + const value = yield 42; + return value; + }); + assert.strictEqual(func(), 42); + }); + + it("then should return input value", () => { + const func = bipedal(function* (then) { + const value = yield* then(42); + return value; + }); + assert.strictEqual(func(), 42); + }); + }); + + describe("with async", () => { + it("should return asynchronously", async () => { + const func = bipedal(function* () { + yield Promise.resolve(); + return 42; + }); + + const promise = func(); + assert.ok(promise instanceof Promise); + + const result = await (promise as Promise); + assert.strictEqual(result, 42); + }); + + it("should reject asynchronously", async () => { + let error!: Error; + const func = bipedal(function* () { + error = new Error("test"); + yield Promise.reject(error); + return 42; + }); + + const promise = func(); + assert.ok(promise instanceof Promise); + await assert.rejects(async () => promise, error); + }); + + it("yield should return resolved value", async () => { + const func = bipedal(function* () { + const value = yield Promise.resolve(42); + return value; + }); + assert.strictEqual(await func(), 42); + }); + + it("then should return resolved value", async () => { + const func = bipedal(function* (then) { + const value = yield* then(Promise.resolve(42)); + return value; + }); + assert.strictEqual(await func(), 42); + }); + }); + + it("should have target as `this` if not bound", () => { + const context = { + value: 42, + func: bipedal(function* (this: { value: number }) { + return this.value; + }), + }; + assert.strictEqual(context.func(), 42); + }); + + it("should bind `this` if specified", () => { + const context = { value: 42 }; + const func = bipedal(function* () { + return this.value; + }, context); + assert.strictEqual(func(), 42); + }); +}); diff --git a/libraries/struct/src/bipedal.ts b/libraries/struct/src/bipedal.ts index b37ac06bd..16bc5865e 100644 --- a/libraries/struct/src/bipedal.ts +++ b/libraries/struct/src/bipedal.ts @@ -2,7 +2,7 @@ import type { MaybePromiseLike } from "@yume-chan/async"; import { isPromiseLike } from "@yume-chan/async"; function advance( - iterator: Iterator, + iterator: Generator, next: unknown, ): MaybePromiseLike { while (true) { @@ -12,17 +12,25 @@ function advance( } if (isPromiseLike(value)) { return value.then( - (value) => advance(iterator, { resolved: value }), - (error: unknown) => advance(iterator, { error }), + (value) => advance(iterator, value), + (error: unknown) => { + iterator.throw(error); + throw error; + }, ); } next = value; } } +export type BipedalThen = ( + value: MaybePromiseLike, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Iterable, T, unknown>; + export type BipedalGenerator = ( this: This, - then: (value: MaybePromiseLike) => Iterable, + then: BipedalThen, ...args: A ) => Generator, unknown>; @@ -30,31 +38,22 @@ export type BipedalGenerator = ( export function bipedal( fn: BipedalGenerator, bindThis?: This, -): { (this: This, ...args: A): MaybePromiseLike } { +): { (...args: A): MaybePromiseLike } { function result(this: This, ...args: A): MaybePromiseLike { - const iterator = fn.call( + const generator = fn.call( this, function* ( value: MaybePromiseLike, - ): Generator< - PromiseLike, - U, - { resolved: U } | { error: unknown } - > { + ): Generator, U, U> { if (isPromiseLike(value)) { - const result = yield value; - if ("resolved" in result) { - return result.resolved; - } else { - throw result.error; - } + return yield value; } return value; }, ...args, ) as never; - return advance(iterator, undefined); + return advance(generator, undefined); } if (bindThis) { diff --git a/libraries/struct/src/number.ts b/libraries/struct/src/number.ts index 9b21cacb6..6a531ef81 100644 --- a/libraries/struct/src/number.ts +++ b/libraries/struct/src/number.ts @@ -1,4 +1,3 @@ -import type { MaybePromiseLike } from "@yume-chan/async"; import { getInt16, getInt32, @@ -15,6 +14,7 @@ import { setUint64, } from "@yume-chan/no-data-view"; +import type { BipedalThen } from "./bipedal.js"; import type { Field, FieldByobSerializeContext, @@ -35,7 +35,7 @@ function number( context: FieldByobSerializeContext & { index: number }, ) => void, deserialize: ( - then: (value: MaybePromiseLike) => Iterable, + then: BipedalThen, reader: AsyncExactReadable, context: FieldDeserializeContext, ) => Generator, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e1c0c273..53d55dcc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,12 +165,21 @@ importers: specifier: workspace:* version: link:../struct devDependencies: + '@types/node': + specifier: ^26.5.1 + version: 26.5.1 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config + '@yume-chan/test-runner': + specifier: workspace:^ + version: link:../../toolchain/test-runner '@yume-chan/tsconfig': specifier: workspace:^ version: link:../../toolchain/tsconfig + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -690,6 +699,9 @@ importers: '@yume-chan/adb': specifier: workspace:^ version: link:../../libraries/adb + '@yume-chan/adb-credential-web': + specifier: workspace:^ + version: link:../../libraries/adb-credential-web '@yume-chan/scrcpy': specifier: workspace:^ version: link:../../libraries/scrcpy @@ -1389,6 +1401,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2267,6 +2283,8 @@ snapshots: esutils@2.0.3: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} From a7122e8097a3c07586638918cd774222b942b57b Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:02:32 +0800 Subject: [PATCH 2/5] chore: minor changes --- toolchain/side-effect-test/package.json | 2 ++ toolchain/side-effect-test/rollup.config.ts | 26 ++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/toolchain/side-effect-test/package.json b/toolchain/side-effect-test/package.json index 277f5ad45..95531d2a7 100644 --- a/toolchain/side-effect-test/package.json +++ b/toolchain/side-effect-test/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "description": "", + "type": "module", "main": "index.js", "scripts": { "start": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript --watch", @@ -21,6 +22,7 @@ }, "dependencies": { "@yume-chan/adb": "workspace:^", + "@yume-chan/adb-credential-web": "workspace:^", "@yume-chan/scrcpy": "workspace:^", "@yume-chan/scrcpy-decoder-h264bsd": "workspace:^", "@yume-chan/struct": "workspace:^" diff --git a/toolchain/side-effect-test/rollup.config.ts b/toolchain/side-effect-test/rollup.config.ts index d697e3d8b..da9f9bc35 100644 --- a/toolchain/side-effect-test/rollup.config.ts +++ b/toolchain/side-effect-test/rollup.config.ts @@ -3,6 +3,17 @@ import node from "@rollup/plugin-node-resolve"; import terser from "@rollup/plugin-terser"; import { defineConfig } from "rollup"; +const terserPlugin = terser({ + module: true, + format: { + beautify: true, + }, + compress: { + passes: 10, + }, + mangle: false, +}); + export default defineConfig({ input: "src/index.js", external: ["yuv-canvas"], @@ -14,18 +25,5 @@ export default defineConfig({ dir: "dist", format: "esm", }, - plugins: [ - node(), - commonjs(), - terser({ - module: true, - format: { - beautify: true, - }, - compress: { - passes: 10, - }, - mangle: false, - }), - ], + plugins: [node(), commonjs(), terserPlugin], }); From b334357411f4514b77aff3e27c1a6ba9c8ef3606 Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:21:48 +0800 Subject: [PATCH 3/5] fix: review comments --- .../src/storage/indexed-db/shared.spec.ts | 19 +++++++-- .../src/storage/indexed-db/shared.ts | 12 ++++-- .../src/storage/indexed-db/v2.ts | 7 +++- libraries/struct/src/bipedal.spec.ts | 14 +++++++ libraries/struct/src/bipedal.ts | 39 +++++++++++-------- 5 files changed, 67 insertions(+), 24 deletions(-) diff --git a/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts b/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts index 29c452076..39de85e75 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts @@ -28,18 +28,32 @@ describe("waitRequest", () => { const request = indexedDB.open(dbName, 1); await assert.rejects(waitRequest(request), /VersionError/); }); + + it("should throw if used inside a transaction", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + const tx = db.transaction("store", "readwrite"); + const store = tx.objectStore("store"); + const request = store.put({ value: 1 }); + await assert.rejects( + () => waitRequest(request), + /Cannot wait for a request inside a transaction/, + ); + db.close(); + }); }); describe("openDatabase", () => { it("should close the database when callback finishes", async () => { - let db: IDBDatabase; + let db!: IDBDatabase; await openDatabase( nextDatabaseName(), 1, () => {}, (db_) => { db = db_; - return new Promise((resolve) => setTimeout(resolve, 2000)); + return new Promise((resolve) => setTimeout(resolve, 0)); }, ); assert.throws(() => db.transaction("store"), /InvalidStateError/); @@ -53,7 +67,6 @@ describe("transaction", () => { }); const tx = db.transaction("store", "readwrite"); const store = tx.objectStore("store"); - await waitRequest(store.put({ value: 1 })); await new Promise(setImmediate); assert.throws( () => store.put({ value: 2 }), diff --git a/libraries/adb-credential-web/src/storage/indexed-db/shared.ts b/libraries/adb-credential-web/src/storage/indexed-db/shared.ts index a8e359246..e2c7fb469 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/shared.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/shared.ts @@ -8,6 +8,12 @@ * or rejects with the request's error. */ export function waitRequest(request: IDBRequest): Promise { + if (request.transaction) { + return Promise.reject( + new Error("Cannot wait for a request inside a transaction."), + ); + } + return new Promise((resolve, reject) => { request.onerror = () => { reject(request.error!); @@ -27,13 +33,13 @@ export async function openDatabase( name: string, version: number, onUpgrade: (db: IDBDatabase) => undefined, - callback: (db: IDBDatabase) => T, + callback: (db: IDBDatabase) => T | Promise, ): Promise; export async function openDatabase( name: string, version: number, onUpgrade: (db: IDBDatabase) => undefined, - callback?: (db: IDBDatabase) => T, + callback?: (db: IDBDatabase) => T | Promise, ): Promise { const request = indexedDB.open(name, version); @@ -83,7 +89,7 @@ function advance( // https://w3c.github.io/IndexedDB/#ref-for-canceled-flag%E2%91%A0 e.preventDefault(); - // Prevent event buddles to transaction's `onerror` + // Prevent the event from bubbling to the transaction's `onerror` // https://w3c.github.io/IndexedDB/#ref-for-get-the-parent%E2%91%A1 e.stopPropagation(); diff --git a/libraries/adb-credential-web/src/storage/indexed-db/v2.ts b/libraries/adb-credential-web/src/storage/indexed-db/v2.ts index 7d24255b3..510352d13 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/v2.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/v2.ts @@ -97,6 +97,11 @@ export class TangoIndexedDbStorage implements TangoKeyStorage { } close() { - return this.#openDatabasePromise?.then((db) => void db.close()); + const promise = this.#openDatabasePromise; + if (promise) { + this.#openDatabasePromise = undefined; + return promise.then((db) => void db.close()); + } + return undefined; } } diff --git a/libraries/struct/src/bipedal.spec.ts b/libraries/struct/src/bipedal.spec.ts index b2bcf2c1a..44834605c 100644 --- a/libraries/struct/src/bipedal.spec.ts +++ b/libraries/struct/src/bipedal.spec.ts @@ -83,6 +83,20 @@ describe("bipedal", () => { }); assert.strictEqual(await func(), 42); }); + + it("generator should be able to catch the error", async () => { + const func = bipedal(function* () { + try { + yield Promise.reject(new Error("test")); + return undefined; + } catch (e) { + return e; + } + }); + const result = await func(); + assert.ok(result instanceof Error); + assert.strictEqual(result.message, "test"); + }); }); it("should have target as `this` if not bound", () => { diff --git a/libraries/struct/src/bipedal.ts b/libraries/struct/src/bipedal.ts index 16bc5865e..59d04a5f8 100644 --- a/libraries/struct/src/bipedal.ts +++ b/libraries/struct/src/bipedal.ts @@ -3,24 +3,22 @@ import { isPromiseLike } from "@yume-chan/async"; function advance( iterator: Generator, - next: unknown, + { done, value }: IteratorResult, ): MaybePromiseLike { - while (true) { - const { done, value } = iterator.next(next); - if (done) { - return value; - } - if (isPromiseLike(value)) { - return value.then( - (value) => advance(iterator, value), - (error: unknown) => { - iterator.throw(error); - throw error; - }, - ); - } - next = value; + if (done) { + return value; } + if (isPromiseLike(value)) { + return value.then( + (value) => advance(iterator, iterator.next(value)), + (error: unknown) => advance(iterator, iterator.throw(error)), + ); + } + return advance(iterator, iterator.next(value)); +} + +function start(generator: Generator) { + return advance(generator, generator.next(undefined)); } export type BipedalThen = ( @@ -35,6 +33,13 @@ export type BipedalGenerator = ( ) => Generator, unknown>; /* #__NO_SIDE_EFFECTS__ */ +export function bipedal( + fn: BipedalGenerator, +): { (this: This, ...args: A): MaybePromiseLike }; +export function bipedal( + fn: BipedalGenerator, + bindThis: This, +): { (...args: A): MaybePromiseLike }; export function bipedal( fn: BipedalGenerator, bindThis?: This, @@ -53,7 +58,7 @@ export function bipedal( }, ...args, ) as never; - return advance(generator, undefined); + return start(generator); } if (bindThis) { From b6f74f38ce1edeac3b40df980518d89b1db548c7 Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:32 +0800 Subject: [PATCH 4/5] fix: review comments --- .../src/storage/indexed-db/shared.spec.ts | 18 +- .../src/storage/indexed-db/shared.ts | 199 ++++++++++-------- libraries/struct/src/bipedal.ts | 32 ++- 3 files changed, 139 insertions(+), 110 deletions(-) diff --git a/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts b/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts index 39de85e75..71902dc7f 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts @@ -77,6 +77,20 @@ describe("transaction", () => { }); describe("createTransaction", () => { + it("should reject async generators", async () => { + const db = await openDatabase(nextDatabaseName(), 1, (db) => { + db.createObjectStore("store", { autoIncrement: true }); + }); + await assert.rejects( + // @ts-expect-error unit test + createTransaction(db, "store", async function* () { + yield new Promise((resolve) => setTimeout(resolve, 0)); + }), + /Async generators are not supported/, + ); + db.close(); + }); + it("should throw if callback throws", async () => { const db = await openDatabase(nextDatabaseName(), 1, (db) => { db.createObjectStore("store", { autoIncrement: true }); @@ -247,11 +261,11 @@ describe("createTransaction", () => { "store", function* (tx) { tx.commit(); - throw new Error("Transaction aborted"); + throw new Error("test error"); }, { mode: "readwrite" }, ), - /Transaction aborted/, + /test error/, ); db.close(); }); diff --git a/libraries/adb-credential-web/src/storage/indexed-db/shared.ts b/libraries/adb-credential-web/src/storage/indexed-db/shared.ts index e2c7fb469..ed1e6f432 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/shared.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/shared.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + /** * Convert an IndexedDB request into a promise. * @@ -15,12 +17,12 @@ export function waitRequest(request: IDBRequest): Promise { } return new Promise((resolve, reject) => { - request.onerror = () => { - reject(request.error!); - }; request.onsuccess = () => { resolve(request.result); }; + request.onerror = () => { + reject(request.error!); + }; }); } @@ -61,110 +63,125 @@ export async function openDatabase( return db; } -function advance( - iterator: Generator, - { done, value }: IteratorResult, -) { - if (done) { - return Promise.resolve(value); - } - if (value instanceof IDBRequest) { - return new Promise((resolve, reject) => { - value.onsuccess = () => { +function waitRequestInTransaction( + generator: Generator, + request: IDBRequest, +): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => { + try { // The transaction is "active" only // when handling a request's success or error events. - try { - resolve(advance(iterator, iterator.next(value.result))); - } catch (e) { - // Prevent automatic transaction abortion - // https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1 - - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(e); - } - }; - value.onerror = (e) => { - try { - // Prevent automatic transaction abortion - // https://w3c.github.io/IndexedDB/#ref-for-canceled-flag%E2%91%A0 - e.preventDefault(); - - // Prevent the event from bubbling to the transaction's `onerror` - // https://w3c.github.io/IndexedDB/#ref-for-get-the-parent%E2%91%A1 - e.stopPropagation(); - - // Throw the error to the generator so it can handle it. - resolve(advance(iterator, iterator.throw(value.error))); - } catch (e) { - // Prevent automatic transaction abortion - // https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1 - - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(e); - } - }; - }); - } - return advance(iterator, iterator.next(value)); + // Run next section of the generator function. + resolve(advance(generator, generator.next(request.result))); + } catch (e) { + // Prevent automatic transaction abortion + // https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1 + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } + }; + request.onerror = (e) => { + try { + // Prevent automatic transaction abortion + // https://w3c.github.io/IndexedDB/#ref-for-canceled-flag%E2%91%A0 + e.preventDefault(); + + // Prevent the event from bubbling to the transaction's `onerror` + // https://w3c.github.io/IndexedDB/#ref-for-get-the-parent%E2%91%A1 + e.stopPropagation(); + + // Let the generator function handle the error. + resolve(advance(generator, generator.throw(request.error))); + } catch (e) { + // Prevent automatic transaction abortion + // https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1 + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } + }; + }); } -function start(generator: Generator) { - return advance(generator, generator.next(undefined)); -} +function advance( + generator: Generator, + result: IteratorResult, +) { + do { + if (result instanceof Promise) { + // Check if `generator` is async. + // Shouldn't be possible with TypeScript type check, + // but just in case + throw new Error("Async generators are not supported"); + } + if (result.done) { + return result.value; + } + if (result.value instanceof IDBRequest) { + return waitRequestInTransaction(generator, result.value); + } -export function createTransaction( - database: IDBDatabase, - storeName: string, - callback: ( - transaction: IDBTransaction, - store: IDBObjectStore, - waitRequest: ( - request: IDBRequest, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Iterable, U, unknown>, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Generator, T, unknown>, - options: IDBTransactionOptions & { mode: IDBTransactionMode } = { - mode: "readonly", - }, -): Promise { - return new Promise((resolve, reject) => { - const transaction = database.transaction(storeName, options.mode); + // Yielding a non-IDBRequest value doesn't make sense in this context, + // but we just continue the loop. + result = generator.next(result.value); + } while (true); +} - let result!: T; +function waitTransaction(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { transaction.oncomplete = () => { - resolve(result); + resolve(undefined); }; transaction.onabort = () => { reject(transaction.error ?? new Error("Transaction aborted")); }; // `IDBTransaction`'s `error` event only receives bubbled events from its requests // So it doesn't need to be listened to + }); +} - const handleError = (e: unknown) => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(e); +type WaitRequestHelper = ( + request: IDBRequest, +) => Iterable, T, unknown>; - try { - transaction.abort(); - } catch { - // ignore - } - }; +function* waitRequestHelper( + request: IDBRequest, +): Iterable, T, T> { + return yield request; +} +type TransactionCallback = ( + transaction: IDBTransaction, + store: IDBObjectStore, + waitRequest: WaitRequestHelper, +) => Generator, T, unknown>; + +export async function createTransaction( + database: IDBDatabase, + storeName: string, + callback: TransactionCallback, + options: IDBTransactionOptions & { mode: IDBTransactionMode } = { + mode: "readonly", + }, +): Promise { + const transaction = database.transaction(storeName, options.mode); + const objectStore = transaction.objectStore(storeName); + + try { + const generator = callback(transaction, objectStore, waitRequestHelper); + const [result] = await Promise.all([ + advance(generator, generator.next(undefined)), + waitTransaction(transaction), + ]); + return result; + } catch (e) { try { - const iterator = callback( - transaction, - transaction.objectStore(storeName), - function* ( - request: IDBRequest, - ): Generator, U, U> { - return yield request; - }, - ); - start(iterator).then((value) => (result = value), handleError); - } catch (e) { - handleError(e); + transaction.abort(); + } catch { + // ignore } - }); + throw e; + } } diff --git a/libraries/struct/src/bipedal.ts b/libraries/struct/src/bipedal.ts index 59d04a5f8..0d2b3527f 100644 --- a/libraries/struct/src/bipedal.ts +++ b/libraries/struct/src/bipedal.ts @@ -3,22 +3,20 @@ import { isPromiseLike } from "@yume-chan/async"; function advance( iterator: Generator, - { done, value }: IteratorResult, + result: IteratorResult, ): MaybePromiseLike { - if (done) { - return value; - } - if (isPromiseLike(value)) { - return value.then( - (value) => advance(iterator, iterator.next(value)), - (error: unknown) => advance(iterator, iterator.throw(error)), - ); - } - return advance(iterator, iterator.next(value)); -} - -function start(generator: Generator) { - return advance(generator, generator.next(undefined)); + do { + if (result.done) { + return result.value; + } + if (isPromiseLike(result.value)) { + return result.value.then( + (value) => advance(iterator, iterator.next(value)), + (error: unknown) => advance(iterator, iterator.throw(error)), + ); + } + result = iterator.next(result.value); + } while (true); } export type BipedalThen = ( @@ -57,8 +55,8 @@ export function bipedal( return value; }, ...args, - ) as never; - return start(generator); + ) as Generator; + return advance(generator, generator.next(undefined)); } if (bindThis) { From adc3074d10cb423a600b1eeff1f92e863f4e452a Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:15:08 +0800 Subject: [PATCH 5/5] fix: review comments --- libraries/adb-credential-web/src/storage/indexed-db/v2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/adb-credential-web/src/storage/indexed-db/v2.ts b/libraries/adb-credential-web/src/storage/indexed-db/v2.ts index 510352d13..c45555179 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db/v2.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db/v2.ts @@ -47,7 +47,7 @@ export class TangoIndexedDbStorage implements TangoKeyStorage { }); } - async #openDatabase() { + #openDatabase() { return (this.#openDatabasePromise ??= this.#openDatabaseCore()); }