Compare commits

..

13 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
03ed7d88a3 Update fake-indexeddb dependency to version 6.2.5 in package.json and yarn.lock 2026-03-21 22:21:36 +00:00
Matiss Janis Aboltins
1d34d15f12 Add test setup validation and extend global types for API testing 2026-03-21 22:20:44 +00:00
Matiss Janis Aboltins
e512ff7312 Update API package configuration and add integration tests
- Introduced `npm-run-all` for improved script management in package.json.
- Updated Vite configuration to include test settings for both browser and Node environments.
- Added integration tests for API CRUD operations, including budget and category management.
- Created setup files for browser and Node testing environments.
- Updated yarn.lock to include new dependencies.

This commit enhances the testing framework and ensures better compatibility across environments.
2026-03-21 22:18:44 +00:00
Matiss Janis Aboltins
f0470a37de Merge branch 'master' into claude/browser-compatible-api-QbhHh 2026-03-21 20:55:30 +00:00
Matiss Janis Aboltins
b662205691 Remove index.web.ts file, eliminating the browser entry point for the API package. This change is part of the ongoing refactor to streamline the API for browser compatibility. 2026-03-21 20:53:30 +00:00
Matiss Janis Aboltins
4a5b572baf Merge branch 'claude/browser-compatible-api-QbhHh' of github.com:actualbudget/actual into claude/browser-compatible-api-QbhHh 2026-03-21 20:53:00 +00:00
Matiss Janis Aboltins
5b179b0672 Add browser entry point and update Vite configuration for API package 2026-03-21 20:51:12 +00:00
autofix-ci[bot]
645623a6c9 [autofix.ci] apply automated fixes 2026-03-20 21:33:35 +00:00
Claude
1442747896 [AI] Fix type error in CLI query command
Add type assertion for aqlQuery result to fix 'result is of type unknown'
error introduced in #7240.

https://claude.ai/code/session_01MnxRXLNjqXrVb5CdsC85Fb
2026-03-20 21:32:39 +00:00
Claude
3e2303e5dc [AI] Address review feedback for browser API build
- Remove `internal` export from index.web.ts (keep as local variable)
- Use `yarn build:node && yarn build:browser` in build script
- Remove unnecessary `node:` external from vite.browser.config.ts
  (Vite handles browser externalization automatically)

https://claude.ai/code/session_01MnxRXLNjqXrVb5CdsC85Fb
2026-03-20 21:27:31 +00:00
Claude
332db28e2e [AI] Add browser-compatible build for @actual-app/api
The API package was previously Node.js-only due to its dependency on
better-sqlite3 and Node.js fs/path modules. This adds a browser build
that uses loot-core's existing browser platform implementations
(sql.js/WASM, IndexedDB, absurd-sql) instead.

Changes:
- Add index.web.ts: browser entry point (no Node.js version check or
  node-fetch polyfill)
- Add vite.browser.config.ts: browser-targeted Vite build that resolves
  to browser platform files (index.ts) instead of Node.js ones
  (index.api.ts -> index.electron.ts)
- Update package.json: conditional exports (browser vs default/node),
  module field, build:browser script
- Update tsconfig.json: exclude new config file from type checking

The browser build outputs dist/browser.js (ESM) alongside the existing
dist/index.js (CJS/Node). Bundlers that support the "browser" condition
in package.json exports will automatically use the browser build.

https://claude.ai/code/session_01MnxRXLNjqXrVb5CdsC85Fb
2026-03-20 21:27:31 +00:00
Claude
6d6e032429 [AI] Address review feedback for browser API build
- Remove `internal` export from index.web.ts (keep as local variable)
- Use `yarn build:node && yarn build:browser` in build script
- Remove unnecessary `node:` external from vite.browser.config.ts
  (Vite handles browser externalization automatically)

https://claude.ai/code/session_01MnxRXLNjqXrVb5CdsC85Fb
2026-03-17 12:04:21 +00:00
Claude
399e59c088 [AI] Add browser-compatible build for @actual-app/api
The API package was previously Node.js-only due to its dependency on
better-sqlite3 and Node.js fs/path modules. This adds a browser build
that uses loot-core's existing browser platform implementations
(sql.js/WASM, IndexedDB, absurd-sql) instead.

