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
33 changes: 17 additions & 16 deletions apps/dev-playground/client/src/routes/files.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FileBreadcrumb,
FilePreviewPanel,
NewFolderInput,
usePluginClientConfig,
} from "@databricks/appkit-ui/react";
import { createFileRoute, retainSearchParams } from "@tanstack/react-router";
import { FolderPlus, Loader2, Upload } from "lucide-react";
Expand Down Expand Up @@ -36,8 +37,15 @@ export const Route = createFileRoute("/files")({
},
});

interface FilesClientConfig {
volumes?: string[];
}

const EMPTY_VOLUMES: readonly string[] = Object.freeze([]);

function FilesRoute() {
const [volumes, setVolumes] = useState<string[]>([]);
const { volumes = EMPTY_VOLUMES } =
usePluginClientConfig<FilesClientConfig>("files");
const [volumeKey, setVolumeKey] = useState<string>(
() => localStorage.getItem("appkit:files:volumeKey") ?? "",
);
Expand Down Expand Up @@ -139,21 +147,14 @@ function FilesRoute() {
);

useEffect(() => {
fetch("/api/files/volumes")
.then((res) => res.json())
.then((data: { volumes: string[] }) => {
const list = data.volumes ?? [];
setVolumes(list);
if (!volumeKey || !list.includes(volumeKey)) {
const first = list[0];
if (first) {
setVolumeKey(first);
localStorage.setItem("appkit:files:volumeKey", first);
}
}
})
.catch(() => {});
}, [volumeKey]);
if (!volumeKey || !volumes.includes(volumeKey)) {
const first = volumes[0];
if (first) {
setVolumeKey(first);
localStorage.setItem("appkit:files:volumeKey", first);
}
}
}, [volumeKey, volumes]);

// Load root directory when volume key is set
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion apps/dev-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"start": "node build/index.mjs",
"start:local": "NODE_ENV=production node --env-file=./server/.env build/index.mjs",
"dev": "NODE_ENV=development tsx watch server/index.ts",
"dev:inspect": "tsx --inspect --tsconfig ./tsconfig.json ./server",
"dev:inspect": "NODE_ENV=development tsx --inspect --tsconfig ./tsconfig.json ./server",
"build": "npm run build:app",
"build:app": "tsdown --out-dir build server/index.ts && cd client && npm run build",
"build:server": "tsdown --out-dir build server/index.ts",
Expand Down
63 changes: 63 additions & 0 deletions docs/docs/api/appkit/Class.Plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,69 @@ AuthenticationError if user token is not available in request headers (productio

***

### clientConfig()

```ts
clientConfig(): Record<string, unknown>;
```

Returns startup config to expose to the client.
Override this to surface server-side values that are safe to publish to the
frontend, such as feature flags, resource IDs, or other app boot settings.

This runs once when the server starts, so it should not depend on
request-scoped or user-specific state.

String values that match non-public environment variables are redacted
unless you intentionally expose them via a matching `PUBLIC_APPKIT_` env var.

Values must be JSON-serializable plain data (no functions, Dates, classes,
Maps, Sets, BigInts, or circular references).
By default returns an empty object (plugin contributes nothing to client config).

On the client, read the config with the `usePluginClientConfig` hook
(React) or the `getPluginClientConfig` function (vanilla JS), both
from `@databricks/appkit-ui`.

#### Returns

`Record`\<`string`, `unknown`\>

#### Example

```ts
// Server — plugin definition
class MyPlugin extends Plugin<MyConfig> {
clientConfig() {
return {
warehouseId: this.config.warehouseId,
features: { darkMode: true },
};
}
}

// Client — React component
import { usePluginClientConfig } from "@databricks/appkit-ui/react";

interface MyPluginConfig { warehouseId: string; features: { darkMode: boolean } }

const config = usePluginClientConfig<MyPluginConfig>("myPlugin");
config.warehouseId; // "abc-123"

// Client — vanilla JS
import { getPluginClientConfig } from "@databricks/appkit-ui/js";

const config = getPluginClientConfig<MyPluginConfig>("myPlugin");
```

#### Implementation of

```ts
BasePlugin.clientConfig
```

***

### execute()

```ts
Expand Down
113 changes: 113 additions & 0 deletions packages/appkit-ui/src/js/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
_resetConfigCache,
getClientConfig,
getPluginClientConfig,
} from "./config";

describe("js/config", () => {
afterEach(() => {
document.body.innerHTML = "";
_resetConfigCache();
});

test("parses runtime config from the DOM script payload", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"demo","queries":{"q":"q"},"endpoints":{"analytics":{"query":"/api/analytics/query"}},"plugins":{"analytics":{"warehouseId":"abc"}}}
</script>
`;

expect(getClientConfig()).toEqual({
appName: "demo",
queries: { q: "q" },
endpoints: { analytics: { query: "/api/analytics/query" } },
plugins: { analytics: { warehouseId: "abc" } },
});
expect(getPluginClientConfig("analytics")).toEqual({ warehouseId: "abc" });
});

test("returns empty config when no script tag is present", () => {
const config = getClientConfig();
expect(config).toEqual({
appName: "",
queries: {},
endpoints: {},
plugins: {},
});
});

