diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..9a9a199
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,20 @@
+node_modules
+dist
+.git
+.github
+.husky
+.env
+.env.*
+!.env.example
+playwright-report
+test-results
+coverage
+*.md
+!README.md
+.vscode
+.idea
+.cursor
+*.log
+temp
+tmp
+.tmp
diff --git a/.env.example b/.env.example
index 116d61a..b9866dd 100644
--- a/.env.example
+++ b/.env.example
@@ -10,7 +10,13 @@
# App Configuration
# =================
+# Full URL where the app is served, including any path prefix — this is
+# the only var needed to configure where the app is mounted. Everything
+# else (Vite's asset base, the router, Docker's nginx rewrite, and the
+# GitHub Pages 404 redirect) derives from it automatically.
+# Examples: http://localhost:9000 | https://example.com/subpath
VITE_BASE_URL=
+VITE_OPENEO_API_URL=https://api.explorer.eopf.copernicus.eu/openeo
VITE_APP_TITLE=openEO Studio
VITE_APP_DESCRIPTION=Interactive code editor for openEO satellite imagery processing
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..762c57d
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,70 @@
+name: Docker
+
+on:
+ pull_request:
+ paths:
+ - 'Dockerfile'
+ - '.dockerignore'
+ - 'docker/**'
+ - '.github/workflows/docker.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'Dockerfile'
+ - '.dockerignore'
+ - 'docker/**'
+ - '.github/workflows/docker.yml'
+ release:
+ types: [published]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=sha-
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/404.html b/404.html
new file mode 100644
index 0000000..bb06dd9
--- /dev/null
+++ b/404.html
@@ -0,0 +1,48 @@
+
+
+
+
+ Redirecting...
+
+
+
+ Redirecting...
+
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e428b37
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,39 @@
+# syntax=docker/dockerfile:1
+
+FROM node:22-alpine AS build
+
+WORKDIR /app
+
+RUN corepack enable
+
+COPY package.json pnpm-lock.yaml ./
+ENV HUSKY=0
+RUN pnpm install --frozen-lockfile
+
+COPY . .
+
+# File copy (not ENV) so Docker does not expand the $ placeholders.
+COPY docker/vite-placeholders.env .env.production.local
+
+RUN pnpm build
+
+FROM nginx:stable-alpine
+
+COPY --from=build /app/dist /usr/share/nginx/html
+
+COPY docker/default.conf.template /etc/nginx/templates/default.conf.template
+COPY docker/90-app-config.sh docker/lib.sh /docker-entrypoint.d/
+RUN chmod +x /docker-entrypoint.d/90-app-config.sh \
+ && touch /etc/nginx/conf.d/path-prefix.inc
+
+ENV OPENEO_API_URL=https://api.explorer.eopf.copernicus.eu/openeo \
+ BASE_URL= \
+ APP_TITLE="openEO Studio" \
+ APP_DESCRIPTION="Interactive code editor for openEO satellite imagery processing" \
+ MAPTILER_KEY= \
+ AUTH_AUTHORITY= \
+ AUTH_CLIENT_ID= \
+ AUTH_REDIRECT_URI= \
+ ENABLE_NARRATIVE_EXPORT=false
+
+EXPOSE 80
diff --git a/README.md b/README.md
index d8666a3..1a67870 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,15 @@ To configure the application:
2. Modify the `.env` file with your specific configuration values
3. Never modify `.env.example` directly as it serves as documentation
+#### Where the app is mounted: `VITE_BASE_URL`
+
+`VITE_BASE_URL` is the one variable that controls where the app is served from — set it once to the full URL (including any path prefix) and everything else follows automatically: Vite's built asset paths, the client-side router's mount point, Docker's nginx rewrite rules, and the GitHub Pages 404 redirect.
+
+- **Local dev** (`pnpm dev`): leave it as the dev server's own URL, e.g. `http://localhost:9000` (no path prefix).
+- **`pnpm build` + serving locally under a prefix**: set it to the full URL you'll serve from, e.g. `http://localhost:8888/subpath`, then serve `dist/` so it's reachable at that path (e.g. behind a reverse proxy). The build's asset URLs and router will match automatically.
+- **GitHub Pages**: set the `VITE_BASE_URL` repository variable to the site's full deployed URL, e.g. `https://developmentseed.github.io/openeo-studio` for a project page, or your custom domain root if one is configured.
+- **Docker**: set the `BASE_URL` environment variable at `docker run` time (see below) — no rebuild needed to change it.
+
### Starting the app
```sh
@@ -78,7 +87,13 @@ or
pnpm stage
```
-This will package the app and place all the contents in the `dist` directory.
-The app can then be run by any web server.
+This will package the app and place all the contents in the `dist` directory, with asset URLs already baked to match whatever `VITE_BASE_URL` was set to at build time. The app can then be run by any web server capable of serving a single-page app (falling back to `index.html` for unknown paths).
+
+### Docker
+
+```sh
+docker build -t openeo-studio .
+docker run -p 8888:80 -e BASE_URL=http://localhost:8888/subpath openeo-studio
+```
-**When building the site for deployment provide the base url trough the `VITE_BASE_URL` environment variable. Omit the leading slash. (E.g. )**
+The image is built once with a relative asset base and configured per-container via environment variables (`BASE_URL`, `OPENEO_API_URL`, `APP_TITLE`, etc. — see `Dockerfile` for the full list). The entrypoint script derives the mount path from `BASE_URL` and writes it into nginx's rewrite rules, the page's `` tag, and `window.__APP_CONFIG__` at container start — so the same image can be redeployed under a different `BASE_URL` without rebuilding.
diff --git a/app/config/baseUrl.ts b/app/config/baseUrl.ts
new file mode 100644
index 0000000..169a84b
--- /dev/null
+++ b/app/config/baseUrl.ts
@@ -0,0 +1,21 @@
+/**
+ * Parses VITE_BASE_URL / BASE_URL — the single source of truth for where
+ * the app is mounted — into a URL, or undefined if it isn't a real,
+ * parseable absolute URL yet. During Docker's image build, VITE_BASE_URL is
+ * deliberately left as the literal escaped string `${BASE_URL}` (see
+ * docker/vite-placeholders.env) to be resolved by envsubst at container
+ * start; callers must treat that case the same as "not set yet".
+ */
+export function parseBaseUrl(rawUrl: string | undefined): URL | undefined {
+ if (!rawUrl) return undefined;
+ try {
+ return new URL(rawUrl);
+ } catch {
+ return undefined;
+ }
+}
+
+/** The URL's path only, no trailing slash; '' if there is no path or no valid URL. */
+export function pathPrefixFromUrl(rawUrl: string | undefined): string {
+ return parseBaseUrl(rawUrl)?.pathname.replace(/\/+$/, '') ?? '';
+}
diff --git a/app/config/constants.ts b/app/config/constants.ts
index bf2ccaf..cfe3693 100644
--- a/app/config/constants.ts
+++ b/app/config/constants.ts
@@ -1,10 +1,8 @@
-/**
- * Application-wide constants from environment variables
- */
-export const APP_TITLE = import.meta.env.VITE_APP_TITLE;
-export const APP_DESCRIPTION = import.meta.env.VITE_APP_DESCRIPTION;
+import { appConfig } from './runtime';
-export const MAPTILER_KEY = import.meta.env.VITE_MAPTILER_KEY;
+export const APP_TITLE = appConfig.appTitle;
+export const APP_DESCRIPTION = appConfig.appDescription;
-export const ENABLE_NARRATIVE_EXPORT =
- import.meta.env.VITE_ENABLE_NARRATIVE_EXPORT === 'true';
+export const MAPTILER_KEY = appConfig.maptilerKey;
+
+export const ENABLE_NARRATIVE_EXPORT = appConfig.enableNarrativeExport;
diff --git a/app/config/runtime.ts b/app/config/runtime.ts
new file mode 100644
index 0000000..4c75229
--- /dev/null
+++ b/app/config/runtime.ts
@@ -0,0 +1,68 @@
+/**
+ * Runtime config: window.__APP_CONFIG__ (Docker) with VITE_* fallbacks (local).
+ */
+
+import { pathPrefixFromUrl } from '$config/baseUrl';
+
+export interface AppConfig {
+ openeoApiUrl: string;
+ pathPrefix: string;
+ appTitle: string;
+ appDescription: string;
+ maptilerKey: string;
+ authAuthority: string;
+ authClientId: string;
+ authRedirectUri: string;
+ enableNarrativeExport: boolean;
+}
+
+declare global {
+ interface Window {
+ __APP_CONFIG__?: Partial;
+ }
+}
+
+function pick(
+ runtimeValue: string | undefined,
+ envValue: string | undefined,
+ fallback = ''
+): string {
+ return runtimeValue || envValue || fallback;
+}
+
+/** Leading slash, no trailing slash; empty when served at root. */
+function normalizePathPrefix(prefix: string): string {
+ if (!prefix || prefix === '/') return '';
+ return `/${prefix.replace(/^\/+|\/+$/g, '')}`;
+}
+
+const runtime = window.__APP_CONFIG__ ?? {};
+
+export const appConfig: AppConfig = {
+ openeoApiUrl: pick(
+ runtime.openeoApiUrl,
+ import.meta.env.VITE_OPENEO_API_URL,
+ 'https://api.explorer.eopf.copernicus.eu/openeo'
+ ),
+ pathPrefix: normalizePathPrefix(
+ pick(runtime.pathPrefix, pathPrefixFromUrl(import.meta.env.VITE_BASE_URL))
+ ),
+ appTitle: pick(runtime.appTitle, import.meta.env.VITE_APP_TITLE),
+ appDescription: pick(
+ runtime.appDescription,
+ import.meta.env.VITE_APP_DESCRIPTION
+ ),
+ maptilerKey: pick(runtime.maptilerKey, import.meta.env.VITE_MAPTILER_KEY),
+ authAuthority: pick(
+ runtime.authAuthority,
+ import.meta.env.VITE_AUTH_AUTHORITY
+ ),
+ authClientId: pick(runtime.authClientId, import.meta.env.VITE_AUTH_CLIENT_ID),
+ authRedirectUri: pick(
+ runtime.authRedirectUri,
+ import.meta.env.VITE_AUTH_REDIRECT_URI
+ ),
+ enableNarrativeExport:
+ runtime.enableNarrativeExport ??
+ import.meta.env.VITE_ENABLE_NARRATIVE_EXPORT === 'true'
+};
diff --git a/app/main.tsx b/app/main.tsx
index c80be1b..621ea6d 100644
--- a/app/main.tsx
+++ b/app/main.tsx
@@ -6,6 +6,7 @@ import { StacApiProvider } from '@developmentseed/stac-react';
import { BrowserRouter } from 'react-router';
import { WebStorageStateStore } from 'oidc-client-ts';
+import { appConfig } from '$config/runtime';
import { PyodideProvider } from '$contexts/pyodide-context';
import { AuthMonitor } from '$utils/auth-monitor';
import { setupReloadDetector } from './utils/reload-detector';
@@ -21,15 +22,11 @@ if (import.meta.env.DEV) {
setupReloadDetector();
}
-const authAuthority = import.meta.env.VITE_AUTH_AUTHORITY || '';
-const authClientId = import.meta.env.VITE_AUTH_CLIENT_ID || '';
-const authRedirectUri = import.meta.env.VITE_AUTH_REDIRECT_URI || '';
-
const oidcConfig: AuthProviderProps = {
userStore: new WebStorageStateStore({ store: window.localStorage }),
- authority: authAuthority,
- client_id: authClientId,
- redirect_uri: authRedirectUri,
+ authority: appConfig.authAuthority,
+ client_id: appConfig.authClientId,
+ redirect_uri: appConfig.authRedirectUri,
onSigninCallback: (user) => {
// eslint-disable-next-line no-console
console.log('[AUTH] onSigninCallback triggered', {
@@ -91,11 +88,11 @@ function Root() {
const authProps = window.__MOCK_AUTH__ ? {} : oidcConfig;
return (
-
+
{!window.__MOCK_AUTH__ && }
-
+
diff --git a/app/utils/code-runner.ts b/app/utils/code-runner.ts
index 289148d..dc3cb62 100644
--- a/app/utils/code-runner.ts
+++ b/app/utils/code-runner.ts
@@ -10,6 +10,7 @@ import {
type BackendService
} from '$types';
+import { appConfig } from '$config/runtime';
import { getInstanceId } from './instance-id';
// Track active services for cleanup
@@ -18,7 +19,7 @@ const activeServices: ServiceInfo[] = [];
export const EXAMPLE_CODE = trueColorAlgorithm;
// OpenEO API constants
-const OPENEO_API_URL = 'https://api.explorer.eopf.copernicus.eu/openeo';
+const OPENEO_API_URL = appConfig.openeoApiUrl;
const AUTH_PREFIX = 'Bearer oidc/oidc/';
// Service title conventions for backend-side discovery
diff --git a/docker/90-app-config.sh b/docker/90-app-config.sh
new file mode 100644
index 0000000..9fe2aa1
--- /dev/null
+++ b/docker/90-app-config.sh
@@ -0,0 +1,72 @@
+#!/bin/sh
+# nginx entrypoint hook: template index.html from env.
+set -eu
+
+HTML_ROOT=/usr/share/nginx/html
+INDEX_HTML="${HTML_ROOT}/index.html"
+PATH_PREFIX_INC=/etc/nginx/conf.d/path-prefix.inc
+
+. "$(dirname "$0")/lib.sh"
+
+# BASE_URL is the single source of truth for where the app is mounted —
+# derive the path prefix from it instead of a separate env var.
+PATH_PREFIX="$(normalize_prefix "$(derive_prefix "$BASE_URL")")"
+
+json_escape() {
+ printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
+}
+
+if [ "$ENABLE_NARRATIVE_EXPORT" = "true" ]; then
+ ENABLE_NARRATIVE_EXPORT_JS=true
+else
+ ENABLE_NARRATIVE_EXPORT_JS=false
+fi
+
+tmp_index="$(mktemp)"
+# shellcheck disable=SC2016
+envsubst '${BASE_URL} ${APP_TITLE} ${APP_DESCRIPTION}' <"$INDEX_HTML" >"$tmp_index"
+mv "$tmp_index" "$INDEX_HTML"
+
+# Splice the runtime app config in as an inline script where index.html has
+# . Using sed's `r` (read file) instead of `s|...|...|`
+# avoids fighting sed's replacement-text escaping rules for values that may
+# contain slashes or quotes (URLs, titles, etc.).
+config_script="$(mktemp)"
+cat >"$config_script" <
+window.__APP_CONFIG__ = {
+ openeoApiUrl: "$(json_escape "$OPENEO_API_URL")",
+ pathPrefix: "$(json_escape "$PATH_PREFIX")",
+ appTitle: "$(json_escape "$APP_TITLE")",
+ appDescription: "$(json_escape "$APP_DESCRIPTION")",
+ maptilerKey: "$(json_escape "$MAPTILER_KEY")",
+ authAuthority: "$(json_escape "$AUTH_AUTHORITY")",
+ authClientId: "$(json_escape "$AUTH_CLIENT_ID")",
+ authRedirectUri: "$(json_escape "$AUTH_REDIRECT_URI")",
+ enableNarrativeExport: ${ENABLE_NARRATIVE_EXPORT_JS}
+};
+
+EOF
+
+tmp_index="$(mktemp)"
+sed -e "//r ${config_script}" -e "//d" "$INDEX_HTML" >"$tmp_index"
+mv "$tmp_index" "$INDEX_HTML"
+rm -f "$config_script"
+
+# so Vite relative assets resolve on deep links under PATH_PREFIX.
+tmp_index="$(mktemp)"
+sed "s|||" "$INDEX_HTML" >"$tmp_index"
+mv "$tmp_index" "$INDEX_HTML"
+chmod 644 "$INDEX_HTML"
+
+# Empty include when at root (avoids rewrite loops).
+if [ -n "$PATH_PREFIX" ]; then
+ cat >"$PATH_PREFIX_INC" <"$PATH_PREFIX_INC"
+fi
+
+echo "openeo-studio: templated ${INDEX_HTML} (PATH_PREFIX='${PATH_PREFIX}', OPENEO_API_URL='${OPENEO_API_URL}')"
diff --git a/docker/default.conf.template b/docker/default.conf.template
new file mode 100644
index 0000000..2d6e0e8
--- /dev/null
+++ b/docker/default.conf.template
@@ -0,0 +1,23 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ absolute_redirect off;
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+
+ include /etc/nginx/conf.d/path-prefix.inc;
+
+ location = /index.html {
+ add_header Cache-Control "no-cache" always;
+ }
+
+ location /assets/ {
+ add_header Cache-Control "public, max-age=31536000, immutable" always;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/docker/lib.sh b/docker/lib.sh
new file mode 100644
index 0000000..9803a56
--- /dev/null
+++ b/docker/lib.sh
@@ -0,0 +1,37 @@
+#!/bin/sh
+# Pure string/path helpers shared by 90-app-config.sh. No side effects —
+# safe to source directly in tests.
+
+# Leading slash, no trailing slash; empty for root.
+normalize_prefix() {
+ prefix="$1"
+ if [ -z "$prefix" ] || [ "$prefix" = "/" ]; then
+ printf ''
+ return
+ fi
+ case "$prefix" in
+ /*) ;;
+ *) prefix="/${prefix}" ;;
+ esac
+ printf '%s' "$prefix" | sed 's|/*$||'
+}
+
+# Extracts the path from a BASE_URL of the form scheme://host[/path].
+# Empty string if there's no scheme (e.g. unset, or an unresolved
+# `${BASE_URL}` build-time placeholder that hasn't been envsubst'd yet).
+derive_prefix() {
+ case "$1" in
+ http://*|https://*)
+ rest=${1#*://}
+ path=${rest#*/}
+ if [ "$path" = "$rest" ]; then
+ printf ''
+ else
+ printf '/%s' "$path"
+ fi
+ ;;
+ *)
+ printf ''
+ ;;
+ esac
+}
diff --git a/docker/vite-placeholders.env b/docker/vite-placeholders.env
new file mode 100644
index 0000000..865f81c
--- /dev/null
+++ b/docker/vite-placeholders.env
@@ -0,0 +1,4 @@
+# Escaped $ so Vite leaves these for envsubst at container startup.
+VITE_BASE_URL=\${BASE_URL}
+VITE_APP_TITLE=\${APP_TITLE}
+VITE_APP_DESCRIPTION=\${APP_DESCRIPTION}
diff --git a/index.html b/index.html
index 7010b41..756fff9 100644
--- a/index.html
+++ b/index.html
@@ -25,16 +25,22 @@