mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-12 02:10:19 +00:00
[AI] Stop using .browser extension; removing "resolve.extensions" - prefer conditions via package.json (#7254)
* [AI] Consolidate loot-core connection: default web path, electron split, drop .browser * [autofix.ci] apply automated fixes * [AI] Replace browser-preload .browser extension with package.json subpath imports Use the imports field in desktop-client/package.json with conditional resolution (electron → empty stub, default → real implementation) to eliminate the last .browser file extension from the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Refactor connection imports to use @actual-app/core * Implement connection mock for desktop-client tests and update import path * [AI] Fix formatting and update imports after master merge Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Fix connection mock in TransactionsTable tests and use electron-renderer condition Wire up the manual connection mock for TransactionsTable tests since the __mocks__ directory was removed, and restore electron-renderer condition in loot-core package.json exports. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [autofix.ci] apply automated fixes * [AI] Remove redundant resolveExtensions from vite configs These arrays were identical to Vite's built-in default and served no purpose. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Remove remaining resolveExtensions from vite/vitest configs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Fix build failures: update browser-preload import path and condition - Change loot-core/shared/platform to @actual-app/core/shared/platform - Use electron-renderer condition for #browser-preload to match vite resolve Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Remove redundant resolveExtensions from api and loot-core desktop configs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Remove '*.browser.ts' extension and alias resolutions Removed the special '*.browser.ts' file extension and file resolutions via alias, preferring conditions. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
autofix-ci[bot]
parent
d76d7d3204
commit
4960363de6
+1
-1
@@ -335,7 +335,7 @@
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"group": ["**/*.api", "**/*.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`, `.web`, `.electron`)
|
||||
- Don't directly reference platform-specific imports (`.api`, `.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 (`.web`, `.electron`, `.api`)
|
||||
3. Verify platform-specific imports (`.electron`, `.api`)
|
||||
4. Use absolute imports in `desktop-client` (enforced by ESLint)
|
||||
|
||||
### Build Failures
|
||||
|
||||
@@ -85,7 +85,6 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
conditions: ['api'],
|
||||
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import path from 'path';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const resolveExtensions = [
|
||||
'.testing.ts',
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.json',
|
||||
'.wasm',
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
@@ -23,8 +9,5 @@ export default defineConfig({
|
||||
include: ['src/**/*.web.test.(js|jsx|ts|tsx)'],
|
||||
maxWorkers: 2,
|
||||
},
|
||||
resolve: {
|
||||
extensions: resolveExtensions,
|
||||
},
|
||||
plugins: [react(), peggyLoader()],
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
],
|
||||
"imports": {
|
||||
"#browser-preload": {
|
||||
"electron": "./src/browser-preload.js",
|
||||
"default": "./src/browser-preload.browser.js"
|
||||
"electron-renderer": "./src/browser-preload.electron.ts",
|
||||
"default": "./src/browser-preload.js"
|
||||
},
|
||||
"#accounts": "./src/accounts/index.ts",
|
||||
"#budget": "./src/budget/index.ts",
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
import * as Platform from '@actual-app/core/shared/platform';
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
// oxlint-disable-next-line typescript-paths/absolute-parent-import
|
||||
import packageJson from '../package.json';
|
||||
|
||||
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
|
||||
|
||||
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
|
||||
|
||||
// This file installs global variables that the app expects.
|
||||
// Normally these are already provided by electron, but in a real
|
||||
// browser environment this is where we initialize the backend and
|
||||
// everything else.
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
const ACTUAL_VERSION = Platform.isPlaywright
|
||||
? '99.9.9'
|
||||
: process.env.REACT_APP_REVIEW_ID
|
||||
? '.preview'
|
||||
: packageJson.version;
|
||||
|
||||
// *** Start the backend ***
|
||||
|
||||
let worker = null;
|
||||
// The regular Worker running the backend, created only on the leader tab
|
||||
let localBackendWorker = null;
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
*
|
||||
* The SharedWorker coordinator assigns each tab a role per budget:
|
||||
* - LEADER: this tab runs the backend in a dedicated Worker
|
||||
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
||||
*
|
||||
* Multiple budgets can be open simultaneously — each has its own leader.
|
||||
*/
|
||||
class WorkerBridge {
|
||||
constructor(sharedPort) {
|
||||
this._sharedPort = sharedPort;
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
}
|
||||
|
||||
set onmessage(handler) {
|
||||
this._onmessage = handler;
|
||||
// Setting onmessage on a real MessagePort implicitly starts it.
|
||||
// We need to do this explicitly on the underlying port.
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
|
||||
postMessage(msg) {
|
||||
// All messages go through the SharedWorker for coordination.
|
||||
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
||||
this._sharedPort.postMessage(msg);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(event) {
|
||||
if (this._onmessage) this._onmessage(event);
|
||||
for (const { type, handler } of this._listeners) {
|
||||
if (type === 'message') handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_onSharedMessage(event) {
|
||||
const msg = event.data;
|
||||
|
||||
// Elected as leader: create the real backend Worker on this tab
|
||||
if (msg && msg.type === '__become-leader') {
|
||||
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward requests from SharedWorker to our local Worker
|
||||
if (msg && msg.type === '__to-worker') {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.postMessage(msg.msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Leadership transfer: this tab is closing the budget but other tabs
|
||||
// still need it. Terminate our Worker (don't actually close-budget on
|
||||
// the backend) and dispatch a synthetic reply so the UI navigates to
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
if (msg.requestId) {
|
||||
this._dispatch({
|
||||
data: { type: 'reply', id: msg.requestId, data: {} },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
console.log(
|
||||
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface SharedWorker console output in this tab's DevTools
|
||||
if (msg && msg.type === '__shared-worker-console') {
|
||||
const method = console[msg.level] || console.log;
|
||||
method(...msg.args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond to heartbeat pings
|
||||
if (msg && msg.type === '__heartbeat-ping') {
|
||||
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
}
|
||||
localBackendWorker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(localBackendWorker);
|
||||
|
||||
const sharedPort = this._sharedPort;
|
||||
localBackendWorker.onmessage = workerEvent => {
|
||||
const workerMsg = workerEvent.data;
|
||||
// absurd-sql internal messages are handled by initSQLBackend
|
||||
if (
|
||||
workerMsg &&
|
||||
workerMsg.type &&
|
||||
workerMsg.type.startsWith('__absurd:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// After the backend connects, automatically reload the budget that was
|
||||
// open before the leader left (e.g. page refresh). This lets other tabs
|
||||
// continue working without being sent to the budget list.
|
||||
if (workerMsg.type === 'connect') {
|
||||
if (budgetToRestore) {
|
||||
console.log(
|
||||
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
||||
);
|
||||
const id = budgetToRestore;
|
||||
budgetToRestore = null;
|
||||
localBackendWorker.postMessage({
|
||||
id: '__restore-budget',
|
||||
name: 'load-budget',
|
||||
args: { id },
|
||||
catchErrors: true,
|
||||
});
|
||||
// Tell SharedWorker to track the restore request so
|
||||
// currentBudgetId gets updated when the reply arrives.
|
||||
sharedPort.postMessage({
|
||||
type: '__track-restore',
|
||||
requestId: '__restore-budget',
|
||||
budgetId: id,
|
||||
});
|
||||
} else if (pendingMsg) {
|
||||
const toSend = pendingMsg;
|
||||
pendingMsg = null;
|
||||
localBackendWorker.postMessage(toSend);
|
||||
}
|
||||
}
|
||||
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
||||
};
|
||||
|
||||
localBackendWorker.postMessage(initMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackendWorker() {
|
||||
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
||||
// Each budget gets its own leader tab running a dedicated Worker. All other
|
||||
// tabs on the same budget are followers — their messages are routed through
|
||||
// the SharedWorker to the leader's Worker.
|
||||
// The SharedWorker never touches SharedArrayBuffer, so this works on all
|
||||
// platforms including iOS/Safari.
|
||||
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
|
||||
try {
|
||||
const sharedWorker = new SharedBrowserServerWorker({
|
||||
name: 'actual-backend',
|
||||
});
|
||||
|
||||
const sharedPort = sharedWorker.port;
|
||||
worker = new WorkerBridge(sharedPort);
|
||||
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
||||
|
||||
// Don't call start() here. The port must remain un-started so that
|
||||
// messages (especially 'connect') are queued until connectWorker()
|
||||
// sets onmessage, which implicitly starts the port via the bridge.
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
sharedPort.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log('SharedWorker failed, falling back to Worker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
|
||||
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
|
||||
worker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(worker);
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
createBackendWorker();
|
||||
|
||||
let isUpdateReadyForDownload = false;
|
||||
let markUpdateReadyForDownload;
|
||||
const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
markUpdateReadyForDownload = () => {
|
||||
isUpdateReadyForDownload = true;
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
ACTUAL_VERSION,
|
||||
|
||||
logToTerminal: (...args) => {
|
||||
console.log(...args);
|
||||
},
|
||||
|
||||
relaunch: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
reload: () => {
|
||||
if (window.navigator.serviceWorker == null) return;
|
||||
|
||||
// Unregister the service worker handling routing and then reload. This should force the reload
|
||||
// to query the actual server rather than delegating to the worker
|
||||
return window.navigator.serviceWorker
|
||||
.getRegistration('/')
|
||||
.then(registration => {
|
||||
if (registration == null) return;
|
||||
return registration.unregister();
|
||||
})
|
||||
.then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
},
|
||||
|
||||
startSyncServer: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
|
||||
stopSyncServer: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
|
||||
isSyncServerRunning: () => false,
|
||||
|
||||
startOAuthServer: () => {
|
||||
return '';
|
||||
},
|
||||
|
||||
restartElectronServer: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
|
||||
openFileDialog: async ({ filters = [] }) => {
|
||||
const FILE_ACCEPT_OVERRIDES = {
|
||||
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
|
||||
qfx: [
|
||||
'application/vnd.intu.qfx',
|
||||
'application/x-qfx',
|
||||
'application/qfx',
|
||||
'application/ofx',
|
||||
'application/x-ofx',
|
||||
'application/octet-stream',
|
||||
'com.intuit.qfx',
|
||||
],
|
||||
};
|
||||
|
||||
return new Promise(resolve => {
|
||||
let createdElement = false;
|
||||
// Attempt to reuse an already-created file input.
|
||||
let input = document.body.querySelector(
|
||||
'input[id="open-file-dialog-input"]',
|
||||
);
|
||||
if (!input) {
|
||||
createdElement = true;
|
||||
input = document.createElement('input');
|
||||
}
|
||||
|
||||
input.type = 'file';
|
||||
input.id = 'open-file-dialog-input';
|
||||
input.value = null;
|
||||
|
||||
const filter = filters.find(filter => filter.extensions);
|
||||
if (filter) {
|
||||
input.accept = filter.extensions
|
||||
.flatMap(ext => {
|
||||
const normalizedExt = ext.startsWith('.')
|
||||
? ext.toLowerCase()
|
||||
: `.${ext.toLowerCase()}`;
|
||||
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
|
||||
return [normalizedExt, ...overrides];
|
||||
})
|
||||
.join(',');
|
||||
}
|
||||
|
||||
input.style.position = 'absolute';
|
||||
input.style.top = '0px';
|
||||
input.style.left = '0px';
|
||||
input.style.display = 'none';
|
||||
|
||||
input.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
const filename = file.name.replace(/.*(\.[^.]*)/, 'file$1');
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file);
|
||||
reader.onload = async function (ev) {
|
||||
const filepath = `/uploads/${filename}`;
|
||||
|
||||
void window.__actionsForMenu
|
||||
.uploadFile(filename, ev.target.result)
|
||||
.then(() => resolve([filepath]));
|
||||
};
|
||||
reader.onerror = function () {
|
||||
alert('Error reading file');
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// In Safari the file input has to be in the DOM for change events to
|
||||
// reliably fire.
|
||||
if (createdElement) {
|
||||
document.body.appendChild(input);
|
||||
}
|
||||
|
||||
input.click();
|
||||
});
|
||||
},
|
||||
|
||||
saveFile: (contents, defaultFilename) => {
|
||||
const temp = document.createElement('a');
|
||||
temp.style = 'display: none';
|
||||
temp.download = defaultFilename;
|
||||
temp.rel = 'noopener';
|
||||
|
||||
const blob = new Blob([contents]);
|
||||
temp.href = URL.createObjectURL(blob);
|
||||
temp.dispatchEvent(new MouseEvent('click'));
|
||||
},
|
||||
|
||||
openURLInBrowser: url => {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
openInFileManager: () => {
|
||||
// File manager not available in browser
|
||||
},
|
||||
onEventFromMain: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
|
||||
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
|
||||
applyAppUpdate: async () => {
|
||||
updateSW();
|
||||
|
||||
// Wait for the app to reload
|
||||
await new Promise(() => {
|
||||
// Do nothing
|
||||
});
|
||||
},
|
||||
|
||||
ipcConnect: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
getServerSocket: async () => {
|
||||
return worker;
|
||||
},
|
||||
|
||||
setTheme: theme => {
|
||||
window.__actionsForMenu.saveGlobalPrefs({ prefs: { theme } });
|
||||
},
|
||||
|
||||
moveBudgetDirectory: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
// Electron has its own preload at desktop-electron/preload.ts
|
||||
@@ -1 +1,457 @@
|
||||
// Intentionally left blank to make electron app work
|
||||
import * as Platform from '@actual-app/core/shared/platform';
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
// oxlint-disable-next-line typescript-paths/absolute-parent-import
|
||||
import packageJson from '../package.json';
|
||||
|
||||
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
|
||||
|
||||
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
|
||||
|
||||
// This file installs global variables that the app expects.
|
||||
// Normally these are already provided by electron, but in a real
|
||||
// browser environment this is where we initialize the backend and
|
||||
// everything else.
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
const ACTUAL_VERSION = Platform.isPlaywright
|
||||
? '99.9.9'
|
||||
: process.env.REACT_APP_REVIEW_ID
|
||||
? '.preview'
|
||||
: packageJson.version;
|
||||
|
||||
// *** Start the backend ***
|
||||
|
||||
let worker = null;
|
||||
// The regular Worker running the backend, created only on the leader tab
|
||||
let localBackendWorker = null;
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
*
|
||||
* The SharedWorker coordinator assigns each tab a role per budget:
|
||||
* - LEADER: this tab runs the backend in a dedicated Worker
|
||||
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
||||
*
|
||||
* Multiple budgets can be open simultaneously — each has its own leader.
|
||||
*/
|
||||
class WorkerBridge {
|
||||
constructor(sharedPort) {
|
||||
this._sharedPort = sharedPort;
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
}
|
||||
|
||||
set onmessage(handler) {
|
||||
this._onmessage = handler;
|
||||
// Setting onmessage on a real MessagePort implicitly starts it.
|
||||
// We need to do this explicitly on the underlying port.
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
|
||||
postMessage(msg) {
|
||||
// All messages go through the SharedWorker for coordination.
|
||||
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
||||
this._sharedPort.postMessage(msg);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(event) {
|
||||
if (this._onmessage) this._onmessage(event);
|
||||
for (const { type, handler } of this._listeners) {
|
||||
if (type === 'message') handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_onSharedMessage(event) {
|
||||
const msg = event.data;
|
||||
|
||||
// Elected as leader: create the real backend Worker on this tab
|
||||
if (msg && msg.type === '__become-leader') {
|
||||
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward requests from SharedWorker to our local Worker
|
||||
if (msg && msg.type === '__to-worker') {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.postMessage(msg.msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Leadership transfer: this tab is closing the budget but other tabs
|
||||
// still need it. Terminate our Worker (don't actually close-budget on
|
||||
// the backend) and dispatch a synthetic reply so the UI navigates to
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
if (msg.requestId) {
|
||||
this._dispatch({
|
||||
data: { type: 'reply', id: msg.requestId, data: {} },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
console.log(
|
||||
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface SharedWorker console output in this tab's DevTools
|
||||
if (msg && msg.type === '__shared-worker-console') {
|
||||
const method = console[msg.level] || console.log;
|
||||
method(...msg.args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond to heartbeat pings
|
||||
if (msg && msg.type === '__heartbeat-ping') {
|
||||
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
}
|
||||
localBackendWorker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(localBackendWorker);
|
||||
|
||||
const sharedPort = this._sharedPort;
|
||||
localBackendWorker.onmessage = workerEvent => {
|
||||
const workerMsg = workerEvent.data;
|
||||
// absurd-sql internal messages are handled by initSQLBackend
|
||||
if (
|
||||
workerMsg &&
|
||||
workerMsg.type &&
|
||||
workerMsg.type.startsWith('__absurd:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// After the backend connects, automatically reload the budget that was
|
||||
// open before the leader left (e.g. page refresh). This lets other tabs
|
||||
// continue working without being sent to the budget list.
|
||||
if (workerMsg.type === 'connect') {
|
||||
if (budgetToRestore) {
|
||||
console.log(
|
||||
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
||||
);
|
||||
const id = budgetToRestore;
|
||||
budgetToRestore = null;
|
||||
localBackendWorker.postMessage({
|
||||
id: '__restore-budget',
|
||||
name: 'load-budget',
|
||||
args: { id },
|
||||
catchErrors: true,
|
||||
});
|
||||
// Tell SharedWorker to track the restore request so
|
||||
// currentBudgetId gets updated when the reply arrives.
|
||||
sharedPort.postMessage({
|
||||
type: '__track-restore',
|
||||
requestId: '__restore-budget',
|
||||
budgetId: id,
|
||||
});
|
||||
} else if (pendingMsg) {
|
||||
const toSend = pendingMsg;
|
||||
pendingMsg = null;
|
||||
localBackendWorker.postMessage(toSend);
|
||||
}
|
||||
}
|
||||
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
||||
};
|
||||
|
||||
localBackendWorker.postMessage(initMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackendWorker() {
|
||||
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
||||
// Each budget gets its own leader tab running a dedicated Worker. All other
|
||||
// tabs on the same budget are followers — their messages are routed through
|
||||
// the SharedWorker to the leader's Worker.
|
||||
// The SharedWorker never touches SharedArrayBuffer, so this works on all
|
||||
// platforms including iOS/Safari.
|
||||
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
|
||||
try {
|
||||
const sharedWorker = new SharedBrowserServerWorker({
|
||||
name: 'actual-backend',
|
||||
});
|
||||
|
||||
const sharedPort = sharedWorker.port;
|
||||
worker = new WorkerBridge(sharedPort);
|
||||
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
||||
|
||||
// Don't call start() here. The port must remain un-started so that
|
||||
// messages (especially 'connect') are queued until connectWorker()
|
||||
// sets onmessage, which implicitly starts the port via the bridge.
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
sharedPort.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log('SharedWorker failed, falling back to Worker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
|
||||
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
|
||||
worker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(worker);
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
createBackendWorker();
|
||||
|
||||
let isUpdateReadyForDownload = false;
|
||||
let markUpdateReadyForDownload;
|
||||
const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
markUpdateReadyForDownload = () => {
|
||||
isUpdateReadyForDownload = true;
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
ACTUAL_VERSION,
|
||||
|
||||
logToTerminal: (...args) => {
|
||||
console.log(...args);
|
||||
},
|
||||
|
||||
relaunch: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
reload: () => {
|
||||
if (window.navigator.serviceWorker == null) return;
|
||||
|
||||
// Unregister the service worker handling routing and then reload. This should force the reload
|
||||
// to query the actual server rather than delegating to the worker
|
||||
return window.navigator.serviceWorker
|
||||
.getRegistration('/')
|
||||
.then(registration => {
|
||||
if (registration == null) return;
|
||||
return registration.unregister();
|
||||
})
|
||||
.then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
},
|
||||
|
||||
startSyncServer: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
|
||||
stopSyncServer: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
|
||||
isSyncServerRunning: () => false,
|
||||
|
||||
startOAuthServer: () => {
|
||||
return '';
|
||||
},
|
||||
|
||||
restartElectronServer: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
|
||||
openFileDialog: async ({ filters = [] }) => {
|
||||
const FILE_ACCEPT_OVERRIDES = {
|
||||
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
|
||||
qfx: [
|
||||
'application/vnd.intu.qfx',
|
||||
'application/x-qfx',
|
||||
'application/qfx',
|
||||
'application/ofx',
|
||||
'application/x-ofx',
|
||||
'application/octet-stream',
|
||||
'com.intuit.qfx',
|
||||
],
|
||||
};
|
||||
|
||||
return new Promise(resolve => {
|
||||
let createdElement = false;
|
||||
// Attempt to reuse an already-created file input.
|
||||
let input = document.body.querySelector(
|
||||
'input[id="open-file-dialog-input"]',
|
||||
);
|
||||
if (!input) {
|
||||
createdElement = true;
|
||||
input = document.createElement('input');
|
||||
}
|
||||
|
||||
input.type = 'file';
|
||||
input.id = 'open-file-dialog-input';
|
||||
input.value = null;
|
||||
|
||||
const filter = filters.find(filter => filter.extensions);
|
||||
if (filter) {
|
||||
input.accept = filter.extensions
|
||||
.flatMap(ext => {
|
||||
const normalizedExt = ext.startsWith('.')
|
||||
? ext.toLowerCase()
|
||||
: `.${ext.toLowerCase()}`;
|
||||
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
|
||||
return [normalizedExt, ...overrides];
|
||||
})
|
||||
.join(',');
|
||||
}
|
||||
|
||||
input.style.position = 'absolute';
|
||||
input.style.top = '0px';
|
||||
input.style.left = '0px';
|
||||
input.style.display = 'none';
|
||||
|
||||
input.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
const filename = file.name.replace(/.*(\.[^.]*)/, 'file$1');
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file);
|
||||
reader.onload = async function (ev) {
|
||||
const filepath = `/uploads/${filename}`;
|
||||
|
||||
void window.__actionsForMenu
|
||||
.uploadFile(filename, ev.target.result)
|
||||
.then(() => resolve([filepath]));
|
||||
};
|
||||
reader.onerror = function () {
|
||||
alert('Error reading file');
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// In Safari the file input has to be in the DOM for change events to
|
||||
// reliably fire.
|
||||
if (createdElement) {
|
||||
document.body.appendChild(input);
|
||||
}
|
||||
|
||||
input.click();
|
||||
});
|
||||
},
|
||||
|
||||
saveFile: (contents, defaultFilename) => {
|
||||
const temp = document.createElement('a');
|
||||
temp.style = 'display: none';
|
||||
temp.download = defaultFilename;
|
||||
temp.rel = 'noopener';
|
||||
|
||||
const blob = new Blob([contents]);
|
||||
temp.href = URL.createObjectURL(blob);
|
||||
temp.dispatchEvent(new MouseEvent('click'));
|
||||
},
|
||||
|
||||
openURLInBrowser: url => {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
openInFileManager: () => {
|
||||
// File manager not available in browser
|
||||
},
|
||||
onEventFromMain: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
|
||||
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
|
||||
applyAppUpdate: async () => {
|
||||
updateSW();
|
||||
|
||||
// Wait for the app to reload
|
||||
await new Promise(() => {
|
||||
// Do nothing
|
||||
});
|
||||
},
|
||||
|
||||
ipcConnect: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
getServerSocket: async () => {
|
||||
return worker;
|
||||
},
|
||||
|
||||
setTheme: theme => {
|
||||
window.__actionsForMenu.saveGlobalPrefs({ prefs: { theme } });
|
||||
},
|
||||
|
||||
moveBudgetDirectory: () => {
|
||||
// Only for electron app
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,7 +37,10 @@ import { TransactionTable } from './TransactionsTable';
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
vi.mock('@actual-app/core/platform/client/connection');
|
||||
vi.mock(
|
||||
'@actual-app/core/platform/client/connection',
|
||||
() => import('../../mocks/connection'),
|
||||
);
|
||||
vi.mock('../../hooks/useSyncedPref', () => ({
|
||||
useSyncedPref: vi.fn().mockReturnValue([undefined, vi.fn()]),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file will initialize the app if we are in a real browser
|
||||
// environment (not electron)
|
||||
import './browser-preload';
|
||||
import '#browser-preload';
|
||||
import './fonts.scss';
|
||||
import './i18n';
|
||||
import React from 'react';
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Minimal connection mock used by desktop-client component tests.
|
||||
//
|
||||
// This mirrors `@actual-app/core`'s test-only mock implementation so that
|
||||
// `useSpreadsheet()` and other code paths can call `send()` and receive a
|
||||
// promise with a stable shape.
|
||||
|
||||
type Listener = (args: unknown) => void;
|
||||
type Handler = (args: unknown) => unknown | Promise<unknown>;
|
||||
|
||||
let listeners = new Map<string, Array<Listener>>();
|
||||
let serverHandler:
|
||||
| null
|
||||
| ((msg: { name: string; args: unknown; catchErrors: boolean }) => unknown) =
|
||||
null;
|
||||
|
||||
export const initServer = (handlers: Record<string, Handler>) => {
|
||||
serverHandler = msg => {
|
||||
const { name, args, catchErrors } = msg;
|
||||
if (handlers[name]) {
|
||||
return Promise.resolve().then(() => {
|
||||
const promise = Promise.resolve(handlers[name](args));
|
||||
|
||||
if (catchErrors) {
|
||||
return promise.then(
|
||||
data => ({ data }),
|
||||
error => ({ error }),
|
||||
);
|
||||
}
|
||||
|
||||
return promise;
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const clearServer = async () => {
|
||||
serverHandler = null;
|
||||
listeners = new Map();
|
||||
};
|
||||
|
||||
export const serverPush = (name: string, args: unknown) => {
|
||||
void Promise.resolve().then(() => {
|
||||
const listens = listeners.get(name);
|
||||
if (listens) {
|
||||
listens.forEach(listener => {
|
||||
listener(args);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const send = async (
|
||||
name: string,
|
||||
args: unknown,
|
||||
{ catchErrors = false } = {},
|
||||
) => {
|
||||
if (serverHandler) {
|
||||
return serverHandler({ name, args, catchErrors });
|
||||
} else {
|
||||
throw new Error('`send` called with no mock server installed');
|
||||
}
|
||||
};
|
||||
|
||||
export const sendCatch = (name: string, args: unknown) => {
|
||||
return send(name, args, { catchErrors: true });
|
||||
};
|
||||
|
||||
export const listen = (name: string, cb: Listener) => {
|
||||
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),
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -116,26 +116,6 @@ export default defineConfig(async ({ mode }) => {
|
||||
process.env.REACT_APP_BRANCH = process.env.BRANCH;
|
||||
}
|
||||
|
||||
let resolveExtensions = [
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.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 {
|
||||
@@ -184,7 +164,6 @@ export default defineConfig(async ({ mode }) => {
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: resolveExtensions,
|
||||
...(!env.IS_GENERIC_BROWSER && {
|
||||
conditions: ['electron-renderer', 'module', 'browser', 'default'],
|
||||
}),
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"default": "./src/platform/server/sqlite/index.ts"
|
||||
},
|
||||
"#platform/client/connection": {
|
||||
"electron-renderer": "./src/platform/client/connection/index.ts",
|
||||
"default": "./src/platform/client/connection/index.browser.ts"
|
||||
"electron-renderer": "./src/platform/client/connection/index.electron.ts",
|
||||
"default": "./src/platform/client/connection/index.ts"
|
||||
},
|
||||
"#server/encryption/encryption-internals": {
|
||||
"electron": "./src/server/encryption/encryption-internals.electron.ts",
|
||||
@@ -101,8 +101,8 @@
|
||||
"./client/undo": "./src/client/undo.ts",
|
||||
"./mocks": "./src/mocks/index.ts",
|
||||
"./platform/client/connection": {
|
||||
"electron-renderer": "./src/platform/client/connection/index.ts",
|
||||
"default": "./src/platform/client/connection/index.browser.ts"
|
||||
"electron-renderer": "./src/platform/client/connection/index.electron.ts",
|
||||
"default": "./src/platform/client/connection/index.ts"
|
||||
},
|
||||
"./platform/client/undo": "./src/platform/client/undo/index.ts",
|
||||
"./platform/exceptions": "./src/platform/exceptions/index.ts",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
import type * as T from '#platform/client/connection/index-types';
|
||||
|
||||
let listeners = new Map();
|
||||
let serverHandler = null;
|
||||
|
||||
export const initServer: T.InitServer = handlers => {
|
||||
serverHandler = msg => {
|
||||
const { name, args, catchErrors } = msg;
|
||||
if (handlers[name]) {
|
||||
return Promise.resolve().then(() => {
|
||||
const promise = handlers[name](args);
|
||||
|
||||
if (catchErrors) {
|
||||
return promise.then(
|
||||
data => ({ data }),
|
||||
error => ({ error }),
|
||||
);
|
||||
}
|
||||
return promise;
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const clearServer: T.ClearServer = async () => {
|
||||
serverHandler = null;
|
||||
listeners = new Map();
|
||||
};
|
||||
|
||||
export const serverPush: T.ServerPush = (name, args) => {
|
||||
void Promise.resolve().then(() => {
|
||||
const listens = listeners.get(name);
|
||||
if (listens) {
|
||||
listens.forEach(listener => {
|
||||
listener(args);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const send = async (name, args, { catchErrors = false } = {}) => {
|
||||
if (serverHandler) {
|
||||
return serverHandler({ name, args, catchErrors });
|
||||
} else {
|
||||
throw new Error('`send` called with no mock server installed');
|
||||
}
|
||||
};
|
||||
|
||||
export const sendCatch = (name, args) => {
|
||||
return send(name, args, { catchErrors: true });
|
||||
};
|
||||
|
||||
export const listen = (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),
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
export * from './index.electron';
|
||||
@@ -1,213 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
import { t } from 'i18next';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import * as undo from '#platform/client/undo';
|
||||
import { captureBreadcrumb, captureException } from '#platform/exceptions';
|
||||
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
// @ts-strict-ignore
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import * as undo from '#platform/client/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,83 +1,159 @@
|
||||
// @ts-strict-ignore
|
||||
import { t } from 'i18next';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import * as undo from '#platform/client/undo';
|
||||
import { captureBreadcrumb, captureException } from '#platform/exceptions';
|
||||
|
||||
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;
|
||||
let globalWorker = null;
|
||||
|
||||
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;
|
||||
class ReconstructedError extends Error {
|
||||
url: string;
|
||||
line: string;
|
||||
column: string;
|
||||
|
||||
// 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);
|
||||
}
|
||||
constructor(message, stack, url, line, column) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.message = message;
|
||||
|
||||
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));
|
||||
}
|
||||
Object.defineProperty(this, 'stack', {
|
||||
get: function () {
|
||||
return 'extended ' + this._stack;
|
||||
},
|
||||
set: function (value) {
|
||||
this._stack = value;
|
||||
},
|
||||
});
|
||||
|
||||
socketClient = client;
|
||||
this.stack = stack;
|
||||
this.url = url;
|
||||
this.line = line;
|
||||
this.column = column;
|
||||
}
|
||||
}
|
||||
|
||||
// Send any messages that were queued while closed
|
||||
if (messageQueue.length > 0) {
|
||||
messageQueue.forEach(msg => client.emit('message', msg));
|
||||
messageQueue = [];
|
||||
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;
|
||||
|
||||
onOpen();
|
||||
});
|
||||
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 () {
|
||||
return new Promise(connectSocket);
|
||||
const worker = await global.Actual.getServerSocket();
|
||||
return new Promise((resolve, reject) =>
|
||||
connectWorker(worker, resolve, reject),
|
||||
);
|
||||
};
|
||||
|
||||
export const send: T.Send = function (
|
||||
@@ -86,24 +162,19 @@ 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 });
|
||||
|
||||
if (socketClient) {
|
||||
socketClient.emit('message', {
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
undoTag: undo.snapshot(),
|
||||
catchErrors: !!catchErrors,
|
||||
});
|
||||
replyHandlers.set(id, { resolve, reject });
|
||||
const message = {
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
undoTag: undo.snapshot(),
|
||||
catchErrors,
|
||||
};
|
||||
if (messageQueue) {
|
||||
messageQueue.push(message);
|
||||
} else {
|
||||
messageQueue.push({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
undoTag: undo.snapshot(),
|
||||
catchErrors,
|
||||
});
|
||||
globalWorker.postMessage(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -120,12 +191,10 @@ export const listen: T.Listen = function (name, cb) {
|
||||
|
||||
return () => {
|
||||
const arr = listeners.get(name);
|
||||
if (arr) {
|
||||
listeners.set(
|
||||
name,
|
||||
arr.filter(cb_ => cb_ !== cb),
|
||||
);
|
||||
}
|
||||
listeners.set(
|
||||
name,
|
||||
arr.filter(cb_ => cb_ !== cb),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -133,23 +202,12 @@ 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
|
||||
};
|
||||
|
||||
@@ -59,9 +59,6 @@ export default defineConfig(({ mode }) => {
|
||||
mangle: false,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.ts', '.tsx', '.json'],
|
||||
},
|
||||
define: {
|
||||
'process.env': '{}',
|
||||
'process.env.IS_DEV': JSON.stringify(isDev),
|
||||
|
||||
@@ -34,7 +34,6 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
resolve: {
|
||||
conditions: ['electron'],
|
||||
extensions: ['.js', '.ts', '.tsx', '.json', '.pegjs'],
|
||||
alias: [
|
||||
{
|
||||
find: 'handlebars',
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const resolveExtensions = [
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.json',
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
@@ -30,7 +20,6 @@ export default defineConfig({
|
||||
resolve: { conditions: ['electron', 'module', 'node', 'development'] },
|
||||
},
|
||||
resolve: {
|
||||
extensions: resolveExtensions,
|
||||
conditions: ['electron', 'module', 'browser', 'development'],
|
||||
},
|
||||
plugins: [peggyLoader()],
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const resolveExtensions = [
|
||||
'.testing.ts',
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.json',
|
||||
'.wasm',
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
@@ -23,8 +11,5 @@ export default defineConfig({
|
||||
],
|
||||
maxWorkers: 2,
|
||||
},
|
||||
resolve: {
|
||||
extensions: resolveExtensions,
|
||||
},
|
||||
plugins: [peggyLoader()],
|
||||
});
|
||||
|
||||
@@ -27,9 +27,6 @@ export default defineConfig(({ mode }) => {
|
||||
mangle: false,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.ts', '.json'],
|
||||
},
|
||||
define: {
|
||||
'process.env': '{}',
|
||||
'process.env.IS_DEV': JSON.stringify(isDev),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Remove special "\*.browser.ts" file extension; remove file resolutions via alias (prefer conditions)
|
||||
Reference in New Issue
Block a user