Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dee3db5
docs: plan local Worker development
roryabraham Oct 21, 2026
780e8d3
feat(web): run local sessions through the Worker
roryabraham Oct 21, 2026
456facc
refactor(web): use Bun-native launcher APIs
roryabraham Oct 21, 2026
a76a0b7
Merge remote-tracking branch 'origin/main' into plan/104387-local-clo…
roryabraham Oct 21, 2026
f732268
Merge commit 'f2978e44948dc8cebfa51f17e59c9d85c478d261' into plan/104…
roryabraham Oct 21, 2026
0e6c505
Merge commit '9279ac926016302cc4d4d7368295983730dff7df' into plan/104…
roryabraham Oct 22, 2026
67ac29d
style(web): prefix boolean variable names
roryabraham Oct 22, 2026
bee309f
Merge commit 'b88098c5d58e596fd40e875074a1e8e65416832f' into plan/104…
roryabraham Oct 22, 2026
fd2a2f1
fix(web): wait for Rsbuild before starting Worker
roryabraham Oct 22, 2026
7ab263d
docs: keep implementation plan local
roryabraham Oct 22, 2026
97588de
refactor(web): simplify local Worker setup
roryabraham Oct 22, 2026
a9999d9
Merge main into plan/104387-local-cloudflare-worker
roryabraham Oct 23, 2026
727fe41
fix(web): allow local backend media and frames in dev CSP
roryabraham Oct 23, 2026
26aa6d5
refactor(web): reuse createNotFoundResponse for dev 404s
roryabraham Oct 23, 2026
cf6b80f
fix(web): stop requiring mkcert to start the dev server
roryabraham Oct 23, 2026
9d524e6
test(web): release the pending handler before stopping the test server
roryabraham Oct 23, 2026
fec2179
fix(web): don't stop the dev session on a slow first compile
roryabraham Oct 23, 2026
8cc1d51
fix(web): only install the Worker tooling when its lockfile changes
roryabraham Oct 23, 2026
893355b
Merge main into plan/104387-local-cloudflare-worker
roryabraham Oct 23, 2026
3b51c62
test(web): fail fast when a reload test edit misses its target
roryabraham Oct 23, 2026
ce6c4df
fix(web): drop redundant hardcoded dev CSP origins
roryabraham Oct 23, 2026
49ab611
fix(web): validate live Worker vars once with named errors
roryabraham Oct 23, 2026
428600f
refactor(web): share the nonce placeholder with the Rsbuild config
roryabraham Oct 23, 2026
320f270
refactor(web): define the API proxy paths and port once
roryabraham Oct 23, 2026
95ac108
refactor(web): define the local backend env keys once
roryabraham Oct 23, 2026
0cd9db1
refactor(web): share one readiness loop between the launcher and Rsbu…
roryabraham Oct 23, 2026
ed8a288
refactor(web): keep getWebDevPorts free of port probes
roryabraham Oct 23, 2026
222c691
fix(web): pass launcher settings to Rsbuild explicitly
roryabraham Oct 23, 2026
c3194a6
style(web): list Wrangler arguments one per line
roryabraham Oct 24, 2026
bec3811
refactor(web): launch Rsbuild and Wrangler with npx
roryabraham Oct 24, 2026
26dc149
chore(deps): bump @types/node to match the Node 26 runtime
roryabraham Oct 24, 2026
1d87d41
refactor(web): move getWebCommands into its own module
roryabraham Oct 24, 2026
2782299
fix(web): allow the QA backend and its Cloudflare Access issuer in de…
roryabraham Oct 24, 2026
5763cd7
fix(web): serve Rsbuild directly when the Worker tooling is missing
roryabraham Oct 24, 2026
97c8032
fix(web): warn instead of failing npm install when the Worker install…
roryabraham Oct 24, 2026
c46878f
ci: only run the Worker tests for changes they cover
roryabraham Oct 24, 2026
f4a652c
fix(web): let a second session pick free ports and share the API proxy
roryabraham Oct 24, 2026
bda7aac
Merge main into plan/104387-local-cloudflare-worker
roryabraham Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ These instructions should get you set up ready to work on New Expensify 🙌
3. Install dependencies: `npm install`
4. Run the specific platform with the following command: `npm run <platform>`, e.g. `npm run web`

