mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-12-01 11:57:53 +08:00
Merge pull request #36 from AppFlowy-IO/dynamic_env
chore: support inject env in runtime
This commit is contained in:
82
.github/workflows/web_ci.yaml
vendored
82
.github/workflows/web_ci.yaml
vendored
@@ -65,3 +65,85 @@ jobs:
|
||||
name: stats.html
|
||||
path: dist/stats.html
|
||||
retention-days: 30
|
||||
|
||||
docker-runtime-test:
|
||||
if: github.event.pull_request.draft != true
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image (runtime env injection test)
|
||||
run: |
|
||||
docker build -t appflowy-web-test:ci -f docker/Dockerfile .
|
||||
|
||||
- name: Test container fails without required env vars
|
||||
run: |
|
||||
set -e
|
||||
echo "Testing container fails without environment variables..."
|
||||
OUTPUT=$(docker run --rm appflowy-web-test:ci 2>&1 || true)
|
||||
echo "$OUTPUT" | grep -q "ERROR: AF_BASE_URL environment variable is required" || (echo "ERROR: Container should fail without env vars" && exit 1)
|
||||
echo "✓ Container correctly fails without required env vars"
|
||||
|
||||
- name: Run container with injected env vars
|
||||
run: |
|
||||
docker run -d --rm --name appflowy-web-test -p 8080:80 \
|
||||
-e AF_BASE_URL=https://ci-backend.example.com \
|
||||
-e AF_GOTRUE_URL=https://ci-backend.example.com/gotrue \
|
||||
-e AF_WS_V2_URL=wss://ci-backend.example.com/ws/v2 \
|
||||
appflowy-web-test:ci
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://localhost:8080/ >/dev/null 2>&1; then
|
||||
echo "Server is up"; break; fi; sleep 1; done
|
||||
curl -fsS http://localhost:8080/ >/dev/null
|
||||
|
||||
- name: Verify injected runtime config in index.html
|
||||
run: |
|
||||
set -e
|
||||
echo "Fetching HTML content..."
|
||||
HTML=$(curl -fsS http://localhost:8080/)
|
||||
|
||||
echo "Checking for __APP_CONFIG__ injection..."
|
||||
echo "$HTML" | grep -q "window.__APP_CONFIG__" || (echo "ERROR: Missing window.__APP_CONFIG__" && exit 1)
|
||||
echo "✓ Found window.__APP_CONFIG__"
|
||||
|
||||
echo "Verifying injected values..."
|
||||
# Note: The config is injected as a single line with format: {AF_BASE_URL:'value',AF_GOTRUE_URL:'value',AF_WS_V2_URL:'value'}
|
||||
echo "$HTML" | grep -q "AF_BASE_URL:'https://ci-backend.example.com'" || (echo "ERROR: AF_BASE_URL not correctly injected" && exit 1)
|
||||
echo "✓ AF_BASE_URL correctly injected"
|
||||
|
||||
echo "$HTML" | grep -q "AF_GOTRUE_URL:'https://ci-backend.example.com/gotrue'" || (echo "ERROR: AF_GOTRUE_URL not correctly injected" && exit 1)
|
||||
echo "✓ AF_GOTRUE_URL correctly injected"
|
||||
|
||||
echo "$HTML" | grep -q "AF_WS_V2_URL:'wss://ci-backend.example.com/ws/v2'" || (echo "ERROR: AF_WS_V2_URL not correctly injected" && exit 1)
|
||||
echo "✓ AF_WS_V2_URL correctly injected"
|
||||
|
||||
echo "All runtime configuration values verified successfully!"
|
||||
|
||||
- name: Verify config format and structure
|
||||
run: |
|
||||
set -e
|
||||
echo "Extracting and verifying configuration structure..."
|
||||
|
||||
# Extract the config object from HTML
|
||||
CONFIG=$(curl -fsS http://localhost:8080/ | grep -o "window.__APP_CONFIG__={[^}]*}")
|
||||
echo "Extracted config: $CONFIG"
|
||||
|
||||
# Verify it's a valid JavaScript object format
|
||||
echo "$CONFIG" | grep -q "window.__APP_CONFIG__={AF_BASE_URL:'[^']*',AF_GOTRUE_URL:'[^']*',AF_WS_V2_URL:'[^']*'}" || \
|
||||
(echo "ERROR: Config format is invalid" && exit 1)
|
||||
|
||||
echo "✓ Configuration format and structure verified"
|
||||
|
||||
- name: Show container logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker logs appflowy-web-test || true
|
||||
|
||||
- name: Cleanup container
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f appflowy-web-test || true
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,4 +33,5 @@ coverage
|
||||
.nyc_output
|
||||
|
||||
cypress/snapshots/**/__diff_output__/
|
||||
**/.claude
|
||||
**/.claude
|
||||
cypress/screenshots
|
||||
@@ -1,3 +1,3 @@
|
||||
AF_BASE_URL=http://localhost
|
||||
AF_GOTRUE_URL=http://localhost/gotrue
|
||||
AF_WS_URL=ws://localhost/ws/v1
|
||||
AF_WS_V2_URL=ws://localhost/ws/v2
|
||||
1
dev.env
1
dev.env
@@ -1,4 +1,3 @@
|
||||
AF_BASE_URL=http://localhost:8000
|
||||
AF_GOTRUE_URL=http://localhost:9999
|
||||
AF_WS_URL=ws://localhost:8000/ws/v1
|
||||
AF_WS_V2_URL=ws://localhost:8000/ws/v2
|
||||
@@ -9,10 +9,14 @@ COPY . .
|
||||
RUN corepack enable
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
RUN sed -i 's|https://test.appflowy.cloud||g' src/components/main/app.hooks.ts
|
||||
# Build without environment variables - they will be injected at runtime
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html/
|
||||
COPY --from=builder /app/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /app/docker/entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Set the entrypoint
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
14
docker/docker-compose.example.yml
Normal file
14
docker/docker-compose.example.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
appflowy-web:
|
||||
image: appflowy/appflowy_web:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
# REQUIRED: Configure these to match your AppFlowy backend URLs
|
||||
# The container will fail to start if these are not set
|
||||
- AF_BASE_URL=https://your-backend.example.com
|
||||
- AF_GOTRUE_URL=https://your-backend.example.com/gotrue
|
||||
- AF_WS_V2_URL=wss://your-backend.example.com/ws/v2
|
||||
restart: unless-stopped
|
||||
35
docker/entrypoint.sh
Executable file
35
docker/entrypoint.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Check required environment variables
|
||||
if [ -z "${AF_BASE_URL}" ]; then
|
||||
echo "ERROR: AF_BASE_URL environment variable is required but not set"
|
||||
echo "Please set AF_BASE_URL to your AppFlowy backend URL (e.g., https://your-backend.example.com)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${AF_GOTRUE_URL}" ]; then
|
||||
echo "ERROR: AF_GOTRUE_URL environment variable is required but not set"
|
||||
echo "Please set AF_GOTRUE_URL to your GoTrue authentication service URL (e.g., https://your-backend.example.com/gotrue)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${AF_WS_V2_URL}" ]; then
|
||||
echo "ERROR: AF_WS_V2_URL environment variable is required but not set"
|
||||
echo "Please set AF_WS_V2_URL to your WebSocket v2 URL (e.g., wss://your-backend.example.com/ws/v2)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create inline config script
|
||||
CONFIG_SCRIPT="<script>window.__APP_CONFIG__={AF_BASE_URL:'${AF_BASE_URL}',AF_GOTRUE_URL:'${AF_GOTRUE_URL}',AF_WS_V2_URL:'${AF_WS_V2_URL}'};</script>"
|
||||
|
||||
# Inject the config script into index.html right before </head>
|
||||
sed -i "s|</head>|${CONFIG_SCRIPT}</head>|g" /usr/share/nginx/html/index.html
|
||||
|
||||
echo "Runtime configuration injected:"
|
||||
echo " AF_BASE_URL: ${AF_BASE_URL}"
|
||||
echo " AF_GOTRUE_URL: ${AF_GOTRUE_URL}"
|
||||
echo " AF_WS_V2_URL: ${AF_WS_V2_URL}"
|
||||
|
||||
# Start nginx
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -13,6 +13,7 @@ import { ReactComponent as DownloadIcon } from '@/assets/icons/save_as.svg';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { copyTextToClipboard } from '@/utils/copy';
|
||||
import isURL from 'validator/lib/isURL';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
|
||||
export interface GalleryImage {
|
||||
src: string;
|
||||
@@ -101,7 +102,7 @@ function GalleryPreview({ images, open, onClose, previewIndex, workspaceId }: Ga
|
||||
|
||||
const fileId = images[index].src;
|
||||
|
||||
return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
}, [images, index, workspaceId]);
|
||||
|
||||
if (!open) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import isURL from 'validator/lib/isURL';
|
||||
|
||||
import { useDatabaseContext } from '@/application/database-yjs';
|
||||
import { FileMediaCellDataItem } from '@/application/database-yjs/cell.type';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
|
||||
function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick: () => void }) {
|
||||
const { workspaceId } = useDatabaseContext();
|
||||
@@ -11,7 +12,7 @@ function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick:
|
||||
let fileUrl = file.url;
|
||||
|
||||
if (!isURL(file.url)) {
|
||||
fileUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + file.url;
|
||||
fileUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + file.url;
|
||||
}
|
||||
|
||||
const url = new URL(fileUrl);
|
||||
|
||||
@@ -6,6 +6,7 @@ import FileIcon from '@/components/database/components/cell/file-media/FileIcon'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { openUrl } from '@/utils/url';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
|
||||
function UnPreviewFile({ file }: { file: FileMediaCellDataItem }) {
|
||||
const { workspaceId } = useDatabaseContext();
|
||||
@@ -25,7 +26,7 @@ function UnPreviewFile({ file }: { file: FileMediaCellDataItem }) {
|
||||
}
|
||||
|
||||
const fileId = file.url;
|
||||
const newUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
const newUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
|
||||
void openUrl(newUrl, '_blank');
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import FileMediaMore from '@/components/database/components/cell/file-media/File
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openUrl } from '@/utils/url';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
|
||||
function FileMediaItem({
|
||||
file,
|
||||
@@ -53,7 +54,7 @@ function FileMediaItem({
|
||||
|
||||
const fileId = file.url;
|
||||
|
||||
return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
}, [file.url, workspaceId]);
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
@@ -70,7 +71,7 @@ function FileMediaItem({
|
||||
}
|
||||
|
||||
const fileId = file.url;
|
||||
const newUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
const newUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
|
||||
void openUrl(newUrl, '_blank');
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EditorElementProps, FileNode } from '@/components/editor/editor.type';
|
||||
import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { FileHandler } from '@/utils/file';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
import { openUrl } from '@/utils/url';
|
||||
|
||||
export const FileBlock = memo(
|
||||
@@ -41,7 +42,7 @@ export const FileBlock = memo(
|
||||
|
||||
const fileId = dataUrl;
|
||||
|
||||
return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
}, [dataUrl, workspaceId]);
|
||||
|
||||
const className = useMemo(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import { GalleryPreview } from '@/components/_shared/gallery-preview';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { copyTextToClipboard } from '@/utils/copy';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
|
||||
const GalleryBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<GalleryBlockNode>>(({ node, children, ...attributes }, ref) => {
|
||||
@@ -34,7 +35,7 @@ const GalleryBlock = memo(
|
||||
let imageUrl = image.url;
|
||||
|
||||
if (!isURL(image.url)) {
|
||||
imageUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + image.url;
|
||||
imageUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + image.url;
|
||||
}
|
||||
|
||||
const url = new URL(imageUrl);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FileHandler } from '@/utils/file';
|
||||
|
||||
import ImageEmpty from './ImageEmpty';
|
||||
import ImageRender from './ImageRender';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
|
||||
export const ImageBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<ImageBlockNode>>(({ node, children, ...attributes }, ref) => {
|
||||
@@ -43,7 +44,7 @@ export const ImageBlock = memo(
|
||||
|
||||
const fileId = dataUrl;
|
||||
|
||||
return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
|
||||
}, [dataUrl, workspaceId]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||
import { User } from '@/application/types';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
|
||||
const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
|
||||
const wsURL = import.meta.env.AF_WS_URL || 'wss://test.appflowy.cloud/ws/v1';
|
||||
const baseURL = getConfigValue('AF_BASE_URL', 'https://test.appflowy.cloud');
|
||||
const gotrueURL = getConfigValue('AF_GOTRUE_URL', 'https://test.appflowy.cloud/gotrue');
|
||||
|
||||
export const defaultConfig: AFServiceConfig = {
|
||||
cloudConfig: {
|
||||
baseURL,
|
||||
gotrueURL,
|
||||
wsURL,
|
||||
wsURL: '', // Legacy field - not used, keeping for backward compatibility
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
import { getTokenParsed } from '@/application/session/token';
|
||||
import { getConfigValue } from '@/utils/runtime-config';
|
||||
import { messages } from '@/proto/messages';
|
||||
|
||||
const wsURL = import.meta.env.AF_WS_V2_URL || 'ws://localhost:8000/ws/v2';
|
||||
const wsURL = getConfigValue('AF_WS_V2_URL', 'ws://localhost:8000/ws/v2');
|
||||
|
||||
// WebSocket close code enum
|
||||
enum CloseCode {
|
||||
|
||||
31
src/utils/runtime-config.ts
Normal file
31
src/utils/runtime-config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
interface RuntimeConfig {
|
||||
AF_BASE_URL?: string;
|
||||
AF_GOTRUE_URL?: string;
|
||||
AF_WS_V2_URL?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: RuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigValue(key: keyof RuntimeConfig, defaultValue: string): string {
|
||||
// First check runtime config (injected by Docker entrypoint)
|
||||
if (typeof window !== 'undefined' && window.__APP_CONFIG__) {
|
||||
const runtimeValue = window.__APP_CONFIG__[key];
|
||||
|
||||
if (runtimeValue && !runtimeValue.startsWith('${')) {
|
||||
return runtimeValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to build-time environment variables from Vite
|
||||
const envValue = import.meta.env[key];
|
||||
|
||||
if (envValue) {
|
||||
return envValue;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
Reference in New Issue
Block a user