diff --git a/README.md b/README.md index 5e737b99952d..1a0b7ad99a4b 100644 --- a/README.md +++ b/README.md @@ -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 `, 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 @@ -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 diff --git a/config/rsbuild/developmentEnvKeys.ts b/config/rsbuild/developmentEnvKeys.ts new file mode 100644 index 000000000000..4816802b58e1 --- /dev/null +++ b/config/rsbuild/developmentEnvKeys.ts @@ -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 = [ + '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}; diff --git a/config/rsbuild/getDevServerSettings.ts b/config/rsbuild/getDevServerSettings.ts new file mode 100644 index 000000000000..eaf04424030d --- /dev/null +++ b/config/rsbuild/getDevServerSettings.ts @@ -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, findPort: () => Promise): Promise { + 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; diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index c8a3c35947f9..a8262a5abc25 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -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'; @@ -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: {}}, @@ -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. diff --git a/config/rsbuild/rsbuild.config.ts b/config/rsbuild/rsbuild.config.ts index d5140d759494..e766d5a4e98f 100644 --- a/config/rsbuild/rsbuild.config.ts +++ b/config/rsbuild/rsbuild.config.ts @@ -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'; @@ -48,9 +57,9 @@ 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; @@ -58,29 +67,31 @@ export default defineConfig(async ({command}) => { ...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 diff --git a/contributingGuides/SETUP_WEB.md b/contributingGuides/SETUP_WEB.md index 5383fb997109..373f9ee69aac 100644 --- a/contributingGuides/SETUP_WEB.md +++ b/contributingGuides/SETUP_WEB.md @@ -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: @@ -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). diff --git a/package-lock.json b/package-lock.json index b8960f52946a..ac9f02481181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -220,7 +220,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", @@ -19625,10 +19625,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.9", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/pako": { @@ -41896,7 +41898,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/package.json b/package.json index e5f6a27ebafd..98ceb8571575 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/installCloudflareWorker.ts b/scripts/installCloudflareWorker.ts new file mode 100644 index 000000000000..d28df501ea60 --- /dev/null +++ b/scripts/installCloudflareWorker.ts @@ -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 { + await $`npm ci --include=dev`.cwd(workerDir); +} + +async function installCloudflareWorker(workerDir: string, env: Record = Bun.env, install = runNpmCI): Promise { + 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; diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index bd95fa016e40..d55013d920e5 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -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 diff --git a/scripts/startWeb.ts b/scripts/startWeb.ts new file mode 100644 index 000000000000..794c62d3f397 --- /dev/null +++ b/scripts/startWeb.ts @@ -0,0 +1,83 @@ +/** Start the live Worker and Rsbuild together, keeping browser-facing URLs independent of the upstream. */ +import {$} from 'bun'; +import concurrently from 'concurrently'; + +// The Worker is a separately installed package that the App does not depend on, so it can only be reached by path. +// eslint-disable-next-line import/no-relative-packages +import {API_PROXY_PORT} from '../web/cloudflare-worker/src/developmentAPIProxy'; +import checkWebServer from './utils/checkWebServer'; +import getWebCommands from './utils/getWebCommands'; +import {findWebDevPorts, getDevelopmentConnectOrigins, getWebDevFlags, isWebPortAvailable} from './utils/webDevConfig'; +import waitForWebServer from './waitForWebServer'; + +async function startWeb() { + // Bun loads .env automatically, preserving explicit shell overrides. + const isWorkerInstalled = await Bun.file('web/cloudflare-worker/node_modules/wrangler/bin/wrangler.js').exists(); + const {isWorkerEnabled, isWorkerMissing, isProxyEnabled, shouldOpenBrowser} = getWebDevFlags(Bun.env, isWorkerInstalled); + if (isWorkerMissing) { + console.warn('Cloudflare Worker tooling is not installed, so serving Rsbuild directly without the Worker. Run npm install (it skips the Worker when CI is set).'); + } + const {publicPort, internalPort} = findWebDevPorts(Bun.env, isWorkerEnabled, isWebPortAvailable); + const shouldStartProxy = isProxyEnabled && isWebPortAvailable(API_PROXY_PORT, '::'); + if (isProxyEnabled && !shouldStartProxy) { + console.log(`Reusing the API proxy already on port ${API_PROXY_PORT}, e.g. from another worktree's session. API requests stop working if that session stops.`); + } + for (const file of ['key.pem', 'certificate.pem']) { + if (!(await Bun.file(`config/rsbuild/${file}`).exists())) { + throw new Error('Missing local HTTPS certificate. Run npm run setup-https first.'); + } + } + const origin = `https://dev.new.expensify.com:${publicPort}`; + const commands = getWebCommands({ + root: process.cwd(), + isWorkerEnabled, + isProxyEnabled, + shouldStartProxy, + publicPort, + internalPort, + connectOrigins: getDevelopmentConnectOrigins(Bun.env), + }); + const controller = new AbortController(); + const {commands: children, result} = concurrently(commands, {killOthersOn: ['success', 'failure'], killTimeout: 3000}); + const stop = () => { + controller.abort(); + for (const child of children) { + child.kill('SIGTERM'); + } + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + // Verify the browser-facing listener and certificate, not just the internal compiler's listener. + const readiness = (async () => { + const isReady = await waitForWebServer((signal) => checkWebServer(origin, isWorkerEnabled, signal), controller.signal, { + slowAfter: 180_000, + onSlow: () => console.warn(`Still waiting for ${origin}/. Check the dev server output. If it compiled, check that its certificate is trusted (npm run setup-https).`), + }); + if (!isReady) { + return; + } + console.log(`Web app ready at ${origin}/ (${isWorkerEnabled ? 'Cloudflare Worker' : 'direct Rsbuild'})`); + if (shouldOpenBrowser) { + const browserCommands: Partial> = {darwin: 'open', win32: 'cmd'}; + const command = browserCommands[process.platform] ?? 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', `${origin}/`] : [`${origin}/`]; + $`${command} ${args}`.quiet().catch((error: unknown) => { + console.error(`Open ${origin}/ in your browser:`, error); + }); + } + })(); + try { + await Promise.race([result, readiness]); + await result; + } finally { + stop(); + await readiness.catch(() => {}); + process.removeListener('SIGINT', stop); + process.removeListener('SIGTERM', stop); + } +} + +startWeb().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 48dcbb6ccebb..7a17ca2850b8 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -13,7 +13,8 @@ import {error as logError, warn as logWarn} from './Logger'; type ExecOptions = Omit & {cwd?: ExecWithCallbackOptions['cwd']}; function exec(command: string, options?: ExecOptions) { - const optionsWithEncoding = { + // The explicit encoding type selects the overload that resolves stdout and stderr as strings. + const optionsWithEncoding: ExecWithCallbackOptions & {encoding: BufferEncoding} = { encoding: 'utf8', cwd: process.cwd(), ...options, diff --git a/scripts/utils/checkWebServer.ts b/scripts/utils/checkWebServer.ts new file mode 100644 index 000000000000..50f4eb2adfa7 --- /dev/null +++ b/scripts/utils/checkWebServer.ts @@ -0,0 +1,20 @@ +import {getCACertificates} from 'node:tls'; + +/** + * Probe the public HTTPS listener without disabling certificate verification. `npm run setup-https` trusts the mkcert CA + * in the OS store, so reading that store avoids requiring mkcert on PATH whenever the server starts. + */ +async function checkWebServer(origin: string, isWorkerEnabled: boolean, signal: AbortSignal): Promise { + try { + const response = await fetch(origin, { + tls: {ca: getCACertificates('system')}, + signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]), + }); + await response.body?.cancel(); + return response.status === 200 && (!isWorkerEnabled || response.headers.has('Content-Security-Policy')); + } catch { + return false; + } +} + +export default checkWebServer; diff --git a/scripts/utils/getWebCommands.ts b/scripts/utils/getWebCommands.ts new file mode 100644 index 000000000000..83537f08a879 --- /dev/null +++ b/scripts/utils/getWebCommands.ts @@ -0,0 +1,58 @@ +import {$} from 'bun'; + +type WebCommandSettings = { + root: string; + isWorkerEnabled: boolean; + isProxyEnabled: boolean; + + /** False when another session already runs the API proxy, which this session's Rsbuild then shares. */ + shouldStartProxy: boolean; + publicPort: number; + internalPort: number; + connectOrigins: string[]; +}; + +/** Each child gets the settings it needs in its own env, so the launcher's environment stays as the user set it. */ +function getWebCommands({root, isWorkerEnabled, isProxyEnabled, shouldStartProxy, publicPort, internalPort, connectOrigins}: WebCommandSettings) { + const workerDir = `${root}/web/cloudflare-worker`; + // concurrently accepts command strings, so escape its dynamic paths and JSON bindings. + return [ + ...(isProxyEnabled && shouldStartProxy ? [{name: 'api', command: 'npm run web-proxy', env: {USE_WEB_PROXY: 'true'}}] : []), + { + name: 'rsbuild', + command: 'npx rsbuild dev --config config/rsbuild/rsbuild.config.ts', + // WEB_LAUNCHER tells Rsbuild these settings came from this launcher, not from a user's .env. + env: { + WEB_LAUNCHER: 'true', + WEB_PORT: String(publicPort), + WEB_INTERNAL_PORT: String(internalPort), + USE_CLOUDFLARE_WORKER: String(isWorkerEnabled), + USE_WEB_PROXY: String(isProxyEnabled), + }, + }, + ...(isWorkerEnabled + ? [ + { + name: 'worker', + cwd: workerDir, + command: [ + `bun ../../scripts/waitForWebServer.ts http://127.0.0.1:${internalPort}/ &&`, + 'npx wrangler dev', + '--local', + '--config wrangler.dev.jsonc', + '--ip 127.0.0.1', + `--port ${publicPort}`, + '--inspector-port 0', + '--local-protocol https', + `--https-key-path ${$.escape(`${root}/config/rsbuild/key.pem`)}`, + `--https-cert-path ${$.escape(`${root}/config/rsbuild/certificate.pem`)}`, + `--var ${$.escape(`DEV_UPSTREAM:http://127.0.0.1:${internalPort}`)}`, + `--var ${$.escape(`DEV_CONNECT_ORIGINS:${JSON.stringify(connectOrigins)}`)}`, + ].join(' '), + }, + ] + : []), + ]; +} + +export default getWebCommands; diff --git a/scripts/utils/webDevConfig.ts b/scripts/utils/webDevConfig.ts new file mode 100644 index 000000000000..c618f8f61b89 --- /dev/null +++ b/scripts/utils/webDevConfig.ts @@ -0,0 +1,102 @@ +/** Resolve one set of ports and flags for every process in a local web session. */ +import {LOCAL_BACKEND_URL_KEYS, QA_ACCESS_TEAM_DOMAIN_KEY} from '../../config/rsbuild/developmentEnvKeys'; + +/** A session whose install skipped or failed to install the Worker tooling still starts, serving Rsbuild directly. */ +function getWebDevFlags(env: Record, isWorkerInstalled: boolean) { + const isWorkerRequested = env.USE_CLOUDFLARE_WORKER !== 'false'; + return { + isWorkerEnabled: isWorkerRequested && isWorkerInstalled, + isWorkerMissing: isWorkerRequested && !isWorkerInstalled, + isProxyEnabled: env.USE_WEB_PROXY !== 'false', + shouldOpenBrowser: env.WEB_OPEN !== 'false', + }; +} + +function getDevelopmentConnectOrigins(env: Record): string[] { + const origins = new Set(); + for (const key of LOCAL_BACKEND_URL_KEYS) { + const value = env[key]; + if (!value) { + continue; + } + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + throw new Error(`${key} must be an HTTP(S) URL without credentials`); + } + origins.add(url.origin); + } + const teamDomain = env[QA_ACCESS_TEAM_DOMAIN_KEY]; + if (teamDomain) { + const issuer = URL.parse(`https://${teamDomain}`); + if (issuer?.host !== teamDomain.toLowerCase()) { + throw new Error(`${QA_ACCESS_TEAM_DOMAIN_KEY} must be a bare hostname, like team.cloudflareaccess.com`); + } + origins.add(issuer.origin); + } + return [...origins]; +} + +/** Whether a session could bind the port. Releases its probe immediately, so the session's own server can bind it. */ +function isWebPortAvailable(port: number, host = '127.0.0.1'): boolean { + try { + Bun.listen({ + hostname: host, + port, + socket: { + data: (socket) => { + socket.end(); + }, + }, + }).stop(true); + return true; + } catch { + return false; + } +} + +const DEFAULT_WEB_PORT = 8082; +const MIN_WEB_PORT = 1024; +const MAX_WEB_PORT = 65535; + +function parseWebPort(env: Record, key: 'WEB_PORT' | 'WEB_INTERNAL_PORT'): number | undefined { + const value = env[key]; + if (value === undefined) { + return undefined; + } + const port = Number(value); + if (!Number.isInteger(port) || port < MIN_WEB_PORT || port > MAX_WEB_PORT) { + throw new Error(`${key} must be an integer between ${MIN_WEB_PORT} and ${MAX_WEB_PORT}`); + } + return port; +} + +/** + * Ports the user set must be free, since they asked for exactly those. Otherwise take the first free ports from 8082 up, + * so a second worktree's session starts alongside the first, as it did before the Worker. + */ +function findWebDevPorts(env: Record, isWorkerEnabled: boolean, isPortAvailable: (port: number) => boolean) { + const explicitPublicPort = parseWebPort(env, 'WEB_PORT'); + const explicitInternalPort = isWorkerEnabled ? parseWebPort(env, 'WEB_INTERNAL_PORT') : undefined; + if (explicitPublicPort !== undefined && explicitPublicPort === explicitInternalPort) { + throw new Error('WEB_PORT and WEB_INTERNAL_PORT must differ'); + } + for (const port of [explicitPublicPort, explicitInternalPort]) { + if (port !== undefined && !isPortAvailable(port)) { + throw new Error(`Port ${port} is in use. Stop its owner, or unset WEB_PORT and WEB_INTERNAL_PORT to use free ports.`); + } + } + const findFreePort = (from: number, taken: number | undefined) => { + for (let port = from; port <= MAX_WEB_PORT; port++) { + if (port !== taken && isPortAvailable(port)) { + return port; + } + } + throw new Error(`No free port from ${from} up for the web dev server`); + }; + const publicPort = explicitPublicPort ?? findFreePort(DEFAULT_WEB_PORT, explicitInternalPort); + // Rsbuild ignores the internal port without the Worker. + const internalPort = isWorkerEnabled ? (explicitInternalPort ?? findFreePort(publicPort + 1, publicPort)) : publicPort + 1; + return {publicPort, internalPort}; +} + +export {findWebDevPorts, getDevelopmentConnectOrigins, getWebDevFlags, isWebPortAvailable}; diff --git a/scripts/waitForWebServer.ts b/scripts/waitForWebServer.ts new file mode 100644 index 000000000000..60d1bed0f6f9 --- /dev/null +++ b/scripts/waitForWebServer.ts @@ -0,0 +1,71 @@ +type WaitForWebServerOptions = { + interval?: number; + + /** How long startup can take before `onSlow` reports it. */ + slowAfter?: number; + onSlow?: () => void; +}; + +/** + * Gate dev processes on a usable HTTP response, not a listening socket or a compiler log message. Resolves false if the + * session stops first. There is no deadline: concurrently already stops the session if a required service exits, so a + * slow first compile is only reported. + */ +async function waitForWebServer( + probe: (signal: AbortSignal) => Promise, + signal: AbortSignal, + {interval = 250, slowAfter = Infinity, onSlow = () => {}}: WaitForWebServerOptions = {}, +): Promise { + const slowAt = Date.now() + slowAfter; + let hasReportedSlow = false; + while (!signal.aborted) { + try { + if (await probe(signal)) { + return !signal.aborted; + } + } catch { + // The server may not have a listener yet, or may still be compiling its first response. + } + if (!hasReportedSlow && Date.now() > slowAt) { + hasReportedSlow = true; + onSlow(); + } + if (!signal.aborted) { + await Bun.sleep(interval); + } + } + return false; +} + +/** Whether the private Rsbuild listener can serve the Worker yet. */ +async function isRsbuildReady(origin: string, signal: AbortSignal): Promise { + const response = await fetch(origin, {signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]), redirect: 'manual'}); + await response.body?.cancel(); + return response.status === 200; +} + +if (import.meta.main) { + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + try { + const origin = Bun.argv.at(2); + if (!origin) { + throw new Error('Missing Rsbuild readiness URL'); + } + // A non-zero exit keeps the shell from starting the Worker. + if (!(await waitForWebServer((signal) => isRsbuildReady(origin, signal), controller.signal))) { + process.exitCode = 1; + } + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } finally { + process.removeListener('SIGINT', stop); + process.removeListener('SIGTERM', stop); + } +} + +export default waitForWebServer; +export {isRsbuildReady}; diff --git a/tests/tooling/checkWebServer.test.ts b/tests/tooling/checkWebServer.test.ts new file mode 100644 index 000000000000..e7039f5cd233 --- /dev/null +++ b/tests/tooling/checkWebServer.test.ts @@ -0,0 +1,82 @@ +/** Readiness probes must time out or cancel without blocking the development launcher. */ +import {afterEach, describe, expect, mock, spyOn, test} from 'bun:test'; + +import {getCACertificates} from 'node:tls'; + +import checkWebServer from '../../scripts/utils/checkWebServer'; + +describe('public web server readiness', () => { + afterEach(() => mock.restore()); + + test.each([new DOMException('Probe timed out', 'TimeoutError'), new TypeError('Connection refused'), new Error('Certificate verification failed')])( + 'treats fetch failures as an unsuccessful probe: %s', + async (error) => { + // Given a listener that is starting up, unresponsive, or has an untrusted certificate + const get = spyOn(globalThis, 'fetch').mockRejectedValue(error); + const signal = new AbortController().signal; + // When the launcher probes the public origin + const isReady = await checkWebServer('https://dev.new.expensify.com:8082', true, signal); + // Then it can retry, verifying TLS against the OS trust store that `npm run setup-https` adds the mkcert CA to + expect(isReady).toBe(false); + const [url, init] = get.mock.calls.at(0) ?? []; + expect(url).toBe('https://dev.new.expensify.com:8082'); + expect(init).toMatchObject({tls: {ca: getCACertificates('system')}}); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }, + ); + + test('cancels an outstanding probe when the launcher stops', async () => { + // Given a probe waiting for its response + const waitingFetch = Object.assign( + (_url: Parameters[0], init?: Parameters[1]) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {once: true}); + }), + {preconnect: fetch.preconnect}, + ); + spyOn(globalThis, 'fetch').mockImplementation(waitingFetch); + const controller = new AbortController(); + const result = checkWebServer('https://dev.new.expensify.com:8082', true, controller.signal); + // When the session is stopped + controller.abort(); + // Then readiness finishes without waiting for the timeout + await expect(result).resolves.toBe(false); + }); + + test('times out a real listener that does not return response headers', async () => { + // Given a real local server that cannot finish its first response yet + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: () => new Promise(() => {}), + }); + try { + // When the launcher probes it without an external cancellation + const result = checkWebServer(server.url.href, false, new AbortController().signal); + // Then the built-in request timeout releases the probe rather than hanging startup + await expect(result).resolves.toBe(false); + } finally { + await server.stop(true); + } + }); + + test.each([ + {status: 200, hasCSP: true, isWorkerEnabled: true, isReady: true}, + {status: 200, hasCSP: false, isWorkerEnabled: true, isReady: false}, + {status: 503, hasCSP: true, isWorkerEnabled: true, isReady: false}, + {status: 200, hasCSP: false, isWorkerEnabled: false, isReady: true}, + ])('requires a usable response from the selected public listener: %j', async ({status, hasCSP, isWorkerEnabled, isReady}) => { + // Given either the direct server or the Worker listener + const headers = new Headers(); + if (hasCSP) { + headers.set('Content-Security-Policy', "default-src 'self'"); + } + const response = new Response('app shell', {status, headers}); + spyOn(globalThis, 'fetch').mockResolvedValue(response); + // When the listener returns its response + const isServerReady = await checkWebServer('https://dev.new.expensify.com:8082', isWorkerEnabled, new AbortController().signal); + // Then only a successful response with the expected Worker headers can open the browser + expect(isServerReady).toBe(isReady); + expect(response.bodyUsed).toBe(true); + }); +}); diff --git a/tests/tooling/developmentEnvKeys.test.ts b/tests/tooling/developmentEnvKeys.test.ts new file mode 100644 index 000000000000..2f14a10dca07 --- /dev/null +++ b/tests/tooling/developmentEnvKeys.test.ts @@ -0,0 +1,24 @@ +/** The app and the Worker CSP must see the same local backends, so the backend keys are defined once. */ +import {describe, expect, test} from 'bun:test'; + +import {FORWARDED_DEVELOPMENT_ENV_KEYS, LOCAL_BACKEND_URL_KEYS, QA_ACCESS_TEAM_DOMAIN_KEY} from '../../config/rsbuild/developmentEnvKeys'; +import {getDevelopmentConnectOrigins} from '../../scripts/utils/webDevConfig'; + +describe('development env keys', () => { + test('forwards every backend the CSP allows to the app', () => { + // Given the backend keys the launcher adds to the Worker CSP + // When Rsbuild forwards development settings to the app + // Then the app sees every one of those backends, and the QA Access issuer + for (const key of [...LOCAL_BACKEND_URL_KEYS, QA_ACCESS_TEAM_DOMAIN_KEY]) { + expect(FORWARDED_DEVELOPMENT_ENV_KEYS).toContain(key); + } + }); + + test('allows each backend key in the CSP', () => { + // Given a distinct origin for every backend key + const env = Object.fromEntries(LOCAL_BACKEND_URL_KEYS.map((key, index) => [key, `https://backend-${index}.test/`])); + // When the launcher resolves the CSP origins + // Then none is missing + expect(getDevelopmentConnectOrigins(env)).toHaveLength(LOCAL_BACKEND_URL_KEYS.length); + }); +}); diff --git a/tests/tooling/getDevServerSettings.test.ts b/tests/tooling/getDevServerSettings.test.ts new file mode 100644 index 000000000000..81ebde137979 --- /dev/null +++ b/tests/tooling/getDevServerSettings.test.ts @@ -0,0 +1,37 @@ +/** Rsbuild should only adopt launcher-owned settings when the launcher says it started Rsbuild. */ +import {describe, expect, mock, test} from 'bun:test'; + +import getDevServerSettings from '../../config/rsbuild/getDevServerSettings'; + +describe('Rsbuild dev server settings', () => { + test('uses the launcher ports exactly when the launcher started it', async () => { + // Given the launcher's explicit settings + const findPort = mock(async () => 8090); + // When Rsbuild resolves its server settings + const settings = await getDevServerSettings({WEB_LAUNCHER: 'true', WEB_PORT: '8102', WEB_INTERNAL_PORT: '8103', USE_CLOUDFLARE_WORKER: 'true'}, findPort); + // Then it binds the private port behind the Worker, never another port, and leaves opening the browser to the launcher + expect(settings).toEqual({isWorkerEnabled: true, publicPort: 8102, port: 8103, strictPort: true, open: false}); + expect(findPort).not.toHaveBeenCalled(); + }); + + test('serves the public port directly when the launcher runs without the Worker', async () => { + // Given a launcher session with the Worker off + // When Rsbuild resolves its server settings + // Then it binds the public port itself + expect(await getDevServerSettings({WEB_LAUNCHER: 'true', WEB_PORT: '8102', WEB_INTERNAL_PORT: '8103', USE_CLOUDFLARE_WORKER: 'false'}, async () => 8090)).toEqual({ + isWorkerEnabled: false, + publicPort: 8102, + port: 8102, + strictPort: true, + open: false, + }); + }); + + test('ignores launcher settings from .env when Rsbuild runs on its own', async () => { + // Given WEB_PORT and the Worker flag set in .env, which docs allow for the launcher + // When Rsbuild runs directly + const settings = await getDevServerSettings({WEB_PORT: '8102', USE_CLOUDFLARE_WORKER: 'true'}, async () => 8090); + // Then it keeps its standalone behavior: a free port, direct HTTPS, and opening the app itself + expect(settings).toEqual({isWorkerEnabled: false, publicPort: 8090, port: 8090, strictPort: false, open: '/'}); + }); +}); diff --git a/tests/tooling/getWebCommands.test.ts b/tests/tooling/getWebCommands.test.ts new file mode 100644 index 000000000000..e2285f2be036 --- /dev/null +++ b/tests/tooling/getWebCommands.test.ts @@ -0,0 +1,50 @@ +/** The launcher should hand its resolved settings to each child explicitly, without mutating its own environment. */ +import {describe, expect, test} from 'bun:test'; + +import getWebCommands from '../../scripts/utils/getWebCommands'; + +const SETTINGS = {root: '/repo', publicPort: 8102, internalPort: 8103, connectOrigins: ['https://www.expensify.com.dev']}; + +describe('web launcher commands', () => { + test('passes the resolved settings and an explicit launcher flag to Rsbuild', () => { + // Given a session with the Worker and the API proxy enabled + // When the launcher builds its commands + const commands = getWebCommands({...SETTINGS, isWorkerEnabled: true, isProxyEnabled: true, shouldStartProxy: true}); + // Then Rsbuild learns that the launcher started it, and which ports to use, from its own env + expect(commands.find(({name}) => name === 'rsbuild')?.env).toEqual({ + WEB_LAUNCHER: 'true', + WEB_PORT: '8102', + WEB_INTERNAL_PORT: '8103', + USE_CLOUDFLARE_WORKER: 'true', + USE_WEB_PROXY: 'true', + }); + expect(commands.map(({name}) => name)).toEqual(['api', 'rsbuild', 'worker']); + }); + + test('reuses an API proxy another session already runs', () => { + // Given a proxy-enabled session whose proxy port another worktree's session owns + // When the launcher builds its commands + const commands = getWebCommands({...SETTINGS, isWorkerEnabled: true, isProxyEnabled: true, shouldStartProxy: false}); + // Then it does not start a second proxy, but Rsbuild still forwards API requests to the running one + expect(commands.map(({name}) => name)).toEqual(['rsbuild', 'worker']); + expect(commands.at(0)?.env).toMatchObject({USE_WEB_PROXY: 'true'}); + }); + + test('does not change its own environment, which every child would inherit', () => { + // Given the launcher's environment + const before = {...process.env}; + // When it builds its commands + getWebCommands({...SETTINGS, isWorkerEnabled: false, isProxyEnabled: false, shouldStartProxy: false}); + // Then nothing leaks into it + expect(process.env).toEqual(before); + }); + + test('starts only the selected services', () => { + // Given both services opted out + // When the launcher builds its commands + const commands = getWebCommands({...SETTINGS, isWorkerEnabled: false, isProxyEnabled: false, shouldStartProxy: false}); + // Then only Rsbuild runs, and it knows the Worker and proxy are off + expect(commands.map(({name}) => name)).toEqual(['rsbuild']); + expect(commands.at(0)?.env).toMatchObject({USE_CLOUDFLARE_WORKER: 'false', USE_WEB_PROXY: 'false'}); + }); +}); diff --git a/tests/tooling/installCloudflareWorker.test.ts b/tests/tooling/installCloudflareWorker.test.ts new file mode 100644 index 000000000000..9b1933963aad --- /dev/null +++ b/tests/tooling/installCloudflareWorker.test.ts @@ -0,0 +1,74 @@ +/** Root installs should set up the Worker tooling locally, but only when its lockfile changes, and never in CI. */ +import {afterEach, beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; + +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; + +import installCloudflareWorker from '../../scripts/installCloudflareWorker'; + +describe('Cloudflare Worker install during App setup', () => { + let workerDir: string; + const install = mock(async () => { + await Bun.write(path.join(workerDir, 'node_modules/wrangler/package.json'), '{}'); + }); + + beforeEach(async () => { + workerDir = mkdtempSync(path.join(tmpdir(), 'worker-install-')); + await Bun.write(path.join(workerDir, 'package-lock.json'), '{"lockfileVersion": 3}'); + install.mockClear(); + }); + + afterEach(() => rmSync(workerDir, {recursive: true, force: true})); + + test('installs once, then skips root installs until the Worker lockfile changes', async () => { + // Given a fresh checkout + // When the root install runs twice + expect(await installCloudflareWorker(workerDir, {}, install)).toBe(true); + expect(await installCloudflareWorker(workerDir, {}, install)).toBe(false); + // Then the Worker tooling is installed only once + expect(install).toHaveBeenCalledTimes(1); + + // When the Worker lockfile changes + await Bun.write(path.join(workerDir, 'package-lock.json'), '{"lockfileVersion": 3, "packages": {}}'); + // Then the next root install updates the Worker tooling + expect(await installCloudflareWorker(workerDir, {}, install)).toBe(true); + expect(install).toHaveBeenCalledTimes(2); + }); + + test('reinstalls when the Worker node_modules was removed', async () => { + // Given an installed Worker whose dependencies were then deleted + await installCloudflareWorker(workerDir, {}, install); + rmSync(path.join(workerDir, 'node_modules'), {recursive: true, force: true}); + // When the root install runs + // Then the tooling is restored + expect(await installCloudflareWorker(workerDir, {}, install)).toBe(true); + expect(install).toHaveBeenCalledTimes(2); + }); + + test('warns instead of failing the App install, and retries next time', async () => { + // Given an install that fails partway, e.g. offline or a failed workerd download + const failingInstall = mock(async () => { + throw new Error('network error'); + }); + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + try { + // When the root install runs + // Then it still succeeds for developers who never run the Worker, and explains what is missing + expect(await installCloudflareWorker(workerDir, {}, failingInstall)).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + // And the next root install tries again + expect(await installCloudflareWorker(workerDir, {}, install)).toBe(true); + }); + + test('skips CI, where only the Worker jobs need the tooling and install it themselves', async () => { + // Given a CI root install + // When postinstall runs + // Then wrangler and workerd are not downloaded + expect(await installCloudflareWorker(workerDir, {CI: 'true'}, install)).toBe(false); + expect(install).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tooling/waitForWebServer.test.ts b/tests/tooling/waitForWebServer.test.ts new file mode 100644 index 000000000000..5da01f73bb6a --- /dev/null +++ b/tests/tooling/waitForWebServer.test.ts @@ -0,0 +1,97 @@ +/** Startup dependencies should wait for usable HTTP responses for as long as the session runs, and stop cleanly on cancellation. */ +import {afterEach, describe, expect, mock, setSystemTime, test} from 'bun:test'; + +import waitForWebServer, {isRsbuildReady} from '../../scripts/waitForWebServer'; + +describe('dev server readiness', () => { + afterEach(() => setSystemTime()); + + test('retries failed and throwing probes until the server is ready', async () => { + // Given a server that is not listening, then compiling, then ready + const results = [new TypeError('Connection refused'), false, true]; + const probe = mock(async () => { + const result = results.shift(); + if (result instanceof Error) { + throw result; + } + return result ?? true; + }); + // When the launcher waits for it + // Then it is ready only after the successful probe + expect(await waitForWebServer(probe, new AbortController().signal, {interval: 1})).toBe(true); + expect(probe).toHaveBeenCalledTimes(3); + }); + + test('waits through a slow first compile, reporting the delay once rather than stopping the session', async () => { + // Given a server whose first compile outlasts any fixed startup deadline + let probes = 0; + const probe = async () => { + if (++probes === 2) { + setSystemTime(new Date(Date.now() + 10 * 60_000)); + } + return probes >= 5; + }; + const onSlow = mock(() => {}); + // When the launcher waits for it + const isReady = await waitForWebServer(probe, new AbortController().signal, {interval: 1, slowAfter: 180_000, onSlow}); + // Then it keeps waiting until the server is ready, and reports the delay once + expect(isReady).toBe(true); + expect(probes).toBe(5); + expect(onSlow).toHaveBeenCalledTimes(1); + }); + + test('stops without readiness when the session stops', async () => { + // Given a server that never becomes ready + const controller = new AbortController(); + const probe = async () => { + controller.abort(); + return false; + }; + // When the session is stopped + // Then waiting ends without starting dependent processes + expect(await waitForWebServer(probe, controller.signal, {interval: 1})).toBe(false); + }); +}); + +describe('Rsbuild startup gate', () => { + test('requires a successful response, not a listening socket', async () => { + // Given Rsbuild compiling, redirecting, then serving the app + const statuses = [503, 302, 200]; + const server = Bun.serve({port: 0, hostname: '127.0.0.1', fetch: () => new Response(null, {status: statuses.shift() ?? 200, headers: {Location: '/'}})}); + try { + // When the gate probes it + const signal = new AbortController().signal; + // Then only the 200 starts the Worker + expect([await isRsbuildReady(server.url.href, signal), await isRsbuildReady(server.url.href, signal), await isRsbuildReady(server.url.href, signal)]).toEqual([ + false, + false, + true, + ]); + } finally { + await server.stop(true); + } + }); + + test('cancels an in-flight request when the session stops', async () => { + // Given a request that cannot yet return headers + const controller = new AbortController(); + const {promise: response, resolve: sendResponse} = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: () => { + controller.abort(); + return response; + }, + }); + try { + // When the session is cancelled + // Then readiness ends promptly rather than starting a dependent process + expect(await waitForWebServer((signal) => isRsbuildReady(server.url.href, signal), controller.signal)).toBe(false); + } finally { + // Bun cannot stop a server while a handler is still pending. + sendResponse(new Response('ready')); + await server.stop(true); + } + }); +}); diff --git a/tests/tooling/webDevConfig.test.ts b/tests/tooling/webDevConfig.test.ts new file mode 100644 index 000000000000..2dc097c5d41c --- /dev/null +++ b/tests/tooling/webDevConfig.test.ts @@ -0,0 +1,132 @@ +/** Keep local Worker/API opt-outs and coordinated port selection independent. */ +import {describe, expect, test} from 'bun:test'; + +import {findWebDevPorts, getDevelopmentConnectOrigins, getWebDevFlags, isWebPortAvailable} from '../../scripts/utils/webDevConfig'; + +const isFree = () => true; + +describe('web development configuration', () => { + test('defaults both services on and allows independent opt-outs', () => { + // Given contributor defaults, with the Worker tooling installed + // When flags are resolved + const defaults = getWebDevFlags({}, true); + // Then neither opt-out changes the meaning of the other + expect(defaults).toEqual({isWorkerEnabled: true, isWorkerMissing: false, isProxyEnabled: true, shouldOpenBrowser: true}); + expect(getWebDevFlags({USE_WEB_PROXY: 'false'}, true)).toMatchObject({isWorkerEnabled: true, isProxyEnabled: false}); + expect(getWebDevFlags({USE_CLOUDFLARE_WORKER: 'false', WEB_OPEN: 'false'}, true)).toEqual({ + isWorkerEnabled: false, + isWorkerMissing: false, + isProxyEnabled: true, + shouldOpenBrowser: false, + }); + expect(getWebDevFlags({USE_CLOUDFLARE_WORKER: 'false', USE_WEB_PROXY: 'false'}, true)).toMatchObject({isWorkerEnabled: false, isProxyEnabled: false}); + }); + + test('serves Rsbuild directly when the Worker tooling is not installed', () => { + // Given a checkout whose install skipped or failed to install the Worker, e.g. with CI set + // When flags are resolved + // Then the session still starts without the Worker, and says why, rather than failing + expect(getWebDevFlags({}, false)).toMatchObject({isWorkerEnabled: false, isWorkerMissing: true}); + // And a session that opted out of the Worker has nothing to report + expect(getWebDevFlags({USE_CLOUDFLARE_WORKER: 'false'}, false)).toMatchObject({isWorkerEnabled: false, isWorkerMissing: false}); + }); + + test('validates and deduplicates explicit backend origins', () => { + // Given backend settings with paths + // When CSP origins are resolved + const origins = getDevelopmentConnectOrigins({EXPENSIFY_URL: 'https://api.example.test/path', SECURE_EXPENSIFY_URL: 'https://api.example.test/secure'}); + // Then only exact origins survive and credentials/non-HTTP schemes cannot enter the policy + expect(origins).toEqual(['https://api.example.test']); + expect(() => getDevelopmentConnectOrigins({EXPENSIFY_URL: 'file:///etc/hosts'})).toThrow(); + expect(() => getDevelopmentConnectOrigins({EXPENSIFY_URL: 'https://user:pass@example.test'})).toThrow(); + }); + + test('allows the QA backend and its Cloudflare Access team domain', () => { + // Given a QA backend behind Cloudflare Access, whose token endpoint the app calls on the bare team domain + // When CSP origins are resolved + const origins = getDevelopmentConnectOrigins({ + QA_EXPENSIFY_URL: 'https://qa.example.test/', + QA_SECURE_EXPENSIFY_URL: 'https://secure-qa.example.test/', + QA_CF_TEAM_DOMAIN: 'team.cloudflareaccess.com', + }); + // Then the app can reach the QA hosts and the Access issuer + expect(origins).toEqual(['https://qa.example.test', 'https://secure-qa.example.test', 'https://team.cloudflareaccess.com']); + }); + + test('names QA_CF_TEAM_DOMAIN when it is not a bare hostname', () => { + // Given a team domain written as a URL, which the app would prefix with https:// again + // When CSP origins are resolved + // Then startup explains the setting rather than allowing an origin the app never calls + for (const QA_CF_TEAM_DOMAIN of ['https://team.cloudflareaccess.com', 'team.cloudflareaccess.com/path', 'user@team.cloudflareaccess.com']) { + expect(() => getDevelopmentConnectOrigins({QA_CF_TEAM_DOMAIN})).toThrow('QA_CF_TEAM_DOMAIN'); + } + }); + + test('rejects invalid and overlapping ports before starting children', () => { + // Given invalid coordinated ports + // When the launcher resolves them + // Then startup fails instead of changing the upstream port behind the Worker's back + expect(() => findWebDevPorts({WEB_PORT: 'invalid'}, true, isFree)).toThrow(); + expect(() => findWebDevPorts({WEB_PORT: '1023'}, true, isFree)).toThrow(); + expect(() => findWebDevPorts({WEB_PORT: '65536'}, true, isFree)).toThrow(); + expect(() => findWebDevPorts({WEB_PORT: '11000', WEB_INTERNAL_PORT: '11000'}, true, isFree)).toThrow('must differ'); + }); + + test('uses the default ports when they are free', () => { + // Given a machine with no other web session + // When the launcher resolves its ports + // Then the app is at its usual origin + expect(findWebDevPorts({}, true, isFree)).toEqual({publicPort: 8082, internalPort: 8083}); + }); + + test('moves a second session to the next free ports rather than failing', () => { + // Given another worktree's session on the default ports + const busy = new Set([8082, 8083, 8085]); + // When this session resolves its ports + // Then it takes the next free ones, as portfinder did before the Worker + expect(findWebDevPorts({}, true, (port) => !busy.has(port))).toEqual({publicPort: 8084, internalPort: 8086}); + expect(findWebDevPorts({}, false, (port) => !busy.has(port)).publicPort).toBe(8084); + }); + + test('fails fast on a busy port the user chose explicitly', () => { + // Given explicitly requested ports that another server owns + const busy = new Set([9100, 9201]); + // When the launcher resolves them + // Then it says which one is taken rather than silently moving the session + expect(() => findWebDevPorts({WEB_PORT: '9100'}, true, (port) => !busy.has(port))).toThrow('Port 9100 is in use'); + expect(() => findWebDevPorts({WEB_PORT: '9200', WEB_INTERNAL_PORT: '9201'}, true, (port) => !busy.has(port))).toThrow('Port 9201 is in use'); + // And a free explicit choice is used as is, with the other port found around it + expect(findWebDevPorts({WEB_INTERNAL_PORT: '8082'}, true, isFree)).toEqual({publicPort: 8083, internalPort: 8082}); + }); + + test('detects a port another server owns, and releases its own probe', () => { + // Given another server on one port + const listener = Bun.listen({ + hostname: '127.0.0.1', + port: 0, + socket: { + data: (socket) => { + socket.end(); + }, + }, + }); + try { + // When the launcher probes it, and a port it freed itself + // Then the owned port is unavailable + expect(isWebPortAvailable(listener.port)).toBe(false); + } finally { + listener.stop(true); + } + // And once free, the probe does not keep it from the session's own server + expect(isWebPortAvailable(listener.port)).toBe(true); + Bun.listen({ + hostname: '127.0.0.1', + port: listener.port, + socket: { + data: (socket) => { + socket.end(); + }, + }, + }).stop(true); + }); +}); diff --git a/tests/unit/CloudflareAccessTest.ts b/tests/unit/CloudflareAccessTest.ts index 63e9cd957339..082f528e1423 100644 --- a/tests/unit/CloudflareAccessTest.ts +++ b/tests/unit/CloudflareAccessTest.ts @@ -56,8 +56,8 @@ function resetQAAuthConfig() { beforeEach(() => { jest.clearAllMocks(); resetQAAuthConfig(); - getWebCrypto.getRandomValues.mockImplementation((array: Uint8Array) => webcrypto.getRandomValues(array)); - getWebCrypto.sha256.mockImplementation((data: BufferSource) => webcrypto.subtle.digest('SHA-256', data)); + getWebCrypto.getRandomValues.mockImplementation((array: Uint8Array) => webcrypto.getRandomValues(array)); + getWebCrypto.sha256.mockImplementation((data: NodeJS.BufferSource) => webcrypto.subtle.digest('SHA-256', data)); jest.mocked(getAuthServerEndpoints).mockResolvedValue({ authorizationEndpoint: 'https://team.cloudflareaccess.com/cdn-cgi/access/oauth/authorization', tokenEndpoint: 'https://team.cloudflareaccess.com/cdn-cgi/access/oauth/token', diff --git a/web/cloudflare-worker/.gitignore b/web/cloudflare-worker/.gitignore index b2f02dedeefe..8ede4ac745c9 100644 --- a/web/cloudflare-worker/.gitignore +++ b/web/cloudflare-worker/.gitignore @@ -1,3 +1,4 @@ .wrangler/ .dev.vars* worker-configuration.d.ts +worker-development.d.ts diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 34887a303e78..565f6dce15e0 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -24,13 +24,39 @@ Every request runs the Worker first (`run_worker_first: true`), which reads the The CSP lives in [`src/csp.ts`](src/csp.ts). The `ENVIRONMENT` variable in [`wrangler.jsonc`](wrangler.jsonc) selects the staging or production policy. -## Running it locally +## Live development + +From the App root, run `npm install`, complete the [web HTTPS/hosts setup](../../contributingGuides/SETUP_WEB.md), then run `npm run web`. The root install includes this package's independently locked dependencies. + +The browser opens the Worker at `https://dev.new.expensify.com:8082/`. `wrangler.dev.jsonc` runs the same entry point against live Rsbuild output over loopback HTTP, with no `dist/`, R2, Cloudflare credentials, or ngrok dependency. React Fast Refresh uses WSS through the Worker; Wrangler automatically reloads Worker code and imported modules on edits. Reload the document to test changed CSP headers. + +Only the explicit `development` environment can forward to `DEV_UPSTREAM`, which must be an HTTP `127.0.0.1` origin with a port. The launcher supplies that port, and development CSP adds the public HMR origin, existing local backend endpoints, and explicit configured API origins. Live assets are not cached immutably. Staging/production still use static assets, their original CSP, and normal caching/R2 behavior. + +`USE_CLOUDFLARE_WORKER=false npm run web` bypasses the Worker but leaves the API proxy enabled. `USE_WEB_PROXY=false` disables only the API proxy. `WEB_PORT`, `WEB_INTERNAL_PORT`, and `WEB_OPEN=false` control isolated sessions and browser automation. Shell values take precedence over `.env`; occupied ports fail clearly. See the web setup guide for local backend settings and troubleshooting. + +### Startup and logs + +Wrangler starts only after Rsbuild returns its first successful HTTP response, so normal compilation does not produce Worker `503` request logs. The browser opens after the public HTTPS Worker is ready. + +Worker `console.log`, `console.warn`, and `console.error` output appears in the terminal running `npm run web`, prefixed with `[worker]`. This is separate from the application's browser console. + +Wrangler also exposes a local observability API on the same listener. For the default public port, query recent captured request and console logs with: + +```bash +curl https://127.0.0.1:8082/cdn-cgi/local/explorer/api/local/observability/query \ + -H 'Content-Type: application/json' \ + --data '{"sql":"SELECT ts_ms, level, message FROM logs ORDER BY ts_ms DESC LIMIT 20"}' +``` + +Use your chosen `WEB_PORT` if different. This is a local API, not a separate hosted Miniflare service; no Cloudflare credentials are required. + +## Running a web build locally `wrangler dev` runs the Worker in workerd, the same runtime Cloudflare uses, and serves `dist/` exactly as a deploy would. No Cloudflare account or credentials are needed. ### 1. Install -This package has its own dependencies. They are not installed by the root `npm install`. +Root `npm install` installs these dependencies too. For an independent Worker checkout or CI job, install just this package: ```bash cd web/cloudflare-worker @@ -74,7 +100,7 @@ Over plain HTTP, a few third-party scripts that load protocol-relative URLs (Con ### What you can't test locally -The API at `www.expensify.com`, and Google Sign-In, only accept requests from known origins. The app loads to the sign-in page, but signing in fails with CORS errors. Everything the Worker is responsible for can be tested locally; signing in is verified on a deployed test hostname. +This built-site preview has no API proxy and uses API endpoints and third-party OAuth providers that accept only known origins, so sign-in can fail with CORS errors. This restriction is distinct from live `npm run web`, which retains the existing API proxy or a configured local backend. Do not infer that a successful built-site startup validates authentication, or that an API proxy makes third-party OAuth work on arbitrary origins. ## Checking the behavior @@ -85,6 +111,8 @@ npm test # integration tests: the real wrangler.jsonc in workerd, again npm run typecheck # regenerates worker-configuration.d.ts with `wrangler types`, then type-checks src/ and tests/ ``` +The live-mode tests also launch the real `wrangler dev` CLI against an ephemeral HTTP/WebSocket upstream, exercising the same transport used by `npm run web`. Worker source watching is verified separately with the real app and browser: edit the entry point and `src/csp.ts`, verify subsequent responses change without restarting the command, then verify React Fast Refresh still works. + ### Comparing headers with a live host ```bash diff --git a/web/cloudflare-worker/package-lock.json b/web/cloudflare-worker/package-lock.json index 114be8c9d972..c77eaf3468cc 100644 --- a/web/cloudflare-worker/package-lock.json +++ b/web/cloudflare-worker/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "devDependencies": { "@types/node": "24.19.0", + "@types/ws": "8.18.1", "typescript": "7.0.2", - "wrangler": "4.152.0" + "wrangler": "4.152.0", + "ws": "8.21.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -1252,6 +1254,16 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", diff --git a/web/cloudflare-worker/package.json b/web/cloudflare-worker/package.json index d14f33aca584..d56e92d0554d 100644 --- a/web/cloudflare-worker/package.json +++ b/web/cloudflare-worker/package.json @@ -8,14 +8,16 @@ "prepare-dist": "./scripts/prepareDist.sh", "dev": "wrangler dev", "dev:production": "wrangler dev --env production", - "types": "wrangler types", + "types": "wrangler types && wrangler types worker-development.d.ts --config wrangler.dev.jsonc --env-interface DevelopmentEnv --include-runtime false", "typecheck": "npm run types > /dev/null && tsc -p . && tsc -p tests", "test": "node --test 'tests/**/*.test.ts'", "compare-headers": "node scripts/compareHeaders.ts" }, "devDependencies": { "@types/node": "24.19.0", + "@types/ws": "8.18.1", "typescript": "7.0.2", - "wrangler": "4.152.0" + "wrangler": "4.152.0", + "ws": "8.21.0" } } diff --git a/web/cloudflare-worker/src/csp.ts b/web/cloudflare-worker/src/csp.ts index 32866baf0d55..d418c1ff00f4 100644 --- a/web/cloudflare-worker/src/csp.ts +++ b/web/cloudflare-worker/src/csp.ts @@ -1,12 +1,28 @@ -/** Placeholder in the CSP and in `web/index.html` script tags that the Worker replaces with a per-request nonce. */ -const NONCE_PLACEHOLDER = 'nonce-random-value'; +import NONCE_PLACEHOLDER from './noncePlaceholder'; type CSPOptions = { fontSrcHosts?: string[]; frameSrcHosts?: string[]; scriptSrcHashes?: string[]; + connectSrcHosts?: string[]; + mediaSrcHosts?: string[]; }; +/** Origins only the live development Worker allows, beyond what the deployed CSP does. */ +type DevelopmentOrigins = { + /** The public Worker origin as a WebSocket origin, for Rsbuild HMR. */ + hmrOrigin: string; + + /** Local backends from `.env`, which serve API responses, attachments, and OldDot pages. */ + backendOrigins: string[]; +}; + +/** + * The app hardcodes this development OldDot origin (CONST.INTERNAL_DEV_EXPENSIFY_URL) for links and the wallet statement + * iframe, so it is allowed even when no local backend is configured. + */ +const DEVELOPMENT_OLDDOT_ORIGIN = 'https://www.expensify.com.dev'; + /** Inline scripts allowed without a nonce. Tests check that every such script in `web/index.html` is listed. */ const INLINE_SCRIPT_HASHES = [ 'sha256-hK550RmP3t+9myNCpVty39wCs9CDUTXjWUP30rrXvD0=', @@ -17,7 +33,7 @@ const INLINE_SCRIPT_HASHES = [ 'sha256-ZC4Ihfl+1sv3E25DQh090ITQKwffxiocyA9C1vaePKU=', ]; -function buildCSP({fontSrcHosts = [], frameSrcHosts = [], scriptSrcHashes = []}: CSPOptions): string { +function buildCSP({fontSrcHosts = [], frameSrcHosts = [], scriptSrcHashes = [], connectSrcHosts = [], mediaSrcHosts = []}: CSPOptions): string { const csp: Record = { 'default-src': ["'self'", 'https://assets.onfido.com'], 'script-src': [ @@ -53,6 +69,7 @@ function buildCSP({fontSrcHosts = [], frameSrcHosts = [], scriptSrcHashes = []}: ], 'connect-src': [ "'self'", + ...connectSrcHosts, 'blob: *.onfido.com', 'data:', 'https://*.analytics.google.com', @@ -147,7 +164,7 @@ function buildCSP({fontSrcHosts = [], frameSrcHosts = [], scriptSrcHashes = []}: ], 'worker-src': ["'self'", 'blob:'], 'child-src': ["'self'", 'blob:'], - 'media-src': ['blob:', 'https://assets.onfido.com', 'https://d2k5nsl2zxldvw.cloudfront.net', 'https://staging.expensify.com', 'https://www.expensify.com'], + 'media-src': [...mediaSrcHosts, 'blob:', 'https://assets.onfido.com', 'https://d2k5nsl2zxldvw.cloudfront.net', 'https://staging.expensify.com', 'https://www.expensify.com'], }; return Object.entries(csp) @@ -167,9 +184,21 @@ const PRODUCTION_CSP = buildCSP({ scriptSrcHashes: INLINE_SCRIPT_HASHES, }); -function getCSP(environment: Env['ENVIRONMENT'], nonce: string): string { - const csp = environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP; +function getCSP(environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], nonce: string, developmentOrigins?: DevelopmentOrigins): string { + let csp = environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP; + if (environment === 'development') { + const backendOrigins = [...new Set([DEVELOPMENT_OLDDOT_ORIGIN, ...(developmentOrigins?.backendOrigins ?? [])])]; + csp = buildCSP({ + fontSrcHosts: ["'self'"], + frameSrcHosts: backendOrigins, + scriptSrcHashes: INLINE_SCRIPT_HASHES, + connectSrcHosts: [...backendOrigins, ...(developmentOrigins ? [developmentOrigins.hmrOrigin] : [])], + // 'self' covers attachments the Rsbuild proxy serves from the app origin. + mediaSrcHosts: ["'self'", ...backendOrigins], + }); + } return csp.replace(NONCE_PLACEHOLDER, `nonce-${nonce}`); } export {NONCE_PLACEHOLDER, getCSP}; +export type {DevelopmentOrigins}; diff --git a/web/cloudflare-worker/src/developmentAPIProxy.ts b/web/cloudflare-worker/src/developmentAPIProxy.ts new file mode 100644 index 000000000000..03b081673d28 --- /dev/null +++ b/web/cloudflare-worker/src/developmentAPIProxy.ts @@ -0,0 +1,14 @@ +/** + * The local API proxy (`web/proxy.ts`) that Rsbuild forwards these paths to. The live Worker passes the same paths through + * untouched, so this stays free of Worker types for the Rsbuild config, launcher, and proxy to import. + */ +const API_PROXY_PATHS = ['/api', '/staging', '/staging-secure', '/chat-attachments', '/receipts']; + +const API_PROXY_PORT = 9000; + +/** API and attachment responses must never enter the SPA fallback or HTML nonce/cache pipeline. */ +function isAPIRequest(pathname: string): boolean { + return API_PROXY_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + +export {API_PROXY_PATHS, API_PROXY_PORT, isAPIRequest}; diff --git a/web/cloudflare-worker/src/developmentConfig.ts b/web/cloudflare-worker/src/developmentConfig.ts new file mode 100644 index 000000000000..7fed71591dd9 --- /dev/null +++ b/web/cloudflare-worker/src/developmentConfig.ts @@ -0,0 +1,62 @@ +/** Validated settings that `npm run web` passes to the live development Worker as vars. */ +type DevelopmentConfig = { + /** The private loopback Rsbuild origin the Worker forwards to. */ + upstream: URL; + + /** Local backends from `.env`, which serve API responses, attachments, and OldDot pages. */ + backendOrigins: string[]; +}; + +type DevelopmentVars = { + DEV_UPSTREAM: string; + DEV_CONNECT_ORIGINS: string; +}; + +function parseUpstream(value: string): URL { + const upstream = URL.parse(value); + if ( + upstream?.protocol !== 'http:' || + upstream.hostname !== '127.0.0.1' || + !upstream.port || + upstream.pathname !== '/' || + upstream.search || + upstream.hash || + upstream.username || + upstream.password + ) { + throw new Error(`DEV_UPSTREAM must be an HTTP loopback origin with an explicit port, got ${value}`); + } + return upstream; +} + +function isHTTPOrigin(value: unknown): value is string { + const url = typeof value === 'string' ? URL.parse(value) : null; + return (url?.protocol === 'http:' || url?.protocol === 'https:') && url.origin === value; +} + +function parseBackendOrigins(value: string): string[] { + let origins: unknown; + try { + origins = JSON.parse(value); + } catch { + origins = undefined; + } + if (!Array.isArray(origins) || !origins.every(isHTTPOrigin)) { + throw new Error(`DEV_CONNECT_ORIGINS must be a JSON array of HTTP(S) origins, got ${value}`); + } + return origins; +} + +let cached: {vars: DevelopmentVars; config: DevelopmentConfig} | undefined; + +/** Vars are fixed for the life of the Worker, so they are validated on first use rather than on every request. */ +function getDevelopmentConfig(vars: DevelopmentVars): DevelopmentConfig { + if (cached?.vars.DEV_UPSTREAM !== vars.DEV_UPSTREAM || cached.vars.DEV_CONNECT_ORIGINS !== vars.DEV_CONNECT_ORIGINS) { + const config = {upstream: parseUpstream(vars.DEV_UPSTREAM), backendOrigins: parseBackendOrigins(vars.DEV_CONNECT_ORIGINS)}; + cached = {vars: {DEV_UPSTREAM: vars.DEV_UPSTREAM, DEV_CONNECT_ORIGINS: vars.DEV_CONNECT_ORIGINS}, config}; + } + return cached.config; +} + +export default getDevelopmentConfig; +export type {DevelopmentConfig}; diff --git a/web/cloudflare-worker/src/fetchDevelopmentResponse.ts b/web/cloudflare-worker/src/fetchDevelopmentResponse.ts new file mode 100644 index 000000000000..12d61281f408 --- /dev/null +++ b/web/cloudflare-worker/src/fetchDevelopmentResponse.ts @@ -0,0 +1,38 @@ +import {isAPIRequest} from './developmentAPIProxy'; + +/** Forward live requests without relying on Wrangler's implicit upstream host/port handling. */ +async function fetchDevelopmentResponse(request: Request, upstream: URL): Promise { + const publicURL = new URL(request.url); + const url = new URL(request.url); + url.protocol = upstream.protocol; + url.host = upstream.host; + if (url.pathname === '/apple-app-site-association') { + url.pathname = '/.well-known/apple-app-site-association'; + } + const forwarded = new Request(url, request); + if (!isAPIRequest(url.pathname)) { + // Never let an upstream 304 pair a previously cached HTML body with a fresh CSP nonce. + forwarded.headers.delete('If-None-Match'); + forwarded.headers.delete('If-Modified-Since'); + } + let response: Response; + try { + response = await fetch(forwarded, {redirect: 'manual'}); + } catch { + return new Response('The local Rsbuild server is unavailable. Check its output in npm run web.', {status: 503}); + } + if (response.status === 101) { + return response; + } + const location = response.headers.get('Location'); + const redirect = location ? new URL(location, url) : null; + if (redirect?.origin === upstream.origin) { + redirect.protocol = publicURL.protocol; + redirect.host = publicURL.host; + response = new Response(response.body, response); + response.headers.set('Location', redirect.href); + } + return response; +} + +export default fetchDevelopmentResponse; diff --git a/web/cloudflare-worker/src/index.ts b/web/cloudflare-worker/src/index.ts index c85376582c2c..f718917bf461 100644 --- a/web/cloudflare-worker/src/index.ts +++ b/web/cloudflare-worker/src/index.ts @@ -1,4 +1,9 @@ +import type {DevelopmentOrigins} from './csp'; + import {getCSP, NONCE_PLACEHOLDER} from './csp'; +import {isAPIRequest} from './developmentAPIProxy'; +import getDevelopmentConfig from './developmentConfig'; +import fetchDevelopmentResponse from './fetchDevelopmentResponse'; /** Content-hashed build output (`main-<16 hex>.bundle.js`, `main.<10 hex>.css`, ...), emitted at the root of `dist/`. */ const HASHED_ASSET_PATH = /^\/[^/]+(?:-[0-9a-f]{16}\.bundle\.js|\.[0-9a-f]{10}\.[a-z0-9]+)$/; @@ -84,9 +89,9 @@ function buildAssetRequest(request: Request, url: URL, pathSuffix = ''): Request return assetRequest; } -function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'], nonce: string) { +function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], nonce: string, developmentOrigins?: DevelopmentOrigins) { headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); - headers.set('Content-Security-Policy', getCSP(environment, nonce)); + headers.set('Content-Security-Policy', getCSP(environment, nonce, developmentOrigins)); headers.set('X-Frame-Options', 'SAMEORIGIN'); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('Referrer-Policy', 'origin-when-cross-origin'); @@ -94,9 +99,9 @@ function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'], n headers.set('Document-Policy', 'js-profiling'); } -function createNotFoundResponse(environment: Env['ENVIRONMENT'], nonce: string): Response { +function createNotFoundResponse(environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], nonce: string, developmentOrigins?: DevelopmentOrigins): Response { const notFound = new Response('Not Found', {status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'}}); - setSecurityHeaders(notFound.headers, environment, nonce); + setSecurityHeaders(notFound.headers, environment, nonce, developmentOrigins); return notFound; } @@ -179,6 +184,7 @@ async function getPreviousBuildFile(env: Env, url: URL, method: string, mayHaveT export default { async fetch(request, env): Promise { const url = new URL(request.url); + const isDevelopment = env.ENVIRONMENT === 'development'; const nonce = generateNonce(); // Twins are only served as an encoding of the original. @@ -188,17 +194,29 @@ export default { const isNavigation = request.headers.get('Sec-Fetch-Mode') === 'navigate'; const isAASAPath = url.pathname === AASA_PATH || url.pathname === LEGACY_AASA_PATH; - const isPrecompressed = PRECOMPRESSED_PATH.test(url.pathname); + const isPrecompressed = !isDevelopment && PRECOMPRESSED_PATH.test(url.pathname); // Cloudflare presents every request with `Accept-Encoding: br, gzip` and decodes the twin for clients without Brotli. - let served = await getAsset(env, request, url, isPrecompressed); + let served: ServedFile; + let developmentOrigins: DevelopmentOrigins | undefined; + if (isDevelopment) { + const {upstream, backendOrigins} = getDevelopmentConfig(env); + const assetResponse = await fetchDevelopmentResponse(request, upstream); + if (assetResponse.status === 101 || isAPIRequest(url.pathname)) { + return assetResponse; + } + developmentOrigins = {hmrOrigin: url.origin.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'), backendOrigins}; + served = {response: assetResponse, isBrotli: false}; + } else { + served = await getAsset(env, request, url, isPrecompressed); + } // The SPA fallback answers every miss with index.html. A browser asking for a missing chunk needs the file from // an earlier build, or a real 404. if (isHTMLResponse(served.response) && (isAASAPath || (!isNavigation && STATIC_FILE_PATH.test(url.pathname)))) { - const previousBuildFile = isAASAPath ? null : await getPreviousBuildFile(env, url, request.method, isPrecompressed); + const previousBuildFile = isAASAPath || isDevelopment ? null : await getPreviousBuildFile(env, url, request.method, isPrecompressed); if (!previousBuildFile) { await served.response.body?.cancel(); - return createNotFoundResponse(env.ENVIRONMENT, nonce); + return createNotFoundResponse(env.ENVIRONMENT, nonce, developmentOrigins); } await served.response.body?.cancel(); served = previousBuildFile; @@ -206,13 +224,17 @@ export default { const {response: file, isBrotli} = served; const headers = new Headers(file.headers); - setSecurityHeaders(headers, env.ENVIRONMENT, nonce); + setSecurityHeaders(headers, env.ENVIRONMENT, nonce, developmentOrigins); if (isAASAPath) { headers.set('Content-Type', 'application/json'); } - if (HASHED_ASSET_PATH.test(url.pathname)) { + if (isDevelopment) { + headers.set('Cache-Control', 'no-store'); + headers.delete('ETag'); + headers.delete('Last-Modified'); + } else if (HASHED_ASSET_PATH.test(url.pathname)) { headers.set('Cache-Control', 'public, max-age=31536000, immutable'); } @@ -244,4 +266,4 @@ export default { }) .transform(new Response(file.body, {status: file.status, statusText: file.statusText, headers})); }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; diff --git a/web/cloudflare-worker/src/noncePlaceholder.ts b/web/cloudflare-worker/src/noncePlaceholder.ts new file mode 100644 index 000000000000..ea071795dad3 --- /dev/null +++ b/web/cloudflare-worker/src/noncePlaceholder.ts @@ -0,0 +1,7 @@ +/** + * Placeholder in the CSP and in script tags (`web/index.html`, and Rsbuild's dev output) that the Worker replaces with a + * per-request nonce. Kept free of Worker types, so the Rsbuild config can import it. + */ +const NONCE_PLACEHOLDER = 'nonce-random-value'; + +export default NONCE_PLACEHOLDER; diff --git a/web/cloudflare-worker/tests/createWorkerHarness.ts b/web/cloudflare-worker/tests/createWorkerHarness.ts index 9662d1397d4e..e497941e76f0 100644 --- a/web/cloudflare-worker/tests/createWorkerHarness.ts +++ b/web/cloudflare-worker/tests/createWorkerHarness.ts @@ -70,7 +70,7 @@ function writeFakeWebBuild(assetsDir: string, omittedFiles: string[]) { * override the assets directory, so this writes a temporary config that changes `main` and `assets.directory`, and * copies .assetsignore itself in place of the `build` command. */ -function createWorkerHarness(env?: 'test-staging' | 'staging' | 'production', {omittedFiles = []}: {omittedFiles?: string[]} = {}): WorkerHarness { +function createWorkerHarness(env?: 'test-staging' | 'staging' | 'production', {omittedFiles = [], vars = {}}: {omittedFiles?: string[]; vars?: Record} = {}): WorkerHarness { const tempDir = mkdtempSync(path.join(tmpdir(), 'new-expensify-worker-')); const assetsDir = path.join(tempDir, 'dist'); writeFakeWebBuild(assetsDir, omittedFiles); @@ -87,7 +87,7 @@ function createWorkerHarness(env?: 'test-staging' | 'staging' | 'production', {o }), ); - const server = createTestHarness({workers: [{configPath, env}]}); + const server = createTestHarness({workers: [{configPath, env, vars}]}); const workerName = (env ? rawConfig.env?.[env]?.name : undefined) ?? rawConfig.name ?? ''; return { server, diff --git a/web/cloudflare-worker/tests/development.test.ts b/web/cloudflare-worker/tests/development.test.ts new file mode 100644 index 000000000000..25d8fb3219ab --- /dev/null +++ b/web/cloudflare-worker/tests/development.test.ts @@ -0,0 +1,288 @@ +/** Exercise the live forwarding path in real workerd against a loopback HTTP/WebSocket server. */ +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import http from 'node:http'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {after, before, describe, test} from 'node:test'; +import {setTimeout as delay} from 'node:timers/promises'; +import {experimental_readRawConfig} from 'wrangler'; +import {WebSocket, WebSocketServer} from 'ws'; + +describe('live development worker', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'live-worker-')); + const upstream = http.createServer(async (request, response) => { + if (request.url?.startsWith('/echo')) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({method: request.method, url: request.url, body: Buffer.concat(chunks).toString(), validator: request.headers['if-none-match'] ?? null})); + } else if (request.url === '/redirect') { + const address = upstream.address(); + assert.ok(address && typeof address !== 'string'); + response.writeHead(302, {Location: `http://127.0.0.1:${address.port}/target?next=1`}); + response.end(); + } else if (request.url === '/stream.txt') { + response.setHeader('Content-Type', 'text/plain'); + response.write('first '); + setImmediate(() => response.end('second')); + } else if (request.url?.startsWith('/api/') || request.url?.startsWith('/receipts/')) { + response.writeHead(403, {'Content-Type': 'text/html', 'Cache-Control': 'private'}); + response.end('backend error, not the app shell'); + } else if (request.url === '/.well-known/apple-app-site-association') { + response.end('{"applinks":{}}'); + } else if (request.url === '/main-0123456789abcdef.bundle.js') { + response.writeHead(200, {'Content-Type': 'application/javascript', ETag: 'bundle'}); + response.end('console.log("dev bundle");'); + } else { + response.writeHead(200, {'Content-Type': 'text/html', ETag: 'html', 'Last-Modified': new Date().toUTCString()}); + response.end('
app shell
'); + } + }); + const sockets = new WebSocketServer({server: upstream}); + sockets.on('connection', (socket, request) => { + socket.on('message', (message) => socket.send(`${request.url}:${message.toString()}`)); + }); + let origin: string; + let worker: ReturnType; + const server = {fetch: (url: string, init?: RequestInit) => fetch(`${origin}${url}`, init)}; + before(async () => { + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const address = upstream.address(); + assert.ok(address && typeof address !== 'string'); + const portReservation = http.createServer(); + portReservation.listen(0, '127.0.0.1'); + await once(portReservation, 'listening'); + const reservedAddress = portReservation.address(); + assert.ok(reservedAddress && typeof reservedAddress !== 'string'); + const port = reservedAddress.port; + await new Promise((resolve) => portReservation.close(() => resolve())); + origin = `http://127.0.0.1:${port}`; + cpSync(path.resolve(import.meta.dirname, '../src'), path.join(tempDir, 'src'), {recursive: true}); + const {rawConfig} = experimental_readRawConfig({config: path.resolve(import.meta.dirname, '../wrangler.dev.jsonc')}); + const {rawConfig: deployedConfig} = experimental_readRawConfig({config: path.resolve(import.meta.dirname, '../wrangler.jsonc')}); + assert.equal(rawConfig.compatibility_date, deployedConfig.compatibility_date, 'Live and deployed Workers must use the same runtime compatibility date'); + const configPath = path.join(tempDir, 'wrangler.json'); + writeFileSync(configPath, JSON.stringify({...rawConfig, main: './src/index.ts'})); + worker = spawn( + process.execPath, + [ + 'node_modules/wrangler/bin/wrangler.js', + 'dev', + '--local', + '--config', + configPath, + '--ip', + '127.0.0.1', + '--port', + String(port), + '--inspector-port', + '0', + '--var', + `DEV_UPSTREAM:http://127.0.0.1:${address.port}`, + '--var', + 'DEV_CONNECT_ORIGINS:["http://backend.test:8080"]', + ], + {cwd: path.resolve(import.meta.dirname, '..'), env: {...process.env, WRANGLER_SEND_METRICS: 'false'}}, + ); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Wrangler did not start')), 20000); + worker.stdout?.on('data', (data) => { + if (String(data).includes('Ready on')) { + clearTimeout(timeout); + resolve(); + } + }); + worker.stderr?.on('data', (data) => process.stderr.write(data)); + worker.once('exit', (code) => { + clearTimeout(timeout); + reject(new Error(`Wrangler exited before readiness: ${code}`)); + }); + }); + }); + after(async () => { + if (worker && worker.exitCode === null && worker.signalCode === null) { + worker.kill('SIGTERM'); + await once(worker, 'exit'); + } + for (const socket of sockets.clients) { + socket.terminate(); + } + await new Promise((resolve) => sockets.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); + rmSync(tempDir, {recursive: true, force: true}); + }); + + test('forwards POST bodies, queries, and HEAD without consuming the response twice', async () => { + // Given a real HTTP upstream + // When a POST with a query passes through the Worker + const response = await server.fetch('/echo?q=reserved%40value', {method: 'POST', body: 'posted payload'}); + // Then the request reaches the upstream unchanged and HEAD has no body + assert.deepEqual(await response.json(), {method: 'POST', url: '/echo?q=reserved%40value', body: 'posted payload', validator: null}); + assert.equal(await (await server.fetch('/echo', {method: 'HEAD'})).text(), ''); + }); + + test('transforms HTML and removes validators, including on conditional requests', async () => { + // Given cached HTML + // When a conditional navigation reaches the live Worker + const response = await server.fetch('/settings/profile', {headers: {'If-None-Match': 'html'}}); + const html = await response.text(); + const nonce = /nonce="([^"]+)"/.exec(html)?.[1]; + // Then the returned body and CSP agree, no validator survives, and a new load has a new nonce + assert.equal(response.status, 200); + assert.ok(nonce && nonce !== 'nonce-random-value'); + assert.ok(response.headers.get('Content-Security-Policy')?.includes(`'nonce-${nonce}'`)); + assert.equal(response.headers.get('ETag'), null); + assert.equal(response.headers.get('Last-Modified'), null); + assert.equal(response.headers.get('X-Content-Type-Options'), 'nosniff'); + assert.doesNotMatch(await (await server.fetch('/')).text(), new RegExp(nonce)); + assert.equal(((await (await server.fetch('/echo', {headers: {'If-None-Match': 'html'}})).json()) as {validator: string | null}).validator, null); + }); + + test('allows only explicit development connections alongside the normal CSP', async () => { + // Given explicit local backend settings + // When the Worker serves a page + const csp = (await server.fetch('/')).headers.get('Content-Security-Policy') ?? ''; + // Then HMR and that backend are allowed without weakening script enforcement + assert.ok(csp.includes(origin.replace('http:', 'ws:'))); + assert.ok(csp.includes('https://www.expensify.com.dev')); + assert.doesNotMatch(csp, /script-src[^;]*'unsafe-inline'|connect-src \*/); + }); + + test('adds only configured backends and the OldDot origin the app hardcodes', async () => { + // Given a backend from .env, and the app's hardcoded development OldDot URL (CONST.INTERNAL_DEV_EXPENSIFY_URL) + // When the Worker serves a page + const csp = (await server.fetch('/')).headers.get('Content-Security-Policy') ?? ''; + // Then both are allowed, and unconfigured or server-side-only origins are not + assert.ok(csp.includes('http://backend.test:8080')); + assert.match(csp, /frame-src [^;]*https:\/\/www\.expensify\.com\.dev/); + assert.doesNotMatch(csp, /secure\.expensify\.com\.dev|localhost:9000/); + }); + + test('allows local backends to serve videos and the OldDot statement iframe', async () => { + // Given proxied attachments, the default local OldDot and a configured backend + // When the Worker serves a page + const csp = (await server.fetch('/')).headers.get('Content-Security-Policy') ?? ''; + const getSources = (directive: string) => new RegExp(`(?:^|; )${directive} ([^;]*)`).exec(csp)?.[1].split(' ') ?? []; + // Then videos load from the app origin and every local backend, which can also be framed + for (const source of ["'self'", 'https://www.expensify.com.dev', 'http://backend.test:8080']) { + assert.ok(getSources('media-src').includes(source), `media-src is missing ${source}`); + } + for (const source of ['https://www.expensify.com.dev', 'http://backend.test:8080']) { + assert.ok(getSources('frame-src').includes(source), `frame-src is missing ${source}`); + } + // And the HMR WebSocket stays a connect-only source + const hmrOrigin = origin.replace('http:', 'ws:'); + assert.ok(!getSources('media-src').includes(hmrOrigin) && !getSources('frame-src').includes(hmrOrigin)); + }); + + test('does not apply immutable caching to live bundles or mistake missing files for the shell', async () => { + // Given a live bundle and an unknown static asset + // When both are fetched + const bundle = await server.fetch('/main-0123456789abcdef.bundle.js'); + const missing = await server.fetch('/missing.js'); + // Then edits cannot be hidden behind immutable caching and missing JS is not HTML + assert.equal(bundle.headers.get('Cache-Control'), 'no-store'); + assert.equal(missing.status, 404); + assert.match(missing.headers.get('Content-Type') ?? '', /text\/plain/); + // And the 404 keeps the development CSP, like every other live response + assert.ok(missing.headers.get('Content-Security-Policy')?.includes(origin.replace('http:', 'ws:'))); + }); + + test('preserves backend errors/downloads without treating HTML errors as SPA responses', async () => { + // Given API and receipt paths + // When the backend returns its own HTML error + for (const url of ['/api/Test', '/receipts/test.pdf']) { + const response = await server.fetch(url); + // Then status, body and private caching survive without Worker HTML rewriting + assert.equal(response.status, 403); + assert.equal(await response.text(), 'backend error, not the app shell'); + assert.equal(response.headers.get('Cache-Control'), 'private'); + } + }); + + test('streams responses, rewrites internal redirects and preserves AASA aliases', async () => { + // Given live streaming, a redirect and the legacy AASA path + // When they pass through the Worker + const stream = await server.fetch('/stream.txt'); + const redirect = await fetch(`${origin}/redirect`, {redirect: 'manual'}); + const aasa = await server.fetch('/apple-app-site-association'); + // Then the full stream arrives, redirects stay public, and AASA is JSON + assert.equal(await stream.text(), 'first second'); + assert.equal(redirect.headers.get('Location'), `${origin}/target?next=1`, `status=${redirect.status} body=${await redirect.text()}`); + assert.equal(aasa.headers.get('Content-Type'), 'application/json'); + assert.deepEqual(await aasa.json(), {applinks: {}}); + }); + + test('passes WebSocket upgrades, messages and HMR token queries through', {timeout: 10000}, async () => { + // Given a real WebSocket client connecting through the Worker + // When it sends a message + const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/rsbuild-hmr?token=test`); + await once(socket, 'open'); + socket.send('hot update'); + const [message] = await once(socket, 'message'); + // Then the real upstream sees the HMR query and its reply arrives through the upgraded connection + assert.equal(message.toString(), '/rsbuild-hmr?token=test:hot update'); + socket.close(); + await once(socket, 'close'); + }); + + test('automatically reloads both the Worker entry point and imported CSP modules', {timeout: 15000}, async () => { + // Given the CLI is watching an isolated copy of the actual Worker source + const entryPath = path.join(tempDir, 'src/index.ts'); + const cspPath = path.join(tempDir, 'src/csp.ts'); + const entry = readFileSync(entryPath, 'utf8'); + const csp = readFileSync(cspPath, 'utf8'); + // A missing target would otherwise leave the source unchanged, and fail later as a misleading reload timeout. + const replaceOnce = (source: string, target: string, replacement: string) => { + assert.ok(source.includes(target), `Reload test target is missing from the Worker source: ${target}`); + return source.replace(target, replacement); + }; + const waitForResponse = async (matches: (response: Response) => boolean) => { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const response = await server.fetch('/'); + await response.arrayBuffer(); + if (matches(response)) { + return; + } + await delay(50); + } + assert.fail('Wrangler did not reload the edited source'); + }; + try { + // When the entry point and an imported module change, without restarting Wrangler + writeFileSync( + entryPath, + replaceOnce(entry, "headers.set('X-Content-Type-Options', 'nosniff');", "headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Reload-Proof', 'entry');"), + ); + await waitForResponse((response) => response.headers.get('X-Reload-Proof') === 'entry'); + writeFileSync(cspPath, replaceOnce(csp, "'worker-src': [", "'worker-src': ['https://module-reload.invalid', ")); + // Then both changes are served on subsequent requests, including the new CSP + await waitForResponse((response) => response.headers.get('Content-Security-Policy')?.includes('https://module-reload.invalid') ?? false); + } finally { + writeFileSync(entryPath, entry); + writeFileSync(cspPath, csp); + } + await waitForResponse((response) => response.headers.get('X-Reload-Proof') === null && !response.headers.get('Content-Security-Policy')?.includes('https://module-reload.invalid')); + }); + + test('reports an unavailable upstream without serving stale built output', async () => { + // Given the live upstream has stopped + for (const socket of sockets.clients) { + socket.terminate(); + } + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(() => resolve())); + // When a new page is requested + const response = await server.fetch('/'); + // Then the response explains the local failure rather than falling back to dist + assert.equal(response.status, 503); + assert.match(await response.text(), /Rsbuild server is unavailable/); + }); +}); diff --git a/web/cloudflare-worker/tests/developmentAPIProxy.test.ts b/web/cloudflare-worker/tests/developmentAPIProxy.test.ts new file mode 100644 index 000000000000..74938715ec17 --- /dev/null +++ b/web/cloudflare-worker/tests/developmentAPIProxy.test.ts @@ -0,0 +1,27 @@ +/** The Worker must pass through exactly the paths Rsbuild proxies to the API, or API responses get SPA handling. */ +import assert from 'node:assert/strict'; +import {describe, test} from 'node:test'; + +import {API_PROXY_PATHS, isAPIRequest} from '../src/developmentAPIProxy.ts'; + +describe('live development API passthrough', () => { + test('passes through every path Rsbuild proxies to the API', () => { + // Given the shared list Rsbuild also proxies + for (const prefix of API_PROXY_PATHS) { + // When the Worker classifies the prefix and paths beneath it + // Then they bypass the SPA fallback and HTML handling + assert.ok(isAPIRequest(prefix), prefix); + assert.ok(isAPIRequest(`${prefix}/Command?x=1`), prefix); + } + }); + + test('does not treat app routes that only share a prefix as API requests', () => { + // Given app paths that start with the same letters + // When the Worker classifies them + // Then they still get the app shell + for (const prefix of API_PROXY_PATHS) { + assert.equal(isAPIRequest(`${prefix}x`), false, prefix); + } + assert.equal(isAPIRequest('/settings'), false); + }); +}); diff --git a/web/cloudflare-worker/tests/developmentConfig.test.ts b/web/cloudflare-worker/tests/developmentConfig.test.ts new file mode 100644 index 000000000000..16ea94bc5535 --- /dev/null +++ b/web/cloudflare-worker/tests/developmentConfig.test.ts @@ -0,0 +1,37 @@ +/** The live Worker's launcher-provided vars should be validated once, with errors that name the bad variable. */ +import assert from 'node:assert/strict'; +import {describe, test} from 'node:test'; + +import getDevelopmentConfig from '../src/developmentConfig.ts'; + +const VALID_VARS = {DEV_UPSTREAM: 'http://127.0.0.1:8083', DEV_CONNECT_ORIGINS: '["https://www.expensify.com.dev","http://backend.test:8080"]'}; + +describe('live development Worker configuration', () => { + test('parses the upstream and backend origins', () => { + // Given vars from the launcher + // When the Worker reads them + const config = getDevelopmentConfig(VALID_VARS); + // Then it gets a loopback upstream and exact backend origins + assert.equal(config.upstream.href, 'http://127.0.0.1:8083/'); + assert.deepEqual(config.backendOrigins, ['https://www.expensify.com.dev', 'http://backend.test:8080']); + }); + + test('validates the vars once rather than on every request', () => { + // Given the same vars on every request + // When the Worker reads them repeatedly + // Then it reuses the validated configuration + assert.equal(getDevelopmentConfig({...VALID_VARS}), getDevelopmentConfig({...VALID_VARS})); + }); + + for (const DEV_UPSTREAM of ['not a url', 'https://127.0.0.1:8083', 'http://example.test:8083', 'http://127.0.0.1', 'http://127.0.0.1:8083/path']) { + test(`names DEV_UPSTREAM when it is not an HTTP loopback origin: ${DEV_UPSTREAM}`, () => { + assert.throws(() => getDevelopmentConfig({...VALID_VARS, DEV_UPSTREAM}), /DEV_UPSTREAM/); + }); + } + + for (const DEV_CONNECT_ORIGINS of ['not json', '"https://example.test"', '[1]', '["https://example.test/path"]', '["file:///etc/hosts"]', '["https://user:pass@example.test"]']) { + test(`names DEV_CONNECT_ORIGINS when it is not a JSON array of HTTP(S) origins: ${DEV_CONNECT_ORIGINS}`, () => { + assert.throws(() => getDevelopmentConfig({...VALID_VARS, DEV_CONNECT_ORIGINS}), /DEV_CONNECT_ORIGINS/); + }); + } +}); diff --git a/web/cloudflare-worker/tests/worker.test.ts b/web/cloudflare-worker/tests/worker.test.ts index f248706814f6..5a261cca07d1 100644 --- a/web/cloudflare-worker/tests/worker.test.ts +++ b/web/cloudflare-worker/tests/worker.test.ts @@ -478,7 +478,7 @@ describe('test-staging worker', () => { }); describe('production worker', () => { - const {server, addPreviousBuildFiles, cleanup} = createWorkerHarness('production'); + const {server, addPreviousBuildFiles, cleanup} = createWorkerHarness('production', {vars: {DEV_UPSTREAM: 'http://127.0.0.1:1', DEV_CONNECT_ORIGINS: '["https://dev-only.invalid"]'}}); const PREVIOUS_CHUNK_PATH = '/main-aaaaaaaaaaaaaaaa.bundle.js'; before(async () => { @@ -499,6 +499,7 @@ describe('production worker', () => { assert.match(csp, /new-expensify:\/\/new\.expensify\.com/); assert.match(csp, /font-src data: 'self' https:\/\/www\.expensify\.com/); assert.doesNotMatch(csp, /new-expensify:\/\/staging/); + assert.doesNotMatch(csp, /localhost|127\.0\.0\.1|expensify\.com\.dev|dev-only\.invalid/); assertTemplateInlineScriptsAllowed(csp); }); diff --git a/web/cloudflare-worker/tsconfig.json b/web/cloudflare-worker/tsconfig.json index 8e3b138af7e9..c0e7a049bfa1 100644 --- a/web/cloudflare-worker/tsconfig.json +++ b/web/cloudflare-worker/tsconfig.json @@ -11,5 +11,5 @@ "isolatedModules": true, "verbatimModuleSyntax": true }, - "include": ["src", "worker-configuration.d.ts"] + "include": ["src", "worker-configuration.d.ts", "worker-development.d.ts"] } diff --git a/web/cloudflare-worker/wrangler.dev.jsonc b/web/cloudflare-worker/wrangler.dev.jsonc new file mode 100644 index 000000000000..845a0082377a --- /dev/null +++ b/web/cloudflare-worker/wrangler.dev.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "new-expensify-live-local", + "main": "src/index.ts", + "compatibility_date": "2026-09-26", + "workers_dev": false, + "preview_urls": false, + // Live development needs neither a dist/ directory nor Cloudflare bindings. + "vars": { + "ENVIRONMENT": "development", + "DEV_UPSTREAM": "http://127.0.0.1:8083", + "DEV_CONNECT_ORIGINS": "[]", + }, +} diff --git a/web/proxy.ts b/web/proxy.ts index 101a859abb7f..b0133af7da5e 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -5,6 +5,9 @@ import http from 'http'; import https from 'https'; import proxyConfig from '../config/proxyConfig'; +// The Worker is a separately installed package that the App does not depend on, so it can only be reached by path. +// eslint-disable-next-line import/no-relative-packages +import {API_PROXY_PORT} from './cloudflare-worker/src/developmentAPIProxy'; dotenv.config(); @@ -87,7 +90,7 @@ const server = http.createServer((request: IncomingMessage, response: ServerResp }); }); -server.listen(9000, () => { +server.listen(API_PROXY_PORT, () => { /* eslint-disable-next-line no-console */ - console.log('Proxy server listening at http://localhost:9000'); + console.log(`Proxy server listening at http://localhost:${API_PROXY_PORT}`); });