Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent
b89bc27e11 [AI] Fix error propagation in importScriptsWithRetry
- Change Promise executor to accept both resolve and reject
- Properly propagate errors using .then(resolve).catch(reject)
- Fixes issue where errors from recursive retry calls were swallowed

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-05-08 21:12:37 +00:00
Matiss Janis Aboltins
013cc2a5bf Remove support contact message from FatalError
Removed support contact message from FatalError component.
2026-05-08 21:56:27 +01:00
github-actions[bot]
597d2c8889 [AI] Recover from BackendInitFailure and show a meaningful error
When the backend Worker fails to load (e.g., the hashed kcab.worker
asset can't be fetched), the SharedWorker would cache the
app-init-failure and replay it to every subsequent tab forever, while
the FatalError modal showed a misleading "browser version" message.

- Retry importScripts in production (3 attempts) so a transient blip
  doesn't brick the SharedWorker.
- Clear lastAppInitFailure when the client acknowledges the failure,
  when a backend later connects successfully (centralized in
  broadcastConnect), and when a fresh init arrives with no active
  groups (the failed leader is gone).
- Add a BackendInitFailure branch to FatalError's RenderSimple with a
  message that points the user at reload / hard refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:54:05 +01:00
36 changed files with 413 additions and 316 deletions

View File

@@ -3,6 +3,9 @@ contact_links:
- name: Bank-sync issues
url: https://discord.gg/pRYNYr4W5A
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
- name: Support
url: https://discord.gg/pRYNYr4W5A
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
- name: Translations
url: https://hosted.weblate.org/projects/actualbudget/actual/
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.

View File

@@ -1,17 +0,0 @@
name: Tech Support
description: Need help with something? Having troubles setting up? Or perhaps issues using the API?
title: '[Support]: '
labels: ['tech-support']
body:
- type: markdown
attributes:
value: |
> ⚠️ **Tech support tickets opened here are automatically closed.** GitHub Issues are reserved for bug reports and feature requests. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
- type: textarea
id: problem
attributes:
label: Describe your problem
description: Please describe, in as much detail as you can, what you need help with.
placeholder: I'm trying to [...] but [...]
validations:
required: true

View File

@@ -1,23 +0,0 @@
name: Close tech support issues with automated message
on:
issues:
types: [labeled]
jobs:
tech-support:
if: ${{ github.event.label.name == 'tech-support' }}
runs-on: ubuntu-latest
steps:
- name: Create comment and close issue
run: |
gh issue comment "$ISSUE_URL" --body ":wave: Thanks for reaching out!
GitHub Issues are reserved for bug reports and feature requests, so tech support tickets are automatically closed. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
<!-- tech-support-auto-close-comment -->"
gh issue close "$ISSUE_URL"
env:
ISSUE_URL: https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -15,8 +15,7 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly",
"__APP_VERSION__": "readonly"
"FS": "readonly"
},
"rules": {
// Import sorting

View File

@@ -52,7 +52,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",

View File

@@ -50,7 +50,7 @@
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1",
"uuid": "^14.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@typescript/native-preview": "beta",

View File

@@ -10,10 +10,14 @@
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
],
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
@@ -21,9 +25,7 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
},
"scripts": {
"build:node": "vite build",
@@ -35,7 +37,7 @@
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^14.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -199,7 +199,7 @@
"sass": "^1.99.0",
"typescript-strict-plugin": "^2.4.4",
"usehooks-ts": "^3.1.1",
"uuid": "^14.0.0",
"uuid": "^13.0.0",
"vite": "^8.0.5",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.2"

View File

@@ -335,17 +335,10 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
// Skip SW registration in dev so stale cached assets don't override edits
// between page loads. Plugin code that needs a SW can register one itself.
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
// SW lifecycle to swap the page — fall back to a plain reload so callers
// don't hang on the never-resolving promise inside applyAppUpdate.
const updateSW = IS_DEV
? () => window.location.reload()
: registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,

View File

@@ -25,14 +25,15 @@ const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => {
}
// Attempt to retry after a small delay
await new Promise(resolve =>
setTimeout(async () => {
await importScriptsWithRetry(script, {
await new Promise((resolve, reject) => {
setTimeout(() => {
importScriptsWithRetry(script, {
maxRetries: maxRetries - 1,
});
resolve();
}, 5000),
);
})
.then(resolve)
.catch(reject);
}, 5000);
});
}
};
@@ -76,9 +77,11 @@ self.addEventListener('message', async event => {
return;
}
// A single failed importScripts bricks the SharedWorker until
// it's evicted, so retry in production too.
await importScriptsWithRetry(
`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`,
{ maxRetries: isDev ? 5 : 0 },
{ maxRetries: isDev ? 5 : 3 },
);
backend.initApp(isDev, self).catch(err => {

View File

@@ -30,7 +30,7 @@ describe('FatalError', () => {
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
});
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
it('renders a backend-worker message for a BackendInitFailure', () => {
const error = {
type: 'app-init-failure',
BackendInitFailure: true,
@@ -38,6 +38,16 @@ describe('FatalError', () => {
render(<FatalError error={error} />, { wrapper: TestProviders });
expect(
screen.getByText(/couldn't load a critical backend worker/i),
).toBeInTheDocument();
});
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
const error = { type: 'app-init-failure' };
render(<FatalError error={error} />, { wrapper: TestProviders });
expect(
screen.getByText(/problem loading the app in this browser version/i),
).toBeInTheDocument();

View File

@@ -69,10 +69,17 @@ function RenderSimple({ error }: RenderSimpleProps) {
</Trans>
</Text>
);
} else if ('BackendInitFailure' in error && error.BackendInitFailure) {
msg = (
<Text>
<Trans>
Actual couldn't load a critical backend worker. Reload the page to try
again; if the problem persists, do a hard refresh to clear any stale
cached assets.
</Trans>
</Text>
);
} else {
// This indicates the backend failed to initialize. Show the
// user something at least so they aren't looking at a blank
// screen
msg = (
<Text>
<Trans>
@@ -92,19 +99,6 @@ function RenderSimple({ error }: RenderSimpleProps) {
}}
>
<Text>{msg}</Text>
<Text>
<Trans>
Please get{' '}
<Link
variant="external"
linkColor="muted"
to="https://actualbudget.org/contact"
>
in touch
</Link>{' '}
for support
</Trans>
</Text>
</SpaceBetween>
);
}

View File

@@ -172,6 +172,47 @@ describe('SharedWorker coordinator', () => {
expect.objectContaining({ type: 'app-init-failure', error: 'boom' }),
);
});
it('clears the cached init failure when the client acknowledges it', () => {
const leader = connectTab(coordinator);
sendInit(leader);
simulateWorkerConnect(leader);
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'app-init-failure', error: 'boom' },
});
expect(coordinator.getState().lastAppInitFailure).toEqual(
expect.objectContaining({ type: 'app-init-failure' }),
);
sendMsg(leader, { name: '__app-init-failure-acknowledged' });
expect(coordinator.getState().lastAppInitFailure).toBeNull();
const port2 = connectTab(coordinator);
sendInit(port2);
expect(port2.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'app-init-failure' }),
);
});
it('clears the cached init failure when a backend later connects successfully', () => {
const failedLeader = connectTab(coordinator);
sendInit(failedLeader);
sendMsg(failedLeader, {
type: '__from-worker',
msg: { type: 'app-init-failure', error: 'boom' },
});
expect(coordinator.getState().lastAppInitFailure).not.toBeNull();
sendMsg(failedLeader, { type: 'tab-closing' });
const newLeader = connectTab(coordinator);
sendInit(newLeader);
simulateWorkerConnect(newLeader);
expect(coordinator.getState().lastAppInitFailure).toBeNull();
});
});
// ── Load budget ─────────────────────────────────────────────────────

View File

@@ -100,6 +100,7 @@ export function createCoordinator({
}
function broadcastConnect(budgetId: string) {
lastAppInitFailure = null;
const connectMsg = { type: 'connect' };
broadcastToAllInGroup(budgetId, connectMsg);
for (const port of unassignedPorts) {
@@ -544,10 +545,22 @@ export function createCoordinator({
return;
}
// Drop the cache once the client has surfaced the failure, so a
// subsequent init can re-attempt. Falls through to the leader so
// the Worker still stops its retry interval.
if (msg.name === '__app-init-failure-acknowledged') {
lastAppInitFailure = null;
}
// ── Initialization ─────────────────────────────────────────
if (msg.type === 'init') {
cachedInitMsg = msg;
// The leader that produced the failure is gone, so the cache
// can't recover — drop it and let this tab attempt a fresh init.
if (lastAppInitFailure && budgetGroups.size === 0) {
lastAppInitFailure = null;
}
if (lastAppInitFailure) {
port.postMessage(lastAppInitFailure);
} else {

View File

@@ -376,9 +376,7 @@ export default defineConfig(async ({ mode, command }) => {
// swSrc: `service-worker/plugin-sw.js`,
// },
devOptions: {
// Disabled: caches stale assets across reloads in dev. Plugin
// code that explicitly needs a SW can register one itself.
enabled: false,
enabled: true, // We need service worker in dev mode to work with plugins
type: 'module',
},
workbox: {

View File

@@ -239,11 +239,12 @@ async function startSyncServer() {
),
};
// require.resolve will recursively search up the workspace for the module
const syncServerRoot = path.dirname(
require.resolve('@actual-app/sync-server/package.json'),
const serverPath = path.join(
// require.resolve will recursively search up the workspace for the module
path.dirname(require.resolve('@actual-app/sync-server/package.json')),
'build',
'app.js',
);
const serverPath = path.join(syncServerRoot, 'build/app.js');
const webRoot = path.join(
// require.resolve will recursively search up the workspace for the module

View File

@@ -1,16 +1,12 @@
# Preview Builds
Each pull request automatically deploys preview builds to Netlify, so you can try out changes without cloning the branch.
It is possible using our deployment pipeline to run preview builds of Actual directly on Netlify.
To find a PR, browse the [open pull requests](https://github.com/actualbudget/actual/pulls). Once you have the PR number, replace `{pr-number}` in the URLs below.
To do this, find the pull request (PR) that you would like to preview in GitHub, you can find the pull requests in scope of the preview builds [here](https://github.com/actualbudget/actual/pulls).
Three previews are deployed per PR:
Once you have the number of the PR navigate to the following URL: https://deploy-preview-pr_number--actualbudget.netlify.app/ replacing pr_number with the number of the PR you would like to preview, for example https://deploy-preview-414--actualbudget.netlify.app/
- **Demo:** `https://deploy-preview-{pr-number}.demo.actualbudget.org/`
- **Storybook:** `https://deploy-preview-{pr-number}--actualbudget-storybook.netlify.app/`
- **Website:** `https://deploy-preview-{pr-number}.www.actualbudget.org/`
The exact URLs are also posted as a comment on each PR by the Netlify bot.
This will load directly on Netlify where you will be able to preview the changes in that pull request without the need to clone the specific branch.
:::info
There is no sync server on preview builds so when asked "Where's the server" select "Don't use a server." Alternatively, you can use your own self-hosted server. You should exercise caution when using a server with preview builds because they are much more likely to have bugs that could damage your budget. Consider running a separate local server for preview builds.

View File

@@ -68,46 +68,3 @@ Finally, a draft GitHub release should be automatically created; confirm [on the
- [ ] Un-draft the GitHub release which will send announcement notifications to all apps and create a PR to the [Actual Flathub Repository](https://github.com/flathub/com.actualbudget.actual/pulls).
- [ ] Send an announcement on Discord and Twitter.
- [ ] Approve and merge the [Flathub Release PR](https://github.com/flathub/com.actualbudget.actual/pulls) to master. After merge, it can take anywhere from hours to a few days before the app will be available in the Flathub Store.
## Cutting a patch release
Patch releases (e.g. `v26.5.1`) ship a small, targeted set of fixes on top of an existing release. Unlike monthly releases, the release branch is built by cherry-picking specific commits from `master` onto the previous release tag, so unrelated in-progress work on `master` is not pulled in.
### Build the release branch
- [ ] Identify the commits on `master` that should be included in the patch release and note their commit hashes.
- [ ] Check out the previous release tag and create a new release branch from it:
```bash
git checkout v26.5.0
git checkout -b release/v26.5.1
```
- [ ] Cherry-pick each commit onto the new branch, in the same order they were merged to `master`:
```bash
git cherry-pick <commit-sha>
```
- [ ] Push the release branch. This is the branch that will be tagged later — **do not tag it yet**:
```bash
git push -u {remote} release/v26.5.1
```
### Open the release PR against master
The release branch is what gets tagged, but the version bump, release notes cleanup, and blog post still need to land on `master` so future releases pick them up.
- [ ] Check out `master` and create a new branch from it (e.g. `release-notes/v26.5.1`).
- [ ] In this branch:
- Bump the version in the relevant `package.json` files.
- Delete the `upcoming-release-notes/*.md` files that correspond to the cherry-picked commits.
- Add a new blog post under `packages/docs/blog/` (see [`2026-02-22-release-26-2-1.md`](https://github.com/actualbudget/actual/blob/master/packages/docs/blog/2026-02-22-release-26-2-1.md) for an example).
- [ ] Commit the changes and open a PR against `master`. Include a link to the previously pushed release branch (e.g. `release/v26.5.1`) in the PR description so reviewers can see exactly what is shipping.
### Tag the release
- [ ] Once the PR has been approved and merged, tag the **release branch** (not `master`) and push the tag:
```bash
git checkout release/v26.5.1
git tag v26.5.1
git push {remote} v26.5.1
```
From here the rest of the release pipeline (NPM, Docker, Desktop, GitHub draft release) runs automatically. Follow the [Verify the release](#verify-the-release) and [Finalize the release](#finalize-the-release) steps above to complete the rollout.

View File

@@ -191,7 +191,7 @@
"promise-retry": "^2.0.1",
"typescript-strict-plugin": "^2.4.4",
"ua-parser-js": "^2.0.9",
"uuid": "^14.0.0",
"uuid": "^13.0.0",
"xml2js": "^0.6.2"
},
"devDependencies": {

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
const args = process.argv;
@@ -53,7 +54,11 @@ if (values.help) {
}
if (values.version) {
console.log('v' + __APP_VERSION__);
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = resolve(__dirname, '../../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
console.log('v' + packageJson.version);
process.exit();
}

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const buildDir = resolve(__dirname, '../build');
const packageRoot = resolve(__dirname, '..');
const packageJson = JSON.parse(
readFileSync(join(packageRoot, 'package.json'), 'utf-8'),
);
// publishConfig.imports already has ./build/src/ paths with .js extensions
const importsMap = packageJson.publishConfig?.imports || {};
// Sort wildcard patterns longest-prefix-first so more specific patterns
// (e.g. #app-gocardless/services/tests/*) match before broader ones (#app-gocardless/*)
const wildcardEntries = Object.entries(importsMap)
.filter(([p]) => p.includes('*'))
.sort(([a], [b]) => b.length - a.length);
async function getAllJsFiles(dir) {
const files = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await getAllJsFiles(fullPath)));
} else if (entry.isFile() && extname(entry.name) === '.js') {
files.push(fullPath);
}
}
return files;
}
function resolveImportPath(importPath, fromFile) {
const baseDir = dirname(fromFile);
const resolvedPath = resolve(baseDir, importPath);
// Check if it's a file with .js extension
if (existsSync(`${resolvedPath}.js`)) {
return `${importPath}.js`;
}
// Check if it's a directory with index.js
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
return `${importPath}/index.js`;
}
// Verify the file exists before adding extension
if (!existsSync(`${resolvedPath}.js`)) {
console.warn(
`Warning: Could not resolve import '${importPath}' from ${relative(buildDir, fromFile)}`,
);
}
// Default: assume it's a file and add .js
return `${importPath}.js`;
}
function toRelativePath(target, fromFile) {
const absoluteTarget = resolve(packageRoot, target);
let rel = relative(dirname(fromFile), absoluteTarget);
if (!rel.startsWith('.')) rel = './' + rel;
return rel.split('\\').join('/');
}
function resolveSubpathImport(importPath, fromFile) {
if (importsMap[importPath]) {
return toRelativePath(importsMap[importPath], fromFile);
}
for (const [pattern, target] of wildcardEntries) {
const prefix = pattern.replaceAll('*', '');
if (importPath.startsWith(prefix)) {
const wildcard = importPath.slice(prefix.length);
return toRelativePath(target.replaceAll('*', wildcard), fromFile);
}
}
console.warn(
`Warning: Could not resolve subpath import '${importPath}' from ${relative(buildDir, fromFile)}`,
);
return null;
}
function addExtensionsToImports(content, filePath) {
const importRegex =
/(?:import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*\s+from\s+)?|import\s*\(|require\s*\()['"]((\.\.?\/[^'"]+)|(#[^'"]+))['"]/g;
return content.replace(importRegex, (match, importPath) => {
if (!importPath || typeof importPath !== 'string') {
return match;
}
if (importPath.startsWith('#')) {
const resolved = resolveSubpathImport(importPath, filePath);
if (resolved) {
return match.replace(importPath, resolved);
}
return match;
}
// Skip if already has an extension
if (/\.(js|mjs|ts|mts|json)$/.test(importPath)) {
return match;
}
// Skip if ends with / (directory import that already has trailing slash)
if (importPath.endsWith('/')) {
return match;
}
const newImportPath = resolveImportPath(importPath, filePath);
return match.replace(importPath, newImportPath);
});
}
async function processFile(filePath) {
const content = await readFile(filePath, 'utf-8');
const newContent = addExtensionsToImports(content, filePath);
if (content !== newContent) {
await writeFile(filePath, newContent, 'utf-8');
const relativePath = relative(buildDir, filePath);
console.log(`Updated imports in ${relativePath}`);
}
}
async function main() {
try {
const files = await getAllJsFiles(buildDir);
await Promise.all(files.map(processFile));
console.log(`Processed ${files.length} files`);
} catch (error) {
console.error('Error processing files:', error);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,26 @@
import { existsSync } from 'node:fs';
import { dirname, extname, resolve as nodeResolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const extensions = ['.ts', '.js', '.mts', '.mjs'];
export async function resolve(specifier, context, nextResolve) {
// Only handle relative imports without extensions
if (specifier.startsWith('.') && !extname(specifier)) {
const parentURL = context.parentURL;
if (parentURL) {
const parentPath = new URL(parentURL).pathname;
const parentDir = dirname(parentPath);
// Try extensions in order
for (const ext of extensions) {
const resolvedPath = nodeResolve(parentDir, `${specifier}${ext}`);
if (existsSync(resolvedPath)) {
return nextResolve(pathToFileURL(resolvedPath).href, context);
}
}
}
}
return nextResolve(specifier, context);
}

View File

@@ -41,19 +41,47 @@
"#util/title": "./src/util/title/index.js",
"#util/*": "./src/util/*.ts"
},
"publishConfig": {
"imports": {
"#db": "./build/src/db.js",
"#account-db": "./build/src/account-db.js",
"#load-config": "./build/src/load-config.js",
"#migrations": "./build/src/migrations.js",
"#accounts/*": "./build/src/accounts/*.js",
"#app-gocardless/banks/bank.interface": "./build/src/app-gocardless/banks/bank.interface.js",
"#app-gocardless/banks/*": "./build/src/app-gocardless/banks/*.js",
"#app-gocardless/errors": "./build/src/app-gocardless/errors.js",
"#app-gocardless/gocardless-node.types": "./build/src/app-gocardless/gocardless-node.types.js",
"#app-gocardless/gocardless.types": "./build/src/app-gocardless/gocardless.types.js",
"#app-gocardless/services/*": "./build/src/app-gocardless/services/*.js",
"#app-gocardless/services/tests/*": "./build/src/app-gocardless/services/tests/*.js",
"#app-gocardless/util/*": "./build/src/app-gocardless/util/*.js",
"#app-gocardless/*": "./build/src/app-gocardless/*.js",
"#app-pluggyai/*": "./build/src/app-pluggyai/*.js",
"#app-simplefin/*": "./build/src/app-simplefin/*.js",
"#app-sync/services/*": "./build/src/app-sync/services/*.js",
"#app-sync/*": "./build/src/app-sync/*.js",
"#scripts/*": "./build/src/scripts/*.js",
"#services/*": "./build/src/services/*.js",
"#util/title": "./build/src/util/title/index.js",
"#util/*": "./build/src/util/*.js"
}
},
"scripts": {
"start": "yarn build && node build/app.js",
"start-monitor": "nodemon --exec 'yarn build && node build/app.js' --ignore './build/**/*' --ext 'ts,js' build/app.js",
"build": "vite build",
"start": "yarn build && node build/app",
"start-monitor": "nodemon --exec 'yarn build && node build/app' --ignore './build/**/*' --ext 'ts,js' build/app",
"build": "tsgo -b && yarn add-import-extensions && yarn copy-static-assets",
"typecheck": "tsgo -b && tsc-strict",
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' vitest --run",
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js up",
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js down",
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js up",
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js down",
"reset-password": "yarn build && node build/scripts/reset-password.js",
"disable-openid": "yarn build && node build/scripts/disable-openid.js",
"health-check": "yarn build && node build/scripts/health-check.js"
"add-import-extensions": "node bin/add-import-extensions.mjs",
"copy-static-assets": "rm -rf build/src/sql && cp -r src/sql build/src/sql",
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --import ./register-loader.mjs --trace-warnings' vitest --run",
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js up",
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js down",
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js up",
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js down",
"reset-password": "yarn build && node build/src/scripts/reset-password.js",
"disable-openid": "yarn build && node build/src/scripts/disable-openid.js",
"health-check": "yarn build && node build/src/scripts/health-check.js"
},
"dependencies": {
"@actual-app/crdt": "workspace:*",
@@ -71,7 +99,7 @@
"migrate": "^2.1.0",
"openid-client": "^5.7.1",
"pluggy-sdk": "^0.83.0",
"uuid": "^14.0.0",
"uuid": "^13.0.0",
"winston": "^3.19.0"
},
"devDependencies": {
@@ -88,10 +116,6 @@
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -0,0 +1,9 @@
import { register } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const loaderPath = resolve(__dirname, 'loader.mjs');
register(pathToFileURL(loaderPath).href, pathToFileURL(__dirname));

View File

@@ -1,14 +1,22 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import IntegrationBank from './banks/integration-bank';
// Filename convention: <name>_<bic>.{ts,js} (skips bank.interface,
// integration-bank, and any other helper without an underscore).
const bankLoaders = import.meta.glob('./banks/*_*.{ts,js}');
const dirname = path.resolve(fileURLToPath(import.meta.url), '..');
const banksDir = path.resolve(dirname, 'banks');
async function loadBanks() {
const bankHandlers = fs
.readdirSync(banksDir)
.filter(filename => filename.includes('_') && filename.endsWith('.js'));
const imports = await Promise.all(
Object.values(bankLoaders).map(loader =>
loader().then(handler => handler.default),
),
bankHandlers.map(file => {
const fileUrlToBank = pathToFileURL(path.resolve(banksDir, file)); // pathToFileURL for ESM compatibility
return import(fileUrlToBank.toString()).then(handler => handler.default);
}),
);
return imports;

View File

@@ -124,32 +124,17 @@ app.get('/metrics', (_req, res) => {
});
});
// The web frontend.
// Dev mode proxies to Vite, which injects inline preamble scripts and uses
// a websocket for HMR. Loosen script-src and connect-src accordingly.
// `'unsafe-eval'` is required at runtime for the Electron app, so it is
// kept in both branches.
const isDev = process.env.NODE_ENV === 'development';
const scriptSrc = isDev
? "'self' 'unsafe-inline' 'unsafe-eval' blob:"
: "'self' 'unsafe-eval' blob:";
const connectSrc = isDev ? "'self' ws: wss: http: https:" : 'http: https:';
const csp = [
"default-src 'self' blob:",
"img-src 'self' blob: data:",
`script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
`connect-src ${connectSrc}`,
].join('; ');
// The web frontend
app.use((req, res, next) => {
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
res.set('Content-Security-Policy', csp);
res.set(
'Content-Security-Policy',
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
);
next();
});
if (isDev) {
if (process.env.NODE_ENV === 'development') {
console.log(
'Running in development mode - Proxying frontend routes to React Dev Server',
);

View File

@@ -21,6 +21,8 @@ const defaultDataDir = process.env.ACTUAL_DATA_DIR
debug(`Project root: '${projectRoot}'`);
export const sqlDir = path.join(__dirname, 'sql');
const actualAppWebBuildPath = path.join(
path.dirname(require.resolve('@actual-app/web/package.json')),
'build',

View File

@@ -1,34 +1,40 @@
import path from 'node:path';
import { readdir } from 'node:fs/promises';
import path, { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { load } from 'migrate';
import { config } from './load-config';
type MigrationCallback = (err?: Error) => void;
type MigrationModule = {
up: (next?: MigrationCallback) => void;
down: (next?: MigrationCallback) => void;
};
// Vite resolves this glob at build time and inlines a static map of
// () => import('chunks/...js') calls. Each migration becomes its own chunk.
// Runtime fs reads against a migrations/ directory disappear.
const migrationsLoaders = import.meta.glob<MigrationModule>(
'../migrations/*.{ts,js}',
);
export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
console.log(
`Checking if there are any migrations to run for direction "${direction}"...`,
);
try {
const sortedKeys = Object.keys(migrationsLoaders).sort();
const migrationsModules: Record<string, MigrationModule> = {};
const __dirname = dirname(fileURLToPath(import.meta.url)); // this directory
const migrationsDir = path.join(__dirname, '../migrations');
for (const key of sortedKeys) {
const fileName = key.split('/').pop()!;
migrationsModules[fileName] = await migrationsLoaders[key]();
try {
// Load all script files in the migrations directory
const files = await readdir(migrationsDir);
const migrationsModules: Record<
string,
{
up: (next?: MigrationCallback) => void;
down: (next?: MigrationCallback) => void;
}
> = {};
for (const f of files
.filter(
f => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts'),
)
.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0))) {
migrationsModules[f] = await import(
pathToFileURL(path.join(migrationsDir, f)).href
);
}
return new Promise<void>((resolve, reject) => {

View File

@@ -1,9 +1,10 @@
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt';
import { openDatabase } from './db';
import messagesSql from './sql/messages.sql?raw';
import { sqlDir } from './load-config';
import { getPathForGroupFile } from './util/paths';
function getGroupDb(groupId) {
@@ -13,7 +14,8 @@ function getGroupDb(groupId) {
const db = openDatabase(path);
if (needsInit) {
db.exec(messagesSql);
const sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8');
db.exec(sql);
}
return db;

View File

@@ -1,73 +0,0 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { defineConfig } from 'vite';
import type { Plugin } from 'vite';
const pkg = JSON.parse(
readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
);
const shebangPlugin = (entryFile: string): Plugin => ({
name: 'sync-server-shebang',
generateBundle(_options, bundle) {
const chunk = bundle[entryFile];
if (chunk?.type === 'chunk' && !chunk.code.startsWith('#!')) {
chunk.code = `#!/usr/bin/env node\n${chunk.code}`;
}
},
});
export default defineConfig({
ssr: {
target: 'node',
// Inline workspace deps that ship as TS source. Anything else
// (express, better-sqlite3, bcrypt, @actual-app/web, etc.) stays
// external so Node resolves it at runtime.
noExternal: ['@actual-app/crdt'],
},
build: {
ssr: true,
target: 'node22',
outDir: path.resolve(__dirname, 'build'),
emptyOutDir: true,
sourcemap: true,
minify: false,
rollupOptions: {
input: {
app: path.resolve(__dirname, 'app.ts'),
'bin/actual-server': path.resolve(__dirname, 'bin/actual-server.js'),
'scripts/run-migrations': path.resolve(
__dirname,
'src/scripts/run-migrations.js',
),
'scripts/reset-password': path.resolve(
__dirname,
'src/scripts/reset-password.js',
),
'scripts/disable-openid': path.resolve(
__dirname,
'src/scripts/disable-openid.js',
),
'scripts/enable-openid': path.resolve(
__dirname,
'src/scripts/enable-openid.js',
),
'scripts/health-check': path.resolve(
__dirname,
'src/scripts/health-check.js',
),
},
output: {
format: 'esm',
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
},
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
assetsInclude: ['**/*.sql'],
plugins: [shebangPlugin('bin/actual-server.js')],
});

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Replace support contact link with auto-closing tech support issue template.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor module resolution to load `@actual-app/crdt` from source during development.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [nikhilweee]
---
Update the preview-builds contributing doc to reflect the current `deploy-preview-{pr-number}.demo.actualbudget.org` URL pattern and mention the Storybook and website previews.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Surface the backend init failures with a custom error screen and message.

View File

@@ -30,7 +30,7 @@ __metadata:
compare-versions: "npm:^6.1.1"
rollup-plugin-visualizer: "npm:^7.0.1"
typescript-strict-plugin: "npm:^2.4.4"
uuid: "npm:^14.0.0"
uuid: "npm:^13.0.0"
vite: "npm:^8.0.5"
vite-plugin-peggy-loader: "npm:^2.0.1"
vitest: "npm:^4.1.2"
@@ -147,7 +147,7 @@ __metadata:
typescript-strict-plugin: "npm:^2.4.4"
ua-parser-js: "npm:^2.0.9"
util: "npm:^0.12.5"
uuid: "npm:^14.0.0"
uuid: "npm:^13.0.0"
vite: "npm:^8.0.5"
vite-plugin-node-polyfills: "npm:^0.26.0"
vite-plugin-peggy-loader: "npm:^2.0.1"
@@ -168,7 +168,7 @@ __metadata:
protoc-gen-js: "npm:3.21.4-4"
rollup-plugin-visualizer: "npm:^7.0.1"
ts-protoc-gen: "npm:0.15.0"
uuid: "npm:^14.0.0"
uuid: "npm:^13.0.0"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
languageName: unknown
@@ -206,8 +206,7 @@ __metadata:
pluggy-sdk: "npm:^0.83.0"
supertest: "npm:^7.2.2"
typescript-strict-plugin: "npm:^2.4.4"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
uuid: "npm:^13.0.0"
vitest: "npm:^4.1.2"
winston: "npm:^3.19.0"
bin:
@@ -297,7 +296,7 @@ __metadata:
sass: "npm:^1.99.0"
typescript-strict-plugin: "npm:^2.4.4"
usehooks-ts: "npm:^3.1.1"
uuid: "npm:^14.0.0"
uuid: "npm:^13.0.0"
vite: "npm:^8.0.5"
vite-plugin-pwa: "npm:^1.2.0"
vitest: "npm:^4.1.2"
@@ -28353,12 +28352,12 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^14.0.0":
version: 14.0.0
resolution: "uuid@npm:14.0.0"
"uuid@npm:^13.0.0":
version: 13.0.2
resolution: "uuid@npm:13.0.2"
bin:
uuid: dist-node/bin/uuid
checksum: 10/8ee9b98f9650e25555515f7a28d3c3ae9364e72f7bb19b9e08b681bc135338beba5509b2830f6ae1cfaba4d45401da0d16d4d109b977097bc3d6ba0c5583341b
checksum: 10/567dddca18a8520796dd3cd1e4513f4c7c522f25602c15381615395d60c7892f330366680fc21373f19fb83c991f3da8413f57dbd85bf976069cf0818aa6c61c
languageName: node
linkType: hard