Skip to content

Commit 3f613b6

Browse files
authored
feat: add app router and build info endpoint (#78)
* feat: add app router and build info endpoint - Introduced a new router for application-related endpoints, including a `/build-info` endpoint to serve current build information from a generated JSON file. - Implemented a Vite plugin to generate `build-info.json` during the build process, containing a unique build hash and timestamp. - Added a `BuildVersionCheck` component in the frontend to monitor build version changes and refresh the application when a new version is detected. - Updated existing routers to include necessary imports for the new functionality. * refactor: remove runtime config generation and implement API for app configuration - Removed the runtime configuration generation from the startup script, streamlining the application startup process. - Added a new endpoint `/config` in the app router to serve application configuration, fetching values from environment variables. - Updated the frontend to retrieve configuration dynamically from the new API instead of relying on a static runtime config file. - Refactored the PostHog initialization to use the fetched configuration, improving analytics setup.
1 parent b3963a0 commit 3f613b6

File tree

14 files changed

+306
-47
lines changed

14 files changed

+306
-47
lines changed

scripts/startup.sh

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
#!/bin/bash
22
set -e
33

4-
# Create runtime config with environment variables
5-
mkdir -p /app/frontend/dist/assets
6-
cat > /app/frontend/dist/assets/runtime-config.js <<EOL
7-
window.RUNTIME_CONFIG = {
8-
CODER_URL: "${CODER_URL}",
9-
VITE_PUBLIC_POSTHOG_KEY: "${VITE_PUBLIC_POSTHOG_KEY}",
10-
VITE_PUBLIC_POSTHOG_HOST: "${VITE_PUBLIC_POSTHOG_HOST}"
11-
};
12-
EOL
13-
144
# Start the application
155
exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers $API_WORKERS

src/backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from routers.workspace_router import workspace_router
1818
from routers.pad_router import pad_router
1919
from routers.template_pad_router import template_pad_router
20+
from routers.app_router import app_router
2021
from database.service import TemplatePadService
2122
from database.database import async_session, run_migrations_with_lock
2223

@@ -140,6 +141,7 @@ async def read_root(request: Request, auth: Optional[UserSession] = Depends(opti
140141
app.include_router(workspace_router, prefix="/api/workspace")
141142
app.include_router(pad_router, prefix="/api/pad")
142143
app.include_router(template_pad_router, prefix="/api/templates")
144+
app.include_router(app_router, prefix="/api/app")
143145

144146
if __name__ == "__main__":
145147
import uvicorn

src/backend/routers/app_router.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
import json
3+
import time
4+
5+
from fastapi import APIRouter
6+
from config import STATIC_DIR
7+
8+
app_router = APIRouter()
9+
10+
@app_router.get("/build-info")
11+
async def get_build_info():
12+
"""
13+
Return the current build information from the static assets
14+
"""
15+
try:
16+
# Read the build-info.json file that will be generated during build
17+
build_info_path = os.path.join(STATIC_DIR, "build-info.json")
18+
with open(build_info_path, 'r') as f:
19+
build_info = json.load(f)
20+
return build_info
21+
except Exception as e:
22+
# Return a default response if file doesn't exist
23+
print(f"Error reading build-info.json: {str(e)}")
24+
return {"buildHash": "development", "timestamp": int(time.time())}
25+
26+
@app_router.get("/config")
27+
async def get_app_config():
28+
"""
29+
Return runtime configuration for the frontend
30+
"""
31+
return {
32+
"coderUrl": os.getenv("CODER_URL", ""),
33+
"posthogKey": os.getenv("VITE_PUBLIC_POSTHOG_KEY", ""),
34+
"posthogHost": os.getenv("VITE_PUBLIC_POSTHOG_HOST", "")
35+
}

src/backend/routers/workspace_router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import os
2+
import json
3+
import time
24

35
from pydantic import BaseModel
46
from fastapi import APIRouter, Depends, HTTPException
57
from fastapi.responses import JSONResponse
68

79
from dependencies import UserSession, require_auth, get_coder_api
810
from coder import CoderAPI
11+
from config import STATIC_DIR
912

1013
workspace_router = APIRouter()
1114

src/frontend/index.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
name="viewport"
77
content="width=device-width, initial-scale=1, shrink-to-fit=no"
88
/>
9-
<meta name="theme-color" content="#000000" />
9+
<meta name="theme-color" content="#untime0" />
1010

1111
<title>Pad.ws</title>
1212
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
13-
<script src="/assets/runtime-config.js"></script>
1413
</head>
1514

1615
<body>

src/frontend/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type * as TExcalidraw from "@atyrode/excalidraw";
1515

1616
import App from "./src/App";
1717
import AuthGate from "./src/AuthGate";
18+
import { BuildVersionCheck } from "./src/BuildVersionCheck";
1819

1920

2021
declare global {
@@ -31,6 +32,7 @@ async function initApp() {
3132
<StrictMode>
3233
<PostHogProvider client={posthog}>
3334
<QueryClientProvider client={queryClient}>
35+
<BuildVersionCheck />
3436
<AuthGate>
3537
<App
3638
useCustom={(api: any, args?: any[]) => { }}

src/frontend/src/AuthGate.tsx

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useRef, useState } from "react";
22
import { useAuthCheck } from "./api/hooks";
3+
import { getAppConfig } from "./api/configService";
34

45
/**
56
* If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it.
@@ -19,26 +20,42 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
1920
useEffect(() => {
2021
// Only run the Coder OIDC priming once per session, after auth is confirmed
2122
if (isAuthenticated === true && !coderAuthDone) {
22-
const iframe = document.createElement("iframe");
23-
iframe.style.display = "none";
24-
// Use runtime config if available, fall back to import.meta.env
25-
const coderUrl = window.RUNTIME_CONFIG?.CODER_URL || import.meta.env.CODER_URL;
26-
iframe.src = `${coderUrl}/api/v2/users/oidc/callback`;
27-
console.debug(`[pad.ws] (Silently) Priming Coder OIDC session for ${coderUrl}`);
23+
const setupIframe = async () => {
24+
try {
25+
// Get config from API
26+
const config = await getAppConfig();
27+
28+
if (!config.coderUrl) {
29+
console.warn('[pad.ws] Coder URL not found, skipping OIDC priming');
30+
setCoderAuthDone(true);
31+
return;
32+
}
33+
34+
const iframe = document.createElement("iframe");
35+
iframe.style.display = "none";
36+
iframe.src = `${config.coderUrl}/api/v2/users/oidc/callback`;
37+
console.debug(`[pad.ws] (Silently) Priming Coder OIDC session for ${config.coderUrl}`);
2838

29-
// Remove iframe as soon as it loads, or after 2s fallback
30-
const cleanup = () => {
31-
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
32-
setCoderAuthDone(true);
33-
};
34-
35-
iframe.onload = cleanup;
36-
document.body.appendChild(iframe);
37-
iframeRef.current = iframe;
39+
// Remove iframe as soon as it loads, or after fallback timeout
40+
const cleanup = () => {
41+
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
42+
setCoderAuthDone(true);
43+
};
3844

39-
// Fallback: remove iframe after 5s if onload doesn't fire
40-
timeoutRef.current = window.setTimeout(cleanup, 5000);
45+
iframe.onload = cleanup;
46+
document.body.appendChild(iframe);
47+
iframeRef.current = iframe;
4148

49+
// Fallback: remove iframe after 5s if onload doesn't fire
50+
timeoutRef.current = window.setTimeout(cleanup, 5000);
51+
} catch (error) {
52+
console.error('[pad.ws] Error setting up Coder OIDC priming:', error);
53+
setCoderAuthDone(true);
54+
}
55+
};
56+
57+
setupIframe();
58+
4259
// Cleanup on unmount or re-run
4360
return () => {
4461
if (iframeRef.current && iframeRef.current.parentNode) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useEffect, useState, useCallback } from 'react';
2+
import { useBuildInfo, useSaveCanvas } from './api/hooks';
3+
import { saveCurrentCanvas } from './utils/canvasUtils';
4+
5+
/**
6+
* Component that checks for application version changes and refreshes the page when needed.
7+
* This component doesn't render anything visible.
8+
*/
9+
export function BuildVersionCheck() {
10+
// Store the initial build hash when the component first loads
11+
const [initialBuildHash, setInitialBuildHash] = useState<string | null>(null);
12+
13+
// Query for the current build info from the server
14+
const { data: buildInfo } = useBuildInfo();
15+
16+
// Get the saveCanvas mutation
17+
const { mutate: saveCanvas } = useSaveCanvas({
18+
onSuccess: () => {
19+
console.debug("[pad.ws] Canvas saved before refresh");
20+
// Refresh the page immediately after saving
21+
window.location.reload();
22+
},
23+
onError: (error) => {
24+
console.error("[pad.ws] Failed to save canvas before refresh:", error);
25+
// Refresh anyway even if save fails
26+
window.location.reload();
27+
}
28+
});
29+
30+
// Function to handle version update
31+
const handleVersionUpdate = useCallback(() => {
32+
// Save the canvas and then refresh
33+
saveCurrentCanvas(
34+
saveCanvas,
35+
undefined, // No success callback needed as it's handled in the useSaveCanvas hook
36+
() => window.location.reload() // On error, just refresh
37+
);
38+
}, [saveCanvas]);
39+
40+
useEffect(() => {
41+
// On first load, store the initial build hash
42+
if (buildInfo?.buildHash && initialBuildHash === null) {
43+
console.log('Initial build hash:', buildInfo.buildHash);
44+
setInitialBuildHash(buildInfo.buildHash);
45+
}
46+
47+
// If we have both values and they don't match, a new version is available
48+
if (initialBuildHash !== null &&
49+
buildInfo?.buildHash &&
50+
initialBuildHash !== buildInfo.buildHash) {
51+
52+
console.log('New version detected. Current:', initialBuildHash, 'New:', buildInfo.buildHash);
53+
54+
// Save the canvas and then refresh
55+
handleVersionUpdate();
56+
}
57+
}, [buildInfo, initialBuildHash, handleVersionUpdate]);
58+
59+
// This component doesn't render anything
60+
return null;
61+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { fetchApi } from './apiUtils';
2+
3+
/**
4+
* Application configuration interface
5+
*/
6+
export interface AppConfig {
7+
coderUrl: string;
8+
posthogKey: string;
9+
posthogHost: string;
10+
}
11+
12+
// Cache the config to avoid unnecessary API calls
13+
let cachedConfig: AppConfig | null = null;
14+
15+
/**
16+
* Get the application configuration from the API
17+
* @returns The application configuration
18+
*/
19+
export async function getAppConfig(): Promise<AppConfig> {
20+
// Return cached config if available
21+
if (cachedConfig) {
22+
return cachedConfig;
23+
}
24+
25+
try {
26+
// Fetch config from API
27+
const config = await fetchApi('/api/app/config');
28+
cachedConfig = config;
29+
return config;
30+
} catch (error) {
31+
console.error('[pad.ws] Failed to load application configuration:', error);
32+
// Return default values as fallback
33+
return {
34+
coderUrl: '',
35+
posthogKey: '',
36+
posthogHost: ''
37+
};
38+
}
39+
}

src/frontend/src/api/hooks.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export interface CanvasBackupsResponse {
3939
backups: CanvasBackup[];
4040
}
4141

42+
export interface BuildInfo {
43+
buildHash: string;
44+
timestamp: number;
45+
}
46+
4247
// API functions
4348
export const api = {
4449
// Authentication
@@ -134,6 +139,16 @@ export const api = {
134139
throw error;
135140
}
136141
},
142+
143+
// Build Info
144+
getBuildInfo: async (): Promise<BuildInfo> => {
145+
try {
146+
const result = await fetchApi('/api/app/build-info');
147+
return result;
148+
} catch (error) {
149+
throw error;
150+
}
151+
},
137152
};
138153

139154
// Query hooks
@@ -192,6 +207,15 @@ export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions<C
192207
});
193208
}
194209

210+
export function useBuildInfo(options?: UseQueryOptions<BuildInfo>) {
211+
return useQuery({
212+
queryKey: ['buildInfo'],
213+
queryFn: api.getBuildInfo,
214+
refetchInterval: 60000, // Check every minute
215+
...options,
216+
});
217+
}
218+
195219
// Mutation hooks
196220
export function useStartWorkspace(options?: UseMutationOptions) {
197221
return useMutation({

0 commit comments

Comments
 (0)