BizFirst Deployment Models V2
Chapter 2 How It Works in BizFirst

The Function

Lives at bizfirst-common-js/src/utils/index.ts, re-exported from @bizfirst/common-js:

bizfirst-common-js/src/utils/index.tsexport function calculateApiUrl(value?: string): string {
  const trimmed = (value ?? '').trim()
  if (/^(https?:)?\/\//i.test(trimmed)) return trimmed
  if (!trimmed) return typeof window === 'undefined' ? '' : window.location.origin
  const normalizedPath = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
  if (typeof window === 'undefined') return normalizedPath
  return `${window.location.origin}${normalizedPath}`
}

Its job is narrow and deliberate: decide which origin to talk to. It does not decide the API path itself — every endpoint constant across the codebase (e.g. /api/v1/atlas/forms/list in api-client-js/src/form.client.ts, or /api/v1/project/projects/list in flow-studio-api/src/config/apiEndpoints.ts) already carries its own leading /api/v1/... segment. The final request URL is always calculateApiUrl(value) + endpointConstant, built by a separate buildUrl() helper that does a plain string concatenation.

Walking Through All Four Input Cases

1. Unset / empty — VITE_API_BASE_URL=

Resolves to the bare current origin, with nothing appended. On a page loaded at https://qa.grippingly.com/flowstudio/, calculateApiUrl(undefined) returns exactly https://qa.grippingly.com.

The API call still ends up in the right place because the endpoint constant supplies the /api/v1/... segment itself. A call to the data-template endpoint composes as:

calculateApiUrl(undefined)              // "https://qa.grippingly.com"
+ "/api/v1/ai/template/data-template/by-type"
= "https://qa.grippingly.com/api/v1/ai/template/data-template/by-type"

— which matches the real API. The /api prefix comes from the endpoint constant, not from calculateApiUrl's default.

2. Relative path, explicitly set — VITE_API_BASE_URL=/custom-api

Also resolves against window.location.origin, giving https://qa.grippingly.com/custom-api. This is an escape hatch for deployments where a reverse proxy expects a different prefix than what the endpoint constants assume by default — whoever sets this value is responsible for making sure it actually matches what's in front of the API, because it changes what gets prepended in front of every endpoint constant's own /api/v1/... path.

3. Protocol-relative — VITE_API_BASE_URL=//other-host/path

Passed through unchanged, exactly like a full absolute URL. This form (starting with //, no explicit scheme) lets the browser inherit whichever scheme the page itself was loaded with. It is matched by the same regular expression as http:// / https:// values, so it is never treated as a same-origin relative path.

4. Absolute — VITE_API_BASE_URL=https://api.example.com

Returned unchanged. Used for independent deployments, where the frontend's own origin has no API behind it at all — see Chapter 1.6.

Decision Tree

graph TD Start["calculateApiUrl(value)"] --> Trim["trim value"] Trim --> Q1{"starts with
http(s):// or //?"} Q1 -->|"yes"| Absolute["return unchanged
(absolute or protocol-relative)"] Q1 -->|"no"| Q2{"empty after trim?"} Q2 -->|"yes"| Bare["return window.location.origin
(bare, no suffix)"] Q2 -->|"no"| Normalize["prefix with / if missing"] Normalize --> Relative["return window.location.origin + path"]

Every branch resolves WHICH ORIGIN to call. The actual /api/v1/... path always comes from the endpoint constants, never from this function.

A Real Regression: the Doubled /api/api Bug

What went wrong

An earlier version of this function defaulted the empty case to window.location.origin + '/api' instead of the bare origin. That looked reasonable in isolation, but every endpoint constant in the codebase already hardcodes its own leading /api/v1/... segment, and the URL builder (buildUrl() in apiEndpoints.ts) does a naive concatenation with zero de-duplication:

export const buildUrl = (baseUrl: string, endpoint: string): string => {
  const cleanBaseUrl = baseUrl.replace(/\/$/, '')
  const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
  return `${cleanBaseUrl}${cleanEndpoint}`
}

With the old default, an empty VITE_API_BASE_URL on https://qa.grippingly.com produced a base URL of https://qa.grippingly.com/api. Concatenated with an endpoint constant like /api/v1/atlas/forms/list, the final request became https://qa.grippingly.com/api/api/v1/atlas/forms/list — a 404, in exactly the same-origin inline scenario this whole change was meant to make effortless. It was caught by an independent code review before shipping, and the default was corrected to return the bare origin with no suffix.

The lesson for anyone touching this function again: never add a default path suffix here. This function's only job is choosing an origin; path composition belongs entirely to the endpoint constants and buildUrl(). If a default suffix is ever reintroduced, watch for doubled /api/api segments in the Network tab exactly as described in Chapter 3.2.

Call Sites Fixed to Use This Function

These files previously (at various points) risked hardcoded URLs like 'https://localhost:10001' leaking into production builds, or threw fail-fast errors on a legitimately-empty base URL. All now route through calculateApiUrl():

  • atlas-forms/packages/api-client-js/src/config.ts
  • atlas-forms/packages/client-js/src/atlas-forms.client.ts
  • flow-studio/packages/flow-studio-api/src/config/apiConfig.ts
  • flow-studio/apps/flow-studio/src/main.tsx
  • flow-studio/apps/flow-studio/src/App.tsx — also removed a fail-fast throw new Error(...) that used to fire whenever VITE_API_BASE_URL was missing. An empty value is now a legitimate, expected configuration (the inline case), not an error.
  • flow-studio/packages/flow-studio-designer/src/components/Modules/EdgeStreamPageRenderer.tsx

Why This Is Better Than a Baked-In Relative String

Because calculateApiUrl() reads window.location.origin in the browser, at page-load time — not something Vite substitutes once at build time — a single inline-deployment build can be deployed to any same-origin domain without a rebuild. Deploy the exact same dist/ output to qa.grippingly.com, staging.grippingly.com, or grippingly.com, and each one correctly resolves its own origin — something even a build-time-baked relative string couldn't do across genuinely different domains, since a relative string still has to be reasoned about relative to something, and here that "something" is resolved fresh, live, by the browser itself, every time the page loads.