diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f5d0466 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +.git +.github +.husky +.vscode +.idea + +# Build + deps +node_modules +**/node_modules +dist +dist-ssr +dist-lib +src/lib/dist +src/lib/*.tgz +docs-site/dist +web/dist +tsconfig.tsbuildinfo + +# Tests / reports +playwright-report +test-results +coverage +benchmarks/results + +# Env / secrets +.env +.env.* +!.env.example + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# OS / misc +.DS_Store +*.suo +*.sw? +diff.patch diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..29dc832 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1.7 + +# ---------- Build stage ---------- +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy manifests first for better layer caching. +# Workspace manifests are required because the root package declares them. +COPY package.json package-lock.json ./ +COPY pnpm-workspace.yaml ./ +COPY web/package.json ./web/package.json +COPY docs-site/package.json ./docs-site/package.json + +# CI avoids running the husky prepare hook. +ENV CI=1 + +RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ + npm ci --no-audit --no-fund + +COPY . . + +RUN npm run build + +# ---------- Runtime stage ---------- +FROM nginx:1.27-alpine AS runtime + +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 3045 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3045/ >/dev/null || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 1eafadd..4a60b43 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,16 @@ npm run build # upload dist/ to your provider ``` -No database. No secrets. No infrastructure. One folder. +**Docker:** + +```bash +docker build -t openflowkit . +docker run --rm -p 3045:3045 openflowkit +``` + +Open [http://localhost:3045](http://localhost:3045). The container serves the production build with nginx, SPA route fallback, long-lived caching for hashed assets, and the same security headers used by the static hosting path. + +No database. No secrets. No infrastructure. One folder, or one container. --- diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..1ada7d7 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,59 @@ +server { + listen 3045; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.openai.com https://*.anthropic.com https://*.googleapis.com https://api.groq.com https://*.mistral.ai https://*.cerebras.ai https://openrouter.ai https://*.posthog.com wss://*.openflowkit.com wss://signaling.yjs.dev; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; frame-ancestors 'none';" always; + + location /assets/ { + access_log off; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.openai.com https://*.anthropic.com https://*.googleapis.com https://api.groq.com https://*.mistral.ai https://*.cerebras.ai https://openrouter.ai https://*.posthog.com wss://*.openflowkit.com wss://signaling.yjs.dev; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; frame-ancestors 'none';" always; + try_files $uri =404; + } + + location = /index.html { + add_header Cache-Control "no-store" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.openai.com https://*.anthropic.com https://*.googleapis.com https://api.groq.com https://*.mistral.ai https://*.cerebras.ai https://openrouter.ai https://*.posthog.com wss://*.openflowkit.com wss://signaling.yjs.dev; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; frame-ancestors 'none';" always; + } + + location / { + add_header Cache-Control "no-store" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.openai.com https://*.anthropic.com https://*.googleapis.com https://api.groq.com https://*.mistral.ai https://*.cerebras.ai https://openrouter.ai https://*.posthog.com wss://*.openflowkit.com wss://signaling.yjs.dev; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; frame-ancestors 'none';" always; + try_files $uri $uri/ /index.html; + } +} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index d118710..ca55cc8 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -138,10 +138,6 @@ export function ContextMenu({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [onClose]); - useEffect(() => { - setMenuPosition(position); - }, [position]); - useLayoutEffect(() => { const menu = menuRef.current; if (!menu) { @@ -158,8 +154,7 @@ export function ContextMenu({ if (nextPosition.x !== menuPosition.x || nextPosition.y !== menuPosition.y) { setMenuPosition(nextPosition); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [menuPosition.x, menuPosition.y, position.x, position.y, type]); + }, [menuPosition.x, menuPosition.y, position, type]); return (
({ }), })); +vi.mock('@/hooks/useProviderShapePreview', () => ({ + useProviderShapePreview: ( + packId: string | undefined, + shapeId: string | undefined, + customIconUrl: string | undefined + ) => customIconUrl ?? (packId && shapeId ? `/provider-icons/${packId}/${shapeId}.svg` : null), +})); + vi.mock('@/lib/reactflowCompat', async (importOriginal) => { const actual = await importOriginal(); @@ -130,6 +138,33 @@ describe('CustomNode handle interaction policy', () => { expect(diagnosticsNode?.getAttribute('data-transform-family')).toBe('custom'); }); + it('renders provider icon previews on generic nodes', () => { + render( + + ); + + expect(screen.getByRole('img', { name: 'icon' })).toHaveAttribute( + 'src', + '/provider-icons/aws-official-starter-v1/compute-lambda.svg' + ); + }); + it('shows an empty-shape prompt instead of seeded fallback text', () => { currentNodeId = 'n4'; render( diff --git a/src/components/CustomNode.tsx b/src/components/CustomNode.tsx index 53e9aa5..864462c 100644 --- a/src/components/CustomNode.tsx +++ b/src/components/CustomNode.tsx @@ -106,7 +106,8 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { const subLabelIsNumericSize = !isNaN(Number(subLabelFontSize)); const subLabelSizeClass = fontSizeClassFor(subLabelFontSize); const subLabelFontSizeStyle = subLabelIsNumericSize ? { fontSize: subLabelFontSize + 'px' } : {}; - const hasIcon = Boolean(iconName) || Boolean(data.customIconUrl); + const hasProviderIcon = Boolean(resolvedAssetIconUrl) || Boolean(data.archIconPackId); + const hasIcon = Boolean(iconName) || Boolean(data.customIconUrl) || hasProviderIcon; const hasLabel = Boolean(data.label?.trim()); const hasSubLabel = Boolean(data.subLabel); const mermaidImportedNodeMetadata = readMermaidImportedNodeMetadataFromData(data); diff --git a/src/components/properties/IconPicker.tsx b/src/components/properties/IconPicker.tsx index 36c52e0..f47c8f1 100644 --- a/src/components/properties/IconPicker.tsx +++ b/src/components/properties/IconPicker.tsx @@ -89,30 +89,16 @@ export const IconPicker: React.FC = ({ onCustomIconChange, }) => { const [iconSearch, setIconSearch] = useState(''); - const [iconSource, setIconSource] = useState( - getInitialSource(selectedProviderPackId, selectedProviderShapeId, customIconUrl) - ); - const [provider, setProvider] = useState( + const [userIconSource, setUserIconSource] = useState(null); + const [userProvider, setUserProvider] = useState(null); + const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId); + const iconSource = userIconSource ?? getInitialSource(selectedProviderPackId, selectedProviderShapeId, customIconUrl); + const provider = selectedProvider - ?? inferAssetProviderFromPackId(selectedProviderPackId) + ?? inferredProvider + ?? userProvider ?? (PROVIDER_OPTIONS[0]?.value as DomainLibraryCategory) - ?? 'aws' - ); - - useEffect(() => { - setIconSource(getInitialSource(selectedProviderPackId, selectedProviderShapeId, customIconUrl)); - }, [selectedProviderPackId, selectedProviderShapeId, customIconUrl]); - - useEffect(() => { - if (selectedProvider) { - setProvider(selectedProvider); - return; - } - const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId); - if (inferredProvider) { - setProvider(inferredProvider); - } - }, [selectedProvider, selectedProviderPackId]); + ?? 'aws'; const filteredIcons = useMemo(() => { const term = iconSearch.toLowerCase(); @@ -197,7 +183,7 @@ export const IconPicker: React.FC = ({ columns={3} size="sm" selectedId={iconSource} - onSelect={(value) => setIconSource(value as IconSource)} + onSelect={(value) => setUserIconSource(value as IconSource)} items={ICON_SOURCE_OPTIONS.map((option) => ({ id: option.id, label: option.label }))} /> @@ -255,7 +241,7 @@ export const IconPicker: React.FC = ({