test("returns empty config and warns on malformed JSON", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
document.body.innerHTML = `
<script id="__appkit__" type="application/json">NOT VALID JSON</script>
`;

const config = getClientConfig();
expect(config).toEqual({
appName: "",
queries: {},
endpoints: {},
plugins: {},
});
expect(warnSpy).toHaveBeenCalledWith(
"[appkit] Failed to parse config from DOM:",
expect.any(SyntaxError),
);
warnSpy.mockRestore();
});

test("caches parsed config across calls", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"cached","queries":{},"endpoints":{},"plugins":{}}
</script>
`;

const first = getClientConfig();
const second = getClientConfig();
expect(first).toBe(second);
});

test("returns stable reference for missing plugin config", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"app","queries":{},"endpoints":{},"plugins":{}}
</script>
`;

const a = getPluginClientConfig("nonexistent");
const b = getPluginClientConfig("nonexistent");
expect(a).toBe(b);
});

test("returns empty config when script tag has empty content", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json"></script>
`;

const config = getClientConfig();
expect(config).toEqual({
appName: "",
queries: {},
endpoints: {},
plugins: {},
});
});

test("normalizes partial data with missing fields", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"partial"}
</script>
`;

const config = getClientConfig();
expect(config).toEqual({
appName: "partial",
queries: {},
endpoints: {},
plugins: {},
});
});
});
84 changes: 84 additions & 0 deletions packages/appkit-ui/src/js/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { PluginClientConfigs, PluginEndpoints } from "shared";

export interface AppKitClientConfig {
appName: string;
queries: Record<string, string>;
endpoints: PluginEndpoints;
plugins: PluginClientConfigs;
}

declare global {
interface Window {
__appkit__?: AppKitClientConfig;
}
}

const APPKIT_CONFIG_SCRIPT_ID = "__appkit__";
const EMPTY_CONFIG: AppKitClientConfig = Object.freeze({
appName: "",
queries: Object.freeze({}),
endpoints: Object.freeze({}),
plugins: Object.freeze({}),
});

function normalizeClientConfig(config: unknown): AppKitClientConfig {
if (!config || typeof config !== "object") {
return EMPTY_CONFIG;
}

const normalized = config as Partial<AppKitClientConfig>;

return {
appName: normalized.appName ?? EMPTY_CONFIG.appName,
queries: normalized.queries ?? EMPTY_CONFIG.queries,
endpoints: normalized.endpoints ?? EMPTY_CONFIG.endpoints,
plugins: normalized.plugins ?? EMPTY_CONFIG.plugins,
};
}

function readClientConfigFromDom(): AppKitClientConfig {
if (typeof document === "undefined") {
return EMPTY_CONFIG;
}

const configScript = document.getElementById(APPKIT_CONFIG_SCRIPT_ID);
if (!configScript?.textContent) {
return EMPTY_CONFIG;
}

try {
return normalizeClientConfig(JSON.parse(configScript.textContent));
} catch (error) {
console.warn("[appkit] Failed to parse config from DOM:", error);
return EMPTY_CONFIG;
}
}

let _cache: AppKitClientConfig | undefined;

/**
* @internal Reset the module-scoped config cache. Test utility only.
*/
export function _resetConfigCache(): void {
_cache = undefined;
}

export function getClientConfig(): AppKitClientConfig {
if (typeof window === "undefined") {
return EMPTY_CONFIG;
}

if (!_cache) {
_cache = window.__appkit__ ?? readClientConfigFromDom();
}

return _cache;
}

const EMPTY_PLUGIN_CONFIG = Object.freeze({});

export function getPluginClientConfig<T = Record<string, unknown>>(
pluginName: string,
): T {
return (getClientConfig().plugins[pluginName] ?? EMPTY_PLUGIN_CONFIG) as T;
}
1 change: 1 addition & 0 deletions packages/appkit-ui/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export {
sql,
} from "shared";
export * from "./arrow";
export * from "./config";
export * from "./constants";
export * from "./sse";
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { _resetConfigCache } from "@/js/config";
import { usePluginClientConfig } from "../use-plugin-config";

describe("usePluginClientConfig", () => {
afterEach(() => {
document.body.innerHTML = "";
_resetConfigCache();
});

test("returns typed plugin config from the boot payload", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"app","queries":{},"endpoints":{},"plugins":{"files":{"volumes":["vol-a","vol-b"]}}}
</script>
`;

interface FilesConfig {
volumes: string[];
}

const { result } = renderHook(() =>
usePluginClientConfig<FilesConfig>("files"),
);

expect(result.current).toEqual({ volumes: ["vol-a", "vol-b"] });
});

test("returns empty object for unknown plugin", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"app","queries":{},"endpoints":{},"plugins":{}}
</script>
`;

const { result } = renderHook(() => usePluginClientConfig("unknown"));

expect(result.current).toEqual({});
});

test("returns stable reference across re-renders", () => {
document.body.innerHTML = `
<script id="__appkit__" type="application/json">
{"appName":"app","queries":{},"endpoints":{},"plugins":{"genie":{"spaceId":"s1"}}}
</script>
`;

const { result, rerender } = renderHook(() =>
usePluginClientConfig("genie"),
);

const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});
Loading
Loading