Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,20 @@ Admin → Embedding → Embedded analytics SDK → CORS.

`src/App.tsx` and anything you add under `src/` is shared between dev
and prod. The two modes only diverge at the entry layer.

## Calling external APIs (`allowed_hosts`)

A data app runs sandboxed: by default it **can't** `fetch`/XHR anything. To let
it reach an external API, list the origins in `data_app.yaml` under
`allowed_hosts` (supports a `*.` subdomain wildcard):

```yaml
allowed_hosts:
- https://api.example.com
- https://*.internal.acme.com
```

The same allowlist is enforced in both places: `npm run dev` applies it via the
dev server's CSP, and Metabase applies it via the iframe CSP + the membrane
sandbox. The Metabase instance itself is reached through the SDK (not listed
here). A call to any other host fails in dev exactly as it will in production.
63 changes: 63 additions & 0 deletions config/dev-connect-src.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from "node:fs";
import { parse as parseYaml } from "yaml";

/**
* Read the `allowed_hosts` list from this app's `data_app.yml`/`.yaml`. Returns
* `[]` when the file is missing/unparseable or the key is absent; non-string
* entries are dropped.
*/
export function readAllowedHosts(yamlPath: string): string[] {
let parsed: unknown;
try {
parsed = parseYaml(fs.readFileSync(yamlPath, "utf8"));
} catch {
return [];
}

const hosts =
typeof parsed === "object" && parsed !== null
? (parsed as { allowed_hosts?: unknown }).allowed_hosts
: undefined;

return Array.isArray(hosts)
? hosts.filter((host): host is string => typeof host === "string")
: [];
}

/**
* Dev-server CSP `connect-src` that mirrors what Metabase emits for a data app
* in production: the app may reach its declared `allowed_hosts` (plus the
* Metabase instance, for the SDK's own calls) and the Vite dev server / HMR
* websocket — nothing else. So a `fetch`/XHR a production data app couldn't
* make is blocked by the browser in `npm run dev` too, instead of silently
* working locally and failing once sandboxed in Metabase.
*/
function toOrigin(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).origin;
} catch {
return url;
}
}

export function buildDevConnectSrcCsp(
allowedHosts: string[],
metabaseUrl: string | undefined,
): string {
// The Metabase instance origin MUST be allowed — the SDK calls it (and in dev
// it's a different origin than the dev server, so `'self'` doesn't cover it).
const instanceOrigin = toOrigin(metabaseUrl);
const sources = [
"'self'",
"ws://localhost:*",
"wss://localhost:*",
"ws://127.0.0.1:*",
"wss://127.0.0.1:*",
...(instanceOrigin ? [instanceOrigin] : []),
...allowedHosts,
];
return `connect-src ${sources.join(" ")}`;
}
7 changes: 7 additions & 0 deletions data_app.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
name: Data App Template
slug: data-app-template
path: ./dist/index.js

# Origins this app may call with fetch/XHR. Everything else is blocked — both in
# `npm run dev` (via the dev server's CSP) and inside Metabase (the sandbox).
# The Metabase instance itself is reached through the SDK, not listed here.
# allowed_hosts:
# - https://api.example.com
# - https://*.internal.acme.com
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"vite": "^8.0.16",
"yaml": "^2.9.0"
}
}
86 changes: 57 additions & 29 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,67 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { type LibraryFormats, defineConfig } from "vite";
import { type LibraryFormats, defineConfig, loadEnv } from "vite";
import {
buildDevConnectSrcCsp,
readAllowedHosts,
} from "./config/dev-connect-src";
import { findEnvRoot } from "./config/env-root";

const appRoot = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig(() => ({
envDir: findEnvRoot(appRoot),
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true,
lib: {
entry: "src/index.tsx",
formats: ["iife"] satisfies LibraryFormats[],
fileName: () => "index.js",
name: "__dataAppFactory__",
},
rollupOptions: {
external: [
"react",
"react/jsx-runtime",
"@metabase/embedding-sdk-react",
"@metabase/embedding-sdk-react/data-app",
],
output: {
globals: {
react: "React",
"react/jsx-runtime": "__react_jsx_runtime__",
"@metabase/embedding-sdk-react": "__metabase_sdk__",
"@metabase/embedding-sdk-react/data-app": "__metabase_data_app__",
export default defineConfig(({ mode }) => {
const envDir = findEnvRoot(appRoot);
const env = loadEnv(mode, envDir, "");

const manifestPath = [
path.join(appRoot, "data_app.yaml"),
path.join(appRoot, "data_app.yml"),
].find((candidate) => fs.existsSync(candidate));
const allowedHosts = manifestPath ? readAllowedHosts(manifestPath) : [];

return {
envDir,
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true,
lib: {
entry: "src/index.tsx",
formats: ["iife"] satisfies LibraryFormats[],
fileName: () => "index.js",
name: "__dataAppFactory__",
},
rollupOptions: {
external: [
"react",
"react/jsx-runtime",
"@metabase/embedding-sdk-react",
"@metabase/embedding-sdk-react/data-app",
],
output: {
globals: {
react: "React",
"react/jsx-runtime": "__react_jsx_runtime__",
"@metabase/embedding-sdk-react": "__metabase_sdk__",
"@metabase/embedding-sdk-react/data-app": "__metabase_data_app__",
},
},
},
},
},
server: { port: 5174, host: "localhost" },
}));
server: {
port: 5174,
host: "localhost",
// Enforce the app's `allowed_hosts` in dev the same way Metabase does in
// production (via CSP `connect-src`), so a fetch/XHR a sandboxed data app
// couldn't make fails here too instead of only in Metabase.
headers: {
"Content-Security-Policy": buildDevConnectSrcCsp(
allowedHosts,
env.VITE_MB_URL,
),
},
},
};
});