mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
Compare commits
13 Commits
matiss/rem
...
claude/bro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ed7d88a3 | ||
|
|
1d34d15f12 | ||
|
|
e512ff7312 | ||
|
|
f0470a37de | ||
|
|
b662205691 | ||
|
|
4a5b572baf | ||
|
|
5b179b0672 | ||
|
|
645623a6c9 | ||
|
|
1442747896 | ||
|
|
3e2303e5dc | ||
|
|
332db28e2e | ||
|
|
6d6e032429 | ||
|
|
399e59c088 |
@@ -335,7 +335,7 @@
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.electron"],
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
25
packages/api/index.browser.ts
Normal file
25
packages/api/index.browser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
12
packages/api/test/globals.d.ts
vendored
Normal 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;
|
||||
}
|
||||
92
packages/api/test/setup.browser.ts
Normal file
92
packages/api/test/setup.browser.ts
Normal 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;
|
||||
64
packages/api/test/setup.node.ts
Normal file
64
packages/api/test/setup.node.ts
Normal 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;
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
1
packages/api/typings/vite-plugin-peggy-loader.d.ts
vendored
Normal file
1
packages/api/typings/vite-plugin-peggy-loader.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module 'vite-plugin-peggy-loader';
|
||||
33
packages/api/vite.browser.config.ts
Normal file
33
packages/api/vite.browser.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
export * from './index.electron';
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
6
upcoming-release-notes/7247.md
Normal file
6
upcoming-release-notes/7247.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
api: add browser support
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Remove special "\*.browser.ts" file extension
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user