diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a9f18abac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm +globs: '*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json' +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run

OneSignal Dev

-

- This html file is for local development with hot-reloading. To preview the - bundle, it will use a separate html file in the preview folder. -

- -
-

Demo: Suppress Notifications When Tab is Focused

-

- When enabled, push notifications will be suppressed when this tab is in focus. - Instead of a system notification, an in-app notification will be shown. -

-

- How to test: -

    -
  1. Enable "Suppress when focused" below
  2. -
  3. Send a test push notification from your OneSignal dashboard
  4. -
  5. With this tab in focus, you'll see an in-app notification instead of a system notification
  6. -
  7. With this tab in background, you'll see a normal system notification
  8. -
-

-
- - -
-
- -
-

App ID Migration Regression Test

-

- Reproduces the issue where migrating App IDs on the same origin leaves the - push subscription in an "unsubscribed" state. Open the browser console to - observe each step. -

-
    -
  1. Enter App ID 1 and click Init & Subscribe with App ID 1. Grant notification permission when prompted.
  2. -
  3. Enter App ID 2 and click Migrate to App ID 2. The page reloads with the new App ID.
  4. -
  5. Check the status panel below — optedIn should be true after migration.
  6. -
- -
- - -
- -
- - - -
- -

Subscription Status

-
-Click "Refresh Status" after init to see current state.
-    
- - - - diff --git a/package.json b/package.json index a888eb5af..1001c3f4d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "description": "Web push notifications from OneSignal.", "type": "module", "dependencies": { - "idb": "^8.0.3", "jsonp": "github:OneSignal/jsonp#onesignal" }, "scripts": { @@ -47,8 +46,7 @@ "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@size-limit/file": "^12.0.0", - "@types/body-parser": "latest", - "@types/express": "^5.0.6", + "@types/bun": "^1.3.10", "@types/intl-tel-input": "^18.1.4", "@types/jsdom": "^28.0.0", "@types/jsonp": "^0.2.3", @@ -84,12 +82,12 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "46.44 kB", + "limit": "45.556 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "13.68 kB", + "limit": "12.751 kB", "gzip": true }, { diff --git a/src/onesignal/OneSignal.ts b/src/onesignal/OneSignal.ts index 9b3704a34..f786c61a5 100644 --- a/src/onesignal/OneSignal.ts +++ b/src/onesignal/OneSignal.ts @@ -1,7 +1,7 @@ import type Bell from 'src/page/bell/Bell'; import { getAppConfig } from 'src/shared/config/app'; import type { AppConfig, AppUserConfig } from 'src/shared/config/types'; -import { db } from 'src/shared/database/client'; +import { db, dbPromise } from 'src/shared/database/client'; import { getConsentGiven, isConsentRequiredButNotGiven, @@ -152,6 +152,14 @@ export default class OneSignal { return; } + const idb = await dbPromise.catch((e) => { + Log._error( + 'OneSignal: IndexedDB unavailable, close & reopen the page to retry init', + e, + ); + }); + if (!idb) return; + await OneSignal._initializeCoreModuleAndOSNamespaces(); OneSignal._consentGiven = await getConsentGiven(); diff --git a/src/onesignal/User.ts b/src/onesignal/User.ts index 0fa19e4cf..a78b5a0c0 100644 --- a/src/onesignal/User.ts +++ b/src/onesignal/User.ts @@ -71,6 +71,11 @@ export default class User { return IDManager._isLocalId(onesignalId) ? undefined : onesignalId; } + get externalId(): string | undefined { + const identityModel = OneSignal._coreDirector._getIdentityModel(); + return identityModel?._externalId; + } + public addAlias(label: string, id: string): void { logMethodCall('addAlias', { label, id }); if (isConsentRequiredButNotGiven()) return; diff --git a/src/onesignal/UserNamespace.ts b/src/onesignal/UserNamespace.ts index a4409bdc6..8499a2452 100644 --- a/src/onesignal/UserNamespace.ts +++ b/src/onesignal/UserNamespace.ts @@ -35,8 +35,7 @@ export default class UserNamespace extends EventListenerBase { } get externalId(): string | undefined { - const identityModel = OneSignal._coreDirector._getIdentityModel(); - return identityModel?._externalId; + return this._currentUser?.externalId; } public addAlias(label: string, id: string): void { diff --git a/src/shared/database/client.test.ts b/src/shared/database/client.test.ts index a799547d9..083bafd6f 100644 --- a/src/shared/database/client.test.ts +++ b/src/shared/database/client.test.ts @@ -1,6 +1,6 @@ import { APP_ID, EXTERNAL_ID, ONESIGNAL_ID } from '__test__/constants'; -import type * as idb from 'idb'; -import { deleteDB, type IDBPDatabase } from 'idb'; +import type * as idbLite from './idb-lite'; +import { wrapRequest } from './idb-lite'; import { SubscriptionType } from '../subscriptions/constants'; import { closeDb, getDb } from './client'; import { DATABASE_NAME } from './constants'; @@ -8,7 +8,7 @@ import type { IndexedDBSchema } from './types'; beforeEach(async () => { await closeDb(); - await deleteDB(DATABASE_NAME); + await wrapRequest(indexedDB.deleteDatabase(DATABASE_NAME)); }); describe('general', () => { @@ -196,7 +196,7 @@ describe('migrations', () => { describe('v6', () => { const populateLegacySubscriptions = async ( - db: IDBPDatabase, + db: Awaited>, ) => { await db.put('emailSubscriptions', { modelId: '1', @@ -323,20 +323,15 @@ test('should reopen db when terminated', async () => { let terminatedCallback = vi.hoisted(() => vi.fn(() => false)); const openFn = vi.hoisted(() => vi.fn()); - const deleteDatabaseFn = vi.hoisted(() => vi.fn()); - vi.mock('idb', async (importOriginal) => { - const actual = (await importOriginal()) as typeof idb; + vi.mock('./idb-lite', async (importOriginal) => { + const actual = (await importOriginal()) as typeof idbLite; return { ...actual, openDB: openFn.mockImplementation((name, version, callbacks) => { terminatedCallback = callbacks!.terminated!; return actual.openDB(name, version, callbacks); }), - deleteDB: deleteDatabaseFn.mockImplementation((name) => { - terminatedCallback(); - return actual.deleteDB(name); - }), }; }); @@ -346,7 +341,7 @@ test('should reopen db when terminated', async () => { await db.put('Options', { key: 'userConsent', value: true }); // real world db.close() will trigger the terminated callback - deleteDB(DATABASE_NAME); + terminatedCallback(); // terminate callback should reopen the db expect(openFn).toHaveBeenCalledTimes(2); diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts index 2c01c903a..e3c3b8a07 100644 --- a/src/shared/database/client.ts +++ b/src/shared/database/client.ts @@ -1,8 +1,8 @@ -import { openDB } from 'idb'; import Log from '../libraries/Log'; import { ONESIGNAL_SESSION_KEY } from '../session/constants'; import { IS_SERVICE_WORKER } from '../utils/env'; import { DATABASE_NAME, VERSION } from './constants'; +import { openDB, wrapDb } from './idb-lite'; import type { IDBStoreName, IdKey, IndexedDBSchema, OptionKey } from './types'; import { migrateModelNameSubscriptionsTableForV6, @@ -12,7 +12,7 @@ import { let terminated = false; const open = async (version = VERSION) => { - return openDB(DATABASE_NAME, version, { + const raw = await openDB(DATABASE_NAME, version, { upgrade(_db, oldVersion, newVersion, transaction) { const newDbVersion = newVersion || version; if (newDbVersion >= 1 && oldVersion < 1) { @@ -84,9 +84,10 @@ const open = async (version = VERSION) => { } }, }); + return wrapDb(raw); }; -let dbPromise = open(); +export let dbPromise = open(); export const getDb = (version = VERSION) => { dbPromise = open(version); return dbPromise; @@ -124,7 +125,7 @@ export const clearStore = async (storeName: K) => { }; export const getObjectStoreNames = async () => { - return Array.from((await dbPromise).objectStoreNames); + return Array.from((await dbPromise).objectStoreNames) as IDBStoreName[]; }; export const getOptionsValue = async (key: OptionKey): Promise => { @@ -148,9 +149,8 @@ export const cleanupCurrentSession = async () => { }; export const clearAll = async () => { - const objectStoreNames = await getObjectStoreNames(); - for (const storeName of objectStoreNames) { - await clearStore(storeName); + for (const name of await getObjectStoreNames()) { + await clearStore(name); } }; diff --git a/src/shared/database/idb-lite.ts b/src/shared/database/idb-lite.ts new file mode 100644 index 000000000..5776a4ba0 --- /dev/null +++ b/src/shared/database/idb-lite.ts @@ -0,0 +1,64 @@ +export function wrapRequest(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export function wrapDb< + S extends { [K in keyof S]: { key: IDBValidKey; value: unknown } }, +>(raw: IDBDatabase) { + const store = (name: string, mode?: IDBTransactionMode) => + raw.transaction(name, mode).objectStore(name); + return { + get: async ( + s: K, + k: S[K]['key'], + ): Promise => wrapRequest(store(s).get(k)), + getAll: async ( + s: K, + ): Promise => wrapRequest(store(s).getAll()), + put: async (s: K, v: S[K]['value']) => + wrapRequest(store(s, 'readwrite').put(v)), + delete: async (s: K, k: S[K]['key']) => + wrapRequest(store(s, 'readwrite').delete(k)), + clear: async (s: K) => + wrapRequest(store(s, 'readwrite').clear()), + close: () => raw.close(), + get objectStoreNames() { + return raw.objectStoreNames; + }, + }; +} + +export function openDB( + name: string, + version: number, + callbacks?: { + upgrade?: ( + db: IDBDatabase, + oldVersion: number, + newVersion: number | null, + tx: IDBTransaction, + ) => void; + blocked?: () => void; + terminated?: () => void; + }, +): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, version); + req.onupgradeneeded = (e) => + callbacks?.upgrade?.( + req.result, + e.oldVersion, + e.newVersion, + req.transaction!, // per indexeddb spec, transaction is always defined for onupgradeneeded + ); + req.onsuccess = () => { + if (callbacks?.terminated) req.result.onclose = callbacks.terminated; + resolve(req.result); + }; + req.onerror = () => reject(req.error); + req.onblocked = () => callbacks?.blocked?.(); + }); +} diff --git a/src/shared/database/types.ts b/src/shared/database/types.ts index dd4094baf..ed4548da9 100644 --- a/src/shared/database/types.ts +++ b/src/shared/database/types.ts @@ -1,4 +1,3 @@ -import type { DBSchema, StoreNames } from 'idb'; import type { NotificationClickedForOutcomesSchema, NotificationClickForOpenHandlingSchema, @@ -76,7 +75,7 @@ export interface PropertiesSchema { timezone_id: string; } -export interface IndexedDBSchema extends DBSchema { +export interface IndexedDBSchema { /** * @deprecated - should be migrated in openDB() */ @@ -204,4 +203,6 @@ export interface AppState { lastKnownOptedIn: boolean | null; } -export type IDBStoreName = StoreNames; +export type OptionsValue = IndexedDBSchema['Options']['value']['value']; + +export type IDBStoreName = keyof IndexedDBSchema; diff --git a/src/shared/database/upgrade.ts b/src/shared/database/upgrade.ts index a756d592f..7a53471e1 100644 --- a/src/shared/database/upgrade.ts +++ b/src/shared/database/upgrade.ts @@ -1,7 +1,4 @@ -import type { IDBPDatabase, IDBPTransaction } from 'idb'; -import type { IndexedDBSchema } from './types'; - -type Transaction = IDBPTransaction; +import { wrapRequest } from './idb-lite'; // Table rename "NotificationClicked" -> "Outcomes.NotificationClicked" // and migrate existing records. @@ -14,26 +11,25 @@ type Transaction = IDBPTransaction; // However those new on 160000.beta4 to 160000.beta8 will have records // saved as "notification.id" that will be converted here. export async function migrateOutcomesNotificationClickedTableForV5( - db: IDBPDatabase, - transaction: Transaction, + db: IDBDatabase, + transaction: IDBTransaction, ) { const oldTableName = 'NotificationClicked'; const newTableName = 'Outcomes.NotificationClicked'; db.createObjectStore(newTableName, { keyPath: 'notificationId' }); - let cursor = await transaction.objectStore(oldTableName).openCursor(); - - while (cursor) { - const oldValue = cursor.value; + const records = await wrapRequest( + transaction.objectStore(oldTableName).getAll(), + ); - await transaction.objectStore(newTableName).put({ - // notification.id was possible from 160000.beta4 to 160000.beta8 + const newStore = transaction.objectStore(newTableName); + for (const oldValue of records) { + // notification.id was possible from 160000.beta4 to 160000.beta8 + newStore.put({ notificationId: oldValue.notificationId || oldValue.notification.id, appId: oldValue.appId, timestamp: oldValue.timestamp, }); - - cursor = await cursor.continue(); } db.deleteObjectStore(oldTableName); } @@ -43,30 +39,34 @@ export async function migrateOutcomesNotificationClickedTableForV5( // Motivation: Consistency of using pre-fix "Outcomes." like we have for // the "Outcomes.NotificationClicked" table. export async function migrateOutcomesNotificationReceivedTableForV5( - db: IDBPDatabase, - transaction: Transaction, + db: IDBDatabase, + transaction: IDBTransaction, ) { const oldTableName = 'NotificationReceived'; const newTableName = 'Outcomes.NotificationReceived'; db.createObjectStore(newTableName, { keyPath: 'notificationId' }); - let cursor = await transaction.objectStore(oldTableName).openCursor(); - while (cursor) { - await transaction.objectStore(newTableName).put(cursor.value); - cursor = await cursor.continue(); + const records = await wrapRequest( + transaction.objectStore(oldTableName).getAll(), + ); + const newStore = transaction.objectStore(newTableName); + for (const record of records) { + newStore.put(record); } db.deleteObjectStore(oldTableName); } export async function migrateModelNameSubscriptionsTableForV6( - db: IDBPDatabase, - transaction: Transaction, + db: IDBDatabase, + transaction: IDBTransaction, ) { const newTableName = 'subscriptions'; db.createObjectStore(newTableName, { keyPath: 'modelId' }); let currentExternalId: string | undefined; - const identityData = await transaction.objectStore('identity').getAll(); + const identityData = await wrapRequest( + transaction.objectStore('identity').getAll(), + ); if (identityData.length > 0) { currentExternalId = identityData[0].externalId; @@ -76,15 +76,17 @@ export async function migrateModelNameSubscriptionsTableForV6( 'emailSubscriptions', 'pushSubscriptions', 'smsSubscriptions', - ] as const) { - let cursor = await transaction.objectStore(legacyModelName).openCursor(); - while (cursor) { - await transaction.objectStore(newTableName).put({ - ...cursor.value, + ]) { + const records = await wrapRequest( + transaction.objectStore(legacyModelName).getAll(), + ); + const newStore = transaction.objectStore(newTableName); + for (const record of records) { + newStore.put({ + ...record, modelName: 'subscriptions', externalId: currentExternalId, }); - cursor = await cursor.continue(); } db.deleteObjectStore(legacyModelName); } diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index 2d58c7aab..ea5403bc8 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -1,5 +1,5 @@ -import Bell from '../../page/bell/Bell'; import { ModelChangeTags } from 'src/core/types/models'; +import Bell from '../../page/bell/Bell'; import type { AppConfig } from '../config/types'; import type { ContextInterface } from '../context/types'; import { db, getIdsValue } from '../database/client'; @@ -187,7 +187,7 @@ export async function processExpiringSubscriptions(): Promise { return false; } - Log._debug('Subscription is considered expiring.'); + Log._debug('Subscription expiring'); const rawPushSubscription = await context._subscriptionManager._subscribe( SubscriptionStrategyKind._SubscribeNew, );