diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 15ade00..389e7e6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -130,6 +130,7 @@ Current behavior at a high level: - IndexedDB store and index definitions are now declared in one schema manifest in `src/services/storage/indexedDbSchema.ts` - schema migration markers now live in a dedicated IndexedDB schema metadata store instead of sharing the persisted Zustand state store - local-first chat persistence now uses document-scoped IndexedDB indexes instead of full chat-message store scans +- user images/icons can be stored by content-hash ref in the IndexedDB `assets` store (`assetStoreV1` rollout flag) instead of embedding multi-MB data URLs into every document/history/snapshot copy; nodes hold `imageAssetId` / `iconAssetId` and resolve display URLs at render time Important constraint: diff --git a/src/components/CustomNode.tsx b/src/components/CustomNode.tsx index 864462c..18f8ecd 100644 --- a/src/components/CustomNode.tsx +++ b/src/components/CustomNode.tsx @@ -12,6 +12,7 @@ import { NodeTransformControls } from './NodeTransformControls'; import { useActiveNodeSelection } from './useActiveNodeSelection'; import { useTranslation } from 'react-i18next'; import { useProviderShapePreview } from '@/hooks/useProviderShapePreview'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { useShiftHeld } from '@/hooks/useShiftHeld'; import { NodeShapeSVG } from './NodeShapeSVG'; import { DiffBadge, LintViolationBadge } from './NodeBadges'; @@ -73,10 +74,12 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { const explicitHeightPx = getNumericNodeDimension(explicitHeight); const measuredHeight = (props as { height?: number }).height; const shiftHeld = useShiftHeld(Boolean(selected)); + const resolvedUploadedIconUrl = useResolvedMediaUrl(data, 'image'); + const resolvedCustomIconUrl = useResolvedMediaUrl(data, 'icon'); const resolvedAssetIconUrl = useProviderShapePreview( typeof data.archIconPackId === 'string' ? data.archIconPackId : undefined, typeof data.archIconShapeId === 'string' ? data.archIconShapeId : undefined, - typeof data.customIconUrl === 'string' ? data.customIconUrl : undefined + resolvedCustomIconUrl ); const designSystem = useDesignSystem(); const isActiveSelected = useActiveNodeSelection(Boolean(selected)); @@ -107,7 +110,12 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { const subLabelSizeClass = fontSizeClassFor(subLabelFontSize); const subLabelFontSizeStyle = subLabelIsNumericSize ? { fontSize: subLabelFontSize + 'px' } : {}; const hasProviderIcon = Boolean(resolvedAssetIconUrl) || Boolean(data.archIconPackId); - const hasIcon = Boolean(iconName) || Boolean(data.customIconUrl) || hasProviderIcon; + const hasIcon = + Boolean(iconName) + || Boolean(data.customIconUrl) + || Boolean(data.iconAssetId) + || Boolean(resolvedCustomIconUrl) + || hasProviderIcon; const hasLabel = Boolean(data.label?.trim()); const hasSubLabel = Boolean(data.subLabel); const mermaidImportedNodeMetadata = readMermaidImportedNodeMetadataFromData(data); @@ -294,7 +302,10 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { )} void; onAddClassNode?: () => void; onAddEntityNode?: () => void; - onAddImage: (imageUrl: string) => void; + onAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; onAddBrowserWireframe: () => void; onAddMobileWireframe: () => void; onAddDomainLibraryItem?: (item: DomainLibraryItem) => void; diff --git a/src/components/ImageNode.tsx b/src/components/ImageNode.tsx index 711ca4a..73f5094 100644 --- a/src/components/ImageNode.tsx +++ b/src/components/ImageNode.tsx @@ -1,9 +1,12 @@ import React, { memo } from 'react'; import type { LegacyNodeProps } from '@/lib/reactflowCompat'; import type { NodeData } from '@/lib/types'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { NodeChrome } from './NodeChrome'; function ImageNode({ id, data, selected }: LegacyNodeProps): React.ReactElement { + const imageSrc = useResolvedMediaUrl(data, 'image'); + return ( ): React.Rea transform: data.rotation ? `rotate(${data.rotation}deg)` : 'none', }} > - {data.imageUrl ? ( + {imageSrc ? ( {data.label diff --git a/src/components/command-bar/types.ts b/src/components/command-bar/types.ts index 17aeac0..2813735 100644 --- a/src/components/command-bar/types.ts +++ b/src/components/command-bar/types.ts @@ -61,7 +61,7 @@ export interface CommandBarProps { onAddSequence?: () => void; onAddClassNode?: () => void; onAddEntityNode?: () => void; - onAddImage?: (imageUrl: string) => void; + onAddImage?: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; onAddBrowserWireframe?: () => void; onAddMobileWireframe?: () => void; onAddDomainLibraryItem?: (item: DomainLibraryItem) => void; diff --git a/src/components/custom-nodes/ArchitectureNode.tsx b/src/components/custom-nodes/ArchitectureNode.tsx index d70621f..3496c5f 100644 --- a/src/components/custom-nodes/ArchitectureNode.tsx +++ b/src/components/custom-nodes/ArchitectureNode.tsx @@ -7,6 +7,7 @@ import { NodeChrome } from '@/components/NodeChrome'; import { getTransformDiagnosticsAttrs } from '@/components/transformDiagnostics'; import { resolveNodeVisualStyle } from '@/theme'; import { useProviderShapePreview } from '@/hooks/useProviderShapePreview'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import type { DomainLibraryCategory } from '@/services/domainLibrary'; import type { LucideIcon } from 'lucide-react'; import { @@ -57,13 +58,16 @@ function ArchitectureNode({ id, data, selected }: LegacyNodeProps): Re }; const ResourceIcon: LucideIcon = resourceIconMap[resourceType] ?? Server; const customIconUrl = typeof data.customIconUrl === 'string' ? data.customIconUrl : undefined; + const resolvedCustomIconUrl = useResolvedMediaUrl(data, 'icon'); const providerPreviewUrl = useProviderShapePreview( typeof data.archIconPackId === 'string' ? data.archIconPackId : undefined, typeof data.archIconShapeId === 'string' ? data.archIconShapeId : undefined, - customIconUrl + resolvedCustomIconUrl ?? customIconUrl ); const resolvedProviderIconUrl = - provider === 'custom' && customIconUrl ? customIconUrl : providerPreviewUrl; + provider === 'custom' && (resolvedCustomIconUrl || customIconUrl) + ? (resolvedCustomIconUrl ?? customIconUrl) + : providerPreviewUrl; return ( ): React.R const { t } = useTranslation(); const nodeColorPalette = getNodeColorPalette(true); const style = nodeColorPalette[data.color || 'slate'] || nodeColorPalette.slate; + const imageUrl = useResolvedMediaUrl(data, 'image'); return ( ): React.R {/* Content Area */} {renderBrowserVariantContent({ - imageUrl: data.imageUrl, + imageUrl, variant: data.variant, label: data.label, style, diff --git a/src/components/custom-nodes/MobileNode.tsx b/src/components/custom-nodes/MobileNode.tsx index 8388ab3..87c4761 100644 --- a/src/components/custom-nodes/MobileNode.tsx +++ b/src/components/custom-nodes/MobileNode.tsx @@ -3,6 +3,7 @@ import type { LegacyNodeProps } from '@/lib/reactflowCompat'; import { useTranslation } from 'react-i18next'; import { NodeData } from '@/lib/types'; import { NodeChrome } from '@/components/NodeChrome'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { renderMobileVariantContent } from './mobileVariantRenderer'; import { getNodeColorPalette } from '../../theme'; @@ -10,6 +11,7 @@ function MobileNode({ id, data, selected }: LegacyNodeProps): React.Re const { t } = useTranslation(); const nodeColorPalette = getNodeColorPalette(true); const style = nodeColorPalette[data.color || 'slate'] || nodeColorPalette.slate; + const imageUrl = useResolvedMediaUrl(data, 'image'); return ( ): React.Re {/* Screen Content */}
{renderMobileVariantContent({ - imageUrl: data.imageUrl, + imageUrl, variant: data.variant, style, imageAlt: t('customNodes.mobileContent'), diff --git a/src/components/flow-canvas/useFlowCanvasDragDrop.ts b/src/components/flow-canvas/useFlowCanvasDragDrop.ts index c99a22b..edde484 100644 --- a/src/components/flow-canvas/useFlowCanvasDragDrop.ts +++ b/src/components/flow-canvas/useFlowCanvasDragDrop.ts @@ -1,8 +1,10 @@ import { useCallback } from 'react'; +import { ingestUserMediaFile } from '@/services/storage/assetStore'; +import { reportStorageTelemetry } from '@/services/storage/storageTelemetry'; interface UseFlowCanvasDragDropParams { screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; - handleAddImage: (imageUrl: string, position: { x: number; y: number }) => void; + handleAddImage: (imageUrl: string, position: { x: number; y: number }, imageAssetId?: string) => void; onFileDrop?: (file: File, content: string) => void; } @@ -52,17 +54,29 @@ export function useFlowCanvasDragDrop({ if (!file) return; if (file.type.startsWith('image/')) { - const reader = new FileReader(); - reader.onload = (loadEvent) => { - const imageUrl = loadEvent.target?.result as string; - if (!imageUrl) return; - const position = screenToFlowPosition({ - x: event.clientX, - y: event.clientY, + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + void ingestUserMediaFile(file, 'image', { fileName: file.name }) + .then((result) => { + handleAddImage( + result.assetId ? '' : result.displayUrl, + position, + result.assetId + ); + }) + .catch((error) => { + // Best-effort: leave canvas unchanged so the user can retry. + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_DROP_INGEST_FAILED', + severity: 'warning', + message: `Image drop ingest failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }); }); - handleAddImage(imageUrl, position); - }; - reader.readAsDataURL(file); return; } diff --git a/src/components/flow-editor/buildFlowEditorControllerParams.ts b/src/components/flow-editor/buildFlowEditorControllerParams.ts index 576b2bc..52359c6 100644 --- a/src/components/flow-editor/buildFlowEditorControllerParams.ts +++ b/src/components/flow-editor/buildFlowEditorControllerParams.ts @@ -106,7 +106,7 @@ interface BuildFlowEditorControllerChromeParams { handleAddSequenceParticipant: () => void; handleAddClassNode: () => void; handleAddEntityNode: () => void; - handleAddImage: (imageUrl: string) => void; + handleAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; handleAddWireframe: (surface: 'browser' | 'mobile') => void; handleAddDomainLibraryItem: (item: DomainLibraryItem) => void; } diff --git a/src/components/flow-editor/panelProps.ts b/src/components/flow-editor/panelProps.ts index 5cb7a89..ee5e2ff 100644 --- a/src/components/flow-editor/panelProps.ts +++ b/src/components/flow-editor/panelProps.ts @@ -43,7 +43,7 @@ export interface CommandBarPanelBuilderParams { handleAddSequenceParticipant: () => void; handleAddClassNode: () => void; handleAddEntityNode: () => void; - handleAddImage: (imageUrl: string) => void; + handleAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; handleAddWireframe: (surface: 'browser' | 'mobile') => void; handleAddDomainLibraryItem: (item: DomainLibraryItem) => void; handleCodeAnalysis?: (code: string, language: SupportedLanguage) => Promise; diff --git a/src/components/flow-editor/useFlowEditorController.ts b/src/components/flow-editor/useFlowEditorController.ts index 3181a45..b627956 100644 --- a/src/components/flow-editor/useFlowEditorController.ts +++ b/src/components/flow-editor/useFlowEditorController.ts @@ -197,7 +197,7 @@ export interface UseFlowEditorChromeParams { handleAddSequenceParticipant: () => void; handleAddClassNode: () => void; handleAddEntityNode: () => void; - handleAddImage: (imageUrl: string) => void; + handleAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; handleAddWireframe: (surface: 'browser' | 'mobile') => void; handleAddDomainLibraryItem: (item: DomainLibraryItem) => void; } diff --git a/src/components/properties/BulkNodeProperties.tsx b/src/components/properties/BulkNodeProperties.tsx index 34cc961..2a925a7 100644 --- a/src/components/properties/BulkNodeProperties.tsx +++ b/src/components/properties/BulkNodeProperties.tsx @@ -175,13 +175,14 @@ export function BulkNodeProperties({ })); } - function handleCustomIconChange(url?: string): void { - const updates = createUploadedIconData(url); + function handleCustomIconChange(url?: string, iconAssetId?: string): void { + const updates = createUploadedIconData(url, iconAssetId); setForm((current) => ({ ...current, - iconMode: url ? 'upload' : '', + iconMode: url || iconAssetId ? 'upload' : '', icon: updates.icon ?? '', customIconUrl: updates.customIconUrl, + iconAssetId: updates.iconAssetId, assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'], assetCategory: updates.assetCategory, archIconPackId: updates.archIconPackId, @@ -267,6 +268,7 @@ export function BulkNodeProperties({ void; onSelectProviderIcon: (selection: ProviderIconSelection) => void; - onCustomIconChange: (url?: string) => void; -} - -function readFileAsDataUrl(file: File, onLoad: (result: string) => void): void { - const reader = new FileReader(); - reader.onloadend = () => { - if (typeof reader.result === 'string') { - onLoad(reader.result); - } - }; - reader.readAsDataURL(file); + onCustomIconChange: (url?: string, iconAssetId?: string) => void; } function getInitialSource( selectedProviderPackId: string | undefined, selectedProviderShapeId: string | undefined, - customIconUrl: string | undefined + customIconUrl: string | undefined, + iconAssetId: string | undefined ): IconSource { if (selectedProviderPackId && selectedProviderShapeId) { return 'provider'; } - if (customIconUrl) { + if (customIconUrl || iconAssetId) { return 'upload'; } return 'built-in'; @@ -80,6 +74,7 @@ function getProviderLabel(provider: DomainLibraryCategory): string { export const IconPicker: React.FC = ({ selectedIcon, customIconUrl, + iconAssetId, selectedProvider, selectedProviderCategory, selectedProviderPackId, @@ -91,8 +86,20 @@ export const IconPicker: React.FC = ({ const [iconSearch, setIconSearch] = useState(''); const [userIconSource, setUserIconSource] = useState(null); const [userProvider, setUserProvider] = useState(null); + const [uploadError, setUploadError] = useState(null); const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId); - const iconSource = userIconSource ?? getInitialSource(selectedProviderPackId, selectedProviderShapeId, customIconUrl); + const resolvedCustomIconUrl = useResolvedMediaUrl( + { customIconUrl, iconAssetId }, + 'icon' + ); + const iconSource = + userIconSource + ?? getInitialSource( + selectedProviderPackId, + selectedProviderShapeId, + customIconUrl, + iconAssetId + ); const provider = selectedProvider ?? inferredProvider @@ -147,13 +154,32 @@ export const IconPicker: React.FC = ({ setCategory('all'); }, [provider, setCategory, setQuery]); - function handleCustomIconFileChange(event: React.ChangeEvent): void { + async function handleCustomIconFileChange( + event: React.ChangeEvent + ): Promise { const file = event.target.files?.[0]; + event.target.value = ''; if (!file) { return; } - readFileAsDataUrl(file, onCustomIconChange); + setUploadError(null); + try { + const result = await ingestUserMediaFile(file, 'icon', { fileName: file.name }); + onCustomIconChange( + result.assetId ? undefined : result.displayUrl, + result.assetId + ); + } catch (error) { + // Keep picker usable; surface a short message so the failure is not silent. + const message = + error instanceof AssetEncodeError + ? error.message + : error instanceof Error + ? error.message + : 'Failed to process the selected icon.'; + setUploadError(message); + } } async function handleProviderIconSelect(item: DomainLibraryItem): Promise { @@ -324,15 +350,23 @@ export const IconPicker: React.FC = ({ {iconSource === 'upload' ? (
- {customIconUrl ? ( + {resolvedCustomIconUrl || iconAssetId ? (
- custom + {resolvedCustomIconUrl ? ( + custom + ) : ( + + )}
Uploaded icon
) : null}
diff --git a/src/components/properties/ImageUpload.tsx b/src/components/properties/ImageUpload.tsx index c5a9ca3..fcc79c3 100644 --- a/src/components/properties/ImageUpload.tsx +++ b/src/components/properties/ImageUpload.tsx @@ -1,60 +1,98 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { Upload } from 'lucide-react'; +import { AssetEncodeError, ingestUserMediaFile } from '@/services/storage/assetStore'; + +export interface ImageUploadChange { + displayUrl?: string; + assetId?: string; +} interface ImageUploadProps { - imageUrl?: string; - onChange: (url?: string) => void; + imageUrl?: string; + onChange: (value?: string, meta?: ImageUploadChange) => void; + kind?: 'image' | 'icon'; } -export const ImageUpload: React.FC = ({ imageUrl, onChange }) => { - const fileInputRef = useRef(null); - - const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - onChange(reader.result as string); - }; - reader.readAsDataURL(file); - } - }; - - return ( -
- -
- - {imageUrl ? ( -
- attached -
- -
-
- ) : ( - - )} - - +export const ImageUpload: React.FC = ({ + imageUrl, + onChange, + kind = 'image', +}) => { + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Allow re-selecting the same file later. + e.target.value = ''; + if (!file) { + return; + } + + setIsUploading(true); + setErrorMessage(null); + try { + const result = await ingestUserMediaFile(file, kind, { fileName: file.name }); + onChange(result.displayUrl, { + displayUrl: result.displayUrl, + assetId: result.assetId, + }); + } catch (error) { + const message = + error instanceof AssetEncodeError + ? error.message + : 'Failed to process the selected image.'; + setErrorMessage(message); + } finally { + setIsUploading(false); + } + }; + + return ( +
+
+ {imageUrl ? ( +
+ attached +
+
-
- ); +
+ ) : ( + + )} + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + { + void handleImageUpload(event); + }} + /> +
+
+ ); }; diff --git a/src/components/properties/NodeProperties.tsx b/src/components/properties/NodeProperties.tsx index 03ef3a7..c766bff 100644 --- a/src/components/properties/NodeProperties.tsx +++ b/src/components/properties/NodeProperties.tsx @@ -9,6 +9,7 @@ import { IconPicker, type ProviderIconSelection } from './IconPicker'; import { ImageUpload } from './ImageUpload'; import { CollapsibleSection } from '../ui/CollapsibleSection'; import { useMarkdownEditor } from '@/hooks/useMarkdownEditor'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { NodeActionButtons } from './NodeActionButtons'; import { NodeContentSection } from './NodeContentSection'; import { NodeImageSettingsSection } from './NodeImageSettingsSection'; @@ -52,6 +53,7 @@ export const NodeProperties: React.FC = ({ const isGroup = selectedNode.type === 'group'; const isWireframeApp = selectedNode.type === 'browser' || selectedNode.type === 'mobile'; const normalizedIconData = normalizeNodeIconData(selectedNode.data); + const resolvedImageUrl = useResolvedMediaUrl(selectedNode.data, 'image'); const isIconAssetNode = normalizedIconData?.assetPresentation === 'icon'; const assetProvider = normalizedIconData?.assetProvider as DomainLibraryCategory | undefined; const assetCategory = @@ -150,8 +152,8 @@ export const NodeProperties: React.FC = ({ ); } - function handleCustomIconChange(url?: string): void { - onChange(selectedNode.id, createUploadedIconData(url)); + function handleCustomIconChange(url?: string, iconAssetId?: string): void { + onChange(selectedNode.id, createUploadedIconData(url, iconAssetId)); } return ( @@ -263,6 +265,7 @@ export const NodeProperties: React.FC = ({ = ({ onToggle={() => toggleSection('upload')} > onChange(selectedNode.id, { imageUrl: url })} + imageUrl={resolvedImageUrl} + onChange={(url, meta) => + onChange(selectedNode.id, { + imageUrl: meta?.assetId ? undefined : url, + imageAssetId: meta?.assetId, + }) + } /> )} diff --git a/src/components/properties/bulkNodePropertiesModel.ts b/src/components/properties/bulkNodePropertiesModel.ts index 0580b7f..5151bc4 100644 --- a/src/components/properties/bulkNodePropertiesModel.ts +++ b/src/components/properties/bulkNodePropertiesModel.ts @@ -43,6 +43,7 @@ export interface BulkNodePropertiesFormState { customColor: string | undefined; icon: string; customIconUrl: string | undefined; + iconAssetId: string | undefined; iconMode: BulkIconMode; assetProvider: DomainLibraryCategory | undefined; assetCategory: string | undefined; @@ -71,6 +72,7 @@ export const INITIAL_BULK_NODE_PROPERTIES_FORM_STATE: BulkNodePropertiesFormStat customColor: undefined, icon: '', customIconUrl: undefined, + iconAssetId: undefined, iconMode: '', assetProvider: undefined, assetCategory: undefined, @@ -164,7 +166,7 @@ export function buildBulkUpdates( } if (form.iconMode === 'upload') { - Object.assign(updates, createUploadedIconData(form.customIconUrl)); + Object.assign(updates, createUploadedIconData(form.customIconUrl, form.iconAssetId)); } if (form.iconMode === 'provider') { diff --git a/src/components/properties/families/ArchitectureNodeSection.tsx b/src/components/properties/families/ArchitectureNodeSection.tsx index 0b038f5..d6c8c59 100644 --- a/src/components/properties/families/ArchitectureNodeSection.tsx +++ b/src/components/properties/families/ArchitectureNodeSection.tsx @@ -3,7 +3,9 @@ import type { NodeData } from '@/lib/types'; import type { DomainLibraryCategory, DomainLibraryItem } from '@/services/domainLibrary'; import { loadProviderCatalog } from '@/services/shapeLibrary/providerCatalog'; import { useAssetCatalog } from '@/hooks/useAssetCatalog'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { createProviderIconData, createUploadedIconData } from '@/lib/nodeIconState'; +import { AssetEncodeError, ingestUserMediaFile } from '@/services/storage/assetStore'; import { InspectorField } from '@/components/properties/InspectorPrimitives'; import { SegmentedChoice } from '@/components/properties/SegmentedChoice'; import { Input } from '@/components/ui/Input'; @@ -29,16 +31,6 @@ const PROVIDER_OPTIONS: Array<{ id: DomainLibraryCategory | 'custom'; label: str { id: 'custom', label: 'Custom' }, ]; -function readFileAsDataUrl(file: File, onLoad: (result: string) => void): void { - const reader = new FileReader(); - reader.onloadend = () => { - if (typeof reader.result === 'string') { - onLoad(reader.result); - } - }; - reader.readAsDataURL(file); -} - export function ArchitectureNodeSection({ nodeId, data, @@ -49,6 +41,12 @@ export function ArchitectureNodeSection({ const customProviderLabel = typeof data.archProviderLabel === 'string' ? data.archProviderLabel : ''; const customIconUrl = typeof data.customIconUrl === 'string' ? data.customIconUrl : undefined; + const iconAssetId = typeof data.iconAssetId === 'string' ? data.iconAssetId : undefined; + const resolvedCustomIconUrl = useResolvedMediaUrl( + { customIconUrl, iconAssetId }, + 'icon' + ); + const [uploadError, setUploadError] = React.useState(null); const effectiveProvider = provider === 'custom' ? null : provider; const { @@ -98,15 +96,35 @@ export function ArchitectureNodeSection({ }); } - function handleCustomIconChange(event: React.ChangeEvent): void { + async function handleCustomIconChange( + event: React.ChangeEvent + ): Promise { const file = event.target.files?.[0]; + event.target.value = ''; if (!file) { return; } - readFileAsDataUrl(file, (result) => { - onChange(nodeId, createUploadedIconData(result)); - }); + setUploadError(null); + try { + const result = await ingestUserMediaFile(file, 'icon', { fileName: file.name }); + onChange( + nodeId, + createUploadedIconData( + result.assetId ? undefined : result.displayUrl, + result.assetId + ) + ); + } catch (error) { + // Keep inspector usable; surface a short message so the failure is not silent. + const message = + error instanceof AssetEncodeError + ? error.message + : error instanceof Error + ? error.message + : 'Failed to process the selected icon.'; + setUploadError(message); + } } function handleProviderSelect(value: string): void { @@ -124,6 +142,7 @@ export function ArchitectureNodeSection({ archProvider: value as DomainLibraryCategory, archProviderLabel: undefined, customIconUrl: undefined, + iconAssetId: undefined, }); } @@ -154,14 +173,18 @@ export function ArchitectureNodeSection({ placeholder="Provider name, e.g. Hetzner" /> - {customIconUrl ? ( + {resolvedCustomIconUrl || iconAssetId ? (
- Custom provider icon + {resolvedCustomIconUrl ? ( + Custom provider icon + ) : ( + + )}
Custom icon added @@ -172,13 +195,20 @@ export function ArchitectureNodeSection({ type="file" accept="image/svg+xml,image/png,image/jpeg,image/webp" className="hidden" - onChange={handleCustomIconChange} + onChange={(event) => { + void handleCustomIconChange(event); + }} /> @@ -191,10 +221,17 @@ export function ArchitectureNodeSection({ type="file" accept="image/svg+xml,image/png,image/jpeg,image/webp" className="hidden" - onChange={handleCustomIconChange} + onChange={(event) => { + void handleCustomIconChange(event); + }} /> )} + {uploadError ? ( +

+ {uploadError} +

+ ) : null}
) : ( diff --git a/src/config/rolloutFlags.ts b/src/config/rolloutFlags.ts index 857d296..8ce6d13 100644 --- a/src/config/rolloutFlags.ts +++ b/src/config/rolloutFlags.ts @@ -6,7 +6,8 @@ export type RolloutFlagKey = | 'importSql' | 'importOpenApi' | 'importInfraTerraformHcl' - | 'importCodebase'; + | 'importCodebase' + | 'assetStoreV1'; interface RolloutFlagDefinition { key: RolloutFlagKey; @@ -66,6 +67,15 @@ const ROLLOUT_FLAG_DEFINITIONS: Record = defaultEnabled: false, description: 'Repo/codebase analyzer importer (hidden — niche, heavy)', }, + assetStoreV1: { + key: 'assetStoreV1', + envVar: 'VITE_ASSET_STORE_V1', + // Enabled by default: user media is stored by reference in IndexedDB instead of + // embedding multi-MB data URLs into every document/history/snapshot copy. + // Set VITE_ASSET_STORE_V1=0 to force legacy inline data-URL behavior. + defaultEnabled: true, + description: 'Store user images/icons in IndexedDB assets store by content hash', + }, }; function readBooleanEnvFlag(envValue: string | undefined, defaultEnabled: boolean): boolean { @@ -96,4 +106,5 @@ export const ROLLOUT_FLAGS: Record = { importOpenApi: isRolloutFlagEnabled('importOpenApi'), importInfraTerraformHcl: isRolloutFlagEnabled('importInfraTerraformHcl'), importCodebase: isRolloutFlagEnabled('importCodebase'), + assetStoreV1: isRolloutFlagEnabled('assetStoreV1'), }; diff --git a/src/hooks/node-operations/nodeFactories.ts b/src/hooks/node-operations/nodeFactories.ts index edbf9cd..473455a 100644 --- a/src/hooks/node-operations/nodeFactories.ts +++ b/src/hooks/node-operations/nodeFactories.ts @@ -120,12 +120,19 @@ export function createImageNode( id: string, imageUrl: string, position: { x: number; y: number }, - label: string + label: string, + imageAssetId?: string ): FlowNode { return { id, position, - data: { label, imageUrl, transparency: 1, rotation: 0 }, + data: { + label, + imageUrl: imageAssetId ? undefined : imageUrl, + imageAssetId, + transparency: 1, + rotation: 0, + }, type: 'image', style: { width: 200, height: 200 }, }; diff --git a/src/hooks/node-operations/useNodeOperationAdders.ts b/src/hooks/node-operations/useNodeOperationAdders.ts index 259e0ff..f485ea6 100644 --- a/src/hooks/node-operations/useNodeOperationAdders.ts +++ b/src/hooks/node-operations/useNodeOperationAdders.ts @@ -255,7 +255,7 @@ export function useNodeOperationAdders({ ); const handleAddImage = useCallback( - (imageUrl: string, position?: { x: number; y: number }) => { + (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => { recordHistory(); const id = createId('image'); commitAddedNode( @@ -264,7 +264,8 @@ export function useNodeOperationAdders({ id, imageUrl, resolvedPosition || getDefaultNodePosition(nodesLength, 100, 100), - t('nodes.image') + t('nodes.image'), + imageAssetId ), position ); diff --git a/src/hooks/useResolvedMediaUrl.ts b/src/hooks/useResolvedMediaUrl.ts new file mode 100644 index 0000000..7a0b7fe --- /dev/null +++ b/src/hooks/useResolvedMediaUrl.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import type { NodeData } from '@/lib/types'; +import { + getImmediateMediaUrl, + getNodeIconRef, + getNodeImageRef, + type NodeMediaField, +} from '@/lib/nodeMediaState'; +import { resolveAssetDisplayUrl } from '@/services/storage/assetStore'; + +/** + * Resolve node image/icon media for display. + * Asset ids are resolved asynchronously to cached blob: URLs. + * Inline / remote URLs are returned immediately (no effect-driven setState). + */ +export function useResolvedMediaUrl( + data: Partial | undefined, + field: NodeMediaField +): string | undefined { + const ref = field === 'image' ? getNodeImageRef(data) : getNodeIconRef(data); + const immediate = getImmediateMediaUrl(data, field); + const [resolvedCache, setResolvedCache] = useState<{ + assetId: string; + url: string; + } | null>(null); + + useEffect(() => { + if (!ref.assetId) { + return; + } + + const assetId = ref.assetId; + let cancelled = false; + + void resolveAssetDisplayUrl(assetId).then((url) => { + if (!cancelled && url) { + setResolvedCache({ assetId, url }); + } + }); + + return () => { + cancelled = true; + }; + }, [ref.assetId]); + + if (ref.assetId) { + if (resolvedCache?.assetId === ref.assetId) { + return resolvedCache.url; + } + // Keep any interim inline URL while the asset resolves. + return immediate; + } + + return immediate; +} diff --git a/src/lib/nodeBulkEditing.ts b/src/lib/nodeBulkEditing.ts index 4bbde37..caac900 100644 --- a/src/lib/nodeBulkEditing.ts +++ b/src/lib/nodeBulkEditing.ts @@ -89,6 +89,7 @@ const BULK_CAPABILITY_RULES: CapabilityRule[] = [ keys: [ 'icon', 'customIconUrl', + 'iconAssetId', 'assetProvider', 'assetCategory', 'archIconPackId', diff --git a/src/lib/nodeIconState.test.ts b/src/lib/nodeIconState.test.ts index 00d9962..55fc7e0 100644 --- a/src/lib/nodeIconState.test.ts +++ b/src/lib/nodeIconState.test.ts @@ -30,6 +30,7 @@ describe('nodeIconState', () => { expect(createBuiltInIconData('Database')).toEqual({ icon: 'Database', customIconUrl: undefined, + iconAssetId: undefined, assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, @@ -48,6 +49,7 @@ describe('nodeIconState', () => { ).toEqual({ icon: undefined, customIconUrl: undefined, + iconAssetId: undefined, archIconPackId: 'aws-official-starter-v1', archIconShapeId: 'compute-lambda', assetProvider: 'aws', @@ -59,6 +61,19 @@ describe('nodeIconState', () => { expect(createUploadedIconData('data:image/svg+xml;base64,abc')).toEqual({ icon: undefined, customIconUrl: 'data:image/svg+xml;base64,abc', + iconAssetId: undefined, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }); + }); + + it('createUploadedIconData accepts iconAssetId without inline url', () => { + expect(createUploadedIconData(undefined, 'sha256:abc')).toEqual({ + icon: undefined, + customIconUrl: undefined, + iconAssetId: 'sha256:abc', assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, diff --git a/src/lib/nodeIconState.ts b/src/lib/nodeIconState.ts index 5b38d42..981a540 100644 --- a/src/lib/nodeIconState.ts +++ b/src/lib/nodeIconState.ts @@ -66,6 +66,7 @@ export function createBuiltInIconData(icon: string): Partial { return { icon, customIconUrl: undefined, + iconAssetId: undefined, assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, @@ -73,10 +74,11 @@ export function createBuiltInIconData(icon: string): Partial { }; } -export function createUploadedIconData(url?: string): Partial { +export function createUploadedIconData(url?: string, iconAssetId?: string): Partial { return { icon: undefined, customIconUrl: url, + iconAssetId, assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, @@ -95,6 +97,7 @@ export function createProviderIconData(input: { return { icon: undefined, customIconUrl: undefined, + iconAssetId: undefined, archIconPackId: input.packId, archIconShapeId: input.shapeId, assetProvider: input.provider ?? resolved.provider, @@ -110,7 +113,8 @@ export function normalizeNodeIconData | undefined>(d const next: Partial = { ...data }; const hasProviderIcon = isNonEmptyString(next.archIconPackId) && isNonEmptyString(next.archIconShapeId); - const hasUploadIcon = isNonEmptyString(next.customIconUrl); + const hasUploadIcon = + isNonEmptyString(next.customIconUrl) || isNonEmptyString(next.iconAssetId); const hasBuiltInIcon = isNonEmptyString(next.icon); if (hasProviderIcon) { @@ -127,7 +131,13 @@ export function normalizeNodeIconData | undefined>(d } if (hasUploadIcon) { - Object.assign(next, createUploadedIconData(next.customIconUrl as string)); + Object.assign( + next, + createUploadedIconData( + next.customIconUrl as string | undefined, + next.iconAssetId as string | undefined + ) + ); return next as T; } @@ -138,6 +148,7 @@ export function normalizeNodeIconData | undefined>(d next.icon = undefined; next.customIconUrl = undefined; + next.iconAssetId = undefined; next.assetProvider = undefined; next.assetCategory = undefined; next.archIconPackId = undefined; diff --git a/src/lib/nodeMediaState.test.ts b/src/lib/nodeMediaState.test.ts new file mode 100644 index 0000000..8764bb4 --- /dev/null +++ b/src/lib/nodeMediaState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + collectReferencedAssetIds, + createIconMediaData, + createImageMediaData, + getImmediateMediaUrl, + getNodeIconRef, + getNodeImageRef, + nodeHasInlineDataUrlMedia, +} from './nodeMediaState'; + +describe('nodeMediaState', () => { + it('prefers imageAssetId over imageUrl', () => { + expect( + getNodeImageRef({ + imageAssetId: 'sha256:abc', + imageUrl: 'data:image/png;base64,xx', + }) + ).toEqual({ assetId: 'sha256:abc' }); + }); + + it('falls back to imageUrl when no asset id', () => { + expect(getNodeImageRef({ imageUrl: 'https://example.com/a.png' })).toEqual({ + url: 'https://example.com/a.png', + }); + }); + + it('prefers iconAssetId over customIconUrl', () => { + expect( + getNodeIconRef({ + iconAssetId: 'sha256:abcdef0123456789', + customIconUrl: 'data:image/svg+xml;base64,abc', + }) + ).toEqual({ assetId: 'sha256:abcdef0123456789' }); + }); + + it('creates image and icon media payloads', () => { + expect(createImageMediaData({ imageAssetId: 'sha256:1' })).toEqual({ + imageUrl: undefined, + imageAssetId: 'sha256:1', + }); + expect(createIconMediaData({ iconAssetId: 'sha256:2' })).toMatchObject({ + icon: undefined, + iconAssetId: 'sha256:2', + archIconPackId: undefined, + }); + }); + + it('detects inline data URLs', () => { + expect(nodeHasInlineDataUrlMedia({ imageUrl: 'data:image/png;base64,aa' })).toBe(true); + expect(nodeHasInlineDataUrlMedia({ imageUrl: 'https://x' })).toBe(false); + }); + + it('collects referenced asset ids from nodes', () => { + const ids = collectReferencedAssetIds([ + { data: { imageAssetId: 'sha256:a' } }, + { data: { iconAssetId: 'sha256:b', imageUrl: 'https://x' } }, + { data: { imageUrl: 'data:image/png;base64,z' } }, + ]); + expect(ids.sort()).toEqual(['sha256:a', 'sha256:b']); + }); + + it('returns immediate urls only for non-asset refs', () => { + expect(getImmediateMediaUrl({ imageUrl: 'https://x' }, 'image')).toBe('https://x'); + expect(getImmediateMediaUrl({ imageAssetId: 'sha256:a' }, 'image')).toBeUndefined(); + }); +}); diff --git a/src/lib/nodeMediaState.ts b/src/lib/nodeMediaState.ts new file mode 100644 index 0000000..7c47c2e --- /dev/null +++ b/src/lib/nodeMediaState.ts @@ -0,0 +1,103 @@ +import type { FlowNode, NodeData } from '@/lib/types'; +import { isAssetId } from '@/services/storage/assetHash'; +import { isDataUrl } from '@/services/storage/assetEncode'; + +export type NodeMediaField = 'image' | 'icon'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function createImageMediaData(input: { + imageUrl?: string; + imageAssetId?: string; +}): Partial { + return { + imageUrl: input.imageUrl, + imageAssetId: input.imageAssetId, + }; +} + +export function createIconMediaData(input: { + customIconUrl?: string; + iconAssetId?: string; +}): Partial { + return { + icon: undefined, + customIconUrl: input.customIconUrl, + iconAssetId: input.iconAssetId, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }; +} + +/** + * Prefer asset id for storage-efficient references; fall back to inline URL. + */ +export function getNodeImageRef(data: Partial | undefined): { + assetId?: string; + url?: string; +} { + if (!data) { + return {}; + } + if (isNonEmptyString(data.imageAssetId) && isAssetId(data.imageAssetId)) { + return { assetId: data.imageAssetId.trim() }; + } + if (isNonEmptyString(data.imageUrl)) { + return { url: data.imageUrl }; + } + return {}; +} + +export function getNodeIconRef(data: Partial | undefined): { + assetId?: string; + url?: string; +} { + if (!data) { + return {}; + } + if (isNonEmptyString(data.iconAssetId) && isAssetId(data.iconAssetId)) { + return { assetId: data.iconAssetId.trim() }; + } + if (isNonEmptyString(data.customIconUrl)) { + return { url: data.customIconUrl }; + } + return {}; +} + +/** + * Synchronous display source for places that already have a resolved URL + * or a non-asset URL (https / remaining data URLs). Asset ids need async resolve. + */ +export function getImmediateMediaUrl(data: Partial | undefined, field: NodeMediaField): string | undefined { + const ref = field === 'image' ? getNodeImageRef(data) : getNodeIconRef(data); + if (ref.url) { + return ref.url; + } + return undefined; +} + +export function collectReferencedAssetIds(nodes: Iterable }>): string[] { + const ids = new Set(); + for (const node of nodes) { + const imageRef = getNodeImageRef(node.data); + if (imageRef.assetId) { + ids.add(imageRef.assetId); + } + const iconRef = getNodeIconRef(node.data); + if (iconRef.assetId) { + ids.add(iconRef.assetId); + } + } + return Array.from(ids); +} + +export function nodeHasInlineDataUrlMedia(data: Partial | undefined): boolean { + if (!data) { + return false; + } + return isDataUrl(data.imageUrl) || isDataUrl(data.customIconUrl); +} diff --git a/src/lib/nodeStyleData.ts b/src/lib/nodeStyleData.ts index ce6e131..4264fb4 100644 --- a/src/lib/nodeStyleData.ts +++ b/src/lib/nodeStyleData.ts @@ -7,6 +7,7 @@ export const NODE_STYLE_FIELDS: Array = [ 'colorMode', 'customColor', 'customIconUrl', + 'iconAssetId', 'fontFamily', 'fontSize', 'fontStyle', diff --git a/src/lib/types.ts b/src/lib/types.ts index 5240d2e..3dd2a56 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,8 +68,12 @@ export interface NodeLabelData { export interface NodeIconData { icon?: string; // Key for the icon map secondaryIcon?: string; // Optional secondary icon key - customIconUrl?: string; // User-uploaded icon (base64 or URL) - imageUrl?: string; // Base64 or URL + customIconUrl?: string; // User-uploaded icon (base64, URL, or legacy inline) + /** Content-addressed ref into the IndexedDB assets store (preferred over customIconUrl). */ + iconAssetId?: string; + imageUrl?: string; // Base64, URL, or legacy inline + /** Content-addressed ref into the IndexedDB assets store (preferred over imageUrl). */ + imageAssetId?: string; mermaidSvg?: string; // Rendered Mermaid SVG markup } @@ -233,6 +237,7 @@ export type NodeStyleData = Pick< | 'colorMode' | 'customColor' | 'customIconUrl' + | 'iconAssetId' | 'fontFamily' | 'fontSize' | 'fontStyle' diff --git a/src/services/storage/assetEncode.test.ts b/src/services/storage/assetEncode.test.ts new file mode 100644 index 0000000..629e19c --- /dev/null +++ b/src/services/storage/assetEncode.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { AssetEncodeError, isDataUrl } from './assetEncode'; + +describe('assetEncode helpers', () => { + it('detects data URLs', () => { + expect(isDataUrl('data:image/png;base64,abc')).toBe(true); + expect(isDataUrl('https://example.com/x.png')).toBe(false); + expect(isDataUrl(undefined)).toBe(false); + }); + + it('AssetEncodeError carries a stable code', () => { + const error = new AssetEncodeError('TOO_LARGE', 'too big'); + expect(error.code).toBe('TOO_LARGE'); + expect(error.message).toBe('too big'); + expect(error.name).toBe('AssetEncodeError'); + }); +}); diff --git a/src/services/storage/assetEncode.ts b/src/services/storage/assetEncode.ts new file mode 100644 index 0000000..fdd39b9 --- /dev/null +++ b/src/services/storage/assetEncode.ts @@ -0,0 +1,246 @@ +import { + ASSET_ENCODE_DEFAULTS, + type AssetEncodeOptions, + type AssetIngestKind, +} from './assetTypes'; + +export class AssetEncodeError extends Error { + readonly code: 'TOO_LARGE' | 'UNSUPPORTED' | 'ENCODE_FAILED'; + + constructor(code: AssetEncodeError['code'], message: string) { + super(message); + this.name = 'AssetEncodeError'; + this.code = code; + } +} + +export interface EncodedAssetBytes { + blob: Blob; + mimeType: string; + byteLength: number; + width?: number; + height?: number; +} + +function isSvgMime(mimeType: string, fileName?: string): boolean { + if (mimeType.includes('svg')) { + return true; + } + return Boolean(fileName?.toLowerCase().endsWith('.svg')); +} + +async function blobToArrayBuffer(blob: Blob): Promise { + return blob.arrayBuffer(); +} + +async function loadImageBitmap(blob: Blob): Promise { + if (typeof createImageBitmap === 'function') { + return createImageBitmap(blob); + } + + // Fallback for environments without createImageBitmap (rare in modern browsers). + const objectUrl = URL.createObjectURL(blob); + try { + const image = await new Promise((resolve, reject) => { + const element = new Image(); + element.onload = () => resolve(element); + element.onerror = () => reject(new AssetEncodeError('ENCODE_FAILED', 'Failed to decode image.')); + element.src = objectUrl; + }); + + if (typeof createImageBitmap === 'function') { + return createImageBitmap(image); + } + + // Last resort: draw via canvas using the HTMLImageElement dimensions. + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + const context = canvas.getContext('2d'); + if (!context) { + throw new AssetEncodeError('ENCODE_FAILED', 'Canvas 2D context is unavailable.'); + } + context.drawImage(image, 0, 0); + const fallbackBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => { + if (result) { + resolve(result); + return; + } + reject(new AssetEncodeError('ENCODE_FAILED', 'Failed to encode canvas fallback.')); + }, + 'image/png' + ); + }); + return createImageBitmap(fallbackBlob); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function computeScaledSize( + width: number, + height: number, + maxLongEdgePx: number +): { width: number; height: number } { + const longEdge = Math.max(width, height); + if (longEdge <= maxLongEdgePx || longEdge === 0) { + return { width, height }; + } + const scale = maxLongEdgePx / longEdge; + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +async function canvasEncode( + source: CanvasImageSource, + width: number, + height: number, + preferWebp: boolean +): Promise<{ blob: Blob; mimeType: string }> { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + throw new AssetEncodeError('ENCODE_FAILED', 'Canvas 2D context is unavailable.'); + } + context.drawImage(source, 0, 0, width, height); + + const tryMimeTypes = preferWebp + ? (['image/webp', 'image/jpeg', 'image/png'] as const) + : (['image/jpeg', 'image/png'] as const); + + for (const mimeType of tryMimeTypes) { + const blob = await new Promise((resolve) => { + canvas.toBlob((result) => resolve(result), mimeType, mimeType === 'image/png' ? undefined : 0.88); + }); + if (blob && blob.size > 0) { + return { blob, mimeType: blob.type || mimeType }; + } + } + + throw new AssetEncodeError('ENCODE_FAILED', 'Browser could not encode the image.'); +} + +/** + * Normalize a user-selected image/icon file for storage. + * SVGs are kept as-is (with size check). Rasters are resized and re-encoded. + */ +export async function encodeUserMediaFile( + file: Blob, + kind: AssetIngestKind, + options: Partial = {}, + fileName?: string +): Promise { + const defaults = ASSET_ENCODE_DEFAULTS[kind]; + const resolved: AssetEncodeOptions = { + ...defaults, + ...options, + kind, + }; + + const mimeType = file.type || 'application/octet-stream'; + + if (isSvgMime(mimeType, fileName)) { + if (file.size > resolved.maxBytes) { + throw new AssetEncodeError( + 'TOO_LARGE', + `SVG exceeds the ${Math.round(resolved.maxBytes / (1024 * 1024))}MB limit.` + ); + } + return { + blob: file, + mimeType: mimeType.includes('svg') ? mimeType : 'image/svg+xml', + byteLength: file.size, + }; + } + + if (!mimeType.startsWith('image/')) { + throw new AssetEncodeError('UNSUPPORTED', `Unsupported media type: ${mimeType}`); + } + + let bitmap: ImageBitmap | null = null; + try { + bitmap = await loadImageBitmap(file); + const scaled = computeScaledSize(bitmap.width, bitmap.height, resolved.maxLongEdgePx); + const encoded = await canvasEncode(bitmap, scaled.width, scaled.height, resolved.preferWebp); + + if (encoded.blob.size > resolved.maxBytes) { + throw new AssetEncodeError( + 'TOO_LARGE', + `Encoded image exceeds the ${Math.round(resolved.maxBytes / (1024 * 1024))}MB limit.` + ); + } + + return { + blob: encoded.blob, + mimeType: encoded.mimeType, + byteLength: encoded.blob.size, + width: scaled.width, + height: scaled.height, + }; + } catch (error) { + if (error instanceof AssetEncodeError) { + throw error; + } + // If decode/resize fails, fall back to the original bytes when small enough. + if (file.size <= resolved.maxBytes) { + return { + blob: file, + mimeType, + byteLength: file.size, + }; + } + throw new AssetEncodeError( + 'ENCODE_FAILED', + error instanceof Error ? error.message : 'Failed to process image.' + ); + } finally { + bitmap?.close?.(); + } +} + +export async function encodeDataUrl( + dataUrl: string, + kind: AssetIngestKind +): Promise { + const response = await fetch(dataUrl); + if (!response.ok) { + throw new AssetEncodeError('ENCODE_FAILED', 'Failed to parse data URL.'); + } + const blob = await response.blob(); + return encodeUserMediaFile(blob, kind); +} + +export async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + if (!response.ok) { + throw new AssetEncodeError('ENCODE_FAILED', 'Failed to parse data URL.'); + } + return response.blob(); +} + +export async function blobToDataUrl(blob: Blob): Promise { + const buffer = await blobToArrayBuffer(blob); + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCharCode(...chunk); + } + const base64 = + typeof btoa === 'function' + ? btoa(binary) + : Buffer.from(bytes).toString('base64'); + const mimeType = blob.type || 'application/octet-stream'; + return `data:${mimeType};base64,${base64}`; +} + +export function isDataUrl(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('data:'); +} diff --git a/src/services/storage/assetHash.test.ts b/src/services/storage/assetHash.test.ts new file mode 100644 index 0000000..3824914 --- /dev/null +++ b/src/services/storage/assetHash.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { hashBytesToAssetId, isAssetId } from './assetHash'; + +describe('assetHash', () => { + it('produces a stable content-addressed id for the same bytes', async () => { + const bytes = new TextEncoder().encode('openflowkit-asset'); + const first = await hashBytesToAssetId(bytes); + const second = await hashBytesToAssetId(bytes); + expect(first).toBe(second); + expect(isAssetId(first)).toBe(true); + }); + + it('produces different ids for different bytes', async () => { + const left = await hashBytesToAssetId(new TextEncoder().encode('a')); + const right = await hashBytesToAssetId(new TextEncoder().encode('b')); + expect(left).not.toBe(right); + }); + + it('accepts sha256 and fnv1a id formats', () => { + expect(isAssetId('sha256:abcdef0123456789')).toBe(true); + expect(isAssetId('fnv1a:deadbeef:12')).toBe(true); + expect(isAssetId('data:image/png;base64,abc')).toBe(false); + expect(isAssetId('https://example.com/x.png')).toBe(false); + expect(isAssetId('')).toBe(false); + }); +}); diff --git a/src/services/storage/assetHash.ts b/src/services/storage/assetHash.ts new file mode 100644 index 0000000..4482161 --- /dev/null +++ b/src/services/storage/assetHash.ts @@ -0,0 +1,42 @@ +function toHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let hex = ''; + for (let index = 0; index < bytes.length; index += 1) { + hex += bytes[index].toString(16).padStart(2, '0'); + } + return hex; +} + +/** + * Content-addressed asset id: `sha256:`. + * Falls back to a length+prefix hash when SubtleCrypto is unavailable (rare). + */ +export async function hashBytesToAssetId(bytes: ArrayBuffer | Uint8Array): Promise { + const view = + bytes instanceof Uint8Array + ? bytes + : new Uint8Array(bytes); + + // Copy into a fresh ArrayBuffer so SubtleCrypto always receives a plain ArrayBuffer + // (avoids SharedArrayBuffer / ArrayBufferLike typing issues). + const copy = new Uint8Array(view.byteLength); + copy.set(view); + + if (typeof crypto !== 'undefined' && crypto.subtle?.digest) { + const digest = await crypto.subtle.digest('SHA-256', copy.buffer); + return `sha256:${toHex(digest)}`; + } + + // Deterministic fallback for non-secure contexts / test environments without subtle. + let hash = 2166136261; + for (let index = 0; index < copy.length; index += 1) { + hash ^= copy[index]; + hash = Math.imul(hash, 16777619); + } + const unsigned = hash >>> 0; + return `fnv1a:${unsigned.toString(16).padStart(8, '0')}:${copy.byteLength}`; +} + +export function isAssetId(value: unknown): value is string { + return typeof value === 'string' && /^(sha256|fnv1a):[a-f0-9:]+$/i.test(value.trim()); +} diff --git a/src/services/storage/assetMigration.test.ts b/src/services/storage/assetMigration.test.ts new file mode 100644 index 0000000..11bb53d --- /dev/null +++ b/src/services/storage/assetMigration.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowNode, NodeData } from '@/lib/types'; +import { migrateNodeMediaData, migrateNodesMedia } from './assetMigration'; + +vi.mock('./assetStore', () => ({ + isAssetStoreAvailable: vi.fn(() => true), + isDataUrl: (value: unknown) => typeof value === 'string' && value.startsWith('data:'), + ingestDataUrlAsAsset: vi.fn(async (dataUrl: string, kind: 'image' | 'icon') => ({ + assetId: kind === 'image' ? 'sha256:image1' : 'sha256:icon1', + displayUrl: dataUrl, + mimeType: 'image/png', + byteLength: 12, + })), +})); + +import { ingestDataUrlAsAsset, isAssetStoreAvailable } from './assetStore'; + +function node(data: NodeData): FlowNode { + return { + id: 'n1', + position: { x: 0, y: 0 }, + data, + }; +} + +describe('assetMigration', () => { + beforeEach(() => { + vi.mocked(isAssetStoreAvailable).mockReturnValue(true); + vi.mocked(ingestDataUrlAsAsset).mockClear(); + }); + + it('migrates image data URLs into imageAssetId and clears imageUrl', async () => { + const result = await migrateNodeMediaData({ + label: 'Img', + imageUrl: 'data:image/png;base64,aaa', + }); + expect(result.changed).toBe(true); + expect(result.data.imageAssetId).toBe('sha256:image1'); + expect(result.data.imageUrl).toBeUndefined(); + expect(result.assetIds).toContain('sha256:image1'); + }); + + it('migrates custom icon data URLs into iconAssetId', async () => { + const result = await migrateNodeMediaData({ + label: 'Icon', + customIconUrl: 'data:image/svg+xml;base64,bbb', + }); + expect(result.changed).toBe(true); + expect(result.data.iconAssetId).toBe('sha256:icon1'); + expect(result.data.customIconUrl).toBeUndefined(); + }); + + it('leaves https URLs alone', async () => { + const result = await migrateNodeMediaData({ + label: 'Remote', + imageUrl: 'https://example.com/a.png', + }); + expect(result.changed).toBe(false); + expect(ingestDataUrlAsAsset).not.toHaveBeenCalled(); + }); + + it('no-ops when the asset store is disabled', async () => { + vi.mocked(isAssetStoreAvailable).mockReturnValue(false); + const result = await migrateNodesMedia([ + node({ label: 'A', imageUrl: 'data:image/png;base64,aaa' }), + ]); + expect(result.changed).toBe(false); + expect(result.nodes[0].data.imageUrl).toBe('data:image/png;base64,aaa'); + }); +}); diff --git a/src/services/storage/assetMigration.ts b/src/services/storage/assetMigration.ts new file mode 100644 index 0000000..064499a --- /dev/null +++ b/src/services/storage/assetMigration.ts @@ -0,0 +1,83 @@ +import type { FlowNode, NodeData } from '@/lib/types'; +import { getNodeIconRef, getNodeImageRef, nodeHasInlineDataUrlMedia } from '@/lib/nodeMediaState'; +import { ingestDataUrlAsAsset, isAssetStoreAvailable, isDataUrl } from './assetStore'; + +export interface MigrateNodeMediaResult { + data: NodeData; + changed: boolean; + assetIds: string[]; +} + +/** + * Migrate a single node's inline data: URLs into the asset store. + * Leaves https:// URLs alone. Clears the large data URL once an asset id is stored. + */ +export async function migrateNodeMediaData(data: NodeData): Promise { + if (!isAssetStoreAvailable() || !nodeHasInlineDataUrlMedia(data)) { + return { data, changed: false, assetIds: [] }; + } + + let next: NodeData = { ...data }; + let changed = false; + const assetIds: string[] = []; + + if (isDataUrl(next.imageUrl) && !getNodeImageRef(next).assetId) { + const ingested = await ingestDataUrlAsAsset(next.imageUrl, 'image'); + if (ingested?.assetId) { + next = { + ...next, + imageAssetId: ingested.assetId, + // Drop embedded payload once the asset store owns the bytes. + imageUrl: undefined, + }; + changed = true; + assetIds.push(ingested.assetId); + } + } else if (getNodeImageRef(next).assetId) { + assetIds.push(getNodeImageRef(next).assetId as string); + } + + if (isDataUrl(next.customIconUrl) && !getNodeIconRef(next).assetId) { + const ingested = await ingestDataUrlAsAsset(next.customIconUrl, 'icon'); + if (ingested?.assetId) { + next = { + ...next, + iconAssetId: ingested.assetId, + customIconUrl: undefined, + }; + changed = true; + assetIds.push(ingested.assetId); + } + } else if (getNodeIconRef(next).assetId) { + assetIds.push(getNodeIconRef(next).assetId as string); + } + + return { data: next, changed, assetIds }; +} + +export async function migrateNodesMedia(nodes: FlowNode[]): Promise<{ + nodes: FlowNode[]; + changed: boolean; + assetIds: string[]; +}> { + if (!isAssetStoreAvailable()) { + return { nodes, changed: false, assetIds: [] }; + } + + let changed = false; + const assetIds: string[] = []; + const nextNodes: FlowNode[] = []; + + for (const node of nodes) { + const migrated = await migrateNodeMediaData(node.data); + if (migrated.changed) { + changed = true; + nextNodes.push({ ...node, data: migrated.data }); + } else { + nextNodes.push(node); + } + assetIds.push(...migrated.assetIds); + } + + return { nodes: nextNodes, changed, assetIds }; +} diff --git a/src/services/storage/assetStore.ts b/src/services/storage/assetStore.ts new file mode 100644 index 0000000..86a852e --- /dev/null +++ b/src/services/storage/assetStore.ts @@ -0,0 +1,294 @@ +import { ROLLOUT_FLAGS } from '@/config/rolloutFlags'; +import { + ASSETS_STORE_NAME, + openFlowPersistenceDatabase, +} from './indexedDbSchema'; +import { + deleteRecord, + getAllRecords, + getIndexedDbFactory, + getRecord, + putRecord, + withDatabase, +} from './indexedDbHelpers'; +import { ensureStorageSchemaReady, getBrowserIndexedDbFactory } from './storageRuntime'; +import { reportStorageTelemetry } from './storageTelemetry'; +import { hashBytesToAssetId, isAssetId } from './assetHash'; +import { + AssetEncodeError, + blobToDataUrl, + encodeUserMediaFile, + isDataUrl, +} from './assetEncode'; +import type { + AssetIngestKind, + AssetIngestResult, + FlowAsset, + FlowAssetKind, +} from './assetTypes'; + +const blobUrlCache = new Map(); + +function kindToAssetKind(kind: AssetIngestKind): FlowAssetKind { + return kind === 'icon' ? 'icon' : 'image'; +} + +function revokeCachedUrl(assetId: string): void { + const existing = blobUrlCache.get(assetId); + if (existing) { + URL.revokeObjectURL(existing); + blobUrlCache.delete(assetId); + } +} + +export function isAssetStoreEnabled(): boolean { + return ROLLOUT_FLAGS.assetStoreV1; +} + +export async function putFlowAsset(asset: FlowAsset): Promise { + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + await withDatabase(async (database) => { + await putRecord(database, ASSETS_STORE_NAME, asset); + }); +} + +export async function getFlowAsset(assetId: string): Promise { + if (!isAssetId(assetId)) { + return null; + } + + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + try { + return await withDatabase(async (database) => { + return getRecord(database, ASSETS_STORE_NAME, assetId); + }); + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_READ_FAILED', + severity: 'warning', + message: `Failed to read asset ${assetId}: ${error instanceof Error ? error.message : String(error)}`, + }); + return null; + } +} + +export async function deleteFlowAsset(assetId: string): Promise { + revokeCachedUrl(assetId); + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + await withDatabase(async (database) => { + await deleteRecord(database, ASSETS_STORE_NAME, assetId); + }); +} + +export async function listFlowAssets(): Promise { + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + return withDatabase(async (database) => { + return getAllRecords(database, ASSETS_STORE_NAME); + }); +} + +/** + * Resolve an asset id to a displayable URL (cached blob: URL). + * Returns null when the asset is missing or the store is unavailable. + */ +export async function resolveAssetDisplayUrl(assetId: string): Promise { + if (!isAssetId(assetId)) { + return null; + } + + const cached = blobUrlCache.get(assetId); + if (cached) { + return cached; + } + + const asset = await getFlowAsset(assetId); + if (!asset?.bytes) { + return null; + } + + const url = URL.createObjectURL(asset.bytes); + blobUrlCache.set(assetId, url); + return url; +} + +/** + * Drop blob URLs that are no longer referenced. Safe to call from GC. + */ +export function clearAssetUrlCache(assetIds?: string[]): void { + if (!assetIds) { + for (const [assetId, url] of blobUrlCache) { + URL.revokeObjectURL(url); + blobUrlCache.delete(assetId); + } + return; + } + + for (const assetId of assetIds) { + revokeCachedUrl(assetId); + } +} + +export async function putEncodedAsset(input: { + blob: Blob; + mimeType: string; + kind: FlowAssetKind; + width?: number; + height?: number; + sourceName?: string; +}): Promise { + const buffer = await input.blob.arrayBuffer(); + const id = await hashBytesToAssetId(buffer); + const existing = await getFlowAsset(id); + if (existing) { + return existing; + } + + const asset: FlowAsset = { + id, + kind: input.kind, + mimeType: input.mimeType, + bytes: input.blob, + byteLength: input.blob.size, + width: input.width, + height: input.height, + createdAt: new Date().toISOString(), + sourceName: input.sourceName, + }; + + await putFlowAsset(asset); + return asset; +} + +/** + * Ingest a user file into the asset store (when enabled) or as a data URL (legacy). + */ +export async function ingestUserMediaFile( + file: File | Blob, + kind: AssetIngestKind, + options?: { fileName?: string } +): Promise { + const fileName = options?.fileName ?? (file instanceof File ? file.name : undefined); + const encoded = await encodeUserMediaFile(file, kind, {}, fileName); + + if (!isAssetStoreEnabled() || !getIndexedDbFactory()) { + return { + displayUrl: await blobToDataUrl(encoded.blob), + mimeType: encoded.mimeType, + byteLength: encoded.byteLength, + width: encoded.width, + height: encoded.height, + }; + } + + try { + const asset = await putEncodedAsset({ + blob: encoded.blob, + mimeType: encoded.mimeType, + kind: kindToAssetKind(kind), + width: encoded.width, + height: encoded.height, + sourceName: fileName, + }); + const displayUrl = await resolveAssetDisplayUrl(asset.id); + return { + assetId: asset.id, + displayUrl: displayUrl ?? (await blobToDataUrl(encoded.blob)), + mimeType: encoded.mimeType, + byteLength: encoded.byteLength, + width: encoded.width, + height: encoded.height, + }; + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_WRITE_FAILED', + severity: 'warning', + message: `Asset store write failed; falling back to data URL. ${error instanceof Error ? error.message : String(error)}`, + }); + return { + displayUrl: await blobToDataUrl(encoded.blob), + mimeType: encoded.mimeType, + byteLength: encoded.byteLength, + width: encoded.width, + height: encoded.height, + }; + } +} + +/** + * Ingest an existing data URL into the asset store. Used by lazy migration. + * Returns null when the value is not a data URL or the store is disabled. + */ +export async function ingestDataUrlAsAsset( + dataUrl: string, + kind: AssetIngestKind +): Promise { + if (!isDataUrl(dataUrl) || !isAssetStoreEnabled() || !getIndexedDbFactory()) { + return null; + } + + try { + const response = await fetch(dataUrl); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + return ingestUserMediaFile(blob, kind); + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_MIGRATE_FAILED', + severity: 'warning', + message: `Failed to migrate data URL into asset store: ${error instanceof Error ? error.message : String(error)}`, + }); + return null; + } +} + +/** + * Delete assets that are not referenced by any of the provided ids. + * Returns the number of deleted assets. + */ +export async function garbageCollectUnreferencedAssets( + referencedAssetIds: Iterable +): Promise { + if (!isAssetStoreEnabled() || !getIndexedDbFactory()) { + return 0; + } + + const referenced = new Set( + Array.from(referencedAssetIds).filter((id) => isAssetId(id)) + ); + + try { + const all = await listFlowAssets(); + const orphaned = all.filter((asset) => !referenced.has(asset.id)); + await Promise.all(orphaned.map((asset) => deleteFlowAsset(asset.id))); + return orphaned.length; + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_GC_FAILED', + severity: 'warning', + message: `Asset GC failed: ${error instanceof Error ? error.message : String(error)}`, + }); + return 0; + } +} + +export function isAssetStoreAvailable(): boolean { + return isAssetStoreEnabled() && Boolean(getIndexedDbFactory()); +} + +export { AssetEncodeError, isAssetId, isDataUrl }; + +/** Test helper: open the assets store via the shared schema (used by tests). */ +export async function openAssetsDatabaseForTests(): Promise { + const factory = getBrowserIndexedDbFactory(); + if (!factory) { + throw new Error('IndexedDB is not available.'); + } + await ensureStorageSchemaReady(factory); + return openFlowPersistenceDatabase(factory); +} diff --git a/src/services/storage/assetTypes.ts b/src/services/storage/assetTypes.ts new file mode 100644 index 0000000..8563067 --- /dev/null +++ b/src/services/storage/assetTypes.ts @@ -0,0 +1,53 @@ +export type FlowAssetKind = 'image' | 'icon' | 'svg'; + +export interface FlowAsset { + id: string; + kind: FlowAssetKind; + mimeType: string; + /** Raw asset bytes. IndexedDB stores Blob natively. */ + bytes: Blob; + byteLength: number; + width?: number; + height?: number; + createdAt: string; + sourceName?: string; +} + +export type AssetIngestKind = 'image' | 'icon'; + +export interface AssetIngestResult { + /** Content-addressed asset id when stored in the asset store. */ + assetId?: string; + /** + * Immediate display URL for the canvas. + * When the asset store is used this is typically a blob: URL from the cache. + * When the store is disabled (or fails), this is a data: URL. + */ + displayUrl: string; + mimeType: string; + byteLength: number; + width?: number; + height?: number; +} + +export interface AssetEncodeOptions { + kind: AssetIngestKind; + maxLongEdgePx: number; + maxBytes: number; + preferWebp: boolean; +} + +export const ASSET_ENCODE_DEFAULTS: Record = { + image: { + kind: 'image', + maxLongEdgePx: 2048, + maxBytes: 4 * 1024 * 1024, + preferWebp: true, + }, + icon: { + kind: 'icon', + maxLongEdgePx: 512, + maxBytes: 1 * 1024 * 1024, + preferWebp: true, + }, +}; diff --git a/src/services/storage/localFirstRuntime.ts b/src/services/storage/localFirstRuntime.ts index e914b55..06a0baf 100644 --- a/src/services/storage/localFirstRuntime.ts +++ b/src/services/storage/localFirstRuntime.ts @@ -17,6 +17,9 @@ import { parseLegacyChatMessagesJson, parsePersistentAISettingsJson, } from './storageSchemas'; +import { isAssetStoreAvailable } from './assetStore'; +import { migrateNodesMedia } from './assetMigration'; +import { reportStorageTelemetry } from './storageTelemetry'; const STORE_SUBSCRIPTION_DEBOUNCE_MS = 250; @@ -142,23 +145,76 @@ async function hydrateStoreFromRepository(): Promise { function persistStoreSnapshot(): void { const nextState = useFlowStore.getState(); - const documents = syncWorkspaceDocuments({ - documents: nextState.documents, - activeDocumentId: nextState.activeDocumentId, - tabs: nextState.tabs.map(sanitizePersistedTab), - activeTabId: nextState.activeTabId, - nodes: nextState.nodes, - edges: nextState.edges, - }); - void localFirstRepository.saveFlowDocuments( - documents, - nextState.activeDocumentId, - ); + void (async () => { + // Lazy-migrate legacy inline data: URLs into the assets store before saving. + // Only rewrites the live store when migration actually changes nodes. + let nodesForSave = nextState.nodes; + let tabsForSave = nextState.tabs; - if (nextState.aiSettings.storageMode === 'local') { - void localFirstRepository.savePersistentAISettings(JSON.stringify(nextState.aiSettings)); - } + if (isAssetStoreAvailable()) { + try { + const activeMigrated = await migrateNodesMedia(nextState.nodes); + if (activeMigrated.changed) { + nodesForSave = activeMigrated.nodes; + const activeTabId = nextState.activeTabId; + tabsForSave = nextState.tabs.map((tab) => + tab.id === activeTabId ? { ...tab, nodes: activeMigrated.nodes } : tab + ); + useFlowStore.setState({ + nodes: activeMigrated.nodes, + tabs: tabsForSave, + }); + } + + // Also migrate inactive tab pages so reloads don't re-expand data URLs. + const migratedTabs = await Promise.all( + tabsForSave.map(async (tab) => { + if (tab.id === nextState.activeTabId && activeMigrated.changed) { + return { ...tab, nodes: activeMigrated.nodes }; + } + const migrated = await migrateNodesMedia(tab.nodes); + if (!migrated.changed) { + return tab; + } + return { ...tab, nodes: migrated.nodes }; + }) + ); + if (migratedTabs.some((tab, index) => tab !== tabsForSave[index])) { + tabsForSave = migratedTabs; + useFlowStore.setState({ tabs: tabsForSave }); + } + } catch (error) { + // Migration is best-effort; always fall through to save. + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_MIGRATE_ON_SAVE_FAILED', + severity: 'warning', + message: `Asset media migration failed during save; continuing with unmigrated media. ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } + + const documents = syncWorkspaceDocuments({ + documents: nextState.documents, + activeDocumentId: nextState.activeDocumentId, + tabs: tabsForSave.map(sanitizePersistedTab), + activeTabId: nextState.activeTabId, + nodes: nodesForSave, + edges: nextState.edges, + }); + + await localFirstRepository.saveFlowDocuments( + documents, + nextState.activeDocumentId, + ); + + if (nextState.aiSettings.storageMode === 'local') { + await localFirstRepository.savePersistentAISettings(JSON.stringify(nextState.aiSettings)); + } + })(); } let syncStopper: (() => void) | null = null;