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 = ({