From 15fb2f8456b75d9e4d2f1e776b897418f2475561 Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:49:44 +0200 Subject: [PATCH] feat: allow injecting custom client-side scripts via BASEROW_EXTRA_CLIENT_SCRIPT_URLS (#5244) * feat: allow injecting custom client-side scripts via env vars Add an opt-in mechanism for self-hosted operators to inject simple frontend scripts without additional dependencies. Controlled by BASEROW_EXTRA_CLIENT_SCRIPT_ENABLED and BASEROW_EXTRA_CLIENT_SCRIPT_PATHS environment variables. * feat: add changelog entry for custom client scripts * address copilot feedback * address feedback * address copilot feedback v2 * add doc --- .../feature/allow_custom_client_scripts.json | 7 + docker-compose.no-caddy.yml | 1 + docker-compose.yml | 1 + docs/installation/configuration.md | 1 + docs/plugins/custom-client-scripts.md | 121 ++++++++++++++++++ .../middleware/extraClientScripts.js | 68 ++++++++++ .../modules/baserow_enterprise/module.js | 12 ++ .../plugins/extraClientScriptUrls.js | 28 ++++ web-frontend/Dockerfile | 3 +- web-frontend/env-remap.mjs | 2 + 10 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 changelog/entries/unreleased/feature/allow_custom_client_scripts.json create mode 100644 docs/plugins/custom-client-scripts.md create mode 100644 enterprise/web-frontend/modules/baserow_enterprise/middleware/extraClientScripts.js create mode 100644 enterprise/web-frontend/modules/baserow_enterprise/plugins/extraClientScriptUrls.js diff --git a/changelog/entries/unreleased/feature/allow_custom_client_scripts.json b/changelog/entries/unreleased/feature/allow_custom_client_scripts.json new file mode 100644 index 0000000000..cf4c299d26 --- /dev/null +++ b/changelog/entries/unreleased/feature/allow_custom_client_scripts.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Allow self-hosted operators to inject custom client-side scripts via environment variables.", + "domain": "core", + "bullet_points": [], + "created_at": "2026-04-21" +} diff --git a/docker-compose.no-caddy.yml b/docker-compose.no-caddy.yml index 879391adaf..959c2b6c0d 100644 --- a/docker-compose.no-caddy.yml +++ b/docker-compose.no-caddy.yml @@ -233,6 +233,7 @@ services: DOWNLOAD_FILE_VIA_XHR: BASEROW_DISABLE_GOOGLE_DOCS_FILE_PREVIEW: BASEROW_DISABLE_SUPPORT: + BASEROW_EXTRA_CLIENT_SCRIPT_URLS: HOURS_UNTIL_TRASH_PERMANENTLY_DELETED: DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS: FEATURE_FLAGS: diff --git a/docker-compose.yml b/docker-compose.yml index b2d761014a..a648c4e46c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -312,6 +312,7 @@ services: DOWNLOAD_FILE_VIA_XHR: BASEROW_DISABLE_GOOGLE_DOCS_FILE_PREVIEW: BASEROW_DISABLE_SUPPORT: + BASEROW_EXTRA_CLIENT_SCRIPT_URLS: HOURS_UNTIL_TRASH_PERMANENTLY_DELETED: DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS: FEATURE_FLAGS: diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index 06bfdeb035..4b96496f12 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -354,6 +354,7 @@ domain than your Baserow, you need to make sure CORS is configured correctly. | BASEROW\_BUILDER\_DOMAINS | A comma separated list of domain names that can be used as the domains to create sub domains in the application builder. | | | BASEROW\_FRONTEND\_SAME\_SITE\_COOKIE | String value indicating what the sameSite value of the created cookies should be. | lax | | BASEROW\_DISABLE\_SUPPORT | Set to any value to disable the support features in Baserow. | | +| BASEROW\_EXTRA\_CLIENT\_SCRIPT\_URLS | A comma-separated list of URLs pointing to externally hosted JavaScript files that can be used to customize the frontend application. Requires an active enterprise license. | | ### SSO Configuration diff --git a/docs/plugins/custom-client-scripts.md b/docs/plugins/custom-client-scripts.md new file mode 100644 index 0000000000..ec7d3ec3ac --- /dev/null +++ b/docs/plugins/custom-client-scripts.md @@ -0,0 +1,121 @@ +# Custom Client Scripts + +> **Enterprise feature** -- requires an active enterprise license. + +The `BASEROW_EXTRA_CLIENT_SCRIPT_URLS` environment variable lets self-hosted operators +inject custom client-side JavaScript into every page without building a full plugin. +Scripts are loaded in the `` and execute before the application hydrates. + +## Configuration + +Set the environment variable to one or more comma-separated URLs pointing to your +scripts: + +```bash +BASEROW_EXTRA_CLIENT_SCRIPT_URLS=https://example.com/my-script.js +``` + +Multiple scripts: + +```bash +BASEROW_EXTRA_CLIENT_SCRIPT_URLS=https://example.com/analytics.js,https://example.com/banner.js +``` + +## The `window.__baserow` API + +Before your scripts execute, Baserow creates a `window.__baserow` global with the +following properties: + +| Property | Description | +|---|---| +| `window.__baserow.config` | The public runtime configuration object (read-only). | +| `window.__baserow.hook(name, fn)` | Register a callback for a Nuxt lifecycle hook. | +| `window.__baserow.$router` | The Vue Router instance (available after the app plugin runs). | + +### Registering hooks + +Use `window.__baserow.hook()` to run code at specific lifecycle moments. Hooks +registered before the Nuxt app is ready are queued and replayed automatically once the +app finishes loading. + +```js +// Log when the application has mounted +window.__baserow.hook('app:mounted', () => { + console.log('Baserow has mounted') +}) +``` + +```js +// Access runtime config inside a hook +window.__baserow.hook('app:mounted', () => { + const config = window.__baserow.config + console.log('Public URL:', config.PUBLIC_WEB_FRONTEND_URL) +}) +``` + +```js +// Use the router to react to navigation +window.__baserow.hook('app:mounted', () => { + window.__baserow.$router.afterEach((to) => { + console.log('Navigated to', to.fullPath) + }) +}) +``` + +## Examples + +### Inject a third-party analytics snippet + +```js +window.__baserow.hook('app:mounted', () => { + const script = document.createElement('script') + script.src = 'https://analytics.example.com/tracker.js' + script.async = true + document.head.appendChild(script) +}) +``` + +### Show a maintenance banner + +```js +window.__baserow.hook('app:mounted', () => { + const banner = document.createElement('div') + banner.textContent = 'Scheduled maintenance tonight at 22:00 UTC' + banner.style.cssText = + 'position:fixed;top:0;left:0;right:0;padding:8px;background:#f59e0b;' + + 'color:#000;text-align:center;z-index:9999;font-size:14px;' + document.body.prepend(banner) +}) +``` + +### Track page views + +```js +window.__baserow.hook('app:mounted', () => { + const track = (path) => { + fetch('https://analytics.example.com/collect', { + method: 'POST', + body: JSON.stringify({ path, ts: Date.now() }), + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Initial page + track(window.location.pathname) + + // Subsequent navigations + window.__baserow.$router.afterEach((to) => { + track(to.fullPath) + }) +}) +``` + +## Notes + +* Scripts are only loaded when the enterprise license includes the + `ENTERPRISE_SETTINGS` feature. +* The `hook()` function accepts any + [Nuxt lifecycle hook name](https://nuxt.com/docs/api/advanced/hooks#app-hooks-runtime). + `app:mounted` is the most common choice for DOM manipulation. +* Because scripts run in the browser, they cannot access backend secrets or server-side + configuration. diff --git a/enterprise/web-frontend/modules/baserow_enterprise/middleware/extraClientScripts.js b/enterprise/web-frontend/modules/baserow_enterprise/middleware/extraClientScripts.js new file mode 100644 index 0000000000..5bb3faa8b3 --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/middleware/extraClientScripts.js @@ -0,0 +1,68 @@ +import { + useHead, + useNuxtApp, + useRequestEvent, + useRuntimeConfig, +} from '#imports' +import EnterpriseFeatures from '@baserow_enterprise/features' + +const getUrls = (raw) => + raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + +const bootstrapScript = (config) => ` +window.__baserow = window.__baserow || {}; +window.__baserow.config = ${JSON.stringify(config).replace(/ { + if (import.meta.client) return + + const event = import.meta.server ? useRequestEvent() : null + + if (import.meta.server && !event) return + + const runtimeConfig = useRuntimeConfig() + const raw = runtimeConfig.public.baserowExtraClientScriptUrls + if (!raw) return + + const urls = getUrls(raw) + if (urls.length === 0) return + + const nuxtApp = useNuxtApp() + const store = nuxtApp.$store + + // This runs on SSR so entitled extra scripts can be emitted into the initial HTML + // head and execute before hydration/first paint. We must gate here, per request, + // because module-level head injection would bypass the ENTERPRISE_SETTINGS check and + // load the scripts for every visitor. + if (!store.getters['settings/isLoaded']) { + await store.dispatch('settings/load') + } + + if (!nuxtApp.$hasFeature(EnterpriseFeatures.ENTERPRISE_SETTINGS)) { + return + } + + useHead({ + script: [ + { + key: 'baserow-extra-client-script-bootstrap', + innerHTML: bootstrapScript(runtimeConfig.public), + tagPosition: 'head', + }, + ...urls.map((src, index) => ({ + key: `baserow-extra-client-script-${index}`, + src, + tagPosition: 'head', + 'data-baserow-extra-client-script': 'true', + })), + ], + }) +}) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/module.js b/enterprise/web-frontend/modules/baserow_enterprise/module.js index 9ffdaa3452..7c806838b7 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/module.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/module.js @@ -4,6 +4,7 @@ import { addPlugin, createResolver, extendPages, + addRouteMiddleware, } from 'nuxt/kit' import { routes, rootChildRoutes } from './routes' import { locales } from '../../../../web-frontend/config/locales.js' @@ -72,12 +73,23 @@ export default defineNuxtModule({ src: resolve('./plugin.js'), }) + addPlugin({ + src: resolve('./plugins/extraClientScriptUrls.js'), + }) + + addRouteMiddleware({ + name: 'enterpriseExtraClientScripts', + path: resolve('./middleware/extraClientScripts'), + global: true, + }) + // Runtime config defaults - values can be overridden at runtime via NUXT_ prefixed env vars // See env-remap.mjs for the env var remapping that enables backwards compatibility nuxt.options.runtimeConfig.public = _.defaultsDeep( nuxt.options.runtimeConfig.public, { baserowEnterpriseAssistantLlmModel: '', + baserowExtraClientScriptUrls: '', } ) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/plugins/extraClientScriptUrls.js b/enterprise/web-frontend/modules/baserow_enterprise/plugins/extraClientScriptUrls.js new file mode 100644 index 0000000000..650068cb0b --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/plugins/extraClientScriptUrls.js @@ -0,0 +1,28 @@ +import { useNuxtApp, useRuntimeConfig } from '#imports' + +export default defineNuxtPlugin({ + name: 'enterprise-extra-client-script-runtime', + dependsOn: ['enterprise'], + setup() { + if (!import.meta.client) return + + if (!window.__baserow) return + + const nuxtApp = useNuxtApp() + const runtimeConfig = useRuntimeConfig() + const queuedHooks = window.__baserow._queuedHooks || [] + + window.__baserow = { + ...window.__baserow, + $router: nuxtApp.$router, + config: runtimeConfig.public, + hook: (name, fn) => nuxtApp.hook(name, fn), + } + + for (const [name, fn] of queuedHooks) { + nuxtApp.hook(name, fn) + } + + delete window.__baserow._queuedHooks + }, +}) diff --git a/web-frontend/Dockerfile b/web-frontend/Dockerfile index 741a651c10..c19add0cd3 100644 --- a/web-frontend/Dockerfile +++ b/web-frontend/Dockerfile @@ -88,8 +88,7 @@ WORKDIR /baserow/web-frontend # Build, then clean reinstall production only RUN --mount=type=cache,target=$YARN_CACHE_FOLDER,uid=$UID,gid=$GID,sharing=locked \ - yarn run build \ - && find .output -type f -name "*.map" -delete + yarn run build # ============================================================================= diff --git a/web-frontend/env-remap.mjs b/web-frontend/env-remap.mjs index 2ff211ff86..af76e750d7 100644 --- a/web-frontend/env-remap.mjs +++ b/web-frontend/env-remap.mjs @@ -56,6 +56,8 @@ const envMapping = { SENTRY_DSN: 'NUXT_PUBLIC_SENTRY_DSN', SENTRY_ENVIRONMENT: 'NUXT_PUBLIC_SENTRY_ENVIRONMENT', MEDIA_URL: 'NUXT_PUBLIC_MEDIA_URL', + BASEROW_EXTRA_CLIENT_SCRIPT_URLS: + 'NUXT_PUBLIC_BASEROW_EXTRA_CLIENT_SCRIPT_URLS', } // Remap env vars: only if legacy var exists AND NUXT_ var is not already set