Changes:
- Add index.web.ts: browser entry point (no Node.js version check or
  node-fetch polyfill)
- Add vite.browser.config.ts: browser-targeted Vite build that resolves
  to browser platform files (index.ts) instead of Node.js ones
  (index.api.ts -> index.electron.ts)
- Update package.json: conditional exports (browser vs default/node),
  module field, build:browser script
- Update tsconfig.json: exclude new config file from type checking

The browser build outputs dist/browser.js (ESM) alongside the existing
dist/index.js (CJS/Node). Bundlers that support the "browser" condition
in package.json exports will automatically use the browser build.

https://claude.ai/code/session_01MnxRXLNjqXrVb5CdsC85Fb
2026-03-16 22:19:22 +00:00
23 changed files with 606 additions and 394 deletions

View File

@@ -335,7 +335,7 @@
],
"patterns": [
{
"group": ["**/*.api", "**/*.electron"],
"group": ["**/*.api", "**/*.web", "**/*.electron"],
"message": "Don't directly reference imports from other platforms"
},
{

View File

@@ -331,7 +331,7 @@ Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.electron`)
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
- Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports
@@ -501,7 +501,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.electron`, `.api`)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures

View File

@@ -0,0 +1,25 @@
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
export * from './methods';
export * as utils from './utils';
let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
internal = await initLootCore(config);
return internal;
}
export async function shutdown() {
if (internal) {
try {
await internal.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await internal.send('close-budget');
internal = null;
}
}

View File

@@ -8,24 +8,42 @@
"dist"
],
"main": "dist/index.js",
"module": "dist/browser.js",
"types": "@types/index.d.ts",
"exports": {
".": {
"development": "./index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
".": {
"browser": {
"types": "./@types/index.d.ts",
"default": "./dist/browser.js"
},
"default": {
"types": "./@types/index.d.ts",
"default": "./dist/index.js"
}
}
},
"publishConfig": {
"exports": {
".": {
"browser": {
"types": "./@types/index.d.ts",
"default": "./dist/browser.js"
},
"default": {
"types": "./@types/index.d.ts",
"default": "./dist/index.js"
}
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest --run",
"build": "npm-run-all -cp 'build:*'",
"build:node": "vite build",
"build:browser": "vite build --config vite.browser.config.ts",
"test": "npm-run-all -cp 'test:*'",
"test:node": "vitest --run",
"test:browser": "vitest --run -c vite.browser.config.ts",
"typecheck": "tsgo -b && tsc-strict"
},
"dependencies": {
@@ -38,6 +56,8 @@
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"fake-indexeddb": "^6.2.5",
"npm-run-all": "^4.1.5",
"rollup-plugin-visualizer": "^6.0.11",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",

View File

@@ -1,67 +1,15 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
global.IS_TESTING = true;
beforeEach(async () => {
const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName);
await fs.rm(budgetPath, { force: true, recursive: true });
await createTestBudget('default-budget-template', budgetName);
await api.init({
dataDir: path.join(__dirname, '/mocks/budgets/'),
});
});
afterEach(async () => {
global.currentMonth = null;
await api.shutdown();
});
async function createTestBudget(templateName: string, name: string) {
const templatePath = path.join(
__dirname,
'/../loot-core/src/mocks/files',
templateName,
);
const budgetPath = path.join(__dirname, '/mocks/budgets/', name);
await fs.mkdir(budgetPath);
await fs.copyFile(
path.join(templatePath, 'metadata.json'),
path.join(budgetPath, 'metadata.json'),
);
await fs.copyFile(
path.join(templatePath, 'db.sqlite'),
path.join(budgetPath, 'db.sqlite'),
if (!globalThis.__test_api) {
throw new Error(
'Test setup did not run — __test_api is not defined. ' +
'Ensure a setupFile (setup.node.ts or setup.browser.ts) is configured.',
);
}
const api = globalThis.__test_api;
const budgetName = globalThis.__test_budget_name;
describe('API setup and teardown', () => {
// apis: loadBudget, getBudgetMonths
test('successfully loads budget', async () => {
@@ -93,7 +41,7 @@ describe('API CRUD operations', () => {
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
test('CategoryGroups: successfully update category groups', async () => {
const month = '2023-10';
global.currentMonth = month;
globalThis.currentMonth = month;
// get existing category groups
const groups = await api.getCategoryGroups();
@@ -164,7 +112,7 @@ describe('API CRUD operations', () => {
// apis: createCategory, getCategories, updateCategory, deleteCategory
test('Categories: successfully update categories', async () => {
const month = '2023-10';
global.currentMonth = month;
globalThis.currentMonth = month;
// create our test category group
const mainGroupId = await api.createCategoryGroup({
@@ -246,7 +194,7 @@ describe('API CRUD operations', () => {
// apis: setBudgetAmount, setBudgetCarryover, getBudgetMonth
test('Budgets: successfully update budgets', async () => {
const month = '2023-10';
global.currentMonth = month;
globalThis.currentMonth = month;
// create some new categories to test with
const groupId = await api.createCategoryGroup({

12
packages/api/test/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import type * as BrowserApi from '../index.browser';
declare global {
// oxlint-disable-next-line no-var
var __test_api: typeof BrowserApi;
// oxlint-disable-next-line no-var
var __test_budget_name: string;
// oxlint-disable-next-line no-var
var IS_TESTING: boolean;
// oxlint-disable-next-line no-var
var currentMonth: string | null;
}

View File

@@ -0,0 +1,92 @@
import 'fake-indexeddb/auto';
import * as nodeFs from 'fs/promises';
import * as path from 'path';
import { afterEach, beforeAll, vi } from 'vitest';
import type * as BrowserFs from '@actual-app/core/platform/server/fs';
import * as api from '../index.browser';
const budgetName = 'test-budget';
const lootCoreRoot = path.join(__dirname, '..', '..', 'loot-core');
globalThis.IS_TESTING = true;
// Populate the emscripten virtual FS with migration files and default-db.sqlite
// (normally done by populateDefaultFilesystem() which is skipped in test mode).
async function populateDefaultFiles(fs: typeof BrowserFs) {
if (!(await fs.exists('/migrations'))) {
await fs.mkdir('/migrations');
}
const migrationsDir = path.join(lootCoreRoot, 'migrations');
const migrationFiles = await nodeFs.readdir(migrationsDir);
for (const file of migrationFiles) {
if (file.endsWith('.sql') || file.endsWith('.js')) {
const contents = await nodeFs.readFile(path.join(migrationsDir, file));
await fs.writeFile(`/migrations/${file}`, new Uint8Array(contents));
}
}
const defaultDb = await nodeFs.readFile(
path.join(lootCoreRoot, 'default-db.sqlite'),
);
await fs.writeFile('/default-db.sqlite', new Uint8Array(defaultDb));
}
// Write the test budget template into the virtual FS.
async function writeBudgetFiles(fs: typeof BrowserFs) {
const templatePath = path.join(
lootCoreRoot,
'src/mocks/files/default-budget-template',
);
const metadataContents = await nodeFs.readFile(
path.join(templatePath, 'metadata.json'),
'utf8',
);
const dbContents = await nodeFs.readFile(
path.join(templatePath, 'db.sqlite'),
);
const budgetDir = `/documents/${budgetName}`;
await fs.mkdir(budgetDir);
await fs.writeFile(`${budgetDir}/metadata.json`, metadataContents);
await fs.writeFile(`${budgetDir}/db.sqlite`, new Uint8Array(dbContents));
}
beforeAll(async () => {
const baseURL = `${__dirname}/../../../node_modules/@jlongster/sql.js/dist/`;
process.env.PUBLIC_URL = baseURL;
// Patch fetch so sql.js WASM loader reads from disk instead of HTTP
vi.spyOn(global, 'fetch').mockImplementation(async input => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
if (url.startsWith(baseURL)) {
return new Response(new Uint8Array(await nodeFs.readFile(url)), {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/wasm' },
});
}
return Promise.reject(new Error(`fetch not mocked for ${url}`));
});
await api.init({ dataDir: '/documents' });
const fs = await import('@actual-app/core/platform/server/fs');
await populateDefaultFiles(fs);
await writeBudgetFiles(fs);
});
afterEach(async () => {
globalThis.currentMonth = null;
});
globalThis.__test_api = api;
globalThis.__test_budget_name = budgetName;

View File

@@ -0,0 +1,64 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { afterEach, beforeEach, vi } from 'vitest';
import * as api from '../index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
globalThis.IS_TESTING = true;
async function createTestBudget(templateName: string, name: string) {
const templatePath = path.join(
__dirname,
'/../../loot-core/src/mocks/files',
templateName,
);
const budgetPath = path.join(__dirname, '/../mocks/budgets/', name);
await fs.mkdir(budgetPath);
await fs.copyFile(
path.join(templatePath, 'metadata.json'),
path.join(budgetPath, 'metadata.json'),
);
await fs.copyFile(
path.join(templatePath, 'db.sqlite'),
path.join(budgetPath, 'db.sqlite'),
);
}
beforeEach(async () => {
const budgetPath = path.join(__dirname, '/../mocks/budgets/', budgetName);
await fs.rm(budgetPath, { force: true, recursive: true });
await createTestBudget('default-budget-template', budgetName);
await api.init({
dataDir: path.join(__dirname, '/../mocks/budgets/'),
});
});
afterEach(async () => {
globalThis.currentMonth = null;
await api.shutdown();
});
globalThis.__test_api = api;
globalThis.__test_budget_name = budgetName;

View File

@@ -18,5 +18,5 @@
},
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
"exclude": ["**/node_modules/*", "dist", "@types"]
}

View File

@@ -0,0 +1 @@
declare module 'vite-plugin-peggy-loader';

View File

@@ -0,0 +1,33 @@
import path from 'path';
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
const distDir = path.resolve(__dirname, 'dist');
export default defineConfig({
build: {
target: 'esnext',
outDir: distDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.browser.ts'),
formats: ['es'],
fileName: () => 'browser.js',
},
},
plugins: [peggyLoader()],
resolve: {
// Default extensions — picks up browser implementations (index.ts)
// instead of .api.ts (which resolves to Node.js/Electron code)
extensions: ['.js', '.ts', '.tsx', '.json'],
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.browser.ts'],
include: ['test/**/*.test.ts'],
maxWorkers: 2,
},
});

View File

@@ -2,9 +2,9 @@ import fs from 'fs';
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
const distDir = path.resolve(__dirname, 'dist');
@@ -84,6 +84,8 @@ export default defineConfig({
},
test: {
globals: true,
setupFiles: ['./test/setup.node.ts'],
include: ['test/**/*.test.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';

View File

@@ -304,7 +304,7 @@ export function registerQueryCommand(program: Command) {
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
const result = (await api.aqlQuery(queryObj)) as { data: unknown };
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);

View File

@@ -116,7 +116,7 @@ export default defineConfig(async ({ mode }) => {
process.env.REACT_APP_BRANCH = process.env.BRANCH;
}
const resolveExtensions = [
let resolveExtensions = [
'.mjs',
'.js',
'.mts',
@@ -126,6 +126,16 @@ export default defineConfig(async ({ mode }) => {
'.json',
];
if (env.IS_GENERIC_BROWSER) {
resolveExtensions = [
'.browser.js',
'.browser.jsx',
'.browser.ts',
'.browser.tsx',
...resolveExtensions,
];
}
const browserOpen = env.BROWSER_OPEN ? `//${env.BROWSER_OPEN}` : true;
return {

View File

@@ -32,6 +32,10 @@
"./client/store": "./src/client/store/index.ts",
"./client/store/mock": "./src/client/store/mock.ts",
"./client/users/*": "./src/client/users/*.ts",
"./client/platform": {
"node": "./src/client/platform.electron.ts",
"default": "./src/client/platform.web.ts"
},
"./client/queries": "./src/client/queries.ts",
"./client/query-helpers": "./src/client/query-helpers.ts",
"./client/query-hooks": "./src/client/query-hooks.ts",
@@ -42,8 +46,8 @@
"./client/undo": "./src/client/undo.ts",
"./mocks": "./src/mocks/index.ts",
"./platform/client/connection": {
"electron": "./src/platform/client/connection/index.electron.ts",
"default": "./src/platform/client/connection/index.ts"
"electron": "./src/platform/client/connection/index.ts",
"default": "./src/platform/client/connection/index.browser.ts"
},
"./platform/client/undo": "./src/platform/client/undo/index.ts",
"./platform/exceptions": "./src/platform/exceptions/index.ts",
@@ -51,6 +55,7 @@
"./platform/server/connection": "./src/platform/server/connection/index.ts",
"./platform/server/fetch": "./src/platform/server/fetch/index.ts",
"./platform/server/fs": "./src/platform/server/fs/index.ts",
"./platform/server/indexeddb": "./src/platform/server/indexeddb/index.ts",
"./platform/server/log": "./src/platform/server/log/index.ts",
"./platform/server/sqlite": "./src/platform/server/sqlite/index.ts",
"./server/budget/types/*": "./src/server/budget/types/*.d.ts",

View File

@@ -1,2 +0,0 @@
// oxlint-disable-next-line no-restricted-imports
export * from './index.electron';

View File

@@ -0,0 +1,213 @@
// @ts-strict-ignore
import { t } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { captureBreadcrumb, captureException } from '../../exceptions';
import * as undo from '../undo';
import type * as T from './index-types';
const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let globalWorker = null;
class ReconstructedError extends Error {
url: string;
line: string;
column: string;
constructor(message, stack, url, line, column) {
super(message);
this.name = this.constructor.name;
this.message = message;
Object.defineProperty(this, 'stack', {
get: function () {
return 'extended ' + this._stack;
},
set: function (value) {
this._stack = value;
},
});
this.stack = stack;
this.url = url;
this.line = line;
this.column = column;
}
}
function handleMessage(msg) {
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
const { id, result, mutated, undoTag } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
// Ignore internal messages that start with __
if (!msg.type.startsWith('__')) {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
}
}
// Note that this does not support retry. If the worker
// dies, it will permanently be disconnected. That should be OK since
// I don't think a worker should ever die due to a system error.
function connectWorker(worker, onOpen, onError) {
globalWorker = worker;
worker.onmessage = event => {
const msg = event.data;
// The worker implementation implements its own concept of a
// 'connect' event because the worker is immediately
// available, but we don't know when the backend is actually
// ready to handle messages.
if (msg.type === 'connect') {
// Send any messages that were queued while closed
if (messageQueue?.length > 0) {
messageQueue.forEach(msg => worker.postMessage(msg));
messageQueue = null;
}
// signal to the backend that we're connected to it
globalWorker.postMessage({
name: 'client-connected-to-backend',
});
onOpen();
} else if (msg.type === 'app-init-failure') {
globalWorker.postMessage({
name: '__app-init-failure-acknowledged',
});
onError(msg);
} else if (msg.type === 'capture-exception') {
captureException(
msg.stack
? new ReconstructedError(
msg.message,
msg.stack,
msg.url,
msg.line,
msg.column,
)
: msg.exc,
);
if (msg.message && msg.message.includes('indexeddb-quota-error')) {
alert(
t(
'We hit a limit on the local storage available. Edits may not be saved. Please get in touch https://actualbudget.org/contact/ so we can help debug this.',
),
);
}
} else if (msg.type === 'capture-breadcrumb') {
captureBreadcrumb(msg.data);
} else {
handleMessage(msg);
}
};
// In browsers that don't support wasm in workers well (Safari),
// we run the server on the main process for now. This might not
// actually be a worker, but instead a message port which we
// need to start.
if (worker instanceof MessagePort) {
worker.start();
}
}
export const init: T.Init = async function () {
const worker = await global.Actual.getServerSocket();
return new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
);
};
export const send: T.Send = function (
...params: Parameters<T.Send>
): ReturnType<T.Send> {
const [name, args, { catchErrors = false } = {}] = params;
return new Promise((resolve, reject) => {
const id = uuidv4();
replyHandlers.set(id, { resolve, reject });
const message = {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
};
if (messageQueue) {
messageQueue.push(message);
} else {
globalWorker.postMessage(message);
}
});
};
export const sendCatch: T.SendCatch = function (name, args) {
return send(name, args, { catchErrors: true });
};
export const listen: T.Listen = function (name, cb) {
if (!listeners.get(name)) {
listeners.set(name, []);
}
listeners.get(name).push(cb);
return () => {
const arr = listeners.get(name);
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
};
};
export const unlisten: T.Unlisten = function (name) {
listeners.set(name, []);
};
export const initServer: T.InitServer = async function () {
// initServer is used in tests to mock the server
};
export const serverPush: T.ServerPush = async function () {
// serverPush is used in tests to mock the server
};
export const clearServer: T.ClearServer = async function () {
// clearServer is used in tests to mock the server
};

View File

@@ -1,155 +0,0 @@
// @ts-strict-ignore
import { v4 as uuidv4 } from 'uuid';
import * as undo from '../undo';
import type * as T from './index-types';
const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let socketClient = null;
function connectSocket(onOpen) {
global.Actual.ipcConnect(function (client) {
client.on('message', data => {
const msg = data;
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
let { result } = msg;
const { id, mutated, undoTag } = msg;
// Check if the result is a serialized buffer, and if so
// convert it to a Uint8Array. This is only needed when working
// with node; the web version connection layer automatically
// supports buffers
if (result && result.type === 'Buffer' && Array.isArray(result.data)) {
result = new Uint8Array(result.data);
}
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
});
socketClient = client;
// Send any messages that were queued while closed
if (messageQueue.length > 0) {
messageQueue.forEach(msg => client.emit('message', msg));
messageQueue = [];
}
onOpen();
});
}
export const init: T.Init = async function () {
return new Promise(connectSocket);
};
export const send: T.Send = function (
...params: Parameters<T.Send>
): ReturnType<T.Send> {
const [name, args, { catchErrors = false } = {}] = params;
return new Promise((resolve, reject) => {
const id = uuidv4();
replyHandlers.set(id, { resolve, reject });
if (socketClient) {
socketClient.emit('message', {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors: !!catchErrors,
});
} else {
messageQueue.push({
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
});
}
});
};
export const sendCatch: T.SendCatch = function (name, args) {
return send(name, args, { catchErrors: true });
};
export const listen: T.Listen = function (name, cb) {
if (!listeners.get(name)) {
listeners.set(name, []);
}
listeners.get(name).push(cb);
return () => {
const arr = listeners.get(name);
if (arr) {
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
}
};
};
export const unlisten: T.Unlisten = function (name) {
listeners.set(name, []);
};
async function closeSocket(onClose) {
socketClient.onclose = () => {
socketClient = null;
onClose();
};
await socketClient.close();
}
export const clearServer: T.ClearServer = async function () {
if (socketClient != null) {
return new Promise(closeSocket);
}
};
export const initServer: T.InitServer = async function () {
// initServer is used in tests to mock the server
};
export const serverPush: T.ServerPush = async function () {
// serverPush is used in tests to mock the server
};

View File

@@ -1,8 +1,6 @@
// @ts-strict-ignore
import { t } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { captureBreadcrumb, captureException } from '../../exceptions';
import * as undo from '../undo';
import type * as T from './index-types';
@@ -10,150 +8,76 @@ import type * as T from './index-types';
const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let socketClient = null;
let globalWorker = null;
function connectSocket(onOpen) {
global.Actual.ipcConnect(function (client) {
client.on('message', data => {
const msg = data;
class ReconstructedError extends Error {
url: string;
line: string;
column: string;
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
let { result } = msg;
const { id, mutated, undoTag } = msg;
constructor(message, stack, url, line, column) {
super(message);
this.name = this.constructor.name;
this.message = message;
// Check if the result is a serialized buffer, and if so
// convert it to a Uint8Array. This is only needed when working
// with node; the web version connection layer automatically
// supports buffers
if (result && result.type === 'Buffer' && Array.isArray(result.data)) {
result = new Uint8Array(result.data);
}
Object.defineProperty(this, 'stack', {
get: function () {
return 'extended ' + this._stack;
},
set: function (value) {
this._stack = value;
},
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
});
this.stack = stack;
this.url = url;
this.line = line;
this.column = column;
}
}
socketClient = client;
function handleMessage(msg) {
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
// Send any messages that were queued while closed
if (messageQueue.length > 0) {
messageQueue.forEach(msg => client.emit('message', msg));
messageQueue = [];
}
} else if (msg.type === 'reply') {
const { id, result, mutated, undoTag } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
// Ignore internal messages that start with __
if (!msg.type.startsWith('__')) {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
}
}
// Note that this does not support retry. If the worker
// dies, it will permanently be disconnected. That should be OK since
// I don't think a worker should ever die due to a system error.
function connectWorker(worker, onOpen, onError) {
globalWorker = worker;
worker.onmessage = event => {
const msg = event.data;
// The worker implementation implements its own concept of a
// 'connect' event because the worker is immediately
// available, but we don't know when the backend is actually
// ready to handle messages.
if (msg.type === 'connect') {
// Send any messages that were queued while closed
if (messageQueue?.length > 0) {
messageQueue.forEach(msg => worker.postMessage(msg));
messageQueue = null;
}
// signal to the backend that we're connected to it
globalWorker.postMessage({
name: 'client-connected-to-backend',
});
onOpen();
} else if (msg.type === 'app-init-failure') {
globalWorker.postMessage({
name: '__app-init-failure-acknowledged',
});
onError(msg);
} else if (msg.type === 'capture-exception') {
captureException(
msg.stack
? new ReconstructedError(
msg.message,
msg.stack,
msg.url,
msg.line,
msg.column,
)
: msg.exc,
);
if (msg.message && msg.message.includes('indexeddb-quota-error')) {
alert(
t(
'We hit a limit on the local storage available. Edits may not be saved. Please get in touch https://actualbudget.org/contact/ so we can help debug this.',
),
);
}
} else if (msg.type === 'capture-breadcrumb') {
captureBreadcrumb(msg.data);
} else {
handleMessage(msg);
}
};
// In browsers that don't support wasm in workers well (Safari),
// we run the server on the main process for now. This might not
// actually be a worker, but instead a message port which we
// need to start.
if (worker instanceof MessagePort) {
worker.start();
}
onOpen();
});
}
export const init: T.Init = async function () {
const worker = await global.Actual.getServerSocket();
return new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
);
return new Promise(connectSocket);
};
export const send: T.Send = function (
@@ -162,19 +86,24 @@ export const send: T.Send = function (
const [name, args, { catchErrors = false } = {}] = params;
return new Promise((resolve, reject) => {
const id = uuidv4();
replyHandlers.set(id, { resolve, reject });
const message = {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
};
if (messageQueue) {
messageQueue.push(message);
if (socketClient) {
socketClient.emit('message', {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors: !!catchErrors,
});
} else {
globalWorker.postMessage(message);
messageQueue.push({
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
});
}
});
};
@@ -191,10 +120,12 @@ export const listen: T.Listen = function (name, cb) {
return () => {
const arr = listeners.get(name);
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
if (arr) {
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
}
};
};
@@ -202,12 +133,23 @@ export const unlisten: T.Unlisten = function (name) {
listeners.set(name, []);
};
async function closeSocket(onClose) {
socketClient.onclose = () => {
socketClient = null;
onClose();
};
await socketClient.close();
}
export const clearServer: T.ClearServer = async function () {
if (socketClient != null) {
return new Promise(closeSocket);
}
};
export const initServer: T.InitServer = async function () {
// initServer is used in tests to mock the server
};
export const serverPush: T.ServerPush = async function () {
// serverPush is used in tests to mock the server
};
export const clearServer: T.ClearServer = async function () {
// clearServer is used in tests to mock the server
};

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
api: add browser support

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Remove special "\*.browser.ts" file extension

View File

@@ -28,7 +28,9 @@ __metadata:
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
better-sqlite3: "npm:^12.6.2"
compare-versions: "npm:^6.1.1"
fake-indexeddb: "npm:^6.2.5"
node-fetch: "npm:^3.3.2"
npm-run-all: "npm:^4.1.5"
rollup-plugin-visualizer: "npm:^6.0.11"
typescript-strict-plugin: "npm:^2.4.4"
uuid: "npm:^13.0.0"