Skip to content
Merged
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
41 changes: 41 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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;"]
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
59 changes: 59 additions & 0 deletions nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
7 changes: 1 addition & 6 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,6 @@ export function ContextMenu({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);

useEffect(() => {
setMenuPosition(position);
}, [position]);

useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) {
Expand All @@ -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 (
<div
Expand Down
35 changes: 35 additions & 0 deletions src/components/CustomNode.handleInteraction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ vi.mock('react-i18next', () => ({
}),
}));

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<typeof import('@/lib/reactflowCompat')>();

Expand Down Expand Up @@ -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(
<CustomNode
id="n-provider-icon"
type="process"
selected={false}
dragging={false}
zIndex={1}
data={{
label: 'Lambda',
archIconPackId: 'aws-official-starter-v1',
archIconShapeId: 'compute-lambda',
}}
isConnectable={true}
xPos={0}
yPos={0}
sourcePosition={Position.Right}
targetPosition={Position.Left}
/>
);

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(
Expand Down
3 changes: 2 additions & 1 deletion src/components/CustomNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ function CustomNode(props: LegacyNodeProps<NodeData>): 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);
Expand Down
34 changes: 10 additions & 24 deletions src/components/properties/IconPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,30 +89,16 @@ export const IconPicker: React.FC<IconPickerProps> = ({
onCustomIconChange,
}) => {
const [iconSearch, setIconSearch] = useState('');
const [iconSource, setIconSource] = useState<IconSource>(
getInitialSource(selectedProviderPackId, selectedProviderShapeId, customIconUrl)
);
const [provider, setProvider] = useState<DomainLibraryCategory>(
const [userIconSource, setUserIconSource] = useState<IconSource | null>(null);
const [userProvider, setUserProvider] = useState<DomainLibraryCategory | null>(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();
Expand Down Expand Up @@ -197,7 +183,7 @@ export const IconPicker: React.FC<IconPickerProps> = ({
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 }))}
/>

Expand Down Expand Up @@ -255,7 +241,7 @@ export const IconPicker: React.FC<IconPickerProps> = ({
<div className="space-y-3">
<Select
value={provider}
onChange={(value) => setProvider(value as DomainLibraryCategory)}
onChange={(value) => setUserProvider(value as DomainLibraryCategory)}
options={PROVIDER_OPTIONS}
placeholder="Choose provider"
/>
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useSnapshots.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { FlowNode, FlowEdge, FlowSnapshot } from '@/lib/types';
import { createId } from '@/lib/id';
import { loadSnapshots, saveSnapshots } from '@/services/storage/snapshotStorage';
import {
SNAPSHOT_KIND_AUTO,
Expand Down Expand Up @@ -64,7 +65,7 @@ export const useSnapshots = () => {

const timestamp = new Date(now).toISOString();
const autoSnapshot: FlowSnapshot = {
id: crypto.randomUUID(),
id: createId('snapshot'),
name: buildSnapshotName(SNAPSHOT_KIND_AUTO, timestamp),
timestamp,
kind: SNAPSHOT_KIND_AUTO,
Expand Down Expand Up @@ -111,7 +112,7 @@ export const useSnapshots = () => {
const saveSnapshot = useCallback((name: string, nodes: FlowNode[], edges: FlowEdge[]) => {
const timestamp = new Date().toISOString();
const newSnapshot: FlowSnapshot = {
id: crypto.randomUUID(),
id: createId('snapshot'),
name,
timestamp,
kind: SNAPSHOT_KIND_MANUAL,
Expand Down
41 changes: 41 additions & 0 deletions src/lib/id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createId } from './id';

const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');

function restoreCrypto(): void {
if (originalCryptoDescriptor) {
Object.defineProperty(globalThis, 'crypto', originalCryptoDescriptor);
return;
}

Reflect.deleteProperty(globalThis, 'crypto');
}

describe('createId', () => {
afterEach(() => {
vi.restoreAllMocks();
restoreCrypto();
});

it('uses native randomUUID when available', () => {
const randomUUID = vi.fn(() => 'native-id');
Object.defineProperty(globalThis, 'crypto', {
value: { randomUUID },
configurable: true,
});

expect(createId('node')).toBe('node-native-id');
expect(randomUUID).toHaveBeenCalledOnce();
});

it('falls back when randomUUID is unavailable in insecure contexts', () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
Object.defineProperty(globalThis, 'crypto', {
value: {},
configurable: true,
});

expect(createId()).toBe('00000000-0000-4000-8000-000000000000');
});
});
14 changes: 12 additions & 2 deletions src/lib/id.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
function createFallbackUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (token) => {
const randomNibble = Math.floor(Math.random() * 16);
const value = token === 'x' ? randomNibble : (randomNibble & 0x3) | 0x8;
return value.toString(16);
});
}

export function createId(prefix?: string): string {
const id = crypto.randomUUID();
const randomUUID = globalThis.crypto?.randomUUID;
const id = typeof randomUUID === 'function'
? randomUUID.call(globalThis.crypto)
: createFallbackUuid();
return prefix ? `${prefix}-${id}` : id;
}

Loading