For web, normal setup also installs the Cloudflare Worker tooling. After the [HTTPS/hosts setup](contributingGuides/SETUP_WEB.md), `npm run web` serves the app through the local Worker at `https://dev.new.expensify.com:8082/`, with CSP, security headers, React Fast Refresh, and automatic Worker source reload. No ngrok, Cloudflare account, or web build is required.

You can use any IDE or code editing tool for developing on any platform. Use your favorite!

## Recommended `node` setup
Expand Down Expand Up @@ -78,6 +80,10 @@ variables referenced here get updated since your local `.env` file is ignored.
- `USE_WEB_PROXY`⚠️- Used in web development, it starts a server along the local development server to proxy
requests to the backend. External contributors should set this to `true` otherwise they'll have CORS errors.
If you don't want to start the proxy server set this explicitly to `false`
- `USE_CLOUDFLARE_WORKER` - Defaults to `true` for `npm run web`. Set to `false` to serve Rsbuild directly over HTTPS. This does not disable the API proxy.
- `WEB_PORT` - Browser-facing development port (default: the first free port from `8082`). An explicit port must be free.
- `WEB_INTERNAL_PORT` - Internal loopback HTTP Rsbuild port when the Worker is enabled (default: the next free port after `WEB_PORT`).
- `WEB_OPEN` - Set to `false` to suppress automatic browser opening, for example in browser automation.
- `CAPTURE_METRICS` (optional) - Set this to `true` to capture performance metrics and see them in Flipper
see [PERFORMANCE.md](contributingGuides/PERFORMANCE.md#performance-metrics-opt-in-on-local-release-builds) for more information
- `ONYX_METRICS` (optional) - Set this to `true` to capture even more performance metrics and see them in Flipper
Expand Down
28 changes: 28 additions & 0 deletions config/rsbuild/developmentEnvKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Local backends from `.env`, which the app calls and the launcher adds to the live Worker's CSP. */
const LOCAL_BACKEND_URL_KEYS = [

@AndrewGable AndrewGable Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nab Seems kind of annoying to maintain, is there a better way to do this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I have a better suggestion - fortunately it just references keys in a .env, which makes it easy to configure per-workspace (such as if you're trying to set up parallel Docker stacks).

'EXPENSIFY_URL',
'SECURE_EXPENSIFY_URL',
'STAGING_EXPENSIFY_URL',
'STAGING_SECURE_EXPENSIFY_URL',
'QA_EXPENSIFY_URL',
'QA_SECURE_EXPENSIFY_URL',
'NGROK_URL',
'SECURE_NGROK_URL',
];

/** The Cloudflare Access team for the QA backend: a bare hostname whose token endpoint the app calls over HTTPS. */
const QA_ACCESS_TEAM_DOMAIN_KEY = 'QA_CF_TEAM_DOMAIN';

/** Public development settings that shell variables can override in the app config. Never forward arbitrary shell variables, which may contain secrets. */
const FORWARDED_DEVELOPMENT_ENV_KEYS = [
...LOCAL_BACKEND_URL_KEYS,
QA_ACCESS_TEAM_DOMAIN_KEY,
'USE_WEB_PROXY',
'USE_NGROK',
'NEW_EXPENSIFY_URL',
'SKIP_ONBOARDING',
'PUSHER_APP_KEY',
'PUSHER_DEV_SUFFIX',
];

export {FORWARDED_DEVELOPMENT_ENV_KEYS, LOCAL_BACKEND_URL_KEYS, QA_ACCESS_TEAM_DOMAIN_KEY};
29 changes: 29 additions & 0 deletions config/rsbuild/getDevServerSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type DevServerSettings = {
isWorkerEnabled: boolean;

/** The browser-facing port, which links built by the app use. */
publicPort: number;

/** The port Rsbuild binds: private behind the Worker, otherwise the public port. */
port: number;
strictPort: boolean;
open: string | false;
};

/**
* `npm run web` owns the ports and opening the browser, and says so with WEB_LAUNCHER. Without it, Rsbuild runs on its
* own, so launcher settings that `.env` may contain do not apply.
*/
async function getDevServerSettings(env: Record<string, string | undefined>, findPort: () => Promise<number>): Promise<DevServerSettings> {
if (env.WEB_LAUNCHER !== 'true') {
const port = await findPort();
// Rsbuild's --open flag defaults to the first entry's route, e.g. "/main" for our "main" entry key, which 404s
// since the app has no route by that name. Open the actual app root instead.
return {isWorkerEnabled: false, publicPort: port, port, strictPort: false, open: '/'};
}
const isWorkerEnabled = env.USE_CLOUDFLARE_WORKER === 'true';
const publicPort = Number(env.WEB_PORT);
return {isWorkerEnabled, publicPort, port: isWorkerEnabled ? Number(env.WEB_INTERNAL_PORT) : publicPort, strictPort: true, open: false};
}

export default getDevServerSettings;
11 changes: 10 additions & 1 deletion config/rsbuild/rsbuild.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import BrotliCompressionPlugin from './BrotliCompressionPlugin.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import CustomVersionFilePlugin from './CustomVersionFilePlugin.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import {FORWARDED_DEVELOPMENT_ENV_KEYS} from './developmentEnvKeys.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import ModuleInitTimingPlugin from './ModuleInitTimingPlugin.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import RspackPreloadPlugin from './RspackPreloadPlugin.ts';
Expand Down Expand Up @@ -133,6 +135,10 @@ function mapEnvironmentToLogoSuffix(environmentFile: string): string {
*/
function getDefineValues(file: string): DefinePluginOptions {
const isDevelopmentFile = file === '.env' || file === '.env.development';
const parsedConfig = dotenv.config({path: file}).parsed;
const developmentOverrides = isDevelopmentFile
? Object.fromEntries(FORWARDED_DEVELOPMENT_ENV_KEYS.flatMap((key: string) => (process.env[key] === undefined ? [] : [[key, process.env[key]]])))
: {};
/* eslint-disable @typescript-eslint/naming-convention */
return {
process: {env: {}},
Expand All @@ -142,7 +148,10 @@ function getDefineValues(file: string): DefinePluginOptions {
global: 'globalThis',
// Define EXPO_OS for web platform to fix expo-modules-core warning
'process.env.EXPO_OS': JSON.stringify('web'),
__REACT_WEB_CONFIG__: JSON.stringify(dotenv.config({path: file}).parsed),
__REACT_WEB_CONFIG__: JSON.stringify({
...parsedConfig,
...developmentOverrides,
}),

// React Native JavaScript environment requires the global __DEV__ variable to be accessible.
// react-native-render-html uses variable to log exclusively during development.
Expand Down
37 changes: 24 additions & 13 deletions config/rsbuild/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import {fileURLToPath} from 'url';

import type Environment from './types.ts';

// The Worker is a separately installed package that the App does not depend on, so it can only be reached by path.
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
// eslint-disable-next-line import/no-relative-packages
import {API_PROXY_PATHS, API_PROXY_PORT} from '../../web/cloudflare-worker/src/developmentAPIProxy.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
// eslint-disable-next-line import/no-relative-packages
import NONCE_PLACEHOLDER from '../../web/cloudflare-worker/src/noncePlaceholder.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import getDevServerSettings from './getDevServerSettings.ts';
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import getCommonConfiguration from './rsbuild.common.ts';

Expand Down Expand Up @@ -48,39 +57,41 @@ export default defineConfig(async ({command}) => {
};
}

const port = await portfinder.getPortPromise({port: BASE_PORT});
const {isWorkerEnabled, publicPort, port, strictPort, open} = await getDevServerSettings(process.env, () => portfinder.getPortPromise({port: BASE_PORT}));
// Check if the USE_WEB_PROXY variable has been provided and rewrite any requests to the local proxy server
const proxy = process.env.USE_WEB_PROXY === 'false' ? undefined : [{pathFilter: ['/api', '/staging', '/chat-attachments', '/receipts'], target: 'http://[::1]:9000'}];
const proxy = process.env.USE_WEB_PROXY === 'false' ? undefined : [{pathFilter: API_PROXY_PATHS, target: `http://[::1]:${API_PROXY_PORT}`}];

const commonRspackTool = common.tools?.rspack;

const devConfig: RsbuildConfig = {
...common,
dev: {
client: {overlay: false},
assetPrefix: '/',
lazyCompilation: false,
},
security: isWorkerEnabled ? {nonce: NONCE_PLACEHOLDER} : common.security,
source: {
...common.source,
define: {
...common.source?.define,
// The dev server's resolved port can differ from BASE_PORT if 8082 is already in use.
// CONFIG.DEV_PORT (src/CONFIG.ts) reads this at runtime to build correct dev environment URLs.
// CONFIG.DEV_PORT builds browser-facing links, so it must not use the private Rsbuild port.
// eslint-disable-next-line @typescript-eslint/naming-convention
'process.env.PORT': port,
'process.env.PORT': publicPort,
},
},
server: {
proxy,
port,
host: 'dev.new.expensify.com',
// Rsbuild's --open flag defaults to the first entry's route, e.g. "/main" for our "main" entry key,
// which 404s since the app has no route by that name. Open the actual app root instead.
open: '/',
https: {
key: fs.readFileSync(path.join(dirname, 'key.pem')),
cert: fs.readFileSync(path.join(dirname, 'certificate.pem')),
},
strictPort,
host: isWorkerEnabled ? '127.0.0.1' : 'dev.new.expensify.com',
open,
https: isWorkerEnabled
? undefined
: {
key: fs.readFileSync(path.join(dirname, 'key.pem')),
cert: fs.readFileSync(path.join(dirname, 'certificate.pem')),
},
headers: {
// HTTP header names aren't valid camelCase identifiers.
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down
21 changes: 21 additions & 0 deletions contributingGuides/SETUP_WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ If you already generated certificates before the Webpack → Rsbuild migration,
- To run the **development web app**: `npm run web`
- Changes applied to Javascript will be applied automatically via Rsbuild as configured in `config/rsbuild/rsbuild.config.ts`

`npm run web` starts the API proxy, Rsbuild, and the Cloudflare Worker in local workerd through Wrangler. It opens `https://dev.new.expensify.com:8082/` after the Worker serves the app. The Worker uses your existing mkcert certificate and forwards to a private loopback HTTP Rsbuild listener.

React edits use Fast Refresh through the Worker's WSS connection. Worker entry-point and imported module edits (including CSP changes) use Wrangler's native reload without restarting the command or the other services. A new document request/reload is needed to receive changed CSP headers; existing pages do not automatically acquire new response headers.

Optional shell or `.env` settings (shell values take precedence):

```bash
USE_CLOUDFLARE_WORKER=false npm run web # direct HTTPS Rsbuild, API proxy still enabled
USE_WEB_PROXY=false npm run web # Worker still enabled; use your configured local backend
WEB_PORT=8102 WEB_OPEN=false npm run web # isolated session suitable for Playwright
```

`WEB_PORT` defaults to the first free port from `8082`, and `WEB_INTERNAL_PORT` to the next free port after it, so a second worktree's session starts alongside the first. A port you set explicitly must be free, or startup fails. The API proxy keeps port `9000`: if another session already runs it, the new session reuses it, and its API requests stop working when that session stops. Ctrl-C or a required service exiting stops the remaining services.

Set `EXPENSIFY_URL` and `SECURE_EXPENSIFY_URL` for your local backend and disable `USE_WEB_PROXY` for direct backend requests. Explicit backend origins are added only to the development connection policy. Backend CORS rules and TLS trust still apply; CSP allowances do not bypass them. Third-party OAuth may require an approved origin.

For exact built-asset caching, service-worker, and staging/production CSP checks, use the separate [Worker build preview](../web/cloudflare-worker/README.md#running-a-web-build-locally). Live Rsbuild mode uses development caching and does not exercise Cloudflare's asset store or previous-build R2 fallback.

### Production Build
To build and run the production web build locally:

Expand Down Expand Up @@ -143,5 +161,8 @@ You probably have a misconfigured `.env` file - remove it (`rm .env`) and try ag
1. If you are having issues with **_Getting Started_**, please reference [React Native's Documentation](https://reactnative.dev/docs/environment-setup)
2. Make sure you have the correct Node version installed as specified in `.nvmrc`
3. Ensure HTTPS is properly configured if you're having certificate issues
4. If the Worker tooling is missing, `npm run web` warns and serves Rsbuild directly. Rerun `npm install` to restore the Worker; it skips the Worker when `CI` is set, and launching the server never installs dependencies. Missing certificates require `npm run setup-https`. Trust the mkcert CA in the browser rather than disabling certificate checks.
5. If the Worker returns `503`, inspect the Rsbuild process output. There is no fallback to an old `dist/` build. If a page appears stale, check for an old service worker in the disposable browser profile.
6. The old ngrok workaround for Wrangler upstream ports is not required: live mode explicitly fetches a loopback HTTP origin. Do not clear CA settings or disable TLS verification to work around unrelated certificate errors.

**Note:** Expensify engineers that will be testing with the API in your local dev environment please refer to [these additional instructions](https://stackoverflow.com/c/expensify/questions/7699/7700).
12 changes: 8 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"ipad-sm": "concurrently \"./scripts/run-build.sh --ipad-sm\"",
"ipad-sm-standalone": "concurrently \"STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ipad-sm\"",
"start": "npx rock start",
"web": "./scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server",
"web": "./scripts/set-pusher-suffix.sh && bun scripts/startWeb.ts",
"web-proxy": "bun web/proxy.ts",
"web-server": "node ./node_modules/.bin/rsbuild dev --open --config config/rsbuild/rsbuild.config.ts",
"build": "ENV=production bun ./node_modules/.bin/rsbuild build --config config/rsbuild/rsbuild.config.ts && bun ./scripts/combine-web-sourcemaps.ts",
Expand Down Expand Up @@ -297,7 +297,7 @@
"@types/lodash-es": "4.17.12",
"@types/mapbox-gl": "^2.7.13",
"@types/mime-db": "^1.43.5",
"@types/node": "^20.11.5",
"@types/node": "^26.6.5",
"@types/pako": "^2.0.3",
"@types/pusher-js": "^5.1.0",
"@types/react": "19.2.3",
Expand Down
40 changes: 40 additions & 0 deletions scripts/installCloudflareWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Install the independently locked Worker tooling during local App setup, so `npm run web` can run it. CI jobs that need
* the Worker install it themselves, and local installs skip it until its lockfile changes.
*/
import {$} from 'bun';
import path from 'node:path';

/** Kept inside node_modules, so deleting the Worker dependencies also invalidates it. */
const INSTALLED_LOCKFILE_HASH_PATH = 'node_modules/.app-installed-lockfile-hash';

async function runNpmCI(workerDir: string): Promise<void> {
await $`npm ci --include=dev`.cwd(workerDir);
}

async function installCloudflareWorker(workerDir: string, env: Record<string, string | undefined> = Bun.env, install = runNpmCI): Promise<boolean> {
if (env.CI) {
return false;
}
const lockfileHash = new Bun.CryptoHasher('sha256').update(await Bun.file(path.join(workerDir, 'package-lock.json')).text()).digest('hex');
const installedLockfileHash = Bun.file(path.join(workerDir, INSTALLED_LOCKFILE_HASH_PATH));
if ((await installedLockfileHash.exists()) && (await installedLockfileHash.text()) === lockfileHash) {
return false;
}
console.log('Installing local Cloudflare Worker tooling');
try {
await install(workerDir);
} catch (error) {
// Only `npm run web` uses the Worker, and it serves Rsbuild directly without it, so this must not fail the App install.
console.warn('Could not install the Cloudflare Worker tooling. npm run web will serve Rsbuild directly until the next npm install installs it.', error);
return false;
}
await Bun.write(installedLockfileHash, lockfileHash);
return true;
}

if (import.meta.main) {
await installCloudflareWorker(path.resolve(import.meta.dir, '../web/cloudflare-worker'));
}

export default installCloudflareWorker;
3 changes: 3 additions & 0 deletions scripts/postInstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ bun scripts/applyPatches.ts
# files, and project mode does not follow `references`. `npx tsc` at the repo root therefore checks
# nothing and exits 0. Use `npx tsc --build`, which walks the references, or `npm run typecheck`.
ln -sfn ../typescript/bin/tsc "$ROOT_DIR/node_modules/.bin/tsc"

# Keep the Worker independently installable for deploy CI, but include it in normal local App setup.
bun scripts/installCloudflareWorker.ts
Loading
Loading