[AI] Restructure sync-server to build with Vite

Replace the hand-rolled tsgo + add-import-extensions + copy-static-assets
+ runtime loader pipeline with a single Vite SSR build. Bundles every
entry (app, bin/actual-server, scripts/*) and inlines @actual-app/crdt
source so Node never has to resolve TS at runtime — the
MODULE_TYPELESS_PACKAGE_JSON warning that surfaced via crdt's source
exports is gone. Migrations and bank handlers move from readdir-based
dynamic imports to import.meta.glob; messages.sql becomes a ?raw import.

Drop loader.mjs, register-loader.mjs, start.mjs, and
bin/add-import-extensions.mjs. Electron's startSyncServer() forks
build/app.js directly. publishConfig.imports goes away (subpath imports
are resolved at build time and don't appear in the bundle).

In dev (start:server-dev) sync-server proxies to Vite, so loosen the CSP
to allow Vite's inline preamble script and HMR websocket — production
CSP is unchanged. desktop-client skips registerSW() in dev (and disables
vite-plugin-pwa's devOptions) so stale cached assets don't override
edits between page loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
github-actions[bot]
2026-05-04 22:31:57 +01:00
parent e661951753
commit 9513c1e160
17 changed files with 147 additions and 307 deletions

View File

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

View File

@@ -335,10 +335,14 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
// 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.
const updateSW = IS_DEV
? () => undefined
: registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,

View File

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

View File

@@ -243,11 +243,7 @@ async function startSyncServer() {
const syncServerRoot = path.dirname(
require.resolve('@actual-app/sync-server/package.json'),
);
// start.mjs registers sync-server's TS-source loader before importing
// build/app.js. We can't use Node's --import flag here because Electron's
// utilityProcess.fork accepts execArgv but doesn't actually preload the
// module, so we register imperatively from a bootstrap entry instead.
const serverPath = path.join(syncServerRoot, 'start.mjs');
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,7 +1,6 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
const args = process.argv;
@@ -54,11 +53,7 @@ if (values.help) {
}
if (values.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);
console.log('v' + __APP_VERSION__);
process.exit();
}

View File

@@ -1,146 +0,0 @@
#!/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

@@ -1,34 +0,0 @@
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);
}
}
// Fall back to <specifier>/index.<ext> for directory imports
for (const ext of extensions) {
const resolvedPath = nodeResolve(parentDir, specifier, `index${ext}`);
if (existsSync(resolvedPath)) {
return nextResolve(pathToFileURL(resolvedPath).href, context);
}
}
}
}
return nextResolve(specifier, context);
}

View File

@@ -13,9 +13,6 @@
},
"files": [
"build",
"loader.mjs",
"register-loader.mjs",
"start.mjs",
"LICENSE",
"README.md"
],
@@ -44,47 +41,19 @@
"#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 --import ./register-loader.mjs build/app",
"start-monitor": "nodemon --exec 'yarn build && node --import ./register-loader.mjs build/app' --ignore './build/**/*' --ext 'ts,js' build/app",
"build": "tsgo -b && yarn add-import-extensions && yarn copy-static-assets",
"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",
"typecheck": "tsgo -b && tsc-strict",
"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"
"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"
},
"dependencies": {
"@actual-app/crdt": "workspace:*",
@@ -118,6 +87,7 @@
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vitest": "^4.1.2"
}
}

View File

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

View File

@@ -124,17 +124,30 @@ app.get('/metrics', (_req, res) => {
});
});
// The web frontend
// 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.
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('; ');
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',
"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:;",
);
res.set('Content-Security-Policy', csp);
next();
});
if (process.env.NODE_ENV === 'development') {
if (isDev) {
console.log(
'Running in development mode - Proxying frontend routes to React Dev Server',
);

View File

@@ -21,8 +21,6 @@ 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,40 +1,34 @@
import { readdir } from 'node:fs/promises';
import path, { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
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}"...`,
);
const __dirname = dirname(fileURLToPath(import.meta.url)); // this directory
const migrationsDir = path.join(__dirname, '../migrations');
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;
}
> = {};
const sortedKeys = Object.keys(migrationsLoaders).sort();
const migrationsModules: Record<string, MigrationModule> = {};
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
);
for (const key of sortedKeys) {
const fileName = key.split('/').pop()!;
migrationsModules[fileName] = await migrationsLoaders[key]();
}
return new Promise<void>((resolve, reject) => {

View File

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

View File

@@ -1,8 +0,0 @@
// Bootstrap entry point. Statically imports register-loader.mjs (which
// installs the TS-source/extension/directory-index resolver), then
// dynamic-imports the built app so its module graph is resolved through
// that loader. Used in environments where Node's --import flag isn't
// honored, e.g. Electron's utilityProcess.fork.
import './register-loader.mjs';
await import('./build/app.js');

View File

@@ -0,0 +1,73 @@
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

@@ -203,6 +203,7 @@ __metadata:
pluggy-sdk: "npm:^0.83.0"
supertest: "npm:^7.2.2"
typescript-strict-plugin: "npm:^2.4.4"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
winston: "npm:^3.19.0"
bin: