# @forge/bridge import breaks a Forge resolver

### Key takeaways
- @forge/bridge throws at IMPORT time, not call time. A handler that imports a poisoned module and never calls it still fails, which I measured as HTTP 424 on a live site.
- The error string changed in 5.15.1 (13 April 2026): window.__bridge became globalThis.__bridge, so ReferenceError: window is not defined became BridgeAPIError. The community threads on this all predate the change and use the old string, so searching the error you actually see will not find them.
- The stack trace names the innocent function and never names the file containing the bad import.
- forge lint returned No issues found and forge deploy succeeded on code that cannot run.
- The bundle went from 2,367 to 241,603 bytes. Patching sideEffects:false into @forge/bridge collapses it back to 2,620, but the package cannot honestly declare that, because it really does throw at import.


In March a developer posted to the Atlassian developer community with a Confluence Forge app that had been running for over a year. Their words: *"my logs are overflowing with the following error ReferenceError: window is not defined"*. Nothing had changed in the React app or the Node backend. They had only edited `manifest.yml`.


Five days after the thread was answered, Atlassian shipped a patch that changed the error message. So if you hit this bug today you get a completely different string, and searching for it will not find that thread, or the five others I turned up whose titles carry the same old wording.


The cause is one line, and it is probably in a file you would never look at. This is what actually happens, measured end to end on a live Atlassian site rather than reasoned about: the four-way experiment that isolates it, the bundle numbers, the reason your bundler cannot save you, and the fix.


> **Everything below was measured on 4 August 2026** against `@forge/bridge` 6.2.0 (published 3 August 2026), Forge CLI 13.3.0, `@forge/bundler` 7.1.0 and webpack 5.99.9. The live runs are against a real Jira Cloud site. Version numbers matter more than usual in this piece, because the symptom itself is version-dependent.


## The one-line cause


Forge apps have two halves that feel like one codebase. `@forge/bridge` is the frontend half: it is how Custom UI talks to your resolver. `@forge/api` and your resolver code are the backend half, running in a Node sandbox with no `window` and no browser.


So you write a helper both halves need:


```js
// src/shared.js
import { events } from '@forge/bridge';

export function formatKey(key) {
  return String(key).trim().toUpperCase();
}

// only ever called from the browser
export function subscribeToIssue(cb) {
  return events.on('JIRA_ISSUE_CHANGED', cb);
}
```


This is a completely ordinary file. `formatKey` is a pure string function with no browser dependency at all. `subscribeToIssue` is frontend-only and your resolver never touches it.


Now the resolver imports the safe half, and only the safe half:


```js
// src/poisoned.js
import { formatKey } from './shared';

export const run = async () => ({
  statusCode: 200,
  headers: { 'Content-Type': ['application/json'] },
  body: JSON.stringify({ variant: 'poisoned', key: formatKey(' lz-1 ') })
});
```


That function is dead. Not slow, not degraded. Dead.


## Proving it, four ways


Reasoning about bundlers is how people end up confidently wrong, so I built the smallest thing that could tell me the truth: one Forge app with four web triggers, deployed to a real site. Web triggers are ideal here because you invoke them with `curl` and get a synchronous answer, with no frontend build and no waiting for a product event.


The four variants differ only in their imports:


| variant | shared module | handler calls the helper? |
| --- | --- | --- |
| clean | no bridge import | yes |
| poisoned | unused bridge import | yes |
| untouched | unused bridge import | no |
| fixed | module split in two | yes |


`untouched` is the important one. It imports `formatKey` from the poisoned module and then never calls it, returning a constant instead. If import alone is enough to kill the function, that variant fails too.


Here is the whole manifest:


```yaml
modules:
  webtrigger:
    - key: poisoned-trigger
      function: poisoned
    - key: clean-trigger
      function: clean
    - key: untouched-trigger
      function: untouched
    - key: fixed-trigger
      function: fixed
  function:
    - key: poisoned
      handler: poisoned.run
    - key: clean
      handler: clean.run
    - key: untouched
      handler: untouched.run
    - key: fixed
      handler: fixed.run
app:
  runtime:
    name: nodejs22.x
  id: ari:cloud:ecosystem::app/YOUR-APP-ID
```


Deploy it, install it, and pull each URL:


```bash
forge deploy -e development --non-interactive
forge install -e development -p jira -s your-site.atlassian.net --non-interactive
forge webtrigger -e development --functionKey poisoned-trigger \
  --site your-site.atlassian.net --product jira
```


Note `--functionKey`, camelCase. On CLI 13.3.0 the kebab-case `--function-key` is rejected outright, and `forge webtrigger` does not accept `--non-interactive` at all even though `deploy` and `install` both do.


The results, `curl` against a live site:


```text
clean       {"variant":"clean","key":"LZ-1"}       HTTP 200
poisoned    {"status":424,"error":"Failed Dependency"}  HTTP 424
untouched   {"status":424,"error":"Failed Dependency"}  HTTP 424
fixed       {"variant":"fixed","key":"LZ-1"}       HTTP 200
```


`untouched` returns 424. It never called anything. **The import is the failure.** Not the call, not the API, not a scope, not a permission. Loading the module is enough.


What the caller gets back is worth reading closely:


```json
{
  "timestamp": "2026-08-04T05:07:44.511+00:00",
  "path": "/x1/MdTtPUQ2SCf8lBcJOFXsrPicZyc",
  "status": 424,
  "error": "Failed Dependency",
  "requestId": "535b496e-146645"
}
```


There is nothing in there. No message, no stack, no hint that a bundler or an import is involved. `424 Failed Dependency` is Forge's generic "your function did not survive" response, and it is what an end user's browser sees.


## The stack trace blames the wrong file


`forge logs` has the real error, and it is a lesson in why you should read stack traces to the bottom:


```text
ERROR  BridgeAPIError:
    Unable to establish a connection with the Custom UI bridge.
    If you are trying to run your app locally, Forge apps only work in the
    context of Atlassian products. Refer to
    https://go.atlassian.com/forge-tunneling-with-custom-ui ...

  at getCallBridge (@forge/bridge/out/bridge.js:10:1)
  at Object.9332 (@forge/bridge/out/invoke/invoke.js:8:1)
  at __webpack_require__ (webpack/bootstrap:19:1)
  at Object.8350 (@forge/bridge/out/invoke/index.js:4:22)
  at __webpack_require__ (webpack/bootstrap:19:1)
  at Object.2321 (@forge/bridge/out/index.js:7:22)
  at __webpack_require__ (webpack/bootstrap:19:1)
  at /var/task/poisoned.cjs:7992:11
  at formatKey (webpack://.../src/poisoned.js:8:3)
  at Object.<anonymous> (webpack://.../src/poisoned.js:8:3)
```


Two things to take from this.


First, every frame between the throw and your code is `__webpack_require__`. This is module loading, not a function call. The chain runs through `@forge/bridge/out/index.js` — the package barrel — which re-exports `./invoke`, and `invoke.js` throws while being required.


Second, and this is the part that costs people hours: the bottom of the stack says `formatKey`. `formatKey` is `String(key).trim().toUpperCase()`. It has nothing to do with any of this. And `shared.js`, the file that actually contains the bad import, **does not appear in the stack at all.** The trace points at the innocent function in the innocent file and stays silent about the guilty one.


If you are debugging this from the log alone, you will stare at a pure string function and conclude Forge is broken.


## Why it throws on import


The reason is three lines of shipped code. In `@forge/bridge` 6.2.0, `out/invoke/invoke.js` line 8:


```js
const callBridge = (0, bridge_1.getCallBridge)();
```


That is at module scope. It runs when the module is required, not when you call `invoke`. `out/events/events.js` line 6 does the same thing. And `getCallBridge` in `out/bridge.js`:


```js
const getCallBridge = () => {
  if (!isBridgeAvailable(globalThis.__bridge)) {
    throw new errors_1.BridgeAPIError(`
      Unable to establish a connection with the Custom UI bridge.
      ...
    `);
  }
  return globalThis.__bridge.callBridge;
};
```


In a browser inside an Atlassian product, `globalThis.__bridge` is injected and this is fine. In the Node sandbox it is always absent, so the throw is unconditional. Requiring the barrel requires `invoke`, which calls `getCallBridge()`, which throws.


Because it throws during module initialisation, it takes the whole module down. Every export in that file dies, not just the frontend one. That is why `formatKey` is unreachable despite being harmless.


## The error message changed in April, and the old threads are now unfindable


This is the part that makes the bug so much worse than it looks, and it is why searching does not help.


I bisected the published tarballs on line 9 of `out/bridge.js`:


| version | published | reads | error you get in Node |
| --- | --- | --- | --- |
| 5.14.1 | 2026-03-16 | window.__bridge | ReferenceError: window is not defined |
| 5.15.0 | 2026-03-30 | window.__bridge | ReferenceError: window is not defined |
| 5.15.1 | 2026-04-13 | globalThis.__bridge | BridgeAPIError |
| 6.2.0 | 2026-08-03 | globalThis.__bridge | BridgeAPIError |


You can confirm this yourself in about a minute, and it is worth doing rather than taking my word:


```bash
npm i tslib
for V in 5.15.0 6.2.0; do
  mkdir -p "v$V" && (cd "v$V" && npm pack "@forge/bridge@$V" >/dev/null && tar xzf "forge-bridge-$V.tgz")
  node -e "try{require('./v$V/package/out/index.js')}catch(e){console.log('$V =>', e.constructor.name)}"
done
```


Each version needs its own directory, because every tarball extracts to `package/` and they would otherwise overwrite each other — which would quietly leave you testing one version twice. `tslib` is installed at the top level so both copies resolve it. On 5.15.0 you get `ReferenceError`. On 6.2.0 you get `BridgeAPIError`.


Same bug. Same root cause. Same fix. Completely different searchable string.


The community thread I opened with ran from 23 March to 8 April 2026, entirely inside the `window.__bridge` era. Version 5.15.1 shipped on 13 April, five days after that thread's last reply. So every thread on this subject that Google has indexed and ranked is about an error message that current versions no longer produce, and anyone hitting it today searches `BridgeAPIError` and finds none of them.


It was not a stealth change, and I want to be accurate about that, because "undocumented" is the easy accusation to make. The package ships its own `CHANGELOG.md`, and 5.15.1 lists it under Patch Changes:


```text
## 5.15.1

### Patch Changes

- 0b7cde8: replace window with globalThis to ensure the bridge packages
  can work in both browser and worker environment
```


That is an intentional, sensible change with a stated reason: worker environments have no `window`. Nothing about it is wrong. What nobody flagged is the side effect, that a patch release silently rewrote the error string thousands of search results point at. The platform changelog on developer.atlassian.com is no help either, because it renders only about the last two weeks; when I fetched it on 4 August it served 20 July to 3 August, so I could not check April there at all.


## Your bundler will not save you


The obvious objection is that this should never reach production. `subscribeToIssue` is unused, `events` is unused, and webpack in production mode does tree-shaking. It should drop the import.


It does not. I bundled the variants with the CLI's own webpack 5.99.9, mirroring the shipped `@forge/bundler` 7.1.0 config, which sets `mode: 'production'` and `optimization.minimize: false`:


| bundle | bytes | contains the bridge |
| --- | --- | --- |
| clean | 2,367 | no |
| poisoned | 241,603 | yes |
| fixed | 2,356 | no |


That is **102.1x, an extra 239,236 bytes** of frontend code shipped into a Node function that cannot use any of it. I deliberately ran this without babel, so webpack saw my source as pure ESM and had the best possible chance at static analysis. It still could not drop it.


One methodology note, because it bit me. Webpack writes module paths into unminified output as comments relative to `context`, which defaults to `process.cwd()`. Running the same build from two directories gave me byte counts that differed by single digits and sent me chasing a phantom. Pin `context` explicitly if you are going to compare sizes at this resolution.


To check the bundles rather than trusting a size difference, grep for a string that only exists inside the bridge:


```bash
grep -c "Unable to establish a connection" out/poisoned.js   # 1
grep -c "Unable to establish a connection" out/clean.js      # 0
```


So why can't it? Two properties of the published package, both readable in `node_modules`:


```js
// node_modules/@forge/bridge/package.json
main: "out/index.js"
module: undefined        // no ESM entry point, so this is CJS-only
sideEffects: undefined   // no side-effect declaration
```


Without a `sideEffects` field, webpack has to assume that importing the module might do something observable, so removing it is not provably safe. And with no ESM entry, the barrel is CommonJS built out of `tslib.__exportStar(require(...))`, which webpack's ESM-based analysis cannot see through.


I wanted to know which of those two actually binds, so I patched one line into the installed package and rebuilt:


```bash
# add "sideEffects": false to node_modules/@forge/bridge/package.json
```


| bundle | bytes | bridge refs |
| --- | --- | --- |
| poisoned, as published | 241,603 | 3 |
| poisoned, sideEffects:false | 2,620 | 0 |


Restoring the original `package.json` puts it back to 241,603 exactly, so the patch is what moved it rather than anything else drifting between runs.


The missing `sideEffects` declaration is the binding constraint. Being CommonJS stops webpack tree-shaking *inside* the package, but it does not stop webpack dropping the package wholesale once side-effect freedom is declared.


Which sounds like a one-line fix for Atlassian, and here is the closing twist: it isn't one, and the reason is the bug itself. `@forge/bridge` cannot honestly declare `sideEffects: false`, because it genuinely does have an import-time side effect. It calls `getCallBridge()` at module scope and throws. The declaration would be a lie, and a lie that only holds while nothing goes wrong.


The import-time throw is both the symptom and the reason your bundler cannot rescue you from it. They are the same fact. The real fix is upstream and is also one line: make `getCallBridge()` lazy, called inside `invoke` rather than at module scope. Then the package has no import-time side effect, `sideEffects: false` becomes true, and the entire class of bug disappears.


I searched the public FRGE tracker for this and found nothing. That is a real search rather than an assumption — the same query shape returns FRGE-2221 for a term I knew existed, so it was capable of finding a ticket if one were there.


## Nothing warns you


Here is what makes this expensive rather than merely annoying. I ran the full toolchain against code that cannot run:


```text
$ forge lint
No issues found.

$ forge deploy -e development
✔ Deployed
```


`forge lint` passes. `forge deploy` succeeds and prints a tick. There is no build warning, no bundle-size complaint, no note that a frontend-only package landed in a backend function. The first sign of trouble is a 424 in production with an empty error body.


One honest caveat on the lint result. Both times, the CLI also printed:


```text
Warning: Could not perform some linting actions for ServerSideLinter due to
unhandled error "Pre-Deployment check API is not enabled for this app"
```


So the client-side linter found nothing, and the server-side linter did not run on my app. I cannot tell you what it would have caught. What I can tell you is that a developer in exactly my position sees `No issues found`, sees a successful deploy, and has been told nothing.


## The fix


Split the module. That is the whole thing, and the reason it has to be a split rather than a rearrangement is that the import itself is the poison, so moving it within the file changes nothing.


```js
// src/shared-core.js — backend-safe, no @forge/bridge anywhere in this file
export function formatKey(key) {
  return String(key).trim().toUpperCase();
}
```


```js
// src/shared-ui.js — frontend-only, never imported by resolver or trigger code
import { events } from '@forge/bridge';

export function subscribeToIssue(cb) {
  return events.on('JIRA_ISSUE_CHANGED', cb);
}
```


```js
// src/fixed.js — the resolver imports only the core
import { formatKey } from './shared-core';

export const run = async () => ({
  statusCode: 200,
  headers: { 'Content-Type': ['application/json'] },
  body: JSON.stringify({ variant: 'fixed', key: formatKey(' lz-1 ') })
});
```


Deployed to the same site, that returns `{"variant":"fixed","key":"LZ-1"}` with HTTP 200, and bundles to 2,356 bytes with zero bridge references.


1. Find the real import
   grep for @forge/bridge across src, then check which of those files are reachable from a resolver, trigger or web trigger entry point. The file that breaks you is usually not the one in the stack trace.
2. Split, do not rearrange
   move frontend-only helpers into their own file. Moving the import to the bottom of the shared file does nothing, because loading it is the failure.
3. Verify from the bundle, not the source
   build and grep -c "Unable to establish a connection" the output. Zero means the bridge is genuinely gone. A size drop alone can mislead you.
4. Assume a clean lint proves nothing
   forge lint returned No issues found on code that returns 424 in production, and its server-side half may not even have run.


## What I did not prove


Four limits worth stating, because the measurements above stop where they stop.


The bundle numbers come from webpack invoked directly, mirroring the shipped `@forge/bundler` 7.1.0 config, not from intercepting a real `forge deploy`. The live 200/424 results are from real deploys, so the behaviour is confirmed on the platform; it is the byte counts specifically that are reproduced rather than captured.


The Forge CLI also has a second, EAP-gated `typescript` bundler selectable through `app.package.bundler`. Everything here is the default webpack path, and I did not test the other one.


I did not establish whether the server-side linter catches this, because the Pre-Deployment check API was not enabled on my test app. Treat "forge lint won't catch it" as proven for the client-side linter only.


And my claim that the 5.15.1 string change makes older threads unfindable is an inference, not a measurement. It rests on the strings genuinely differing, which I verified, and on six community threads whose titles carry the old wording. I have not measured what Google actually ranks today, and I did not confirm that every one of those six has this same root cause rather than another route to a missing `window`.


The version numbers throughout are the ones I ran on 4 August 2026. Given that the symptom already changed once under a patch release, check yours rather than inheriting mine.


