New data query node for Directus backend integration

This commit is contained in:
Richard Osborne
2025-12-30 11:55:30 +01:00
parent 6fd59e83e6
commit ae7d3b8a8b
52 changed files with 17798 additions and 303 deletions

View File

@@ -601,7 +601,7 @@ inputs: {
---
### 🔴 GOTCHA #5: Node Registration Path Matters
### 🔴 GOTCHA #5: Node Registration Path Matters (Signals Not Wrapping)
**THE BUG:**
@@ -633,6 +633,104 @@ module.exports = NodeDefinition.defineNode(MyNode);
---
### 🔴 GOTCHA #6: Signal in Static `inputs` + Dynamic Ports = Duplicate Ports (Dec 2025)
**THE BUG:**
```javascript
// Signal defined in static inputs with handler
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() { this.scheduleFetch(); }
}
}
// updatePorts() ALSO adds fetch - CAUSES DUPLICATE!
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// ... other ports ...
ports.push({ name: 'fetch', type: 'signal', plug: 'input' }); // ❌ Duplicate!
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
**SYMPTOM:** When trying to connect to the node, TWO "Fetch" signals appear in the connection popup.
**WHY IT BREAKS:**
- GOTCHA #2 says "include static ports in dynamic ports" which is true for MOST ports
- But signals with `valueChangedToTrue` handlers ALREADY have a runtime registration
- Adding them again in `updatePorts()` creates a duplicate visual port
- The handler still works, but UX is confusing
**THE FIX:**
```javascript
// ✅ CORRECT - Only define signal in static inputs, NOT in updatePorts()
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() { this.scheduleFetch(); }
}
}
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// ... dynamic ports ...
// NOTE: 'fetch' signal is defined in static inputs (with valueChangedToTrue handler)
// DO NOT add it here again or it will appear twice in the connection popup
// ... other ports ...
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
**RULE:** Signals with `valueChangedToTrue` handlers → ONLY in static `inputs`. All other ports (value inputs, outputs) → in `updatePorts()` dynamic ports.
---
### 🔴 GOTCHA #7: Require Path Depth for noodl-runtime (Dec 2025)
**THE BUG:**
```javascript
// File: src/nodes/std-library/data/mynode.js
// Trying to require noodl-runtime.js at package root
const NoodlRuntime = require('../../../noodl-runtime'); // ❌ WRONG - only 3 levels
// This breaks the entire runtime with "Cannot find module" error
```
**WHY IT MATTERS:**
- From `src/nodes/std-library/data/` you need to go UP 4 levels to reach the package root
- Path: data → std-library → nodes → src → (package root)
- One wrong `../` and the entire app fails to load
**THE FIX:**
```javascript
// ✅ CORRECT - Count the directories carefully
// From src/nodes/std-library/data/mynode.js:
const NoodlRuntime = require('../../../../noodl-runtime'); // 4 levels
// Reference: cloudstore.js at src/api/ uses 2 levels:
const NoodlRuntime = require('../../noodl-runtime'); // 2 levels from src/api/
```
**Quick Reference:**
| File Location | Levels to Package Root | Require Path |
| ----------------------------- | ---------------------- | --------------------------- |
| `src/api/` | 2 | `../../noodl-runtime` |
| `src/nodes/` | 2 | `../../noodl-runtime` |
| `src/nodes/std-library/` | 3 | `../../../noodl-runtime` |
| `src/nodes/std-library/data/` | 4 | `../../../../noodl-runtime` |
---
## Complete Working Pattern (HTTP Node Reference)
Here's the proven pattern from the HTTP node that handles all gotchas:

View File

@@ -1071,3 +1071,125 @@ PopupLayer.prototype.dragCompleted = function () {
**Keywords**: PopupLayer, dragCompleted, endDrag, TypeError, drag-and-drop, method name, API
---
## NoodlRuntime.instance.getMetaData() Pattern for Project Data (Dec 2025)
### How Runtime Nodes Access Project Metadata
**Context**: BYOB Query Data node needed to access backend services configuration (URLs, tokens, schema) from runtime code.
**The Pattern**: Use `NoodlRuntime.instance.getMetaData(key)` to access project metadata stored in graphModel.
**Working Example** (from byob-query-data.js):
```javascript
const NoodlRuntime = require('../../../../noodl-runtime');
resolveBackend: function() {
// Get metadata - same pattern as cloudstore.js uses for cloudservices
const backendServices = NoodlRuntime.instance.getMetaData('backendServices');
if (!backendServices || !backendServices.backends) {
console.log('[BYOB Query Data] No backend services metadata found');
console.log('[BYOB Query Data] Available metadata keys:',
Object.keys(NoodlRuntime.instance.metadata || {}));
return null;
}
// Access the data
const backends = backendServices.backends || [];
const activeBackendId = backendServices.activeBackendId;
// Find and use the backend config...
}
```
**Reference Implementation** (from `src/api/cloudstore.js`):
```javascript
const NoodlRuntime = require('../../noodl-runtime');
// Access cloud services config
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
```
**How It Works**:
- `NoodlRuntime.prototype.getMetaData(key)` delegates to `this.graphModel.getMetaData(key)`
- Metadata is stored in the project file and loaded into graphModel
- Editor components set metadata via `graphModel.setMetaData(key, value)`
**Available Metadata Keys** (varies by project):
- `cloudservices` - Parse/Firebase cloud settings
- `backendServices` - BYOB backend configurations
- Project-specific settings
**Location**:
- NoodlRuntime API: `packages/noodl-runtime/noodl-runtime.js` (line 299)
- Pattern reference: `packages/noodl-runtime/src/api/cloudstore.js`
- BYOB usage: `packages/noodl-runtime/src/nodes/std-library/data/byob-query-data.js`
**Keywords**: NoodlRuntime, getMetaData, project metadata, runtime, backend config, cloudservices
---
## 🔴 Runtime Nodes Must Be in coreNodes Index (Dec 2025)
### Problem: Node Module Loads But Doesn't Appear in Node Picker
**Context**: TASK-002 BYOB Data Nodes - Created `byob-query-data.js`, registered in `register-nodes.js`, console showed "Module loaded" but node never appeared in Node Picker.
**Root Cause**: Runtime nodes need to be registered in THREE places:
1. ✅ Node file created (`noodl-runtime/src/nodes/std-library/data/byob-query-data.js`)
2. ✅ Registered in `register-nodes.js` via `require()`
3.**MISSING** - Added to `coreNodes` index in `nodelibraryexport.js`
**The Hidden Requirement**:
```javascript
// In nodelibraryexport.js, the coreNodes array determines Node Picker organization
const coreNodes = [
{
name: 'Read & Write Data',
subCategories: [
{
name: 'External Data',
items: ['net.noodl.HTTP', 'REST2'] // HTTP appears because it's HERE
}
// Node not in this array = not in Node Picker!
]
}
];
```
**The Fix**:
```javascript
// Add node to appropriate category in coreNodes
{
name: 'BYOB Data',
items: ['noodl.byob.QueryData'] // Now appears in Node Picker!
}
```
**Why This Is Easy to Miss**:
- Module loads fine (console log appears)
- No errors anywhere
- Node IS registered in `nodeRegister._constructors`
- Node IS in `nodetypes` array exported to editor
- But Node Picker uses `coreNodes` index for organization
**Critical Rule**: After creating a node, ALWAYS add it to `nodelibraryexport.js` coreNodes array.
**Location**:
- `packages/noodl-runtime/src/nodelibraryexport.js` (coreNodes array)
- Documented in: `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (Step 3)
**Keywords**: node picker, coreNodes, nodelibraryexport, runtime node, silent failure, node not appearing
---

View File

@@ -0,0 +1,546 @@
# CONFIG-001: Core Infrastructure
## Overview
Implement the foundational `Noodl.Config` namespace, data model, project storage, and runtime initialization.
**Estimated effort:** 14-18 hours
**Dependencies:** None
**Blocks:** All other CONFIG subtasks
---
## Objectives
1. Create `Noodl.Config` as an immutable runtime namespace
2. Define TypeScript interfaces for configuration data
3. Implement storage in project.json metadata
4. Initialize config values at app startup
5. Establish default values for required fields
---
## Files to Create
### 1. Type Definitions
**File:** `packages/noodl-runtime/src/config/types.ts`
```typescript
export type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
export interface ConfigVariable {
key: string;
type: ConfigType;
value: any;
description?: string;
category?: string;
validation?: ConfigValidation;
}
export interface ConfigValidation {
required?: boolean;
pattern?: string;
min?: number;
max?: number;
}
export interface AppIdentity {
appName: string;
description: string;
coverImage?: string;
}
export interface AppSEO {
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
favicon?: string;
themeColor?: string;
}
export interface AppPWA {
enabled: boolean;
shortName?: string;
startUrl: string;
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
backgroundColor?: string;
sourceIcon?: string;
}
export interface AppConfig {
identity: AppIdentity;
seo: AppSEO;
pwa?: AppPWA;
variables: ConfigVariable[];
}
export const DEFAULT_APP_CONFIG: AppConfig = {
identity: {
appName: 'My Noodl App',
description: ''
},
seo: {},
variables: []
};
```
### 2. Config Manager (Runtime)
**File:** `packages/noodl-runtime/src/config/config-manager.ts`
```typescript
import { AppConfig, ConfigVariable, DEFAULT_APP_CONFIG } from './types';
class ConfigManager {
private static instance: ConfigManager;
private config: AppConfig = DEFAULT_APP_CONFIG;
private frozenConfig: Readonly<Record<string, any>> | null = null;
static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
/**
* Initialize config from project metadata.
* Called once at app startup.
*/
initialize(config: Partial<AppConfig>): void {
this.config = {
...DEFAULT_APP_CONFIG,
...config,
identity: {
...DEFAULT_APP_CONFIG.identity,
...config.identity
},
seo: {
...DEFAULT_APP_CONFIG.seo,
...config.seo
}
};
// Build the frozen public config object
this.frozenConfig = this.buildFrozenConfig();
}
/**
* Get the immutable Noodl.Config object.
*/
getConfig(): Readonly<Record<string, any>> {
if (!this.frozenConfig) {
this.frozenConfig = this.buildFrozenConfig();
}
return this.frozenConfig;
}
/**
* Get raw config for editor use.
*/
getRawConfig(): AppConfig {
return this.config;
}
/**
* Get a specific variable definition.
*/
getVariable(key: string): ConfigVariable | undefined {
return this.config.variables.find(v => v.key === key);
}
/**
* Get all variable keys for autocomplete.
*/
getVariableKeys(): string[] {
return this.config.variables.map(v => v.key);
}
private buildFrozenConfig(): Readonly<Record<string, any>> {
const config: Record<string, any> = {
// Identity fields
appName: this.config.identity.appName,
description: this.config.identity.description,
coverImage: this.config.identity.coverImage,
// SEO fields (with defaults)
ogTitle: this.config.seo.ogTitle || this.config.identity.appName,
ogDescription: this.config.seo.ogDescription || this.config.identity.description,
ogImage: this.config.seo.ogImage || this.config.identity.coverImage,
favicon: this.config.seo.favicon,
themeColor: this.config.seo.themeColor,
// PWA fields
pwaEnabled: this.config.pwa?.enabled ?? false,
pwaShortName: this.config.pwa?.shortName,
pwaDisplay: this.config.pwa?.display,
pwaStartUrl: this.config.pwa?.startUrl,
pwaBackgroundColor: this.config.pwa?.backgroundColor
};
// Add custom variables
for (const variable of this.config.variables) {
config[variable.key] = variable.value;
}
// Freeze to prevent mutation
return Object.freeze(config);
}
}
export const configManager = ConfigManager.getInstance();
```
### 3. Noodl.Config API
**File:** `packages/noodl-viewer-react/src/config-api.ts`
```typescript
import { configManager } from '@noodl/runtime/src/config/config-manager';
/**
* Create the Noodl.Config proxy object.
* Returns an immutable object that throws on write attempts.
*/
export function createConfigAPI(): Readonly<Record<string, any>> {
const config = configManager.getConfig();
return new Proxy(config, {
get(target, prop: string) {
if (prop in target) {
return target[prop];
}
console.warn(`Noodl.Config.${prop} is not defined`);
return undefined;
},
set(target, prop: string, value) {
console.error(
`Cannot set Noodl.Config.${prop} - Config values are immutable. ` +
`Use Noodl.Variables for runtime-changeable values.`
);
return false;
},
deleteProperty(target, prop: string) {
console.error(`Cannot delete Noodl.Config.${prop} - Config values are immutable.`);
return false;
}
});
}
```
### 4. Update Noodl JS API
**File:** `packages/noodl-viewer-react/src/noodl-js-api.js` (modify)
Add Config to the Noodl namespace:
```javascript
// Add import
import { createConfigAPI } from './config-api';
// In the Noodl object initialization
const Noodl = {
// ... existing properties
Config: createConfigAPI(),
// ...
};
```
---
## Files to Modify
### 1. Project Model
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
Add methods for app config:
```typescript
// Add to ProjectModel class
getAppConfig(): AppConfig {
return this.getMetaData('appConfig') || DEFAULT_APP_CONFIG;
}
setAppConfig(config: AppConfig): void {
this.setMetaData('appConfig', config);
}
updateAppConfig(updates: Partial<AppConfig>): void {
const current = this.getAppConfig();
this.setAppConfig({
...current,
...updates
});
}
// Config variables helpers
getConfigVariables(): ConfigVariable[] {
return this.getAppConfig().variables;
}
setConfigVariable(variable: ConfigVariable): void {
const config = this.getAppConfig();
const index = config.variables.findIndex(v => v.key === variable.key);
if (index >= 0) {
config.variables[index] = variable;
} else {
config.variables.push(variable);
}
this.setAppConfig(config);
}
removeConfigVariable(key: string): void {
const config = this.getAppConfig();
config.variables = config.variables.filter(v => v.key !== key);
this.setAppConfig(config);
}
```
### 2. Runtime Initialization
**File:** `packages/noodl-viewer-react/src/index.js` (or equivalent entry)
Initialize config from project data:
```javascript
// During app initialization
import { configManager } from '@noodl/runtime/src/config/config-manager';
// After project data is loaded
const appConfig = projectData.metadata?.appConfig;
if (appConfig) {
configManager.initialize(appConfig);
}
```
### 3. Type Declarations
**File:** `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
Add Config type declarations:
```typescript
declare namespace Noodl {
// ... existing declarations
/**
* App configuration values defined in App Setup.
* These values are static and cannot be changed at runtime.
*
* @example
* // Access a config value
* const color = Noodl.Config.primaryColor;
*
* // This will throw an error:
* Noodl.Config.primaryColor = "#000"; // ❌ Cannot modify
*/
const Config: Readonly<{
// Identity
appName: string;
description: string;
coverImage?: string;
// SEO
ogTitle: string;
ogDescription: string;
ogImage?: string;
favicon?: string;
themeColor?: string;
// PWA
pwaEnabled: boolean;
pwaShortName?: string;
pwaDisplay?: string;
pwaStartUrl?: string;
pwaBackgroundColor?: string;
// Custom variables (dynamic)
[key: string]: any;
}>;
}
```
---
## Validation Logic
**File:** `packages/noodl-runtime/src/config/validation.ts`
```typescript
import { ConfigVariable, ConfigValidation } from './types';
export interface ValidationResult {
valid: boolean;
errors: string[];
}
export function validateConfigKey(key: string): ValidationResult {
const errors: string[] = [];
// Must be valid JS identifier
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
errors.push('Key must be a valid JavaScript identifier');
}
// Reserved keys
const reserved = [
'appName', 'description', 'coverImage',
'ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor',
'pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor'
];
if (reserved.includes(key)) {
errors.push(`"${key}" is a reserved configuration key`);
}
return { valid: errors.length === 0, errors };
}
export function validateConfigValue(
value: any,
type: string,
validation?: ConfigValidation
): ValidationResult {
const errors: string[] = [];
// Required check
if (validation?.required && (value === undefined || value === null || value === '')) {
errors.push('This field is required');
return { valid: false, errors };
}
// Type-specific validation
switch (type) {
case 'number':
if (typeof value !== 'number' || isNaN(value)) {
errors.push('Value must be a number');
} else {
if (validation?.min !== undefined && value < validation.min) {
errors.push(`Value must be at least ${validation.min}`);
}
if (validation?.max !== undefined && value > validation.max) {
errors.push(`Value must be at most ${validation.max}`);
}
}
break;
case 'string':
if (typeof value !== 'string') {
errors.push('Value must be a string');
} else if (validation?.pattern) {
const regex = new RegExp(validation.pattern);
if (!regex.test(value)) {
errors.push('Value does not match required pattern');
}
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
errors.push('Value must be true or false');
}
break;
case 'color':
if (typeof value !== 'string' || !/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)) {
errors.push('Value must be a valid hex color (e.g., #ff0000)');
}
break;
case 'array':
if (!Array.isArray(value)) {
errors.push('Value must be an array');
}
break;
case 'object':
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
errors.push('Value must be an object');
}
break;
}
return { valid: errors.length === 0, errors };
}
```
---
## Testing Checklist
### Unit Tests
- [ ] ConfigManager initializes with defaults
- [ ] ConfigManager merges provided config with defaults
- [ ] Frozen config includes all identity fields
- [ ] Frozen config includes all SEO fields with defaults
- [ ] Frozen config includes all custom variables
- [ ] Config object is truly immutable (Object.isFrozen)
- [ ] Proxy prevents writes and logs error
- [ ] Validation rejects invalid keys
- [ ] Validation rejects reserved keys
- [ ] Type validation works for all types
### Integration Tests
- [ ] ProjectModel saves config to metadata
- [ ] ProjectModel loads config from metadata
- [ ] Runtime initializes config on app load
- [ ] Noodl.Config accessible in Function nodes
- [ ] Config values correct in running app
---
## Implementation Order
1. Create type definitions
2. Create ConfigManager
3. Create validation utilities
4. Update ProjectModel with config methods
5. Create Noodl.Config API proxy
6. Add to Noodl namespace
7. Update type declarations
8. Add runtime initialization
9. Write tests
---
## Notes for Implementer
### Freezing Behavior
The config object must be deeply frozen to prevent any mutation:
```typescript
function deepFreeze<T extends object>(obj: T): Readonly<T> {
Object.keys(obj).forEach(key => {
const value = (obj as any)[key];
if (value && typeof value === 'object') {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
```
### Error Messages
When users try to modify config values, provide helpful error messages that guide them to use Variables instead:
```javascript
console.error(
`Cannot set Noodl.Config.${prop} - Config values are immutable. ` +
`Use Noodl.Variables for runtime-changeable values.`
);
```
### Cloud Function Context
Ensure ConfigManager works in both browser and cloud function contexts. The cloud functions have a separate noodl-js-api that will need similar updates.

View File

@@ -0,0 +1,944 @@
# CONFIG-002: App Setup Panel UI
## Overview
Create a new "App Setup" top-level sidebar panel for editing app configuration, SEO metadata, PWA settings, and custom variables.
**Estimated effort:** 18-24 hours
**Dependencies:** CONFIG-001
**Blocks:** CONFIG-004, CONFIG-005
---
## Objectives
1. Add new "App Setup" tab to sidebar navigation
2. Create panel sections for Identity, SEO, PWA, and Custom Variables
3. Integrate existing port type editors for type-aware editing
4. Implement add/edit/remove flows for custom variables
5. Support category grouping for variables
6. Migrate relevant settings from Project Settings panel
---
## Files to Create
### 1. App Setup Panel
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/AppSetupPanel.tsx`
```tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
import { AppConfig } from '@noodl/runtime/src/config/types';
import { IdentitySection } from './sections/IdentitySection';
import { SEOSection } from './sections/SEOSection';
import { PWASection } from './sections/PWASection';
import { VariablesSection } from './sections/VariablesSection';
export function AppSetupPanel() {
const [config, setConfig] = useState<AppConfig>(
ProjectModel.instance.getAppConfig()
);
const updateConfig = useCallback((updates: Partial<AppConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
ProjectModel.instance.setAppConfig(newConfig);
}, [config]);
const updateIdentity = useCallback((identity: Partial<AppConfig['identity']>) => {
updateConfig({
identity: { ...config.identity, ...identity }
});
}, [config, updateConfig]);
const updateSEO = useCallback((seo: Partial<AppConfig['seo']>) => {
updateConfig({
seo: { ...config.seo, ...seo }
});
}, [config, updateConfig]);
const updatePWA = useCallback((pwa: Partial<AppConfig['pwa']>) => {
updateConfig({
pwa: { ...config.pwa, ...pwa } as AppConfig['pwa']
});
}, [config, updateConfig]);
return (
<BasePanel title="App Setup" hasContentScroll>
<IdentitySection
identity={config.identity}
onChange={updateIdentity}
/>
<SEOSection
seo={config.seo}
identity={config.identity}
onChange={updateSEO}
/>
<PWASection
pwa={config.pwa}
onChange={updatePWA}
/>
<VariablesSection
variables={config.variables}
onChange={(variables) => updateConfig({ variables })}
/>
</BasePanel>
);
}
```
### 2. Identity Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/IdentitySection.tsx`
```tsx
import React from 'react';
import { AppIdentity } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelTextArea } from '@noodl-core-ui/components/property-panel/PropertyPanelTextArea';
import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker';
interface IdentitySectionProps {
identity: AppIdentity;
onChange: (updates: Partial<AppIdentity>) => void;
}
export function IdentitySection({ identity, onChange }: IdentitySectionProps) {
return (
<CollapsableSection title="App Identity" hasGutter hasVisibleOverflow>
<PropertyPanelRow label="App Name">
<PropertyPanelTextInput
value={identity.appName}
onChange={(value) => onChange({ appName: value })}
placeholder="My Noodl App"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Description">
<PropertyPanelTextArea
value={identity.description}
onChange={(value) => onChange({ description: value })}
placeholder="Describe your app..."
rows={3}
/>
</PropertyPanelRow>
<PropertyPanelRow label="Cover Image">
<ImagePicker
value={identity.coverImage}
onChange={(value) => onChange({ coverImage: value })}
placeholder="Select cover image..."
/>
</PropertyPanelRow>
</CollapsableSection>
);
}
```
### 3. SEO Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/SEOSection.tsx`
```tsx
import React from 'react';
import { AppSEO, AppIdentity } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker';
import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { Box } from '@noodl-core-ui/components/layout/Box';
interface SEOSectionProps {
seo: AppSEO;
identity: AppIdentity;
onChange: (updates: Partial<AppSEO>) => void;
}
export function SEOSection({ seo, identity, onChange }: SEOSectionProps) {
return (
<CollapsableSection title="SEO & Metadata" hasGutter hasVisibleOverflow>
<PropertyPanelRow label="OG Title">
<PropertyPanelTextInput
value={seo.ogTitle || ''}
onChange={(value) => onChange({ ogTitle: value || undefined })}
placeholder={identity.appName || 'Defaults to App Name'}
/>
</PropertyPanelRow>
<Box hasBottomSpacing>
<Text textType="shy">Defaults to App Name if empty</Text>
</Box>
<PropertyPanelRow label="OG Description">
<PropertyPanelTextInput
value={seo.ogDescription || ''}
onChange={(value) => onChange({ ogDescription: value || undefined })}
placeholder={identity.description ? 'Defaults to Description' : 'Enter description...'}
/>
</PropertyPanelRow>
<Box hasBottomSpacing>
<Text textType="shy">Defaults to Description if empty</Text>
</Box>
<PropertyPanelRow label="OG Image">
<ImagePicker
value={seo.ogImage}
onChange={(value) => onChange({ ogImage: value })}
placeholder="Defaults to Cover Image"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Favicon">
<ImagePicker
value={seo.favicon}
onChange={(value) => onChange({ favicon: value })}
placeholder="Select favicon..."
accept=".ico,.png,.svg"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Theme Color">
<PropertyPanelColorPicker
value={seo.themeColor}
onChange={(value) => onChange({ themeColor: value })}
/>
</PropertyPanelRow>
</CollapsableSection>
);
}
```
### 4. PWA Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/PWASection.tsx`
```tsx
import React from 'react';
import { AppPWA } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker';
import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { Box } from '@noodl-core-ui/components/layout/Box';
interface PWASectionProps {
pwa?: AppPWA;
onChange: (updates: Partial<AppPWA>) => void;
}
const DISPLAY_OPTIONS = [
{ value: 'standalone', label: 'Standalone' },
{ value: 'fullscreen', label: 'Fullscreen' },
{ value: 'minimal-ui', label: 'Minimal UI' },
{ value: 'browser', label: 'Browser' }
];
export function PWASection({ pwa, onChange }: PWASectionProps) {
const enabled = pwa?.enabled ?? false;
return (
<CollapsableSection
title="PWA Configuration"
hasGutter
hasVisibleOverflow
hasTopDivider
isClosed={!enabled}
headerContent={
<PropertyPanelCheckbox
value={enabled}
onChange={(value) => onChange({ enabled: value })}
/>
}
>
{enabled && (
<>
<PropertyPanelRow label="Short Name">
<PropertyPanelTextInput
value={pwa?.shortName || ''}
onChange={(value) => onChange({ shortName: value })}
placeholder="Short app name for home screen"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Display Mode">
<PropertyPanelSelectInput
value={pwa?.display || 'standalone'}
options={DISPLAY_OPTIONS}
onChange={(value) => onChange({ display: value as AppPWA['display'] })}
/>
</PropertyPanelRow>
<PropertyPanelRow label="Start URL">
<PropertyPanelTextInput
value={pwa?.startUrl || '/'}
onChange={(value) => onChange({ startUrl: value || '/' })}
placeholder="/"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Background Color">
<PropertyPanelColorPicker
value={pwa?.backgroundColor}
onChange={(value) => onChange({ backgroundColor: value })}
/>
</PropertyPanelRow>
<PropertyPanelRow label="App Icon">
<ImagePicker
value={pwa?.sourceIcon}
onChange={(value) => onChange({ sourceIcon: value })}
placeholder="512x512 PNG recommended"
accept=".png"
/>
</PropertyPanelRow>
<Box hasBottomSpacing>
<Text textType="shy">
Provide a 512x512 PNG. Smaller sizes will be generated automatically.
</Text>
</Box>
</>
)}
</CollapsableSection>
);
}
```
### 5. Variables Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariablesSection.tsx`
```tsx
import React, { useState, useMemo } from 'react';
import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { VariableGroup } from './VariableGroup';
import { AddVariableDialog } from '../dialogs/AddVariableDialog';
interface VariablesSectionProps {
variables: ConfigVariable[];
onChange: (variables: ConfigVariable[]) => void;
}
export function VariablesSection({ variables, onChange }: VariablesSectionProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingVariable, setEditingVariable] = useState<ConfigVariable | null>(null);
// Group variables by category
const groupedVariables = useMemo(() => {
const groups: Record<string, ConfigVariable[]> = {};
for (const variable of variables) {
const category = variable.category || 'Custom';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(variable);
}
return groups;
}, [variables]);
const handleAddVariable = (variable: ConfigVariable) => {
onChange([...variables, variable]);
setShowAddDialog(false);
};
const handleUpdateVariable = (key: string, updates: Partial<ConfigVariable>) => {
onChange(variables.map(v =>
v.key === key ? { ...v, ...updates } : v
));
};
const handleDeleteVariable = (key: string) => {
onChange(variables.filter(v => v.key !== key));
};
const handleEditVariable = (variable: ConfigVariable) => {
setEditingVariable(variable);
setShowAddDialog(true);
};
const handleSaveEdit = (variable: ConfigVariable) => {
if (editingVariable) {
// If key changed, remove old and add new
if (editingVariable.key !== variable.key) {
onChange([
...variables.filter(v => v.key !== editingVariable.key),
variable
]);
} else {
handleUpdateVariable(variable.key, variable);
}
}
setEditingVariable(null);
setShowAddDialog(false);
};
return (
<CollapsableSection
title="Configuration Variables"
hasGutter
hasVisibleOverflow
hasTopDivider
headerContent={
<PrimaryButton
icon={IconName.Plus}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Ghost}
onClick={() => setShowAddDialog(true)}
label="Add"
/>
}
>
{Object.entries(groupedVariables).map(([category, vars]) => (
<VariableGroup
key={category}
category={category}
variables={vars}
onUpdate={handleUpdateVariable}
onDelete={handleDeleteVariable}
onEdit={handleEditVariable}
/>
))}
{variables.length === 0 && (
<Text textType="shy">
No custom variables defined. Click "Add" to create one.
</Text>
)}
{showAddDialog && (
<AddVariableDialog
existingKeys={variables.map(v => v.key)}
editingVariable={editingVariable}
onSave={editingVariable ? handleSaveEdit : handleAddVariable}
onCancel={() => {
setShowAddDialog(false);
setEditingVariable(null);
}}
/>
)}
</CollapsableSection>
);
}
```
### 6. Variable Group Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableGroup.tsx`
```tsx
import React from 'react';
import { ConfigVariable } from '@noodl/runtime/src/config/types';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { VariableRow } from './VariableRow';
import css from './VariableGroup.module.scss';
interface VariableGroupProps {
category: string;
variables: ConfigVariable[];
onUpdate: (key: string, updates: Partial<ConfigVariable>) => void;
onDelete: (key: string) => void;
onEdit: (variable: ConfigVariable) => void;
}
export function VariableGroup({
category,
variables,
onUpdate,
onDelete,
onEdit
}: VariableGroupProps) {
return (
<Box className={css.Root}>
<Text className={css.CategoryLabel}>{category}</Text>
<div className={css.VariablesList}>
{variables.map(variable => (
<VariableRow
key={variable.key}
variable={variable}
onUpdate={(updates) => onUpdate(variable.key, updates)}
onDelete={() => onDelete(variable.key)}
onEdit={() => onEdit(variable)}
/>
))}
</div>
</Box>
);
}
```
### 7. Variable Row Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableRow.tsx`
```tsx
import React from 'react';
import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types';
import { IconName, Icon } from '@noodl-core-ui/components/common/Icon';
import { MenuDialog, MenuDialogItem } from '@noodl-core-ui/components/popups/MenuDialog';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { TypeEditor } from './TypeEditor';
import css from './VariableRow.module.scss';
interface VariableRowProps {
variable: ConfigVariable;
onUpdate: (updates: Partial<ConfigVariable>) => void;
onDelete: () => void;
onEdit: () => void;
}
const TYPE_LABELS: Record<ConfigType, string> = {
string: 'String',
number: 'Number',
boolean: 'Boolean',
color: 'Color',
array: 'Array',
object: 'Object'
};
export function VariableRow({ variable, onUpdate, onDelete, onEdit }: VariableRowProps) {
const menuItems: MenuDialogItem[] = [
{ label: 'Edit', icon: IconName.Pencil, onClick: onEdit },
{ label: 'Delete', icon: IconName.Trash, isDangerousAction: true, onClick: onDelete }
];
return (
<div className={css.Root}>
<div className={css.KeyColumn}>
<Tooltip content={variable.description || `Type: ${TYPE_LABELS[variable.type]}`}>
<span className={css.Key}>{variable.key}</span>
</Tooltip>
<span className={css.Type}>{variable.type}</span>
</div>
<div className={css.ValueColumn}>
<TypeEditor
type={variable.type}
value={variable.value}
onChange={(value) => onUpdate({ value })}
/>
</div>
<MenuDialog items={menuItems}>
<button className={css.MenuButton}>
<Icon icon={IconName.MoreVertical} />
</button>
</MenuDialog>
</div>
);
}
```
### 8. Type Editor Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/TypeEditor.tsx`
```tsx
import React from 'react';
import { ConfigType } from '@noodl/runtime/src/config/types';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelNumberInput } from '@noodl-core-ui/components/property-panel/PropertyPanelNumberInput';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker';
import { PropertyPanelButton } from '@noodl-core-ui/components/property-panel/PropertyPanelButton';
interface TypeEditorProps {
type: ConfigType;
value: any;
onChange: (value: any) => void;
}
export function TypeEditor({ type, value, onChange }: TypeEditorProps) {
switch (type) {
case 'string':
return (
<PropertyPanelTextInput
value={value || ''}
onChange={onChange}
/>
);
case 'number':
return (
<PropertyPanelNumberInput
value={value ?? 0}
onChange={onChange}
/>
);
case 'boolean':
return (
<PropertyPanelCheckbox
value={value ?? false}
onChange={onChange}
/>
);
case 'color':
return (
<PropertyPanelColorPicker
value={value}
onChange={onChange}
/>
);
case 'array':
return (
<PropertyPanelButton
label="Edit Array..."
onClick={() => {
// Open array editor popup
// Reuse existing array editor from port types
}}
/>
);
case 'object':
return (
<PropertyPanelButton
label="Edit Object..."
onClick={() => {
// Open object editor popup
// Reuse existing object editor from port types
}}
/>
);
default:
return <span>Unknown type</span>;
}
}
```
### 9. Add Variable Dialog
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/dialogs/AddVariableDialog.tsx`
```tsx
import React, { useState } from 'react';
import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types';
import { validateConfigKey } from '@noodl/runtime/src/config/validation';
import { DialogRender } from '@noodl-core-ui/components/layout/DialogRender';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { Text } from '@noodl-core-ui/components/typography/Text';
interface AddVariableDialogProps {
existingKeys: string[];
editingVariable?: ConfigVariable | null;
onSave: (variable: ConfigVariable) => void;
onCancel: () => void;
}
const TYPE_OPTIONS = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'color', label: 'Color' },
{ value: 'array', label: 'Array' },
{ value: 'object', label: 'Object' }
];
export function AddVariableDialog({
existingKeys,
editingVariable,
onSave,
onCancel
}: AddVariableDialogProps) {
const [key, setKey] = useState(editingVariable?.key || '');
const [type, setType] = useState<ConfigType>(editingVariable?.type || 'string');
const [description, setDescription] = useState(editingVariable?.description || '');
const [category, setCategory] = useState(editingVariable?.category || '');
const [error, setError] = useState<string | null>(null);
const isEditing = !!editingVariable;
const handleSave = () => {
// Validate key
const validation = validateConfigKey(key);
if (!validation.valid) {
setError(validation.errors[0]);
return;
}
// Check for duplicates (unless editing same key)
if (!isEditing || key !== editingVariable?.key) {
if (existingKeys.includes(key)) {
setError('A variable with this key already exists');
return;
}
}
const variable: ConfigVariable = {
key,
type,
value: editingVariable?.value ?? getDefaultValue(type),
description: description || undefined,
category: category || undefined
};
onSave(variable);
};
return (
<DialogRender
title={isEditing ? 'Edit Variable' : 'Add Variable'}
onClose={onCancel}
footer={
<>
<PrimaryButton label="Cancel" variant="ghost" onClick={onCancel} />
<PrimaryButton label={isEditing ? 'Save' : 'Add'} onClick={handleSave} />
</>
}
>
<PropertyPanelRow label="Key">
<PropertyPanelTextInput
value={key}
onChange={(v) => { setKey(v); setError(null); }}
placeholder="myVariable"
hasError={!!error}
/>
</PropertyPanelRow>
{error && <Text textType="danger">{error}</Text>}
<PropertyPanelRow label="Type">
<PropertyPanelSelectInput
value={type}
options={TYPE_OPTIONS}
onChange={(v) => setType(v as ConfigType)}
isDisabled={isEditing} // Can't change type when editing
/>
</PropertyPanelRow>
<PropertyPanelRow label="Description">
<PropertyPanelTextInput
value={description}
onChange={setDescription}
placeholder="Optional description..."
/>
</PropertyPanelRow>
<PropertyPanelRow label="Category">
<PropertyPanelTextInput
value={category}
onChange={setCategory}
placeholder="Custom"
/>
</PropertyPanelRow>
</DialogRender>
);
}
function getDefaultValue(type: ConfigType): any {
switch (type) {
case 'string': return '';
case 'number': return 0;
case 'boolean': return false;
case 'color': return '#000000';
case 'array': return [];
case 'object': return {};
}
}
```
---
## Files to Modify
### 1. Sidebar Navigation
**File:** `packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx`
Add App Setup tab to sidebar:
```tsx
// Add to sidebar tabs
{
id: 'app-setup',
label: 'App Setup',
icon: IconName.Settings, // or appropriate icon
panel: AppSetupPanel
}
```
### 2. Migrate Project Settings
**File:** `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/`
Move the following to App Setup:
- Project name (becomes App Name in Identity)
- Consider migrating other relevant settings
---
## Styles
### Variable Group Styles
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableGroup.module.scss`
```scss
.Root {
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
padding: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.CategoryLabel {
font-size: var(--text-label-size);
font-weight: var(--text-label-weight);
letter-spacing: var(--text-label-letter-spacing);
color: var(--theme-color-fg-muted);
text-transform: uppercase;
margin-bottom: var(--spacing-2);
}
.VariablesList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
```
### Variable Row Styles
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableRow.module.scss`
```scss
.Root {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-1) 0;
}
.KeyColumn {
flex: 0 0 120px;
display: flex;
flex-direction: column;
}
.Key {
font-weight: var(--font-weight-medium);
color: var(--theme-color-fg-default);
}
.Type {
font-size: var(--font-size-xs);
color: var(--theme-color-fg-muted);
}
.ValueColumn {
flex: 1;
min-width: 0;
}
.MenuButton {
flex: 0 0 24px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-default);
color: var(--theme-color-fg-muted);
cursor: pointer;
&:hover {
background-color: var(--theme-color-bg-hover);
color: var(--theme-color-fg-default);
}
}
```
---
## Testing Checklist
### Functional Tests
- [ ] App Setup panel appears in sidebar
- [ ] Can edit App Name
- [ ] Can edit Description (multiline)
- [ ] Can upload/select Cover Image
- [ ] SEO fields show defaults when empty
- [ ] Can override SEO fields
- [ ] Can enable/disable PWA section
- [ ] PWA fields editable when enabled
- [ ] Can add new variable
- [ ] Can edit existing variable
- [ ] Can delete variable
- [ ] Type editor matches variable type
- [ ] Color picker works
- [ ] Array editor opens
- [ ] Object editor opens
- [ ] Categories group correctly
- [ ] Uncategorized → "Custom"
- [ ] Validation prevents duplicate keys
- [ ] Validation prevents reserved keys
- [ ] Validation prevents invalid key names
### Visual Tests
- [ ] Panel matches design mockup
- [ ] Sections collapsible
- [ ] Proper spacing and alignment
- [ ] Dark theme compatible
- [ ] Responsive to panel width
---
## Notes for Implementer
### Reusing Port Type Editors
The existing port type editors in `views/panels/propertyeditor/DataTypes/Ports.ts` handle most input types. Consider extracting shared components or directly importing the editor logic for consistency.
### Image Picker
Create a shared ImagePicker component that can:
- Browse project files
- Upload new images
- Accept URL input
- Show preview thumbnail
### Array/Object Editors
Reuse the existing popup editors for array and object types. These are used in the Static Array node and Function node.

View File

@@ -0,0 +1,522 @@
# CONFIG-003: App Config Node
## Overview
Create an "App Config" node that provides selected configuration values as outputs, for users who prefer visual programming over expressions.
**Estimated effort:** 10-14 hours
**Dependencies:** CONFIG-001
**Blocks:** None
---
## Objectives
1. Create App Config node with selectable variable outputs
2. Preserve output types (color outputs as color, etc.)
3. Integrate with node picker
4. Rename existing "Config" node to "Noodl Cloud Config"
5. Hide cloud data nodes when no backend connected
---
## Node Design
### App Config Node
```
┌──────────────────────────────┐
│ App Config │
├──────────────────────────────┤
│ Variables │
│ ┌──────────────────────────┐ │
│ │ ☑ appName │ │
│ │ ☑ primaryColor │ │
│ │ ☐ description │ │
│ │ ☑ apiBaseUrl │ │
│ │ ☑ menuItems │ │
│ └──────────────────────────┘ │
├──────────────────────────────┤
│ ○ appName │──→ string
│ ○ primaryColor │──→ color
│ ○ apiBaseUrl │──→ string
│ ○ menuItems │──→ array
└──────────────────────────────┘
```
---
## Files to Create
### 1. App Config Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/appconfignode.js`
```javascript
'use strict';
const { configManager } = require('../../../config/config-manager');
const AppConfigNode = {
name: 'App Config',
displayName: 'App Config',
docs: 'https://docs.noodl.net/nodes/data/app-config',
shortDesc: 'Access app configuration values defined in App Setup.',
category: 'Variables',
color: 'data',
initialize: function() {
this._internal.outputValues = {};
},
getInspectInfo() {
const selected = this._internal.selectedVariables || [];
if (selected.length === 0) {
return [{ type: 'text', value: 'No variables selected' }];
}
return selected.map(key => ({
type: 'text',
value: `${key}: ${JSON.stringify(this._internal.outputValues[key])}`
}));
},
inputs: {
variables: {
type: {
name: 'stringlist',
allowEditOnly: true,
multiline: true
},
displayName: 'Variables',
group: 'General',
set: function(value) {
// Parse selected variables from stringlist
const selected = Array.isArray(value) ? value :
(typeof value === 'string' ? value.split(',').map(s => s.trim()).filter(Boolean) : []);
this._internal.selectedVariables = selected;
// Update output values
const config = configManager.getConfig();
for (const key of selected) {
this._internal.outputValues[key] = config[key];
}
// Flag all outputs as dirty
selected.forEach(key => {
if (this.hasOutput('out-' + key)) {
this.flagOutputDirty('out-' + key);
}
});
}
}
},
outputs: {},
methods: {
registerOutputIfNeeded: function(name) {
if (this.hasOutput(name)) return;
if (name.startsWith('out-')) {
const key = name.substring(4);
const config = configManager.getConfig();
const variable = configManager.getVariable(key);
// Determine output type based on variable type or infer from value
let outputType = '*';
if (variable) {
outputType = variable.type;
} else if (key in config) {
// Built-in config - infer type
const value = config[key];
if (typeof value === 'string') {
outputType = key.toLowerCase().includes('color') ? 'color' : 'string';
} else if (typeof value === 'number') {
outputType = 'number';
} else if (typeof value === 'boolean') {
outputType = 'boolean';
} else if (Array.isArray(value)) {
outputType = 'array';
} else if (typeof value === 'object') {
outputType = 'object';
}
}
this.registerOutput(name, {
type: outputType,
getter: function() {
return this._internal.outputValues[key];
}
});
}
}
}
};
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get available config keys
const allKeys = getAvailableConfigKeys(graphModel);
// Get selected variables
const selected = parameters.variables ?
(Array.isArray(parameters.variables) ? parameters.variables :
parameters.variables.split(',').map(s => s.trim()).filter(Boolean)) : [];
// Create output ports for selected variables
selected.forEach(key => {
const variable = graphModel.getMetaData('appConfig')?.variables?.find(v => v.key === key);
let type = '*';
if (variable) {
type = variable.type;
} else if (isBuiltInKey(key)) {
type = getBuiltInType(key);
}
ports.push({
name: 'out-' + key,
displayName: key,
plug: 'output',
type: type,
group: 'Values'
});
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
function getAvailableConfigKeys(graphModel) {
const config = graphModel.getMetaData('appConfig') || {};
const keys = [];
// Built-in keys
keys.push('appName', 'description', 'coverImage');
keys.push('ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor');
keys.push('pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor');
// Custom variables
if (config.variables) {
config.variables.forEach(v => keys.push(v.key));
}
return keys;
}
function isBuiltInKey(key) {
const builtIn = [
'appName', 'description', 'coverImage',
'ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor',
'pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor'
];
return builtIn.includes(key);
}
function getBuiltInType(key) {
const colorKeys = ['themeColor', 'pwaBackgroundColor'];
const booleanKeys = ['pwaEnabled'];
if (colorKeys.includes(key)) return 'color';
if (booleanKeys.includes(key)) return 'boolean';
return 'string';
}
module.exports = {
node: AppConfigNode,
setup: function(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
node.on('parameterUpdated', function(event) {
if (event.name === 'variables') {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
}
});
// Also update when app config changes
graphModel.on('metadataChanged.appConfig', function() {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.App Config', function(node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('App Config')) {
_managePortsForNode(node);
}
});
}
};
```
### 2. Variable Selector Property Editor
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ConfigVariableSelector.tsx`
```tsx
import React, { useState, useMemo } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { Checkbox } from '@noodl-core-ui/components/inputs/Checkbox';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Text } from '@noodl-core-ui/components/typography/Text';
import css from './ConfigVariableSelector.module.scss';
interface ConfigVariableSelectorProps {
value: string[];
onChange: (value: string[]) => void;
}
interface VariableOption {
key: string;
type: string;
category: string;
}
export function ConfigVariableSelector({ value, onChange }: ConfigVariableSelectorProps) {
const selected = new Set(value);
const options = useMemo(() => {
const config = ProjectModel.instance.getAppConfig();
const opts: VariableOption[] = [];
// Built-in: Identity
opts.push(
{ key: 'appName', type: 'string', category: 'Identity' },
{ key: 'description', type: 'string', category: 'Identity' },
{ key: 'coverImage', type: 'string', category: 'Identity' }
);
// Built-in: SEO
opts.push(
{ key: 'ogTitle', type: 'string', category: 'SEO' },
{ key: 'ogDescription', type: 'string', category: 'SEO' },
{ key: 'ogImage', type: 'string', category: 'SEO' },
{ key: 'favicon', type: 'string', category: 'SEO' },
{ key: 'themeColor', type: 'color', category: 'SEO' }
);
// Built-in: PWA
opts.push(
{ key: 'pwaEnabled', type: 'boolean', category: 'PWA' },
{ key: 'pwaShortName', type: 'string', category: 'PWA' },
{ key: 'pwaDisplay', type: 'string', category: 'PWA' },
{ key: 'pwaStartUrl', type: 'string', category: 'PWA' },
{ key: 'pwaBackgroundColor', type: 'color', category: 'PWA' }
);
// Custom variables
config.variables.forEach(v => {
opts.push({
key: v.key,
type: v.type,
category: v.category || 'Custom'
});
});
return opts;
}, []);
// Group by category
const grouped = useMemo(() => {
const groups: Record<string, VariableOption[]> = {};
options.forEach(opt => {
if (!groups[opt.category]) {
groups[opt.category] = [];
}
groups[opt.category].push(opt);
});
return groups;
}, [options]);
const toggleVariable = (key: string) => {
const newSelected = new Set(selected);
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
onChange(Array.from(newSelected));
};
return (
<div className={css.Root}>
{Object.entries(grouped).map(([category, vars]) => (
<div key={category} className={css.Category}>
<Text className={css.CategoryLabel}>{category}</Text>
{vars.map(v => (
<label key={v.key} className={css.VariableRow}>
<Checkbox
isChecked={selected.has(v.key)}
onChange={() => toggleVariable(v.key)}
/>
<span className={css.VariableKey}>{v.key}</span>
<span className={css.VariableType}>{v.type}</span>
</label>
))}
</div>
))}
</div>
);
}
```
---
## Files to Modify
### 1. Rename Existing Config Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/confignode.js`
```javascript
// Change:
name: 'Config',
displayName: 'Config',
// To:
name: 'Noodl Cloud Config',
displayName: 'Noodl Cloud Config',
shortDesc: 'Access configuration from Noodl Cloud Service.',
```
### 2. Update Node Library Export
**File:** `packages/noodl-runtime/src/nodelibraryexport.js`
```javascript
// Add App Config node
require('./src/nodes/std-library/data/appconfignode'),
// Update node picker categories
{
name: 'Variables',
items: [
'Variable2',
'SetVariable',
'App Config', // Add here
// ...
]
}
```
### 3. Cloud Nodes Visibility
**File:** `packages/noodl-editor/src/editor/src/views/nodepicker/NodePicker.tsx` (or relevant file)
Add logic to hide cloud data nodes when no backend is connected:
```typescript
function shouldShowCloudNodes(): boolean {
const cloudService = ProjectModel.instance.getMetaData('cloudservices');
return cloudService && cloudService.endpoint;
}
function getFilteredCategories(categories: Category[]): Category[] {
const showCloud = shouldShowCloudNodes();
return categories.map(category => {
if (category.name === 'Cloud Data' || category.name === 'Read & Write Data') {
// Filter out cloud-specific nodes if no backend
if (!showCloud) {
const cloudNodes = [
'DbCollection2',
'DbModel2',
'Noodl Cloud Config', // Renamed node
// ... other cloud nodes
];
const filteredItems = category.items.filter(
item => !cloudNodes.includes(item)
);
// Add warning message if category is now empty or reduced
return {
...category,
items: filteredItems,
message: showCloud ? undefined : 'Please add a backend service to use cloud data nodes.'
};
}
}
return category;
});
}
```
### 4. Register Node Type Editor
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts`
Add handler for the variables property in App Config node:
```typescript
// In the port type handlers
if (nodeName === 'App Config' && portName === 'variables') {
return {
component: ConfigVariableSelector,
// ... props
};
}
```
---
## Testing Checklist
### App Config Node
- [ ] Node appears in node picker under Variables
- [ ] Can select/deselect variables in property panel
- [ ] Selected variables appear as outputs
- [ ] Output types match variable types
- [ ] Color variables output as color type
- [ ] Array variables output as array type
- [ ] Values are correct at runtime
- [ ] Node inspector shows current values
- [ ] Updates when app config changes in editor
### Cloud Node Visibility
- [ ] Cloud nodes hidden when no backend
- [ ] Warning message shows in category
- [ ] Cloud nodes visible when backend connected
- [ ] Existing cloud nodes in projects still work
- [ ] Renamed node appears as "Noodl Cloud Config"
### Variable Selector
- [ ] Shows all built-in config keys
- [ ] Shows custom variables
- [ ] Grouped by category
- [ ] Shows variable type
- [ ] Checkbox toggles selection
- [ ] Multiple selection works
---
## Notes for Implementer
### Dynamic Port Pattern
This node uses the dynamic port pattern where outputs are created based on the `variables` property. Study existing nodes like `DbCollection2` for reference on how to:
- Parse the stringlist input
- Create dynamic output ports
- Handle port updates when parameters change
### Type Preservation
It's important that output types match the declared variable types so that connections in the graph validate correctly. A color output should only connect to color inputs, etc.
### Cloud Service Detection
The cloud service information is stored in project metadata under `cloudservices`. Check for both the existence of the object and a valid endpoint URL.

View File

@@ -0,0 +1,397 @@
# CONFIG-004: SEO Build Integration
## Overview
Integrate app configuration values into the HTML build process to generate proper SEO meta tags, Open Graph tags, and favicon links.
**Estimated effort:** 8-10 hours
**Dependencies:** CONFIG-001, CONFIG-002
**Blocks:** None
---
## Objectives
1. Inject `<title>` tag from appName
2. Inject meta description tag
3. Inject Open Graph tags (og:title, og:description, og:image)
4. Inject Twitter Card tags
5. Inject favicon link
6. Inject theme-color meta tag
7. Support canonical URL (optional)
---
## Generated HTML Structure
```html
<!DOCTYPE html>
<html>
<head>
<!-- Basic Meta -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Amazing App</title>
<meta name="description" content="A visual programming app that makes building easy">
<!-- Theme Color -->
<meta name="theme-color" content="#d21f3c">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="My Amazing App">
<meta property="og:description" content="A visual programming app that makes building easy">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:url" content="https://example.com">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="My Amazing App">
<meta name="twitter:description" content="A visual programming app that makes building easy">
<meta name="twitter:image" content="https://example.com/og-image.jpg">
<!-- PWA Manifest (if enabled) -->
<link rel="manifest" href="/manifest.json">
{{#customHeadCode#}}
</head>
```
---
## Files to Modify
### 1. HTML Processor
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/html-processor.ts`
Extend the existing processor:
```typescript
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';
export interface HtmlProcessorParameters {
title?: string;
headCode?: string;
indexJsPath?: string;
baseUrl?: string;
envVariables?: Record<string, string>;
}
export class HtmlProcessor {
constructor(public readonly project: ProjectModel) {}
public async process(content: string, parameters: HtmlProcessorParameters): Promise<string> {
const settings = this.project.getSettings();
const appConfig = this.project.getAppConfig();
let baseUrl = parameters.baseUrl || settings.baseUrl || '/';
if (!baseUrl.endsWith('/')) {
baseUrl = baseUrl + '/';
}
// Title from app config, falling back to settings, then default
const title = parameters.title || appConfig.identity.appName || settings.htmlTitle || 'Noodl App';
// Build head code with SEO tags
let headCode = this.generateSEOTags(appConfig, baseUrl);
headCode += settings.headCode || '';
if (parameters.headCode) {
headCode += parameters.headCode;
}
if (baseUrl !== '/') {
headCode = `<base href="${baseUrl}" target="_blank" />\n` + headCode;
}
// Inject into template
let injected = await this.injectIntoHtml(content, baseUrl);
injected = injected.replace('{{#title#}}', this.escapeHtml(title));
injected = injected.replace('{{#customHeadCode#}}', headCode);
injected = injected.replace(/%baseUrl%/g, baseUrl);
// ... rest of existing processing
return injected;
}
private generateSEOTags(config: AppConfig, baseUrl: string): string {
const tags: string[] = [];
// Description
const description = config.seo.ogDescription || config.identity.description;
if (description) {
tags.push(`<meta name="description" content="${this.escapeAttr(description)}">`);
}
// Theme color
if (config.seo.themeColor) {
tags.push(`<meta name="theme-color" content="${this.escapeAttr(config.seo.themeColor)}">`);
}
// Favicon
if (config.seo.favicon) {
const faviconPath = this.resolveAssetPath(config.seo.favicon, baseUrl);
const faviconType = this.getFaviconType(config.seo.favicon);
tags.push(`<link rel="icon" type="${faviconType}" href="${faviconPath}">`);
// Apple touch icon (use og:image or favicon)
const touchIcon = config.seo.ogImage || config.identity.coverImage || config.seo.favicon;
if (touchIcon) {
tags.push(`<link rel="apple-touch-icon" href="${this.resolveAssetPath(touchIcon, baseUrl)}">`);
}
}
// Open Graph
tags.push(...this.generateOpenGraphTags(config, baseUrl));
// Twitter Card
tags.push(...this.generateTwitterTags(config, baseUrl));
// PWA Manifest
if (config.pwa?.enabled) {
tags.push(`<link rel="manifest" href="${baseUrl}manifest.json">`);
}
return tags.join('\n ') + '\n';
}
private generateOpenGraphTags(config: AppConfig, baseUrl: string): string[] {
const tags: string[] = [];
tags.push(`<meta property="og:type" content="website">`);
const ogTitle = config.seo.ogTitle || config.identity.appName;
if (ogTitle) {
tags.push(`<meta property="og:title" content="${this.escapeAttr(ogTitle)}">`);
}
const ogDescription = config.seo.ogDescription || config.identity.description;
if (ogDescription) {
tags.push(`<meta property="og:description" content="${this.escapeAttr(ogDescription)}">`);
}
const ogImage = config.seo.ogImage || config.identity.coverImage;
if (ogImage) {
// OG image should be absolute URL
const imagePath = this.resolveAssetPath(ogImage, baseUrl);
tags.push(`<meta property="og:image" content="${imagePath}">`);
}
// og:url would need the deployment URL - could be added via env variable
// tags.push(`<meta property="og:url" content="${deployUrl}">`);
return tags;
}
private generateTwitterTags(config: AppConfig, baseUrl: string): string[] {
const tags: string[] = [];
// Use large image card if we have an image
const hasImage = config.seo.ogImage || config.identity.coverImage;
tags.push(`<meta name="twitter:card" content="${hasImage ? 'summary_large_image' : 'summary'}">`);
const title = config.seo.ogTitle || config.identity.appName;
if (title) {
tags.push(`<meta name="twitter:title" content="${this.escapeAttr(title)}">`);
}
const description = config.seo.ogDescription || config.identity.description;
if (description) {
tags.push(`<meta name="twitter:description" content="${this.escapeAttr(description)}">`);
}
if (hasImage) {
const imagePath = this.resolveAssetPath(
config.seo.ogImage || config.identity.coverImage!,
baseUrl
);
tags.push(`<meta name="twitter:image" content="${imagePath}">`);
}
return tags;
}
private resolveAssetPath(path: string, baseUrl: string): string {
if (!path) return '';
// Already absolute URL
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// Relative path - prepend base URL
const cleanPath = path.startsWith('/') ? path.substring(1) : path;
return baseUrl + cleanPath;
}
private getFaviconType(path: string): string {
if (path.endsWith('.ico')) return 'image/x-icon';
if (path.endsWith('.png')) return 'image/png';
if (path.endsWith('.svg')) return 'image/svg+xml';
return 'image/png';
}
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private escapeAttr(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private injectIntoHtml(template: string, pathPrefix: string) {
return new Promise<string>((resolve) => {
ProjectModules.instance.injectIntoHtml(
this.project._retainedProjectDirectory,
template,
pathPrefix,
resolve
);
});
}
}
```
### 2. Update HTML Template
**File:** `packages/noodl-viewer-react/static/index.html` (or equivalent)
Ensure template has the right placeholders:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{#title#}}</title>
{{#customHeadCode#}}
</head>
<body>
<div id="root"></div>
<%index_js%>
</body>
</html>
```
---
## Asset Handling
### Image Resolution
Images referenced in config (coverImage, ogImage, favicon) can be:
1. **Project file paths**: `/assets/images/cover.png` - copied to build output
2. **External URLs**: `https://example.com/image.jpg` - used as-is
3. **Uploaded files**: Handled through the existing project file system
**File:** Add to build process in `packages/noodl-editor/src/editor/src/utils/compilation/build/`
```typescript
async function copyConfigAssets(project: ProjectModel, outputDir: string): Promise<void> {
const config = project.getAppConfig();
const assets: string[] = [];
// Collect asset paths
if (config.identity.coverImage && !isExternalUrl(config.identity.coverImage)) {
assets.push(config.identity.coverImage);
}
if (config.seo.ogImage && !isExternalUrl(config.seo.ogImage)) {
assets.push(config.seo.ogImage);
}
if (config.seo.favicon && !isExternalUrl(config.seo.favicon)) {
assets.push(config.seo.favicon);
}
// Copy each asset to output
for (const asset of assets) {
const sourcePath = path.join(project._retainedProjectDirectory, asset);
const destPath = path.join(outputDir, asset);
if (await fileExists(sourcePath)) {
await ensureDir(path.dirname(destPath));
await copyFile(sourcePath, destPath);
}
}
}
function isExternalUrl(path: string): boolean {
return path.startsWith('http://') || path.startsWith('https://');
}
```
---
## Testing Checklist
### Build Output Tests
- [ ] Title tag contains app name
- [ ] Meta description present
- [ ] Theme color meta tag present
- [ ] Favicon link correct type and path
- [ ] Apple touch icon link present
- [ ] og:type is "website"
- [ ] og:title matches config
- [ ] og:description matches config
- [ ] og:image URL correct
- [ ] Twitter card type correct
- [ ] Twitter tags mirror OG tags
- [ ] Manifest link present when PWA enabled
- [ ] Manifest link absent when PWA disabled
### Asset Handling Tests
- [ ] Local image paths copied to build
- [ ] External URLs used as-is
- [ ] Missing assets don't break build
- [ ] Paths correct with custom baseUrl
### Edge Cases
- [ ] Empty description doesn't create empty tag
- [ ] Missing favicon doesn't break build
- [ ] Special characters escaped in meta content
- [ ] Very long descriptions truncated appropriately
---
## Notes for Implementer
### OG Image Requirements
Open Graph images have specific requirements:
- Minimum 200x200 pixels
- Recommended 1200x630 pixels
- Maximum 8MB file size
Consider adding validation in the App Setup panel.
### Canonical URL
The canonical URL (`og:url`) requires knowing the deployment URL, which isn't always known at build time. Options:
1. Add a `canonicalUrl` field to app config
2. Inject via environment variable at deploy time
3. Use JavaScript to set dynamically (loses SEO benefit)
### Testing SEO Output
Use tools like:
- https://developers.facebook.com/tools/debug/ (OG tags)
- https://cards-dev.twitter.com/validator (Twitter cards)
- Browser DevTools to inspect head tags

View File

@@ -0,0 +1,471 @@
# CONFIG-005: PWA Manifest Generation
## Overview
Generate a valid `manifest.json` file and auto-scale app icons from a single source image for Progressive Web App support.
**Estimated effort:** 10-14 hours
**Dependencies:** CONFIG-001, CONFIG-002
**Blocks:** None (enables Phase 5 Capacitor integration)
---
## Objectives
1. Generate `manifest.json` from app config
2. Auto-scale icons from single 512x512 source
3. Copy icons to build output
4. Validate PWA requirements
5. Generate service worker stub (optional)
---
## Generated manifest.json
```json
{
"name": "My Amazing App",
"short_name": "My App",
"description": "A visual programming app that makes building easy",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#d21f3c",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
```
---
## Files to Create
### 1. Manifest Generator
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/manifest-processor.ts`
```typescript
import * as path from 'path';
import * as fs from 'fs-extra';
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';
// Standard PWA icon sizes
const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
export interface ManifestProcessorOptions {
baseUrl?: string;
outputDir: string;
}
export class ManifestProcessor {
constructor(public readonly project: ProjectModel) {}
public async process(options: ManifestProcessorOptions): Promise<void> {
const config = this.project.getAppConfig();
// Only generate if PWA is enabled
if (!config.pwa?.enabled) {
return;
}
const baseUrl = options.baseUrl || '/';
// Generate icons from source
await this.generateIcons(config, options.outputDir, baseUrl);
// Generate manifest.json
const manifest = this.generateManifest(config, baseUrl);
const manifestPath = path.join(options.outputDir, 'manifest.json');
await fs.writeJson(manifestPath, manifest, { spaces: 2 });
}
private generateManifest(config: AppConfig, baseUrl: string): object {
const manifest: Record<string, any> = {
name: config.identity.appName,
short_name: config.pwa?.shortName || config.identity.appName,
description: config.identity.description || '',
start_url: config.pwa?.startUrl || '/',
display: config.pwa?.display || 'standalone',
background_color: config.pwa?.backgroundColor || '#ffffff',
theme_color: config.seo.themeColor || '#000000',
icons: this.generateIconsManifest(baseUrl)
};
// Optional fields
if (config.identity.description) {
manifest.description = config.identity.description;
}
return manifest;
}
private generateIconsManifest(baseUrl: string): object[] {
return ICON_SIZES.map(size => ({
src: `${baseUrl}icons/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png'
}));
}
private async generateIcons(
config: AppConfig,
outputDir: string,
baseUrl: string
): Promise<void> {
const sourceIcon = config.pwa?.sourceIcon;
if (!sourceIcon) {
console.warn('PWA enabled but no source icon provided. Using placeholder.');
await this.generatePlaceholderIcons(outputDir);
return;
}
// Resolve source icon path
const sourcePath = this.resolveAssetPath(sourceIcon);
if (!await fs.pathExists(sourcePath)) {
console.warn(`Source icon not found: ${sourcePath}. Using placeholder.`);
await this.generatePlaceholderIcons(outputDir);
return;
}
// Ensure icons directory exists
const iconsDir = path.join(outputDir, 'icons');
await fs.ensureDir(iconsDir);
// Generate each size
for (const size of ICON_SIZES) {
const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
await this.resizeIcon(sourcePath, outputPath, size);
}
}
private async resizeIcon(
sourcePath: string,
outputPath: string,
size: number
): Promise<void> {
// Use sharp for image resizing
const sharp = require('sharp');
try {
await sharp(sourcePath)
.resize(size, size, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 0 }
})
.png()
.toFile(outputPath);
} catch (error) {
console.error(`Failed to resize icon to ${size}x${size}:`, error);
throw error;
}
}
private async generatePlaceholderIcons(outputDir: string): Promise<void> {
const iconsDir = path.join(outputDir, 'icons');
await fs.ensureDir(iconsDir);
// Generate simple colored placeholder icons
const sharp = require('sharp');
for (const size of ICON_SIZES) {
const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
// Create a simple colored square as placeholder
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 100, g: 100, b: 100, alpha: 1 }
}
})
.png()
.toFile(outputPath);
}
}
private resolveAssetPath(assetPath: string): string {
if (path.isAbsolute(assetPath)) {
return assetPath;
}
return path.join(this.project._retainedProjectDirectory, assetPath);
}
}
```
### 2. Icon Validation
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/utils/icon-validation.ts`
```typescript
export interface IconValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
dimensions?: { width: number; height: number };
}
export async function validatePWAIcon(filePath: string): Promise<IconValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
try {
const sharp = require('sharp');
const metadata = await sharp(filePath).metadata();
const { width, height, format } = metadata;
// Check format
if (format !== 'png') {
errors.push('Icon must be a PNG file');
}
// Check dimensions
if (!width || !height) {
errors.push('Could not read image dimensions');
} else {
// Check if square
if (width !== height) {
errors.push(`Icon must be square. Current: ${width}x${height}`);
}
// Check minimum size
if (width < 512 || height < 512) {
errors.push(`Icon must be at least 512x512 pixels. Current: ${width}x${height}`);
}
// Warn if not exactly 512x512
if (width !== 512 && width > 512) {
warnings.push(`Icon will be scaled down from ${width}x${height} to required sizes`);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
dimensions: width && height ? { width, height } : undefined
};
} catch (error) {
return {
valid: false,
errors: [`Failed to read image: ${error.message}`],
warnings: []
};
}
}
```
### 3. Service Worker Stub (Optional)
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/templates/sw.js`
```javascript
// Basic service worker for PWA install prompt
// This is a minimal stub - users can replace with their own
const CACHE_NAME = 'app-cache-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html'
];
// Install event - cache basic assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS_TO_CACHE))
);
});
// Fetch event - network first, fallback to cache
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
});
```
---
## Files to Modify
### 1. Build Pipeline
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/build.ts` (or equivalent)
Add manifest processing to build pipeline:
```typescript
import { ManifestProcessor } from './processors/manifest-processor';
async function buildProject(project: ProjectModel, options: BuildOptions): Promise<void> {
// ... existing build steps ...
// Generate PWA manifest and icons
const manifestProcessor = new ManifestProcessor(project);
await manifestProcessor.process({
baseUrl: options.baseUrl,
outputDir: options.outputDir
});
// ... rest of build ...
}
```
### 2. Deploy Build
Ensure the deploy process also runs manifest generation:
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/deploy/`
```typescript
// Include manifest generation in deploy build
await manifestProcessor.process({
baseUrl: deployConfig.baseUrl || '/',
outputDir: deployOutputDir
});
```
---
## Icon Size Reference
| Size | Usage |
|------|-------|
| 72x72 | Android home screen (legacy) |
| 96x96 | Android home screen |
| 128x128 | Chrome Web Store |
| 144x144 | IE/Edge pinned sites |
| 152x152 | iPad home screen |
| 192x192 | Android home screen (modern), Chrome install |
| 384x384 | Android splash screen |
| 512x512 | Android splash screen, PWA install prompt |
---
## Testing Checklist
### Manifest Generation
- [ ] manifest.json created when PWA enabled
- [ ] manifest.json not created when PWA disabled
- [ ] name field matches appName
- [ ] short_name matches config or falls back to name
- [ ] description present if configured
- [ ] start_url correct
- [ ] display mode correct
- [ ] background_color correct
- [ ] theme_color correct
- [ ] icons array has all 8 sizes
### Icon Generation
- [ ] All 8 icon sizes generated
- [ ] Icons are valid PNG files
- [ ] Icons maintain aspect ratio
- [ ] Icons have correct dimensions
- [ ] Placeholder icons generated when no source
- [ ] Warning shown when source icon missing
- [ ] Error shown for invalid source format
### Validation
- [ ] Non-square icons rejected
- [ ] Non-PNG icons rejected
- [ ] Too-small icons rejected
- [ ] Valid icons pass validation
- [ ] Warnings for oversized icons
### Integration
- [ ] manifest.json linked in HTML head
- [ ] Icons accessible at correct paths
- [ ] PWA install prompt appears in Chrome
- [ ] App installs correctly
- [ ] Installed app shows correct icon
- [ ] Splash screen displays correctly
---
## Notes for Implementer
### Sharp Dependency
The `sharp` library is required for image processing. It should already be available in the editor dependencies. If not:
```bash
npm install sharp --save
```
### Service Worker Considerations
The basic service worker stub is intentionally minimal. Advanced features like:
- Offline caching strategies
- Push notifications
- Background sync
...should be implemented by users through custom code or future Noodl features.
### Capacitor Integration (Phase 5)
The icon generation logic will be reused for Capacitor builds:
- iOS requires specific sizes: 1024, 180, 167, 152, 120, 76, 60, 40, 29, 20
- Android uses adaptive icons with foreground/background layers
Consider making icon generation configurable for different target platforms.
### Testing PWA Installation
Test PWA installation on:
- Chrome (desktop and mobile)
- Edge (desktop)
- Safari (iOS - via Add to Home Screen)
- Firefox (Android)
Use Chrome DevTools > Application > Manifest to verify manifest is valid.

View File

@@ -0,0 +1,403 @@
# CONFIG-006: Expression System Integration
## Overview
Integrate `Noodl.Config` access into the expression evaluator with autocomplete support for config keys.
**Estimated effort:** 4-6 hours
**Dependencies:** CONFIG-001, TASK-006 (Expression System Overhaul)
**Blocks:** None
---
## Objectives
1. Make `Noodl.Config` accessible in Expression nodes
2. Add autocomplete for config keys in expression editor
3. Provide type hints for config values
4. Handle undefined config keys gracefully
---
## Expression Usage Examples
```javascript
// Simple value access
Noodl.Config.appName
// Color in style binding
Noodl.Config.primaryColor
// Conditional with config
Noodl.Config.pwaEnabled ? "Install App" : "Use in Browser"
// Template literal
`Welcome to ${Noodl.Config.appName}!`
// Array access
Noodl.Config.menuItems[0].label
// Object property
Noodl.Config.apiSettings.timeout
```
---
## Files to Modify
### 1. Expression Evaluator Context
**File:** `packages/noodl-runtime/src/nodes/std-library/expression.js` (or new expression-evaluator module from TASK-006)
Add Noodl.Config to the expression context:
```javascript
// In the expression preamble or context setup
const { configManager } = require('../../../config/config-manager');
function createExpressionContext() {
return {
// Math helpers
min: Math.min,
max: Math.max,
cos: Math.cos,
sin: Math.sin,
// ... other existing helpers ...
// Noodl globals
Noodl: {
Variables: Model.get('--ndl--global-variables'),
Objects: objectsProxy,
Arrays: arraysProxy,
Config: configManager.getConfig() // Add Config access
}
};
}
```
If following TASK-006's enhanced expression node pattern:
**File:** `packages/noodl-runtime/src/expression-evaluator.ts`
```typescript
import { configManager } from './config/config-manager';
export function createExpressionContext(nodeContext: NodeContext): ExpressionContext {
return {
// ... existing context ...
Noodl: {
// ... existing Noodl properties ...
Config: configManager.getConfig()
}
};
}
```
### 2. Expression Editor Autocomplete
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/` (or relevant autocomplete module)
Add Config keys to autocomplete suggestions:
```typescript
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';
interface AutocompleteItem {
label: string;
kind: 'property' | 'method' | 'variable';
detail?: string;
documentation?: string;
}
function getConfigAutocompletions(prefix: string): AutocompleteItem[] {
const config = ProjectModel.instance.getAppConfig();
const items: AutocompleteItem[] = [];
// Built-in config keys
const builtInKeys = [
{ key: 'appName', type: 'string', doc: 'Application name' },
{ key: 'description', type: 'string', doc: 'Application description' },
{ key: 'coverImage', type: 'string', doc: 'Cover image path' },
{ key: 'ogTitle', type: 'string', doc: 'Open Graph title' },
{ key: 'ogDescription', type: 'string', doc: 'Open Graph description' },
{ key: 'ogImage', type: 'string', doc: 'Open Graph image' },
{ key: 'favicon', type: 'string', doc: 'Favicon path' },
{ key: 'themeColor', type: 'color', doc: 'Theme color' },
{ key: 'pwaEnabled', type: 'boolean', doc: 'PWA enabled state' },
{ key: 'pwaShortName', type: 'string', doc: 'PWA short name' },
{ key: 'pwaDisplay', type: 'string', doc: 'PWA display mode' },
{ key: 'pwaStartUrl', type: 'string', doc: 'PWA start URL' },
{ key: 'pwaBackgroundColor', type: 'color', doc: 'PWA background color' }
];
// Add built-in keys
for (const { key, type, doc } of builtInKeys) {
if (key.startsWith(prefix)) {
items.push({
label: key,
kind: 'property',
detail: type,
documentation: doc
});
}
}
// Add custom variables
for (const variable of config.variables) {
if (variable.key.startsWith(prefix)) {
items.push({
label: variable.key,
kind: 'property',
detail: variable.type,
documentation: variable.description
});
}
}
return items;
}
// In the autocomplete provider
function provideCompletions(
model: editor.ITextModel,
position: Position
): CompletionList {
const lineContent = model.getLineContent(position.lineNumber);
const beforeCursor = lineContent.substring(0, position.column - 1);
// Check if typing after "Noodl.Config."
const configMatch = beforeCursor.match(/Noodl\.Config\.(\w*)$/);
if (configMatch) {
const prefix = configMatch[1];
const items = getConfigAutocompletions(prefix);
return {
suggestions: items.map(item => ({
label: item.label,
kind: monaco.languages.CompletionItemKind.Property,
detail: item.detail,
documentation: item.documentation,
insertText: item.label,
range: new Range(
position.lineNumber,
position.column - prefix.length,
position.lineNumber,
position.column
)
}))
};
}
// Check if typing "Noodl."
const noodlMatch = beforeCursor.match(/Noodl\.(\w*)$/);
if (noodlMatch) {
const prefix = noodlMatch[1];
const noodlProps = ['Variables', 'Objects', 'Arrays', 'Config'];
return {
suggestions: noodlProps
.filter(p => p.startsWith(prefix))
.map(prop => ({
label: prop,
kind: monaco.languages.CompletionItemKind.Module,
insertText: prop,
range: new Range(
position.lineNumber,
position.column - prefix.length,
position.lineNumber,
position.column
)
}))
};
}
return { suggestions: [] };
}
```
### 3. Type Hints
**File:** Add type inference for config values
```typescript
function getConfigValueType(key: string): string {
const config = ProjectModel.instance.getAppConfig();
// Check custom variables first
const variable = config.variables.find(v => v.key === key);
if (variable) {
return variable.type;
}
// Built-in type mappings
const typeMap: Record<string, string> = {
appName: 'string',
description: 'string',
coverImage: 'string',
ogTitle: 'string',
ogDescription: 'string',
ogImage: 'string',
favicon: 'string',
themeColor: 'color',
pwaEnabled: 'boolean',
pwaShortName: 'string',
pwaDisplay: 'string',
pwaStartUrl: 'string',
pwaBackgroundColor: 'color'
};
return typeMap[key] || 'any';
}
```
### 4. Error Handling
**File:** `packages/noodl-runtime/src/config/config-manager.ts`
Ensure graceful handling of undefined keys:
```typescript
// In the config proxy (from CONFIG-001)
get(target, prop: string) {
if (prop in target) {
return target[prop];
}
// Log warning in development
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Noodl.Config.${prop} is not defined. ` +
`Add it in App Setup or check for typos.`
);
}
return undefined;
}
```
---
## Type Declarations Update
**File:** `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
Enhance the Config type declaration with JSDoc:
```typescript
declare namespace Noodl {
/**
* App configuration values defined in App Setup.
*
* Config values are static and cannot be modified at runtime.
* Use `Noodl.Variables` for values that need to change.
*
* @example
* // Access app name
* const name = Noodl.Config.appName;
*
* // Use in template literal
* const greeting = `Welcome to ${Noodl.Config.appName}!`;
*
* // Access custom variable
* const color = Noodl.Config.primaryColor;
*
* @see https://docs.noodl.net/config
*/
const Config: Readonly<{
/** Application name */
readonly appName: string;
/** Application description */
readonly description: string;
/** Cover image path or URL */
readonly coverImage?: string;
/** Open Graph title (defaults to appName) */
readonly ogTitle: string;
/** Open Graph description (defaults to description) */
readonly ogDescription: string;
/** Open Graph image path or URL */
readonly ogImage?: string;
/** Favicon path or URL */
readonly favicon?: string;
/** Theme color (hex format) */
readonly themeColor?: string;
/** Whether PWA is enabled */
readonly pwaEnabled: boolean;
/** PWA short name */
readonly pwaShortName?: string;
/** PWA display mode */
readonly pwaDisplay?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
/** PWA start URL */
readonly pwaStartUrl?: string;
/** PWA background color */
readonly pwaBackgroundColor?: string;
/** Custom configuration variables */
readonly [key: string]: any;
}>;
}
```
---
## Testing Checklist
### Expression Access
- [ ] `Noodl.Config.appName` returns correct value
- [ ] `Noodl.Config.primaryColor` returns color value
- [ ] `Noodl.Config.menuItems` returns array
- [ ] Nested access works: `Noodl.Config.menuItems[0].label`
- [ ] Template literals work: `` `Hello ${Noodl.Config.appName}` ``
- [ ] Undefined keys return undefined (no crash)
- [ ] Warning logged for undefined keys in dev
### Autocomplete
- [ ] Typing "Noodl." suggests "Config"
- [ ] Typing "Noodl.Config." shows all config keys
- [ ] Built-in keys show correct types
- [ ] Custom variables appear in suggestions
- [ ] Variable descriptions show in autocomplete
- [ ] Autocomplete filters as you type
### Integration
- [ ] Works in Expression nodes
- [ ] Works in Function nodes
- [ ] Works in Script nodes
- [ ] Works in inline expressions (if TASK-006 complete)
- [ ] Values update when config changes (editor refresh)
---
## Notes for Implementer
### TASK-006 Dependency
This task should be implemented alongside or after TASK-006 (Expression System Overhaul). If TASK-006 introduces a new expression evaluator module, integrate Config there. Otherwise, update the existing expression.js.
### Monaco Editor
The autocomplete examples assume Monaco editor is used. Adjust the implementation based on the actual editor component used in the expression input fields.
### Performance
The config object is frozen and created once at startup. Accessing it in expressions should have minimal performance impact. However, avoid calling `getConfig()` repeatedly - cache the reference.
### Cloud Functions
Remember to also update `packages/noodl-viewer-cloud/src/noodl-js-api.js` if cloud functions need Config access.

View File

@@ -0,0 +1,367 @@
# TASK-007: App Configuration & Environment System
## Overview
A new "App Setup" sidebar panel for defining app-wide configuration values (`Noodl.Config`), SEO metadata, PWA manifest generation, and an App Config node for accessing values without expressions.
**Estimated effort:** 64-86 hours
**Priority:** High - Foundation for theming, SEO, PWA, and deployment features
**Phase:** 3 (Editor UX Overhaul)
---
## Problem Statement
### Current Pain Points
1. **No central place for app metadata**: App name, description, and SEO values are scattered or missing
2. **Global variables require node clutter**: Users create Object/Variables nodes at App level and wire them everywhere, creating visual noise
3. **No PWA support**: Users must manually create manifest.json and configure service workers
4. **SEO is an afterthought**: No built-in way to set meta tags, Open Graph, or favicon
5. **Theming is manual**: No standard pattern for defining color palettes or configuration values
### User Stories
- *"I want to define my app's primary color once and reference it everywhere"*
- *"I want my app to appear correctly when shared on social media"*
- *"I want to add my app to home screen on mobile with proper icons"*
- *"I want a simple way to access config values without writing expressions"*
---
## Solution
### New Namespace: `Noodl.Config`
A **static, immutable** configuration namespace separate from `Noodl.Variables`:
```javascript
// Static - set once at app initialization, cannot change at runtime
Noodl.Config.appName // "My Amazing App"
Noodl.Config.primaryColor // "#d21f3c"
Noodl.Config.apiBaseUrl // "https://api.example.com"
Noodl.Config.menuItems // [{name: "Home", path: "/"}, ...]
// This is NOT allowed (throws or is ignored):
Noodl.Config.primaryColor = "#000000"; // ❌ Config is immutable
```
### Design Principles
1. **Config is static**: Values are "baked in" at app load - use Variables for runtime changes
2. **Type-aware editing**: Color picker for colors, array editor for arrays, etc.
3. **Required defaults**: Core metadata (name, description) always exists
4. **Expression + Node access**: Both `Noodl.Config.x` in expressions AND an App Config node
5. **Build-time injection**: SEO tags and PWA manifest generated during build/deploy
---
## Architecture
### Data Model
```typescript
interface AppConfig {
// Required sections (non-deletable)
identity: {
appName: string;
description: string;
coverImage?: string; // Path or URL
};
seo: {
ogTitle?: string; // Defaults to appName
ogDescription?: string; // Defaults to description
ogImage?: string; // Defaults to coverImage
favicon?: string;
themeColor?: string;
};
// Optional section
pwa?: {
enabled: boolean;
shortName?: string;
startUrl: string; // Default: "/"
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
backgroundColor?: string;
sourceIcon?: string; // Single source, auto-scaled to required sizes
};
// User-defined variables
variables: ConfigVariable[];
}
interface ConfigVariable {
key: string; // Valid JS identifier
type: ConfigType;
value: any;
description?: string; // Tooltip in UI
category?: string; // Grouping (defaults to "Custom")
validation?: {
required?: boolean;
pattern?: string; // Regex for strings
min?: number; // For numbers
max?: number;
};
}
type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
```
### Storage Location
Stored in `project.json` under metadata:
```json
{
"metadata": {
"appConfig": {
"identity": {
"appName": "My App",
"description": "An amazing app"
},
"seo": {
"themeColor": "#d21f3c"
},
"pwa": {
"enabled": true,
"display": "standalone"
},
"variables": [
{
"key": "primaryColor",
"type": "color",
"value": "#d21f3c",
"category": "Theme"
}
]
}
}
}
```
---
## UI Design
### App Setup Panel
New top-level sidebar tab (migrating some project settings here):
```
┌─────────────────────────────────────────────────┐
│ App Setup [?] │
├─────────────────────────────────────────────────┤
│ │
│ APP IDENTITY │
│ ┌─────────────────────────────────────────────┐ │
│ │ App Name [My Amazing App ] │ │
│ │ Description [A visual programming... ] │ │
│ │ [that makes building easy ] │ │
│ │ Cover Image [🖼️ cover.png ][Browse] │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ SEO & METADATA │
│ ┌─────────────────────────────────────────────┐ │
│ │ OG Title [ ] │ │
│ │ └ defaults to App Name │ │
│ │ OG Description [ ] │ │
│ │ └ defaults to Description │ │
│ │ OG Image [🖼️ ][Browse] │ │
│ │ └ defaults to Cover Image │ │
│ │ Favicon [🖼️ favicon.ico ][Browse] │ │
│ │ Theme Color [■ #d21f3c ] │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ▶ PWA CONFIGURATION [Enable] │
│ ┌─────────────────────────────────────────────┐ │
│ │ Short Name [My App ] │ │
│ │ Display Mode [Standalone ▼ ] │ │
│ │ Start URL [/ ] │ │
│ │ Background [■ #ffffff ] │ │
│ │ App Icon [🖼️ icon-512.png][Browse] │ │
│ │ └ 512x512 PNG, auto-scaled │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ CONFIGURATION VARIABLES [+ Add New] │
│ ┌─────────────────────────────────────────────┐ │
│ │ ┌─ Theme ─────────────────────────────────┐ │ │
│ │ │ primaryColor color [■ #d21f3c ][⋮]│ │ │
│ │ │ secondaryColor color [■ #1f3cd2 ][⋮]│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ ┌─ API ───────────────────────────────────┐ │ │
│ │ │ apiBaseUrl string [https://... ][⋮]│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ ┌─ Custom ────────────────────────────────┐ │ │
│ │ │ maxUploadSize number [10 ][⋮]│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
```
### App Config Node
```
┌──────────────────────────────┐
│ App Config │
├──────────────────────────────┤
│ Variables │
│ ┌──────────────────────────┐ │
│ │ ☑ primaryColor │ │
│ │ ☑ apiBaseUrl │ │
│ │ ☐ secondaryColor │ │
│ │ ☑ menuItems │ │
│ └──────────────────────────┘ │
├──────────────────────────────┤
│ ○ primaryColor │──→
│ ○ apiBaseUrl │──→
│ ○ menuItems │──→
└──────────────────────────────┘
```
---
## Subtasks
| Task | Description | Estimate | Dependencies |
|------|-------------|----------|--------------|
| [CONFIG-001](./CONFIG-001-infrastructure.md) | Core Infrastructure | 14-18h | None |
| [CONFIG-002](./CONFIG-002-app-setup-panel.md) | App Setup Panel UI | 18-24h | CONFIG-001 |
| [CONFIG-003](./CONFIG-003-app-config-node.md) | App Config Node | 10-14h | CONFIG-001 |
| [CONFIG-004](./CONFIG-004-seo-integration.md) | SEO Build Integration | 8-10h | CONFIG-001, CONFIG-002 |
| [CONFIG-005](./CONFIG-005-pwa-manifest.md) | PWA Manifest Generation | 10-14h | CONFIG-001, CONFIG-002 |
| [CONFIG-006](./CONFIG-006-expression-integration.md) | Expression System Integration | 4-6h | CONFIG-001, TASK-006 |
**Total: 64-86 hours**
---
## Cloud Service Node UX Improvement
**Prerequisite cleanup task** (can be done as part of CONFIG-003 or separately):
### Problem
The existing "Config" node accesses Noodl Cloud Service (Parse Server) configuration. This will cause confusion with the new App Config system.
### Solution
1. **Rename existing node**: `Config``Noodl Cloud Config`
2. **Hide cloud nodes when no backend**: If no cloud service is connected, hide ALL nodes in the "Cloud Data" category from the node picker
3. **Show guidance**: Display "Please add a backend service" message in that category until a service is connected
4. **Preserve existing nodes**: Don't break legacy projects - existing nodes continue to work, just hidden from picker
### Files to modify:
- `packages/noodl-runtime/src/nodes/std-library/data/confignode.js` - Rename
- `packages/noodl-editor/src/editor/src/views/nodepicker/` - Category visibility logic
- `packages/noodl-runtime/src/nodelibraryexport.js` - Update node name in exports
---
## Integration Points
### With DEPLOY-003 (Environment Profiles)
DEPLOY-003 can **override** Config values per environment:
```
App Setup Panel DEPLOY-003 Profiles
───────────────── ───────────────────
apiBaseUrl: "dev" → Production: "https://api.prod.com"
Staging: "https://api.staging.com"
Development: (uses default)
```
The App Setup panel shows canonical/default values. DEPLOY-003 shows environment-specific overrides.
### With TASK-006 (Expression System)
The enhanced expression system will provide:
- `Noodl.Config.xxx` access in expressions
- Autocomplete for config keys
- Type hints
### With Phase 5 (Capacitor Deployment)
PWA icon generation will be reused for Capacitor app icons:
- Same 512x512 source image
- Generate iOS/Android required sizes
- Include in platform-specific builds
---
## Component Export Behavior
When exporting a component that uses `Noodl.Config.primaryColor`:
| Variable Type | Export Behavior |
|---------------|-----------------|
| **Custom variables** | Hard-coded to current values |
| **Default variables** (appName, etc.) | Reference preserved |
The importer is responsible for updating hard-coded values if needed.
---
## Testing Checklist
### Manual Testing
- [ ] App Setup panel appears in sidebar
- [ ] Can edit all identity fields
- [ ] Can edit SEO fields (with defaults shown)
- [ ] Can enable/disable PWA section
- [ ] Can add custom variables with different types
- [ ] Color picker works for color type
- [ ] Array editor works for array type
- [ ] Categories group variables correctly
- [ ] App Config node shows all custom variables
- [ ] Selected variables appear as outputs
- [ ] Output types match variable types
- [ ] `Noodl.Config.xxx` accessible in expressions
- [ ] SEO tags appear in built HTML
- [ ] manifest.json generated with correct values
- [ ] PWA icons auto-scaled from source
### Build Testing
- [ ] Export includes correct meta tags
- [ ] manifest.json valid and complete
- [ ] Icons generated at all required sizes
- [ ] Theme color in HTML head
- [ ] Open Graph tags render correctly
---
## Success Criteria
1. **Discoverable**: New users find App Setup easily in sidebar
2. **Complete**: SEO and PWA work without manual file editing
3. **Type-safe**: Color variables show color pickers, arrays show array editors
4. **Non-breaking**: Existing projects work, cloud nodes still function
5. **Extensible**: DEPLOY-003 can override values per environment
---
## Open Questions (Resolved)
| Question | Decision |
|----------|----------|
| Namespace name? | `Noodl.Config` |
| Static or reactive? | Static (immutable at runtime) |
| Panel location? | New top-level sidebar tab |
| PWA icons? | Single 512x512 source, auto-scaled |
| Categories required? | Optional, ungrouped → "Custom" |
| Loaded signal on node? | No, overkill for static values |
| ENV node name? | "App Config" |
---
## References
- Existing project settings: `views/panels/ProjectSettingsPanel/`
- Port type editors: `views/panels/propertyeditor/DataTypes/Ports.ts`
- HTML build processor: `utils/compilation/build/processors/html-processor.ts`
- Expression system: `TASK-006-expressions-overhaul/`
- Deploy settings: `TASK-005-deployment-automation/DEPLOY-003-deploy-settings.md`
- Cloud config node: `noodl-runtime/src/nodes/std-library/data/confignode.js`

View File

@@ -0,0 +1,739 @@
# AGENT-001: Server-Sent Events (SSE) Node
## Overview
Create a new runtime node that establishes and manages Server-Sent Events (SSE) connections, enabling real-time streaming data from servers to Noodl applications. This is critical for agentic UI patterns where AI backends stream responses incrementally.
**Phase:** 3.5 (Real-Time Agentic UI)
**Priority:** CRITICAL (blocks Erleah development)
**Effort:** 3-5 days
**Risk:** Medium
---
## Problem Statement
### Current Limitation
The existing HTTP Request node only supports request-response patterns:
```
User Action → HTTP Request → Wait → Response → UI Update
```
This doesn't work for streaming use cases:
```
User Action → SSE Connect → Stream messages → Progressive UI Updates
Message 1 → UI Update
Message 2 → UI Update
Message 3 → UI Update
...
```
### Real-World Use Cases
1. **AI Chat Responses** - Stream tokens as they generate (Erleah)
2. **Live Notifications** - Server pushes updates without polling
3. **Real-Time Dashboards** - Continuous metric updates
4. **Progress Updates** - Long-running backend operations
5. **Event Feeds** - News, social media, activity streams
---
## Goals
1. ✅ Establish and maintain SSE connections
2. ✅ Parse incoming messages (text and JSON)
3. ✅ Handle connection lifecycle (open, error, close)
4. ✅ Auto-reconnect on connection loss
5. ✅ Clean disconnection on node deletion
6. ✅ Support custom event types (via addEventListener)
---
## Technical Design
### Node Specification
```javascript
{
name: 'net.noodl.SSE',
displayNodeName: 'Server-Sent Events',
category: 'Data',
color: 'data',
docs: 'https://docs.noodl.net/nodes/data/sse',
searchTags: ['sse', 'stream', 'server-sent', 'events', 'realtime', 'websocket']
}
```
### Port Schema
#### Inputs
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| `url` | string | Connection | SSE endpoint URL |
| `connect` | signal | Actions | Establish connection |
| `disconnect` | signal | Actions | Close connection |
| `autoReconnect` | boolean | Connection | Auto-reconnect on disconnect (default: true) |
| `reconnectDelay` | number | Connection | Delay before reconnect (ms, default: 3000) |
| `withCredentials` | boolean | Connection | Include credentials (default: false) |
| `customHeaders` | object | Connection | Custom headers (EventSource doesn't support, document limitation) |
#### Outputs
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| `message` | object | Data | Raw message event object |
| `data` | * | Data | Parsed message data (JSON or text) |
| `eventType` | string | Data | Event type (if custom events used) |
| `connected` | signal | Events | Fired when connection opens |
| `disconnected` | signal | Events | Fired when connection closes |
| `error` | string | Events | Error message |
| `isConnected` | boolean | Status | Current connection state |
| `lastMessageTime` | number | Status | Timestamp of last message (ms) |
### State Machine
```
┌─────────────┐
START │ │
────┬─→│ DISCONNECTED│←──┐
│ │ │ │
│ └─────────────┘ │
│ │ │
│ [connect] │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ CONNECTING │ │
│ └─────────────┘ │
│ │ │
│ [onopen] │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
└──│ CONNECTED │ │
│ │ │
└─────────────┘ │
│ │ │
[onmessage] │
│ │ │
[onerror/close] │
│ │
└───────────┘
[autoReconnect
after delay]
```
---
## Implementation Details
### File Structure
```
packages/noodl-runtime/src/nodes/std-library/data/
├── ssenode.js # Main node implementation
└── ssenode.test.js # Unit tests
```
### Core Implementation
```javascript
var SSENode = {
name: 'net.noodl.SSE',
displayNodeName: 'Server-Sent Events',
category: 'Data',
color: 'data',
initialize: function() {
this._internal.eventSource = null;
this._internal.isConnected = false;
this._internal.messageBuffer = [];
this._internal.reconnectTimer = null;
this._internal.customEventListeners = new Map();
},
inputs: {
url: {
type: 'string',
displayName: 'URL',
group: 'Connection',
set: function(value) {
this._internal.url = value;
}
},
connect: {
type: 'signal',
displayName: 'Connect',
group: 'Actions',
valueChangedToTrue: function() {
this.doConnect();
}
},
disconnect: {
type: 'signal',
displayName: 'Disconnect',
group: 'Actions',
valueChangedToTrue: function() {
this.doDisconnect();
}
},
autoReconnect: {
type: 'boolean',
displayName: 'Auto Reconnect',
group: 'Connection',
default: true,
set: function(value) {
this._internal.autoReconnect = value;
}
},
reconnectDelay: {
type: 'number',
displayName: 'Reconnect Delay (ms)',
group: 'Connection',
default: 3000,
set: function(value) {
this._internal.reconnectDelay = value;
}
},
withCredentials: {
type: 'boolean',
displayName: 'With Credentials',
group: 'Connection',
default: false,
set: function(value) {
this._internal.withCredentials = value;
}
},
// For custom event types (beyond 'message')
eventType: {
type: 'string',
displayName: 'Listen for Event Type',
group: 'Connection',
set: function(value) {
this._internal.customEventType = value;
if (this._internal.eventSource && value) {
this.addCustomEventListener(value);
}
}
}
},
outputs: {
message: {
type: 'object',
displayName: 'Message',
group: 'Data',
getter: function() {
return this._internal.lastMessage;
}
},
data: {
type: '*',
displayName: 'Data',
group: 'Data',
getter: function() {
return this._internal.lastData;
}
},
eventType: {
type: 'string',
displayName: 'Event Type',
group: 'Data',
getter: function() {
return this._internal.lastEventType;
}
},
connected: {
type: 'signal',
displayName: 'Connected',
group: 'Events'
},
disconnected: {
type: 'signal',
displayName: 'Disconnected',
group: 'Events'
},
messageReceived: {
type: 'signal',
displayName: 'Message Received',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter: function() {
return this._internal.lastError;
}
},
isConnected: {
type: 'boolean',
displayName: 'Is Connected',
group: 'Status',
getter: function() {
return this._internal.isConnected;
}
},
lastMessageTime: {
type: 'number',
displayName: 'Last Message Time',
group: 'Status',
getter: function() {
return this._internal.lastMessageTime;
}
}
},
methods: {
doConnect: function() {
// Disconnect existing connection if any
if (this._internal.eventSource) {
this.doDisconnect();
}
const url = this._internal.url;
if (!url) {
this.setError('URL is required');
return;
}
try {
// Create EventSource with options
const options = {
withCredentials: this._internal.withCredentials || false
};
const eventSource = new EventSource(url, options);
this._internal.eventSource = eventSource;
// Connection opened
eventSource.onopen = () => {
this._internal.isConnected = true;
this._internal.lastError = null;
this.flagOutputDirty('isConnected');
this.flagOutputDirty('error');
this.sendSignalOnOutput('connected');
console.log('[SSE Node] Connected to', url);
};
// Message received (default event type)
eventSource.onmessage = (event) => {
this.handleMessage(event, 'message');
};
// Connection error/closed
eventSource.onerror = (error) => {
console.error('[SSE Node] Connection error:', error);
const wasConnected = this._internal.isConnected;
this._internal.isConnected = false;
this.flagOutputDirty('isConnected');
if (wasConnected) {
this.sendSignalOnOutput('disconnected');
}
// Check if connection is permanently closed
if (eventSource.readyState === EventSource.CLOSED) {
this.setError('Connection closed');
// Auto-reconnect if enabled
if (this._internal.autoReconnect) {
const delay = this._internal.reconnectDelay || 3000;
console.log(`[SSE Node] Reconnecting in ${delay}ms...`);
this._internal.reconnectTimer = setTimeout(() => {
this.doConnect();
}, delay);
}
}
};
// Add custom event listener if specified
if (this._internal.customEventType) {
this.addCustomEventListener(this._internal.customEventType);
}
} catch (e) {
this.setError(e.message);
console.error('[SSE Node] Failed to connect:', e);
}
},
doDisconnect: function() {
// Clear reconnect timer
if (this._internal.reconnectTimer) {
clearTimeout(this._internal.reconnectTimer);
this._internal.reconnectTimer = null;
}
// Close connection
if (this._internal.eventSource) {
this._internal.eventSource.close();
this._internal.eventSource = null;
this._internal.isConnected = false;
this.flagOutputDirty('isConnected');
this.sendSignalOnOutput('disconnected');
console.log('[SSE Node] Disconnected');
}
},
addCustomEventListener: function(eventType) {
if (!this._internal.eventSource || !eventType) return;
// Remove old listener if exists
const oldListener = this._internal.customEventListeners.get(eventType);
if (oldListener) {
this._internal.eventSource.removeEventListener(eventType, oldListener);
}
// Add new listener
const listener = (event) => {
this.handleMessage(event, eventType);
};
this._internal.eventSource.addEventListener(eventType, listener);
this._internal.customEventListeners.set(eventType, listener);
},
handleMessage: function(event, type) {
this._internal.lastMessageTime = Date.now();
this._internal.lastEventType = type || 'message';
this._internal.lastMessage = {
data: event.data,
lastEventId: event.lastEventId,
type: type || 'message'
};
// Try to parse as JSON
try {
this._internal.lastData = JSON.parse(event.data);
} catch (e) {
// Not JSON, use raw string
this._internal.lastData = event.data;
}
this.flagOutputDirty('message');
this.flagOutputDirty('data');
this.flagOutputDirty('eventType');
this.flagOutputDirty('lastMessageTime');
this.sendSignalOnOutput('messageReceived');
},
setError: function(message) {
this._internal.lastError = message;
this.flagOutputDirty('error');
},
_onNodeDeleted: function() {
this.doDisconnect();
}
},
getInspectInfo: function() {
if (this._internal.isConnected) {
return {
type: 'value',
value: {
status: 'Connected',
url: this._internal.url,
lastMessage: this._internal.lastData,
messageCount: this._internal.messageCount || 0
}
};
}
return {
type: 'text',
value: this._internal.isConnected ? 'Connected' : 'Disconnected'
};
}
};
module.exports = {
node: SSENode
};
```
---
## Usage Examples
### Example 1: AI Chat Streaming
```
[Text Input: "Hello AI"]
→ [Send] signal
→ [HTTP Request] POST /chat/start → returns chatId
→ [String Format] "/chat/{chatId}/stream"
→ [SSE Node] url
→ [SSE Node] connect
[SSE Node] data
→ [Array] accumulate messages
→ [Repeater] render messages
[SSE Node] messageReceived
→ [Scroll To Bottom] in chat container
```
### Example 2: Live Notifications
```
[Component Mounted] signal
→ [SSE Node] connect to "/notifications/stream"
[SSE Node] data
→ [Show Toast] notification
[SSE Node] connected
→ [Variable] isLive = true → [Visual indicator]
[SSE Node] disconnected
→ [Variable] isLive = false → [Show offline banner]
```
### Example 3: Progress Tracker
```
[Start Upload] signal
→ [HTTP Request] POST /upload → returns uploadId
→ [SSE Node] connect to "/upload/{uploadId}/progress"
[SSE Node] data → { percent: number }
→ [Progress Bar] value
[SSE Node] data → { status: "complete" }
→ [SSE Node] disconnect
→ [Navigate] to success page
```
---
## Testing Checklist
### Functional Tests
- [ ] Connection establishes to valid SSE endpoint
- [ ] Connection fails gracefully with invalid URL
- [ ] Messages are received and parsed correctly
- [ ] JSON messages parse to objects
- [ ] Plain text messages output as strings
- [ ] `connected` signal fires on open
- [ ] `disconnected` signal fires on close
- [ ] `messageReceived` signal fires for each message
- [ ] `isConnected` reflects current state
- [ ] Auto-reconnect works after disconnect
- [ ] Reconnect delay is respected
- [ ] Manual disconnect stops auto-reconnect
- [ ] Custom event types are received
- [ ] withCredentials flag works correctly
- [ ] Node cleanup works (no memory leaks)
### Edge Cases
- [ ] Handles rapid connect/disconnect cycles
- [ ] Handles very large messages (>1MB)
- [ ] Handles malformed JSON gracefully
- [ ] Handles server sending error events
- [ ] Handles network going offline/online
- [ ] Handles multiple SSE nodes simultaneously
- [ ] Connection closes cleanly on component unmount
- [ ] Reconnect timer clears on manual disconnect
### Performance
- [ ] Memory usage stable over 1000+ messages
- [ ] No visible UI lag during streaming
- [ ] Message parsing doesn't block main thread
- [ ] Multiple connections don't interfere
---
## Browser Compatibility
EventSource (SSE) is supported in:
| Browser | Support |
|---------|---------|
| Chrome | ✅ Yes |
| Firefox | ✅ Yes |
| Safari | ✅ Yes |
| Edge | ✅ Yes |
| IE 11 | ❌ No (polyfill available) |
For IE 11 support, can use: [event-source-polyfill](https://www.npmjs.com/package/event-source-polyfill)
---
## Limitations & Workarounds
### Limitation 1: No Custom Headers
EventSource API doesn't support custom headers (like Authorization).
**Workaround:**
- Send auth token as query parameter: `/stream?token=abc123`
- Use cookie-based authentication (withCredentials: true)
- Or use WebSocket instead (AGENT-002)
### Limitation 2: No Request Body
SSE is GET-only, can't send request body.
**Workaround:**
- Create session/channel via POST first
- Connect SSE to session-specific URL: `/stream/{sessionId}`
### Limitation 3: One-Way Communication
Server → Client only. Client can't send messages over same connection.
**Workaround:**
- Use separate HTTP requests for client → server
- Or use WebSocket for bidirectional (AGENT-002)
---
## Documentation Requirements
### User-Facing Docs
Create: `docs/nodes/data/sse.md`
```markdown
# Server-Sent Events
Stream real-time data from your server to your Noodl app using Server-Sent Events (SSE). Perfect for live notifications, AI chat responses, progress updates, and real-time dashboards.
## When to Use
- **AI Chat**: Stream tokens as they generate
- **Notifications**: Push updates without polling
- **Dashboards**: Continuous metric updates
- **Progress**: Long-running operation status
## Basic Usage
1. Add SSE node to your component
2. Set the `URL` to your SSE endpoint
3. Send the `Connect` signal
4. Use the `data` output to access messages
5. Listen to `messageReceived` to trigger actions
## Example: Live Chat
[Screenshot showing SSE node connected to chat UI]
## Authentication
Since EventSource doesn't support custom headers, use query parameters:
```
URL: https://api.example.com/stream?token={authToken}
```
Or enable `With Credentials` for cookie-based auth.
## Auto-Reconnect
By default, SSE nodes auto-reconnect if connection drops. Configure:
- `Auto Reconnect`: Enable/disable
- `Reconnect Delay`: Wait time in milliseconds
## Custom Events
Servers can send named events. Use the `Event Type` input to listen for specific events.
```javascript
// Server sends:
event: notification
data: {"message": "New user signed up"}
// Noodl receives on 'notification' event type
```
```
### Technical Docs
Add to: `dev-docs/reference/NODE-PATTERNS.md`
Section on streaming nodes.
---
## Success Criteria
1. ✅ SSE node successfully streams data in test app
2. ✅ Auto-reconnect works reliably
3. ✅ No memory leaks over extended usage
4. ✅ Clear documentation with examples
5. ✅ Works in Erleah prototype for AI chat streaming
---
## Future Enhancements
Post-MVP features to consider:
1. **Message Buffering** - Store last N messages for replays
2. **Rate Limiting** - Throttle message processing
3. **Event Filtering** - Filter messages by criteria before output
4. **Last Event ID** - Resume from last message on reconnect
5. **Connection Pooling** - Share connection across components
---
## References
- [MDN: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
- [HTML Living Standard: SSE](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [SSE vs WebSocket](https://ably.com/topic/server-sent-events-vs-websockets)
---
## Dependencies
- None (uses native EventSource API)
## Blocked By
- None
## Blocks
- AGENT-007 (Stream Parser Utilities) - needs SSE for testing
- Erleah development - requires streaming AI responses
---
## Estimated Effort Breakdown
| Phase | Estimate | Description |
|-------|----------|-------------|
| Core Implementation | 1 day | Basic SSE node with connect/disconnect/message |
| Error Handling | 0.5 day | Graceful failures, reconnect logic |
| Testing | 1 day | Unit tests, integration tests, edge cases |
| Documentation | 0.5 day | User docs, technical docs, examples |
| Edge Cases & Polish | 0.5-1 day | Performance, memory, browser compat |
**Total: 3.5-4 days**
Buffer: +1 day for unexpected issues = **4-5 days total**

View File

@@ -0,0 +1,923 @@
# AGENT-002: WebSocket Node
## Overview
Create a new runtime node that establishes and manages WebSocket connections, enabling bidirectional real-time communication between Noodl applications and servers. This complements SSE (AGENT-001) by supporting two-way messaging patterns.
**Phase:** 3.5 (Real-Time Agentic UI)
**Priority:** HIGH
**Effort:** 3-5 days
**Risk:** Medium
---
## Problem Statement
### Current Limitation
The existing HTTP Request node and SSE node (AGENT-001) only support one-way communication:
```
HTTP: Client → Server → Response (one-shot)
SSE: Server → Client (one-way stream)
```
WebSocket enables true bidirectional communication:
```
WebSocket: Client ⇄ Server (continuous two-way)
```
### Real-World Use Cases
1. **Collaborative Editing** - Multiple users editing same document
2. **Gaming** - Real-time multiplayer interactions
3. **Chat Applications** - Send and receive messages
4. **Live Cursors** - Show where other users are pointing
5. **Device Control** - Send commands, receive telemetry
6. **Trading Platforms** - Real-time price updates + order placement
---
## Goals
1. ✅ Establish and maintain WebSocket connections
2. ✅ Send messages (text and binary)
3. ✅ Receive messages (text and binary)
4. ✅ Handle connection lifecycle (open, error, close)
5. ✅ Auto-reconnect with exponential backoff
6. ✅ Ping/pong heartbeat for connection health
7. ✅ Queue messages when disconnected
---
## Technical Design
### Node Specification
```javascript
{
name: 'net.noodl.WebSocket',
displayNodeName: 'WebSocket',
category: 'Data',
color: 'data',
docs: 'https://docs.noodl.net/nodes/data/websocket',
searchTags: ['websocket', 'ws', 'realtime', 'bidirectional', 'socket']
}
```
### Port Schema
#### Inputs
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| `url` | string | Connection | WebSocket URL (ws:// or wss://) |
| `connect` | signal | Actions | Establish connection |
| `disconnect` | signal | Actions | Close connection |
| `send` | signal | Actions | Send message |
| `message` | * | Message | Data to send (JSON serialized if object) |
| `messageType` | enum | Message | 'text' or 'binary' (default: text) |
| `autoReconnect` | boolean | Connection | Auto-reconnect on disconnect (default: true) |
| `reconnectDelay` | number | Connection | Initial delay (ms, default: 1000) |
| `maxReconnectDelay` | number | Connection | Max delay with backoff (ms, default: 30000) |
| `protocols` | string | Connection | Comma-separated subprotocols |
| `queueWhenDisconnected` | boolean | Message | Queue messages while offline (default: true) |
| `heartbeatInterval` | number | Connection | Ping interval (ms, 0=disabled, default: 30000) |
#### Outputs
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| `received` | * | Data | Received message data |
| `receivedRaw` | string | Data | Raw message string |
| `messageReceived` | signal | Events | Fired when message arrives |
| `messageSent` | signal | Events | Fired after send succeeds |
| `connected` | signal | Events | Fired when connection opens |
| `disconnected` | signal | Events | Fired when connection closes |
| `error` | string | Events | Error message |
| `isConnected` | boolean | Status | Current connection state |
| `queueSize` | number | Status | Messages waiting to send |
| `latency` | number | Status | Round-trip time (ms) |
### State Machine
```
┌─────────────┐
START │ │
────┬─→│ DISCONNECTED│←──┐
│ │ │ │
│ └─────────────┘ │
│ │ │
│ [connect] │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ CONNECTING │ │
│ └─────────────┘ │
│ │ │
│ [onopen] │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
└──│ CONNECTED │ │
│ │ │
│ [send/recv]│ │
│ │ │
└─────────────┘ │
│ │ │
[onclose/error] │
│ │
└───────────┘
[autoReconnect
with backoff]
```
---
## Implementation Details
### File Structure
```
packages/noodl-runtime/src/nodes/std-library/data/
├── websocketnode.js # Main node implementation
└── websocketnode.test.js # Unit tests
```
### Core Implementation
```javascript
var WebSocketNode = {
name: 'net.noodl.WebSocket',
displayNodeName: 'WebSocket',
category: 'Data',
color: 'data',
initialize: function() {
this._internal.socket = null;
this._internal.isConnected = false;
this._internal.messageQueue = [];
this._internal.reconnectAttempts = 0;
this._internal.reconnectTimer = null;
this._internal.heartbeatTimer = null;
this._internal.lastPingTime = 0;
},
inputs: {
url: {
type: 'string',
displayName: 'URL',
group: 'Connection',
set: function(value) {
this._internal.url = value;
}
},
connect: {
type: 'signal',
displayName: 'Connect',
group: 'Actions',
valueChangedToTrue: function() {
this.doConnect();
}
},
disconnect: {
type: 'signal',
displayName: 'Disconnect',
group: 'Actions',
valueChangedToTrue: function() {
this.doDisconnect();
}
},
send: {
type: 'signal',
displayName: 'Send',
group: 'Actions',
valueChangedToTrue: function() {
this.doSend();
}
},
message: {
type: '*',
displayName: 'Message',
group: 'Message',
set: function(value) {
this._internal.messageToSend = value;
}
},
messageType: {
type: {
name: 'enum',
enums: [
{ label: 'Text', value: 'text' },
{ label: 'Binary', value: 'binary' }
]
},
displayName: 'Message Type',
group: 'Message',
default: 'text',
set: function(value) {
this._internal.messageType = value;
}
},
autoReconnect: {
type: 'boolean',
displayName: 'Auto Reconnect',
group: 'Connection',
default: true,
set: function(value) {
this._internal.autoReconnect = value;
}
},
reconnectDelay: {
type: 'number',
displayName: 'Reconnect Delay (ms)',
group: 'Connection',
default: 1000,
set: function(value) {
this._internal.reconnectDelay = value;
}
},
maxReconnectDelay: {
type: 'number',
displayName: 'Max Reconnect Delay (ms)',
group: 'Connection',
default: 30000,
set: function(value) {
this._internal.maxReconnectDelay = value;
}
},
protocols: {
type: 'string',
displayName: 'Protocols',
group: 'Connection',
set: function(value) {
this._internal.protocols = value;
}
},
queueWhenDisconnected: {
type: 'boolean',
displayName: 'Queue When Disconnected',
group: 'Message',
default: true,
set: function(value) {
this._internal.queueWhenDisconnected = value;
}
},
heartbeatInterval: {
type: 'number',
displayName: 'Heartbeat Interval (ms)',
group: 'Connection',
default: 30000,
set: function(value) {
this._internal.heartbeatInterval = value;
if (this._internal.isConnected) {
this.startHeartbeat();
}
}
}
},
outputs: {
received: {
type: '*',
displayName: 'Received',
group: 'Data',
getter: function() {
return this._internal.lastReceived;
}
},
receivedRaw: {
type: 'string',
displayName: 'Received Raw',
group: 'Data',
getter: function() {
return this._internal.lastReceivedRaw;
}
},
messageReceived: {
type: 'signal',
displayName: 'Message Received',
group: 'Events'
},
messageSent: {
type: 'signal',
displayName: 'Message Sent',
group: 'Events'
},
connected: {
type: 'signal',
displayName: 'Connected',
group: 'Events'
},
disconnected: {
type: 'signal',
displayName: 'Disconnected',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter: function() {
return this._internal.lastError;
}
},
isConnected: {
type: 'boolean',
displayName: 'Is Connected',
group: 'Status',
getter: function() {
return this._internal.isConnected;
}
},
queueSize: {
type: 'number',
displayName: 'Queue Size',
group: 'Status',
getter: function() {
return this._internal.messageQueue.length;
}
},
latency: {
type: 'number',
displayName: 'Latency (ms)',
group: 'Status',
getter: function() {
return this._internal.latency || 0;
}
}
},
methods: {
doConnect: function() {
// Disconnect existing
if (this._internal.socket) {
this.doDisconnect();
}
const url = this._internal.url;
if (!url) {
this.setError('URL is required');
return;
}
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
this.setError('URL must start with ws:// or wss://');
return;
}
try {
// Parse protocols
const protocols = this._internal.protocols
? this._internal.protocols.split(',').map(p => p.trim())
: undefined;
const socket = new WebSocket(url, protocols);
this._internal.socket = socket;
// Connection opened
socket.onopen = () => {
this._internal.isConnected = true;
this._internal.reconnectAttempts = 0;
this._internal.lastError = null;
this.flagOutputDirty('isConnected');
this.flagOutputDirty('error');
this.sendSignalOnOutput('connected');
// Start heartbeat
this.startHeartbeat();
// Flush queued messages
this.flushMessageQueue();
console.log('[WebSocket] Connected to', url);
};
// Message received
socket.onmessage = (event) => {
this.handleMessage(event.data);
};
// Connection closed
socket.onclose = (event) => {
console.log('[WebSocket] Closed:', event.code, event.reason);
this._internal.isConnected = false;
this.flagOutputDirty('isConnected');
this.sendSignalOnOutput('disconnected');
this.stopHeartbeat();
// Auto-reconnect
if (this._internal.autoReconnect && !event.wasClean) {
this.scheduleReconnect();
}
};
// Connection error
socket.onerror = (error) => {
console.error('[WebSocket] Error:', error);
this.setError('Connection error');
};
} catch (e) {
this.setError(e.message);
console.error('[WebSocket] Failed to connect:', e);
}
},
doDisconnect: function() {
// Clear timers
if (this._internal.reconnectTimer) {
clearTimeout(this._internal.reconnectTimer);
this._internal.reconnectTimer = null;
}
this.stopHeartbeat();
// Close socket
if (this._internal.socket) {
this._internal.socket.close(1000, 'Client disconnect');
this._internal.socket = null;
this._internal.isConnected = false;
this.flagOutputDirty('isConnected');
console.log('[WebSocket] Disconnected');
}
},
doSend: function() {
const message = this._internal.messageToSend;
if (message === undefined || message === null) {
return;
}
// If not connected, queue or drop
if (!this._internal.isConnected) {
if (this._internal.queueWhenDisconnected) {
this._internal.messageQueue.push(message);
this.flagOutputDirty('queueSize');
console.log('[WebSocket] Message queued (disconnected)');
} else {
this.setError('Cannot send: not connected');
}
return;
}
try {
const socket = this._internal.socket;
const messageType = this._internal.messageType || 'text';
// Serialize based on type
let data;
if (messageType === 'binary') {
// Convert to ArrayBuffer or Blob
if (typeof message === 'string') {
data = new TextEncoder().encode(message);
} else {
data = message;
}
} else {
// Text mode - serialize objects as JSON
if (typeof message === 'object') {
data = JSON.stringify(message);
} else {
data = String(message);
}
}
socket.send(data);
this.sendSignalOnOutput('messageSent');
} catch (e) {
this.setError('Send failed: ' + e.message);
}
},
handleMessage: function(data) {
this._internal.lastReceivedRaw = data;
// Try to parse as JSON
try {
this._internal.lastReceived = JSON.parse(data);
} catch (e) {
// Not JSON, use raw
this._internal.lastReceived = data;
}
// Check if it's a pong response
if (data === 'pong' && this._internal.lastPingTime) {
this._internal.latency = Date.now() - this._internal.lastPingTime;
this.flagOutputDirty('latency');
return; // Don't emit messageReceived for pong
}
this.flagOutputDirty('received');
this.flagOutputDirty('receivedRaw');
this.sendSignalOnOutput('messageReceived');
},
flushMessageQueue: function() {
const queue = this._internal.messageQueue;
if (queue.length === 0) return;
console.log(`[WebSocket] Flushing ${queue.length} queued messages`);
while (queue.length > 0) {
this._internal.messageToSend = queue.shift();
this.doSend();
}
this.flagOutputDirty('queueSize');
},
scheduleReconnect: function() {
const baseDelay = this._internal.reconnectDelay || 1000;
const maxDelay = this._internal.maxReconnectDelay || 30000;
const attempts = this._internal.reconnectAttempts;
// Exponential backoff: baseDelay * 2^attempts
const delay = Math.min(baseDelay * Math.pow(2, attempts), maxDelay);
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${attempts + 1})`);
this._internal.reconnectTimer = setTimeout(() => {
this._internal.reconnectAttempts++;
this.doConnect();
}, delay);
},
startHeartbeat: function() {
this.stopHeartbeat();
const interval = this._internal.heartbeatInterval;
if (!interval || interval <= 0) return;
this._internal.heartbeatTimer = setInterval(() => {
if (this._internal.isConnected) {
this._internal.lastPingTime = Date.now();
try {
this._internal.socket.send('ping');
} catch (e) {
console.error('[WebSocket] Heartbeat failed:', e);
}
}
}, interval);
},
stopHeartbeat: function() {
if (this._internal.heartbeatTimer) {
clearInterval(this._internal.heartbeatTimer);
this._internal.heartbeatTimer = null;
}
},
setError: function(message) {
this._internal.lastError = message;
this.flagOutputDirty('error');
},
_onNodeDeleted: function() {
this.doDisconnect();
}
},
getInspectInfo: function() {
if (this._internal.isConnected) {
return {
type: 'value',
value: {
status: 'Connected',
url: this._internal.url,
latency: this._internal.latency + 'ms',
queueSize: this._internal.messageQueue.length
}
};
}
return {
type: 'text',
value: 'Disconnected'
};
}
};
module.exports = {
node: WebSocketNode
};
```
---
## Usage Examples
### Example 1: Chat Application
```
[Text Input: message]
→ [WebSocket] message
[Send Button] clicked
→ [WebSocket] send signal
[WebSocket] connected
→ [Variable] isOnline = true
[WebSocket] received
→ [Array] add to messages
→ [Repeater] render chat
[WebSocket] disconnected
→ [Show Toast] "Connection lost, reconnecting..."
```
### Example 2: Collaborative Cursors
```
[Mouse Move Event]
→ [Debounce] 100ms
→ [Object] { x, y, userId }
→ [WebSocket] message
→ [WebSocket] send
[WebSocket] received → { x, y, userId }
→ [Array Filter] exclude own cursor
→ [Repeater] render other cursors
```
### Example 3: Real-Time Game
```
// Send player action
[Keyboard Event: space]
→ [Object] { action: "jump", timestamp: Date.now() }
→ [WebSocket] message
→ [WebSocket] send
// Receive game state
[WebSocket] received → { players: [], score: 100 }
→ [For Each] in players
→ [Sprite] update positions
```
### Example 4: IoT Device Control
```
// Send command
[Toggle Switch] changed
→ [Object] { device: "light-1", state: value }
→ [WebSocket] send
// Receive telemetry
[WebSocket] received → { temperature: 72, humidity: 45 }
→ [Number] temperature
→ [Gauge] display
```
---
## Testing Checklist
### Functional Tests
- [ ] Connection establishes to valid WebSocket server
- [ ] Connection fails gracefully with invalid URL
- [ ] Can send text messages
- [ ] Can send JSON objects (auto-serialized)
- [ ] Can receive text messages
- [ ] Can receive JSON messages (auto-parsed)
- [ ] `connected` signal fires on open
- [ ] `disconnected` signal fires on close
- [ ] `messageSent` signal fires after send
- [ ] `messageReceived` signal fires on receive
- [ ] `isConnected` reflects current state
- [ ] Auto-reconnect works after disconnect
- [ ] Exponential backoff increases delay
- [ ] Messages queue when disconnected
- [ ] Queued messages flush on reconnect
- [ ] Manual disconnect stops auto-reconnect
- [ ] Heartbeat sends ping messages
- [ ] Latency calculated from ping/pong
- [ ] Subprotocols negotiated correctly
- [ ] Node cleanup works (no memory leaks)
### Edge Cases
- [ ] Handles rapid connect/disconnect cycles
- [ ] Handles very large messages (>1MB)
- [ ] Handles binary data correctly
- [ ] Handles server closing connection unexpectedly
- [ ] Handles network going offline/online
- [ ] Queue doesn't grow unbounded
- [ ] Multiple WebSocket nodes don't interfere
- [ ] Connection closes cleanly on component unmount
### Performance
- [ ] Memory usage stable over 1000+ messages
- [ ] No visible UI lag during high-frequency messages
- [ ] Queue processing doesn't block main thread
- [ ] Heartbeat doesn't impact performance
---
## WebSocket vs SSE Decision Guide
Help users choose between WebSocket (AGENT-002) and SSE (AGENT-001):
| Feature | SSE | WebSocket |
|---------|-----|-----------|
| **Direction** | Server → Client only | Bidirectional |
| **Protocol** | HTTP/1.1, HTTP/2 | WebSocket protocol |
| **Auto-reconnect** | Native browser behavior | Manual implementation |
| **Message format** | Text (typically JSON) | Text or Binary |
| **Firewall friendly** | ✅ Yes (uses HTTP) | ⚠️ Sometimes blocked |
| **Complexity** | Simpler | More complex |
| **Use when** | Server pushes updates | Client needs to send data |
**Rule of thumb:**
- Need to **receive** updates → SSE
- Need to **send and receive** → WebSocket
---
## Browser Compatibility
WebSocket is supported in all modern browsers:
| Browser | Support |
|---------|---------|
| Chrome | ✅ Yes |
| Firefox | ✅ Yes |
| Safari | ✅ Yes |
| Edge | ✅ Yes |
| IE 11 | ⚠️ Partial (no binary frames) |
---
## Security Considerations
### 1. Use WSS (WebSocket Secure)
Always use `wss://` in production, not `ws://`. This encrypts traffic.
### 2. Authentication
WebSocket doesn't support custom headers in browser. Options:
- Send auth token as first message after connect
- Include token in URL query: `wss://example.com/ws?token=abc123`
- Use cookie-based authentication
### 3. Message Validation
Always validate received messages server-side. Don't trust client data.
### 4. Rate Limiting
Implement server-side rate limiting to prevent abuse.
---
## Documentation Requirements
### User-Facing Docs
Create: `docs/nodes/data/websocket.md`
```markdown
# WebSocket
Enable real-time bidirectional communication with WebSocket servers. Perfect for chat, collaborative editing, gaming, and live dashboards.
## When to Use
- **Chat**: Send and receive messages
- **Collaboration**: Real-time multi-user editing
- **Gaming**: Multiplayer interactions
- **IoT**: Device control and telemetry
- **Trading**: Price updates + order placement
## WebSocket vs Server-Sent Events
- Use **SSE** when server pushes updates, client doesn't send
- Use **WebSocket** when client needs to send data to server
## Basic Usage
1. Add WebSocket node
2. Set `URL` to your WebSocket endpoint (wss://...)
3. Send `Connect` signal
4. To send: Set `message`, trigger `send` signal
5. To receive: Listen to `messageReceived`, read `received`
## Example: Chat
[Screenshot showing WebSocket in chat application]
## Queuing
When disconnected, messages can queue automatically:
- Enable `Queue When Disconnected`
- Messages send when reconnected
## Heartbeat
Keep connection alive with automatic ping/pong:
- Set `Heartbeat Interval` (milliseconds)
- Monitor `Latency` for connection health
## Security
Always use `wss://` (secure) in production, not `ws://`.
For authentication:
```javascript
// Send auth on connect
[WebSocket] connected
→ [Object] { type: "auth", token: authToken }
→ [WebSocket] message
→ [WebSocket] send
```
```
---
## Success Criteria
1. ✅ WebSocket node successfully connects and exchanges messages
2. ✅ Auto-reconnect works reliably with exponential backoff
3. ✅ Message queuing prevents data loss during disconnects
4. ✅ Heartbeat detects dead connections
5. ✅ No memory leaks over extended usage
6. ✅ Clear documentation with examples
7. ✅ Works in Erleah for real-time backend communication
---
## Future Enhancements
Post-MVP features to consider:
1. **Compression** - Enable permessage-deflate extension
2. **Binary Frames** - Better binary data support
3. **Subprotocol Handling** - React to negotiated protocol
4. **Message Buffering** - Batch sends for performance
5. **Connection Pooling** - Share socket across components
6. **Custom Heartbeat Messages** - Beyond ping/pong
---
## References
- [MDN: WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
- [WebSocket Protocol RFC 6455](https://tools.ietf.org/html/rfc6455)
- [WebSocket vs SSE](https://ably.com/topic/websockets-vs-sse)
---
## Dependencies
- None (uses native WebSocket API)
## Blocked By
- None
## Blocks
- Erleah development - requires bidirectional communication
---
## Estimated Effort Breakdown
| Phase | Estimate | Description |
|-------|----------|-------------|
| Core Implementation | 1.5 days | Basic WebSocket with send/receive |
| Auto-reconnect & Queue | 1 day | Exponential backoff, message queuing |
| Heartbeat | 0.5 day | Ping/pong latency tracking |
| Testing | 1 day | Unit tests, integration tests, edge cases |
| Documentation | 0.5 day | User docs, technical docs, examples |
| Polish | 0.5 day | Error handling, performance, cleanup |
**Total: 5 days**
Buffer: None needed (straightforward implementation)
**Final: 3-5 days** (depending on testing depth)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,883 @@
# AGENT-004: Optimistic Update Pattern
## Overview
Create a pattern and helper nodes for implementing optimistic UI updates - updating the UI immediately before the server confirms the change, then rolling back if the server rejects it. This creates a more responsive user experience for network operations.
**Phase:** 3.5 (Real-Time Agentic UI)
**Priority:** MEDIUM
**Effort:** 2-3 days
**Risk:** Low
---
## Problem Statement
### Current Pattern: Slow & Blocking
```
User clicks "Accept Connection"
Show loading spinner
Wait for server... (300-1000ms)
Update UI to show "Connected"
Hide spinner
```
**Problem:** User waits, UI feels sluggish.
### Desired Pattern: Fast & Optimistic
```
User clicks "Accept Connection"
Immediately show "Connected" (optimistic)
Send request to server (background)
IF success: Do nothing (already updated!)
IF failure: Roll back to "Pending", show error
```
**Benefit:** UI feels instant, even with slow network.
### Real-World Use Cases (Erleah)
1. **Accept Connection Request** - Show "Accepted" immediately
2. **Add to Timeline** - Item appears instantly
3. **Send Chat Message** - Message shows while sending
4. **Toggle Bookmark** - Star fills immediately
5. **Drag-Drop Reorder** - Items reorder before server confirms
---
## Goals
1. ✅ Apply optimistic update to variable/store
2. ✅ Commit if backend succeeds
3. ✅ Rollback if backend fails
4. ✅ Show pending state (optional)
5. ✅ Queue multiple optimistic updates
6. ✅ Handle race conditions (out-of-order responses)
7. ✅ Integrate with Global Store (AGENT-003)
---
## Technical Design
### Node Specification
```javascript
{
name: 'net.noodl.OptimisticUpdate',
displayNodeName: 'Optimistic Update',
category: 'Data',
color: 'orange',
docs: 'https://docs.noodl.net/nodes/data/optimistic-update'
}
```
### Port Schema
#### Inputs
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| `apply` | signal | Actions | Apply optimistic update |
| `commit` | signal | Actions | Confirm update succeeded |
| `rollback` | signal | Actions | Revert update |
| `optimisticValue` | * | Update | Value to apply optimistically |
| `storeName` | string | Store | Global store name (if using store) |
| `key` | string | Store | Store key to update |
| `variableName` | string | Variable | Or Variable node to update |
| `transactionId` | string | Transaction | Unique ID for this update |
| `timeout` | number | Config | Auto-rollback after ms (default: 30000) |
#### Outputs
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| `value` | * | Data | Current value (optimistic or committed) |
| `isPending` | boolean | Status | Update awaiting confirmation |
| `isCommitted` | boolean | Status | Update confirmed |
| `isRolledBack` | boolean | Status | Update reverted |
| `applied` | signal | Events | Fires after optimistic apply |
| `committed` | signal | Events | Fires after commit |
| `rolledBack` | signal | Events | Fires after rollback |
| `timedOut` | signal | Events | Fires if timeout reached |
| `previousValue` | * | Data | Value before optimistic update |
| `error` | string | Events | Error message on rollback |
### State Machine
```
┌─────────┐
START │ │
────┬─→│ IDLE │←──────────┐
│ │ │ │
│ └─────────┘ │
│ │ │
│ [apply] │
│ │ │
│ ▼ │
│ ┌─────────┐ [commit]
│ │ PENDING │───────────┘
│ │ │
│ └─────────┘
│ │
│ [rollback]
│ [timeout]
│ │
│ ▼
│ ┌─────────┐
└──│ROLLED │
│BACK │
└─────────┘
```
---
## Implementation Details
### File Structure
```
packages/noodl-runtime/src/nodes/std-library/data/
├── optimisticupdatenode.js # Main node
├── optimisticmanager.js # Transaction manager
└── optimisticupdate.test.js # Tests
```
### Transaction Manager
```javascript
// optimisticmanager.js
class OptimisticUpdateManager {
constructor() {
this.transactions = new Map();
}
/**
* Apply optimistic update
*/
apply(transactionId, currentValue, optimisticValue, options = {}) {
if (this.transactions.has(transactionId)) {
throw new Error(`Transaction ${transactionId} already exists`);
}
const transaction = {
id: transactionId,
previousValue: currentValue,
optimisticValue: optimisticValue,
appliedAt: Date.now(),
status: 'pending',
timeout: options.timeout || 30000,
timer: null
};
// Set timeout for auto-rollback
if (transaction.timeout > 0) {
transaction.timer = setTimeout(() => {
this.timeout(transactionId);
}, transaction.timeout);
}
this.transactions.set(transactionId, transaction);
console.log(`[OptimisticUpdate] Applied: ${transactionId}`);
return {
value: optimisticValue,
isPending: true
};
}
/**
* Commit transaction (success)
*/
commit(transactionId) {
const transaction = this.transactions.get(transactionId);
if (!transaction) {
console.warn(`[OptimisticUpdate] Transaction not found: ${transactionId}`);
return null;
}
// Clear timeout
if (transaction.timer) {
clearTimeout(transaction.timer);
}
transaction.status = 'committed';
this.transactions.delete(transactionId);
console.log(`[OptimisticUpdate] Committed: ${transactionId}`);
return {
value: transaction.optimisticValue,
isPending: false,
isCommitted: true
};
}
/**
* Rollback transaction (failure)
*/
rollback(transactionId, error = null) {
const transaction = this.transactions.get(transactionId);
if (!transaction) {
console.warn(`[OptimisticUpdate] Transaction not found: ${transactionId}`);
return null;
}
// Clear timeout
if (transaction.timer) {
clearTimeout(transaction.timer);
}
transaction.status = 'rolled_back';
transaction.error = error;
const result = {
value: transaction.previousValue,
isPending: false,
isRolledBack: true,
error: error
};
this.transactions.delete(transactionId);
console.log(`[OptimisticUpdate] Rolled back: ${transactionId}`, error);
return result;
}
/**
* Auto-rollback on timeout
*/
timeout(transactionId) {
const transaction = this.transactions.get(transactionId);
if (!transaction) return null;
console.warn(`[OptimisticUpdate] Timeout: ${transactionId}`);
return this.rollback(transactionId, 'Request timed out');
}
/**
* Check if transaction is pending
*/
isPending(transactionId) {
const transaction = this.transactions.get(transactionId);
return transaction && transaction.status === 'pending';
}
/**
* Get transaction info
*/
getTransaction(transactionId) {
return this.transactions.get(transactionId);
}
/**
* Clear all transactions (cleanup)
*/
clear() {
this.transactions.forEach(transaction => {
if (transaction.timer) {
clearTimeout(transaction.timer);
}
});
this.transactions.clear();
}
}
const optimisticUpdateManager = new OptimisticUpdateManager();
module.exports = { optimisticUpdateManager };
```
### Optimistic Update Node
```javascript
// optimisticupdatenode.js
const { optimisticUpdateManager } = require('./optimisticmanager');
const { globalStoreManager } = require('./globalstore');
var OptimisticUpdateNode = {
name: 'net.noodl.OptimisticUpdate',
displayNodeName: 'Optimistic Update',
category: 'Data',
color: 'orange',
initialize: function() {
this._internal.transactionId = null;
this._internal.currentValue = null;
this._internal.isPending = false;
},
inputs: {
apply: {
type: 'signal',
displayName: 'Apply',
group: 'Actions',
valueChangedToTrue: function() {
this.doApply();
}
},
commit: {
type: 'signal',
displayName: 'Commit',
group: 'Actions',
valueChangedToTrue: function() {
this.doCommit();
}
},
rollback: {
type: 'signal',
displayName: 'Rollback',
group: 'Actions',
valueChangedToTrue: function() {
this.doRollback();
}
},
optimisticValue: {
type: '*',
displayName: 'Optimistic Value',
group: 'Update',
set: function(value) {
this._internal.optimisticValue = value;
}
},
storeName: {
type: 'string',
displayName: 'Store Name',
group: 'Store',
set: function(value) {
this._internal.storeName = value;
}
},
key: {
type: 'string',
displayName: 'Key',
group: 'Store',
set: function(value) {
this._internal.key = value;
}
},
transactionId: {
type: 'string',
displayName: 'Transaction ID',
group: 'Transaction',
set: function(value) {
this._internal.transactionId = value;
}
},
timeout: {
type: 'number',
displayName: 'Timeout (ms)',
group: 'Config',
default: 30000,
set: function(value) {
this._internal.timeout = value;
}
}
},
outputs: {
value: {
type: '*',
displayName: 'Value',
group: 'Data',
getter: function() {
return this._internal.currentValue;
}
},
isPending: {
type: 'boolean',
displayName: 'Is Pending',
group: 'Status',
getter: function() {
return this._internal.isPending;
}
},
isCommitted: {
type: 'boolean',
displayName: 'Is Committed',
group: 'Status',
getter: function() {
return this._internal.isCommitted;
}
},
isRolledBack: {
type: 'boolean',
displayName: 'Is Rolled Back',
group: 'Status',
getter: function() {
return this._internal.isRolledBack;
}
},
applied: {
type: 'signal',
displayName: 'Applied',
group: 'Events'
},
committed: {
type: 'signal',
displayName: 'Committed',
group: 'Events'
},
rolledBack: {
type: 'signal',
displayName: 'Rolled Back',
group: 'Events'
},
timedOut: {
type: 'signal',
displayName: 'Timed Out',
group: 'Events'
},
previousValue: {
type: '*',
displayName: 'Previous Value',
group: 'Data',
getter: function() {
return this._internal.previousValue;
}
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter: function() {
return this._internal.error;
}
}
},
methods: {
doApply: function() {
const transactionId = this._internal.transactionId || this.generateTransactionId();
const optimisticValue = this._internal.optimisticValue;
// Get current value from store
let currentValue;
if (this._internal.storeName && this._internal.key) {
const store = globalStoreManager.getState(this._internal.storeName);
currentValue = store[this._internal.key];
} else {
currentValue = this._internal.currentValue;
}
// Apply optimistic update
const result = optimisticUpdateManager.apply(
transactionId,
currentValue,
optimisticValue,
{ timeout: this._internal.timeout }
);
// Update store if configured
if (this._internal.storeName && this._internal.key) {
globalStoreManager.setKey(
this._internal.storeName,
this._internal.key,
optimisticValue
);
}
// Update internal state
this._internal.previousValue = currentValue;
this._internal.currentValue = optimisticValue;
this._internal.isPending = true;
this._internal.isCommitted = false;
this._internal.isRolledBack = false;
this._internal.transactionId = transactionId;
this.flagOutputDirty('value');
this.flagOutputDirty('isPending');
this.flagOutputDirty('previousValue');
this.sendSignalOnOutput('applied');
},
doCommit: function() {
const transactionId = this._internal.transactionId;
if (!transactionId) {
console.warn('[OptimisticUpdate] No transaction to commit');
return;
}
const result = optimisticUpdateManager.commit(transactionId);
if (!result) return;
this._internal.isPending = false;
this._internal.isCommitted = true;
this.flagOutputDirty('isPending');
this.flagOutputDirty('isCommitted');
this.sendSignalOnOutput('committed');
},
doRollback: function(error = null) {
const transactionId = this._internal.transactionId;
if (!transactionId) {
console.warn('[OptimisticUpdate] No transaction to rollback');
return;
}
const result = optimisticUpdateManager.rollback(transactionId, error);
if (!result) return;
// Revert store if configured
if (this._internal.storeName && this._internal.key) {
globalStoreManager.setKey(
this._internal.storeName,
this._internal.key,
result.value
);
}
this._internal.currentValue = result.value;
this._internal.isPending = false;
this._internal.isRolledBack = true;
this._internal.error = result.error;
this.flagOutputDirty('value');
this.flagOutputDirty('isPending');
this.flagOutputDirty('isRolledBack');
this.flagOutputDirty('error');
this.sendSignalOnOutput('rolledBack');
if (result.error === 'Request timed out') {
this.sendSignalOnOutput('timedOut');
}
},
generateTransactionId: function() {
return 'tx_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
},
_onNodeDeleted: function() {
// Clean up pending transaction
if (this._internal.transactionId) {
optimisticUpdateManager.rollback(this._internal.transactionId);
}
}
},
getInspectInfo: function() {
return {
type: 'value',
value: {
status: this._internal.isPending ? 'Pending' : 'Idle',
value: this._internal.currentValue,
transactionId: this._internal.transactionId
}
};
}
};
module.exports = {
node: OptimisticUpdateNode
};
```
---
## Usage Examples
### Example 1: Accept Connection (Erleah)
```
[Button: "Accept"] clicked
[Optimistic Update]
optimisticValue: { status: "accepted" }
storeName: "connections"
key: "connection-{id}"
timeout: 5000
[Optimistic Update] apply signal
[HTTP Request] POST /connections/{id}/accept
[HTTP Request] success
→ [Optimistic Update] commit
[HTTP Request] failure
→ [Optimistic Update] rollback
→ [Show Toast] "Failed to accept connection"
```
### Example 2: Add to Timeline
```
[AI Agent] suggests session
[Button: "Add to Timeline"] clicked
[Optimistic Update]
optimisticValue: { ...sessionData, id: tempId }
storeName: "agenda"
key: "timeline"
[Optimistic Update] value
→ [Array] push to timeline array
→ [Optimistic Update] optimisticValue
→ [Optimistic Update] apply
// Timeline immediately shows item!
[HTTP Request] POST /agenda/sessions
→ returns real session ID
[Success]
→ [Replace temp ID with real ID]
→ [Optimistic Update] commit
[Failure]
→ [Optimistic Update] rollback
→ [Show Error] "Could not add session"
```
### Example 3: Chat Message Send
```
[Text Input] → messageText
[Send Button] clicked
[Object] create message
id: tempId
text: messageText
status: "sending"
timestamp: now
[Optimistic Update]
storeName: "chat"
key: "messages"
[Optimistic Update] value (current messages)
→ [Array] push new message
→ [Optimistic Update] optimisticValue
→ [Optimistic Update] apply
// Message appears immediately with "sending" indicator
[HTTP Request] POST /messages
[Success] → real message from server
→ [Update message status to "sent"]
→ [Optimistic Update] commit
[Failure]
→ [Optimistic Update] rollback
→ [Update message status to "failed"]
→ [Show Retry Button]
```
### Example 4: Toggle Bookmark
```
[Star Icon] clicked
[Variable: isBookmarked] current value
→ [Expression] !value (toggle)
→ [Optimistic Update] optimisticValue
[Optimistic Update] apply
[Star Icon] filled = [Optimistic Update] value
// Star fills immediately
[HTTP Request] POST /bookmarks
[Success]
→ [Optimistic Update] commit
[Failure]
→ [Optimistic Update] rollback
→ [Show Toast] "Couldn't save bookmark"
// Star unfills on failure
```
---
## Testing Checklist
### Functional Tests
- [ ] Apply sets value optimistically
- [ ] Commit keeps optimistic value
- [ ] Rollback reverts to previous value
- [ ] Timeout triggers automatic rollback
- [ ] isPending reflects correct state
- [ ] Store integration works
- [ ] Multiple transactions don't interfere
- [ ] Transaction IDs are unique
- [ ] Signals fire at correct times
### Edge Cases
- [ ] Commit without apply (no-op)
- [ ] Rollback without apply (no-op)
- [ ] Apply twice with same transaction ID (error)
- [ ] Commit after timeout (no-op)
- [ ] Very fast success (commit before timeout)
- [ ] Network reconnect scenarios
- [ ] Component unmount cleans up transaction
### Performance
- [ ] No memory leaks with many transactions
- [ ] Timeout cleanup works correctly
- [ ] Multiple optimistic updates in quick succession
---
## Documentation Requirements
### User-Facing Docs
Create: `docs/nodes/data/optimistic-update.md`
```markdown
# Optimistic Update
Make your UI feel instant by updating immediately, then confirming with the server later. Perfect for actions like liking, bookmarking, or accepting requests.
## The Problem
Without optimistic updates:
```
User clicks button → Show spinner → Wait... → Update UI
(feels slow)
```
With optimistic updates:
```
User clicks button → Update UI immediately → Confirm in background
(feels instant!)
```
## Basic Pattern
1. Apply optimistic update (UI changes immediately)
2. Send request to server (background)
3. If success: Commit (keep the change)
4. If failure: Rollback (undo the change)
## Example: Like Button
[Full example with visual diagrams]
## With Global Store
For shared state, use with Global Store:
```
[Optimistic Update]
storeName: "posts"
key: "likes"
optimisticValue: likesCount + 1
```
All components subscribing to the store update automatically!
## Timeout
If server doesn't respond:
```
[Optimistic Update]
timeout: 5000 // Auto-rollback after 5s
```
## Best Practices
1. **Always handle rollback**: Show error message
2. **Show pending state**: "Saving..." indicator (optional)
3. **Use unique IDs**: Let node generate, or provide your own
4. **Set reasonable timeout**: 5-30 seconds depending on operation
```
---
## Success Criteria
1. ✅ Optimistic updates apply immediately
2. ✅ Rollback works on failure
3. ✅ Timeout prevents stuck pending states
4. ✅ Integrates with Global Store
5. ✅ No memory leaks
6. ✅ Clear documentation with examples
7. ✅ Works in Erleah for responsive interactions
---
## Future Enhancements
1. **Retry Logic** - Auto-retry failed operations
2. **Conflict Resolution** - Handle concurrent updates
3. **Offline Queue** - Queue updates when offline
4. **Animation Hooks** - Smooth transitions on rollback
5. **Batch Commits** - Commit multiple related transactions
---
## References
- [Optimistic UI](https://www.apollographql.com/docs/react/performance/optimistic-ui/) - Apollo GraphQL docs
- [React Query Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates)
---
## Dependencies
- AGENT-003 (Global State Store) - for store integration
## Blocked By
- AGENT-003
## Blocks
- None (optional enhancement for Erleah)
---
## Estimated Effort Breakdown
| Phase | Estimate | Description |
|-------|----------|-------------|
| Transaction Manager | 0.5 day | Core state machine |
| Optimistic Update Node | 1 day | Main node with store integration |
| Testing | 0.5 day | Unit tests, edge cases |
| Documentation | 0.5 day | User docs, examples |
**Total: 2.5 days**
Buffer: +0.5 day for edge cases = **3 days**
**Final: 2-3 days**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,786 @@
# AGENT-006: State History & Time Travel
## Overview
Create a state history tracking system that enables undo/redo, time-travel debugging, and state snapshots. This helps users recover from mistakes and developers debug complex state interactions.
**Phase:** 3.5 (Real-Time Agentic UI)
**Priority:** LOW (nice-to-have)
**Effort:** 1-2 days
**Risk:** Low
---
## Problem Statement
### Current Limitation
State changes are permanent:
```
User makes mistake → State changes → Can't undo
Developer debugging → State changed 10 steps ago → Can't replay
```
No way to go back in time.
### Desired Pattern
```
User action → State snapshot → Change state
User: "Undo" → Restore previous snapshot
Developer: "Go back 5 steps" → Time travel to that state
```
### Real-World Use Cases
1. **Undo Mistakes** - User accidentally removes item from timeline
2. **Debug State** - Developer replays sequence that caused bug
3. **A/B Comparison** - Save state, test changes, restore to compare
4. **Session Recovery** - Reload state after browser crash
5. **Feature Flags** - Toggle features on/off with instant rollback
---
## Goals
1. ✅ Track state changes automatically
2. ✅ Undo/redo state changes
3. ✅ Jump to specific state in history
4. ✅ Save/restore state snapshots
5. ✅ Limit history size (memory management)
6. ✅ Integrate with Global Store (AGENT-003)
7. ✅ Export/import history (debugging)
---
## Technical Design
### Node Specifications
We'll create THREE nodes:
1. **State History** - Track and manage history
2. **Undo** - Revert to previous state
3. **State Snapshot** - Save/restore snapshots
### State History Node
```javascript
{
name: 'net.noodl.StateHistory',
displayNodeName: 'State History',
category: 'Data',
color: 'blue',
docs: 'https://docs.noodl.net/nodes/data/state-history'
}
```
#### Ports: State History
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `storeName` | string | Store | Global store to track |
| `trackKeys` | string | Config | Comma-separated keys to track (blank = all) |
| `maxHistory` | number | Config | Max history entries (default: 50) |
| `enabled` | boolean | Config | Enable/disable tracking (default: true) |
| `clearHistory` | signal | Actions | Clear history |
| **Outputs** |
| `historySize` | number | Status | Number of entries in history |
| `canUndo` | boolean | Status | Can go back |
| `canRedo` | boolean | Status | Can go forward |
| `currentIndex` | number | Status | Position in history |
| `history` | array | Data | Full history array |
| `stateChanged` | signal | Events | Fires on any state change |
### Undo Node
```javascript
{
name: 'net.noodl.StateHistory.Undo',
displayNodeName: 'Undo',
category: 'Data',
color: 'blue'
}
```
#### Ports: Undo Node
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `storeName` | string | Store | Store to undo |
| `undo` | signal | Actions | Go back one step |
| `redo` | signal | Actions | Go forward one step |
| `jumpTo` | signal | Actions | Jump to specific index |
| `targetIndex` | number | Jump | Index to jump to |
| **Outputs** |
| `undone` | signal | Events | Fires after undo |
| `redone` | signal | Events | Fires after redo |
| `jumped` | signal | Events | Fires after jump |
### State Snapshot Node
```javascript
{
name: 'net.noodl.StateSnapshot',
displayNodeName: 'State Snapshot',
category: 'Data',
color: 'blue'
}
```
#### Ports: State Snapshot
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `storeName` | string | Store | Store to snapshot |
| `save` | signal | Actions | Save current state |
| `restore` | signal | Actions | Restore saved state |
| `snapshotName` | string | Snapshot | Name for this snapshot |
| `snapshotData` | object | Snapshot | Snapshot to restore (from export) |
| **Outputs** |
| `snapshot` | object | Data | Current saved snapshot |
| `saved` | signal | Events | Fires after save |
| `restored` | signal | Events | Fires after restore |
---
## Implementation Details
### File Structure
```
packages/noodl-runtime/src/nodes/std-library/data/
├── statehistorymanager.js # History tracking
├── statehistorynode.js # State History node
├── undonode.js # Undo/Redo node
├── statesnapshotnode.js # Snapshot node
└── statehistory.test.js # Tests
```
### State History Manager
```javascript
// statehistorymanager.js
const { globalStoreManager } = require('./globalstore');
class StateHistoryManager {
constructor() {
this.histories = new Map(); // storeName -> history
this.snapshots = new Map(); // snapshotName -> state
}
/**
* Start tracking a store
*/
trackStore(storeName, options = {}) {
if (this.histories.has(storeName)) {
return; // Already tracking
}
const { maxHistory = 50, trackKeys = [] } = options;
const history = {
entries: [],
currentIndex: -1,
maxHistory,
trackKeys,
unsubscribe: null
};
// Get initial state
const initialState = globalStoreManager.getState(storeName);
history.entries.push({
state: JSON.parse(JSON.stringify(initialState)),
timestamp: Date.now(),
description: 'Initial state'
});
history.currentIndex = 0;
// Subscribe to changes
history.unsubscribe = globalStoreManager.subscribe(
storeName,
(nextState, prevState, changedKeys) => {
this.recordStateChange(storeName, nextState, changedKeys);
},
trackKeys
);
this.histories.set(storeName, history);
console.log(`[StateHistory] Tracking store: ${storeName}`);
}
/**
* Stop tracking a store
*/
stopTracking(storeName) {
const history = this.histories.get(storeName);
if (!history) return;
if (history.unsubscribe) {
history.unsubscribe();
}
this.histories.delete(storeName);
}
/**
* Record a state change
*/
recordStateChange(storeName, newState, changedKeys) {
const history = this.histories.get(storeName);
if (!history) return;
// If we're not at the end, truncate future
if (history.currentIndex < history.entries.length - 1) {
history.entries = history.entries.slice(0, history.currentIndex + 1);
}
// Add new entry
history.entries.push({
state: JSON.parse(JSON.stringify(newState)),
timestamp: Date.now(),
changedKeys: changedKeys,
description: `Changed: ${changedKeys.join(', ')}`
});
// Enforce max history
if (history.entries.length > history.maxHistory) {
history.entries.shift();
} else {
history.currentIndex++;
}
console.log(`[StateHistory] Recorded change in ${storeName}`, changedKeys);
}
/**
* Undo (go back)
*/
undo(storeName) {
const history = this.histories.get(storeName);
if (!history || history.currentIndex <= 0) {
return null; // Can't undo
}
history.currentIndex--;
const entry = history.entries[history.currentIndex];
// Restore state
globalStoreManager.setState(storeName, entry.state);
console.log(`[StateHistory] Undo in ${storeName} to index ${history.currentIndex}`);
return {
state: entry.state,
index: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1
};
}
/**
* Redo (go forward)
*/
redo(storeName) {
const history = this.histories.get(storeName);
if (!history || history.currentIndex >= history.entries.length - 1) {
return null; // Can't redo
}
history.currentIndex++;
const entry = history.entries[history.currentIndex];
// Restore state
globalStoreManager.setState(storeName, entry.state);
console.log(`[StateHistory] Redo in ${storeName} to index ${history.currentIndex}`);
return {
state: entry.state,
index: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1
};
}
/**
* Jump to specific point in history
*/
jumpTo(storeName, index) {
const history = this.histories.get(storeName);
if (!history || index < 0 || index >= history.entries.length) {
return null;
}
history.currentIndex = index;
const entry = history.entries[index];
// Restore state
globalStoreManager.setState(storeName, entry.state);
console.log(`[StateHistory] Jump to index ${index} in ${storeName}`);
return {
state: entry.state,
index: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1
};
}
/**
* Get history info
*/
getHistoryInfo(storeName) {
const history = this.histories.get(storeName);
if (!history) return null;
return {
size: history.entries.length,
currentIndex: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1,
entries: history.entries.map((e, i) => ({
index: i,
timestamp: e.timestamp,
description: e.description,
isCurrent: i === history.currentIndex
}))
};
}
/**
* Clear history
*/
clearHistory(storeName) {
const history = this.histories.get(storeName);
if (!history) return;
const currentState = globalStoreManager.getState(storeName);
history.entries = [{
state: JSON.parse(JSON.stringify(currentState)),
timestamp: Date.now(),
description: 'Reset'
}];
history.currentIndex = 0;
}
/**
* Save snapshot
*/
saveSnapshot(snapshotName, storeName) {
const state = globalStoreManager.getState(storeName);
const snapshot = {
name: snapshotName,
storeName: storeName,
state: JSON.parse(JSON.stringify(state)),
timestamp: Date.now()
};
this.snapshots.set(snapshotName, snapshot);
console.log(`[StateHistory] Saved snapshot: ${snapshotName}`);
return snapshot;
}
/**
* Restore snapshot
*/
restoreSnapshot(snapshotName) {
const snapshot = this.snapshots.get(snapshotName);
if (!snapshot) {
throw new Error(`Snapshot not found: ${snapshotName}`);
}
globalStoreManager.setState(snapshot.storeName, snapshot.state);
console.log(`[StateHistory] Restored snapshot: ${snapshotName}`);
return snapshot;
}
/**
* Export history (for debugging)
*/
exportHistory(storeName) {
const history = this.histories.get(storeName);
if (!history) return null;
return {
storeName,
entries: history.entries,
currentIndex: history.currentIndex,
exportedAt: Date.now()
};
}
/**
* Import history (for debugging)
*/
importHistory(historyData) {
const { storeName, entries, currentIndex } = historyData;
// Stop current tracking
this.stopTracking(storeName);
// Create new history
const history = {
entries: entries,
currentIndex: currentIndex,
maxHistory: 50,
trackKeys: [],
unsubscribe: null
};
this.histories.set(storeName, history);
// Restore to current index
const entry = entries[currentIndex];
globalStoreManager.setState(storeName, entry.state);
}
}
const stateHistoryManager = new StateHistoryManager();
module.exports = { stateHistoryManager };
```
### State History Node (abbreviated)
```javascript
// statehistorynode.js
const { stateHistoryManager } = require('./statehistorymanager');
var StateHistoryNode = {
name: 'net.noodl.StateHistory',
displayNodeName: 'State History',
category: 'Data',
color: 'blue',
initialize: function() {
this._internal.tracking = false;
},
inputs: {
storeName: {
type: 'string',
displayName: 'Store Name',
default: 'app',
set: function(value) {
this._internal.storeName = value;
this.startTracking();
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
default: true,
set: function(value) {
if (value) {
this.startTracking();
} else {
this.stopTracking();
}
}
},
maxHistory: {
type: 'number',
displayName: 'Max History',
default: 50
},
clearHistory: {
type: 'signal',
displayName: 'Clear History',
valueChangedToTrue: function() {
stateHistoryManager.clearHistory(this._internal.storeName);
this.updateOutputs();
}
}
},
outputs: {
historySize: {
type: 'number',
displayName: 'History Size',
getter: function() {
const info = stateHistoryManager.getHistoryInfo(this._internal.storeName);
return info ? info.size : 0;
}
},
canUndo: {
type: 'boolean',
displayName: 'Can Undo',
getter: function() {
const info = stateHistoryManager.getHistoryInfo(this._internal.storeName);
return info ? info.canUndo : false;
}
},
canRedo: {
type: 'boolean',
displayName: 'Can Redo',
getter: function() {
const info = stateHistoryManager.getHistoryInfo(this._internal.storeName);
return info ? info.canRedo : false;
}
}
},
methods: {
startTracking: function() {
if (this._internal.tracking) return;
stateHistoryManager.trackStore(this._internal.storeName, {
maxHistory: this._internal.maxHistory,
trackKeys: this._internal.trackKeys
});
this._internal.tracking = true;
this.updateOutputs();
},
stopTracking: function() {
if (!this._internal.tracking) return;
stateHistoryManager.stopTracking(this._internal.storeName);
this._internal.tracking = false;
},
updateOutputs: function() {
this.flagOutputDirty('historySize');
this.flagOutputDirty('canUndo');
this.flagOutputDirty('canRedo');
}
}
};
```
---
## Usage Examples
### Example 1: Undo Button
```
[Button: "Undo"] clicked
[Undo]
storeName: "app"
undo signal
[Undo] undone
→ [Show Toast] "Undone"
[State History] canUndo
→ [Button] disabled = !canUndo
```
### Example 2: Timeline Slider
```
[State History]
storeName: "app"
historySize → maxValue
currentIndex → value
[Slider] value changed
→ [Undo] targetIndex
→ [Undo] jumpTo signal
// User can scrub through history!
```
### Example 3: Save/Restore Checkpoint
```
[Button: "Save Checkpoint"] clicked
[State Snapshot]
storeName: "app"
snapshotName: "checkpoint-1"
save
// Later...
[Button: "Restore Checkpoint"] clicked
[State Snapshot]
snapshotName: "checkpoint-1"
restore
```
### Example 4: Debug Mode
```
// Dev tools panel
[State History] history
→ [Repeater] show each entry
[Entry] clicked
→ [Undo] jumpTo with entry.index
[Button: "Export History"]
→ [State History] exportHistory
→ [File Download] history.json
```
---
## Testing Checklist
### Functional Tests
- [ ] History tracks state changes
- [ ] Undo reverts to previous state
- [ ] Redo goes forward
- [ ] Jump to specific index works
- [ ] Max history limit enforced
- [ ] Clear history works
- [ ] Snapshots save/restore correctly
- [ ] Export/import preserves history
### Edge Cases
- [ ] Undo at beginning (no-op)
- [ ] Redo at end (no-op)
- [ ] Jump to invalid index
- [ ] Change state while not at end (truncate future)
- [ ] Track empty store
- [ ] Very rapid state changes
- [ ] Large state objects (>1MB)
### Performance
- [ ] No memory leaks with long history
- [ ] History doesn't slow down app
- [ ] Deep cloning doesn't block UI
---
## Documentation Requirements
### User-Facing Docs
Create: `docs/nodes/data/state-history.md`
```markdown
# State History
Add undo/redo and time travel to your app. Track state changes and let users go back in time.
## Use Cases
- **Undo Mistakes**: User accidentally deletes something
- **Debug Complex State**: Developer traces bug through history
- **A/B Testing**: Save state, test, restore to compare
- **Session Recovery**: Reload after crash
## Basic Usage
**Step 1: Track State**
```
[State History]
storeName: "app"
maxHistory: 50
```
**Step 2: Add Undo**
```
[Button: "Undo"] clicked
→ [Undo] storeName: "app", undo signal
```
**Step 3: Disable When Can't Undo**
```
[State History] canUndo
→ [Button] disabled = !canUndo
```
## Time Travel
Build a history slider:
```
[State History] history → entries
→ [Slider] 0 to historySize
→ value changed
→ [Undo] jumpTo
```
## Snapshots
Save points you can return to:
```
[State Snapshot] save → checkpoint
[State Snapshot] restore ← checkpoint
```
## Best Practices
1. **Limit history size**: 50 entries prevents memory issues
2. **Track only what you need**: Use trackKeys for large stores
3. **Disable in production**: Enable only for dev/debug
```
---
## Success Criteria
1. ✅ Undo/redo works reliably
2. ✅ History doesn't leak memory
3. ✅ Snapshots save/restore correctly
4. ✅ Export/import for debugging
5. ✅ Clear documentation
6. ✅ Optional enhancement for Erleah
---
## Future Enhancements
1. **Diff Viewer** - Show what changed between states
2. **Branching History** - Tree instead of linear
3. **Selective Undo** - Undo specific changes only
4. **Persistence** - Save history to localStorage
5. **Collaborative Undo** - Undo others' changes
---
## Dependencies
- AGENT-003 (Global State Store)
## Blocked By
- AGENT-003
## Blocks
- None (optional feature)
---
## Estimated Effort Breakdown
| Phase | Estimate | Description |
|-------|----------|-------------|
| History Manager | 0.5 day | Core tracking system |
| Undo/Redo Node | 0.5 day | Node implementation |
| Snapshot Node | 0.5 day | Save/restore system |
| Testing | 0.5 day | Edge cases, memory leaks |
| Documentation | 0.5 day | User docs, examples |
**Total: 2.5 days**
Buffer: None needed
**Final: 1-2 days** (if scope kept minimal)

View File

@@ -0,0 +1,877 @@
# AGENT-007: Stream Parser Utilities
## Overview
Create utility nodes for parsing streaming data, particularly JSON streams, chunked responses, and incremental text accumulation. These utilities help process SSE and WebSocket messages that arrive in fragments.
**Phase:** 3.5 (Real-Time Agentic UI)
**Priority:** MEDIUM
**Effort:** 2-3 days
**Risk:** Low
---
## Problem Statement
### Current Limitation
Streaming data often arrives in fragments:
```
Chunk 1: {"type":"mes
Chunk 2: sage","conte
Chunk 3: nt":"Hello"}
```
Without parsing utilities, developers must manually:
1. Accumulate chunks
2. Detect message boundaries
3. Parse JSON safely
4. Handle malformed data
This is error-prone and repetitive.
### Desired Pattern
```
[SSE] data → raw chunks
[Stream Parser] accumulate & parse
[Complete JSON Object] → use in app
```
### Real-World Use Cases (Erleah)
1. **AI Chat Streaming** - Accumulate tokens into messages
2. **JSON Streaming** - Parse newline-delimited JSON (NDJSON)
3. **Progress Updates** - Extract percentages from stream
4. **CSV Streaming** - Parse CSV row-by-row
5. **Log Streaming** - Parse structured logs
---
## Goals
1. ✅ Accumulate text chunks into complete messages
2. ✅ Parse NDJSON (newline-delimited JSON)
3. ✅ Parse JSON chunks safely (handle incomplete JSON)
4. ✅ Extract values from streaming text (regex patterns)
5. ✅ Detect message boundaries (delimiters)
6. ✅ Buffer and flush patterns
7. ✅ Handle encoding/decoding
---
## Technical Design
### Node Specifications
We'll create FOUR utility nodes:
1. **Text Accumulator** - Accumulate chunks into complete text
2. **JSON Stream Parser** - Parse NDJSON or chunked JSON
3. **Pattern Extractor** - Extract values using regex
4. **Stream Buffer** - Buffer with custom flush logic
### Text Accumulator Node
```javascript
{
name: 'net.noodl.TextAccumulator',
displayNodeName: 'Text Accumulator',
category: 'Data',
color: 'green',
docs: 'https://docs.noodl.net/nodes/data/text-accumulator'
}
```
#### Ports: Text Accumulator
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `chunk` | string | Data | Text chunk to add |
| `add` | signal | Actions | Add chunk to buffer |
| `clear` | signal | Actions | Clear accumulated text |
| `delimiter` | string | Config | Message delimiter (default: "\\n") |
| `maxLength` | number | Config | Max buffer size (default: 1MB) |
| **Outputs** |
| `accumulated` | string | Data | Current accumulated text |
| `messages` | array | Data | Complete messages (split by delimiter) |
| `messageCount` | number | Status | Number of complete messages |
| `messageReceived` | signal | Events | Fires when complete message |
| `bufferSize` | number | Status | Current buffer size (bytes) |
| `cleared` | signal | Events | Fires after clear |
### JSON Stream Parser Node
```javascript
{
name: 'net.noodl.JSONStreamParser',
displayNodeName: 'JSON Stream Parser',
category: 'Data',
color: 'green'
}
```
#### Ports: JSON Stream Parser
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `chunk` | string | Data | JSON chunk |
| `parse` | signal | Actions | Trigger parse |
| `clear` | signal | Actions | Clear buffer |
| `format` | enum | Config | 'ndjson', 'array', 'single' |
| **Outputs** |
| `parsed` | * | Data | Parsed object |
| `success` | signal | Events | Fires on successful parse |
| `error` | string | Events | Parse error message |
| `isComplete` | boolean | Status | Object is complete |
### Pattern Extractor Node
```javascript
{
name: 'net.noodl.PatternExtractor',
displayNodeName: 'Pattern Extractor',
category: 'Data',
color: 'green'
}
```
#### Ports: Pattern Extractor
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `text` | string | Data | Text to extract from |
| `pattern` | string | Pattern | Regex pattern |
| `extract` | signal | Actions | Trigger extraction |
| `extractAll` | boolean | Config | Extract all matches vs first |
| **Outputs** |
| `match` | string | Data | Matched text |
| `matches` | array | Data | All matches |
| `groups` | array | Data | Capture groups |
| `found` | signal | Events | Fires when match found |
| `notFound` | signal | Events | Fires when no match |
### Stream Buffer Node
```javascript
{
name: 'net.noodl.StreamBuffer',
displayNodeName: 'Stream Buffer',
category: 'Data',
color: 'green'
}
```
#### Ports: Stream Buffer
| Port Name | Type | Group | Description |
|-----------|------|-------|-------------|
| **Inputs** |
| `data` | * | Data | Data to buffer |
| `add` | signal | Actions | Add to buffer |
| `flush` | signal | Actions | Flush buffer |
| `clear` | signal | Actions | Clear buffer |
| `flushSize` | number | Config | Auto-flush after N items |
| `flushInterval` | number | Config | Auto-flush after ms |
| **Outputs** |
| `buffer` | array | Data | Current buffer contents |
| `bufferSize` | number | Status | Items in buffer |
| `flushed` | signal | Events | Fires after flush |
| `flushedData` | array | Data | Data that was flushed |
---
## Implementation Details
### File Structure
```
packages/noodl-runtime/src/nodes/std-library/data/
├── textaccumulatornode.js # Text accumulator
├── jsonstreamparsernode.js # JSON parser
├── patternextractornode.js # Regex extractor
├── streambuffernode.js # Generic buffer
└── streamutils.test.js # Tests
```
### Text Accumulator Implementation
```javascript
// textaccumulatornode.js
var TextAccumulatorNode = {
name: 'net.noodl.TextAccumulator',
displayNodeName: 'Text Accumulator',
category: 'Data',
color: 'green',
initialize: function() {
this._internal.buffer = '';
this._internal.messages = [];
},
inputs: {
chunk: {
type: 'string',
displayName: 'Chunk',
group: 'Data',
set: function(value) {
this._internal.pendingChunk = value;
}
},
add: {
type: 'signal',
displayName: 'Add',
group: 'Actions',
valueChangedToTrue: function() {
this.addChunk();
}
},
clear: {
type: 'signal',
displayName: 'Clear',
group: 'Actions',
valueChangedToTrue: function() {
this.clearBuffer();
}
},
delimiter: {
type: 'string',
displayName: 'Delimiter',
group: 'Config',
default: '\n',
set: function(value) {
this._internal.delimiter = value;
}
},
maxLength: {
type: 'number',
displayName: 'Max Length (bytes)',
group: 'Config',
default: 1024 * 1024, // 1MB
set: function(value) {
this._internal.maxLength = value;
}
}
},
outputs: {
accumulated: {
type: 'string',
displayName: 'Accumulated',
group: 'Data',
getter: function() {
return this._internal.buffer;
}
},
messages: {
type: 'array',
displayName: 'Messages',
group: 'Data',
getter: function() {
return this._internal.messages;
}
},
messageCount: {
type: 'number',
displayName: 'Message Count',
group: 'Status',
getter: function() {
return this._internal.messages.length;
}
},
messageReceived: {
type: 'signal',
displayName: 'Message Received',
group: 'Events'
},
bufferSize: {
type: 'number',
displayName: 'Buffer Size',
group: 'Status',
getter: function() {
return new Blob([this._internal.buffer]).size;
}
},
cleared: {
type: 'signal',
displayName: 'Cleared',
group: 'Events'
}
},
methods: {
addChunk: function() {
const chunk = this._internal.pendingChunk;
if (!chunk) return;
// Add to buffer
this._internal.buffer += chunk;
// Check max length
const maxLength = this._internal.maxLength || (1024 * 1024);
if (this._internal.buffer.length > maxLength) {
console.warn('[TextAccumulator] Buffer overflow, truncating');
this._internal.buffer = this._internal.buffer.slice(-maxLength);
}
// Check for complete messages
const delimiter = this._internal.delimiter || '\n';
const parts = this._internal.buffer.split(delimiter);
// Keep last incomplete part in buffer
this._internal.buffer = parts.pop();
// Add complete messages
if (parts.length > 0) {
this._internal.messages = this._internal.messages.concat(parts);
this.flagOutputDirty('messages');
this.flagOutputDirty('messageCount');
this.sendSignalOnOutput('messageReceived');
}
this.flagOutputDirty('accumulated');
this.flagOutputDirty('bufferSize');
},
clearBuffer: function() {
this._internal.buffer = '';
this._internal.messages = [];
this.flagOutputDirty('accumulated');
this.flagOutputDirty('messages');
this.flagOutputDirty('messageCount');
this.flagOutputDirty('bufferSize');
this.sendSignalOnOutput('cleared');
}
},
getInspectInfo: function() {
return {
type: 'value',
value: {
bufferSize: this._internal.buffer.length,
messageCount: this._internal.messages.length,
lastMessage: this._internal.messages[this._internal.messages.length - 1]
}
};
}
};
module.exports = {
node: TextAccumulatorNode
};
```
### JSON Stream Parser Implementation
```javascript
// jsonstreamparsernode.js
var JSONStreamParserNode = {
name: 'net.noodl.JSONStreamParser',
displayNodeName: 'JSON Stream Parser',
category: 'Data',
color: 'green',
initialize: function() {
this._internal.buffer = '';
},
inputs: {
chunk: {
type: 'string',
displayName: 'Chunk',
group: 'Data',
set: function(value) {
this._internal.chunk = value;
}
},
parse: {
type: 'signal',
displayName: 'Parse',
group: 'Actions',
valueChangedToTrue: function() {
this.doParse();
}
},
clear: {
type: 'signal',
displayName: 'Clear',
group: 'Actions',
valueChangedToTrue: function() {
this._internal.buffer = '';
}
},
format: {
type: {
name: 'enum',
enums: [
{ label: 'NDJSON', value: 'ndjson' },
{ label: 'JSON Array', value: 'array' },
{ label: 'Single Object', value: 'single' }
]
},
displayName: 'Format',
group: 'Config',
default: 'ndjson'
}
},
outputs: {
parsed: {
type: '*',
displayName: 'Parsed',
group: 'Data',
getter: function() {
return this._internal.parsed;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter: function() {
return this._internal.error;
}
},
isComplete: {
type: 'boolean',
displayName: 'Is Complete',
group: 'Status',
getter: function() {
return this._internal.isComplete;
}
}
},
methods: {
doParse: function() {
const chunk = this._internal.chunk;
if (!chunk) return;
this._internal.buffer += chunk;
const format = this._internal.format || 'ndjson';
try {
if (format === 'ndjson') {
this.parseNDJSON();
} else if (format === 'single') {
this.parseSingleJSON();
} else if (format === 'array') {
this.parseJSONArray();
}
} catch (e) {
this._internal.error = e.message;
this._internal.isComplete = false;
this.flagOutputDirty('error');
this.flagOutputDirty('isComplete');
}
},
parseNDJSON: function() {
// NDJSON: one JSON per line
const lines = this._internal.buffer.split('\n');
// Keep last incomplete line
this._internal.buffer = lines.pop();
// Parse complete lines
const parsed = [];
for (const line of lines) {
if (line.trim()) {
try {
parsed.push(JSON.parse(line));
} catch (e) {
console.warn('[JSONStreamParser] Failed to parse line:', line);
}
}
}
if (parsed.length > 0) {
this._internal.parsed = parsed;
this._internal.isComplete = true;
this.flagOutputDirty('parsed');
this.flagOutputDirty('isComplete');
this.sendSignalOnOutput('success');
}
},
parseSingleJSON: function() {
// Try to parse complete JSON object
try {
const parsed = JSON.parse(this._internal.buffer);
this._internal.parsed = parsed;
this._internal.isComplete = true;
this._internal.buffer = '';
this.flagOutputDirty('parsed');
this.flagOutputDirty('isComplete');
this.sendSignalOnOutput('success');
} catch (e) {
// Not complete yet, wait for more chunks
this._internal.isComplete = false;
this.flagOutputDirty('isComplete');
}
},
parseJSONArray: function() {
// JSON array, possibly incomplete: [{"a":1},{"b":2}...
// Try to parse as-is, or add closing bracket
let buffer = this._internal.buffer.trim();
// Try parsing complete array
try {
const parsed = JSON.parse(buffer);
if (Array.isArray(parsed)) {
this._internal.parsed = parsed;
this._internal.isComplete = true;
this._internal.buffer = '';
this.flagOutputDirty('parsed');
this.flagOutputDirty('isComplete');
this.sendSignalOnOutput('success');
return;
}
} catch (e) {
// Not complete, try adding closing bracket
try {
const parsed = JSON.parse(buffer + ']');
if (Array.isArray(parsed)) {
this._internal.parsed = parsed;
this._internal.isComplete = false; // Partial
this.flagOutputDirty('parsed');
this.flagOutputDirty('isComplete');
this.sendSignalOnOutput('success');
}
} catch (e2) {
// Still not parseable
this._internal.isComplete = false;
this.flagOutputDirty('isComplete');
}
}
}
}
};
module.exports = {
node: JSONStreamParserNode
};
```
### Pattern Extractor Implementation (abbreviated)
```javascript
// patternextractornode.js
var PatternExtractorNode = {
name: 'net.noodl.PatternExtractor',
displayNodeName: 'Pattern Extractor',
category: 'Data',
color: 'green',
inputs: {
text: { type: 'string', displayName: 'Text' },
pattern: { type: 'string', displayName: 'Pattern' },
extract: { type: 'signal', displayName: 'Extract' },
extractAll: { type: 'boolean', displayName: 'Extract All', default: false }
},
outputs: {
match: { type: 'string', displayName: 'Match' },
matches: { type: 'array', displayName: 'Matches' },
groups: { type: 'array', displayName: 'Groups' },
found: { type: 'signal', displayName: 'Found' },
notFound: { type: 'signal', displayName: 'Not Found' }
},
methods: {
doExtract: function() {
const text = this._internal.text;
const pattern = this._internal.pattern;
if (!text || !pattern) return;
try {
const regex = new RegExp(pattern, this._internal.extractAll ? 'g' : '');
const matches = this._internal.extractAll
? [...text.matchAll(new RegExp(pattern, 'g'))]
: [text.match(regex)];
if (matches && matches[0]) {
this._internal.match = matches[0][0];
this._internal.matches = matches.map(m => m[0]);
this._internal.groups = matches[0].slice(1);
this.flagOutputDirty('match');
this.flagOutputDirty('matches');
this.flagOutputDirty('groups');
this.sendSignalOnOutput('found');
} else {
this.sendSignalOnOutput('notFound');
}
} catch (e) {
console.error('[PatternExtractor] Invalid regex:', e);
}
}
}
};
```
---
## Usage Examples
### Example 1: AI Chat Streaming (Erleah)
```
[SSE] data → text chunks
[Text Accumulator]
delimiter: "" // No delimiter, accumulate all
chunk: data
add
[Text Accumulator] accumulated
→ [Text] display streaming message
// Real-time text appears as AI types!
```
### Example 2: NDJSON Stream
```
[SSE] data → NDJSON chunks
[JSON Stream Parser]
format: "ndjson"
chunk: data
parse
[JSON Stream Parser] parsed → array of objects
[For Each] object in array
→ [Process each object]
```
### Example 3: Extract Progress
```
[SSE] data → "Processing... 45% complete"
[Pattern Extractor]
text: data
pattern: "(\d+)%"
extract
[Pattern Extractor] groups → [0] = "45"
→ [Number] 45
→ [Progress Bar] value
```
### Example 4: Buffered Updates
```
[SSE] data → frequent updates
[Stream Buffer]
data: item
add
flushInterval: 1000 // Flush every second
[Stream Buffer] flushed
[Stream Buffer] flushedData → batched items
→ [Process batch at once]
// Reduces processing overhead
```
---
## Testing Checklist
### Functional Tests
- [ ] Text accumulator handles chunks correctly
- [ ] NDJSON parser splits on newlines
- [ ] Single JSON waits for complete object
- [ ] Array JSON handles incomplete arrays
- [ ] Pattern extractor finds matches
- [ ] Capture groups extracted correctly
- [ ] Buffer flushes on size/interval
- [ ] Clear operations work
### Edge Cases
- [ ] Empty chunks
- [ ] Very large chunks (>1MB)
- [ ] Malformed JSON
- [ ] Invalid regex patterns
- [ ] No matches found
- [ ] Buffer overflow
- [ ] Rapid chunks (stress test)
- [ ] Unicode/emoji handling
### Performance
- [ ] No memory leaks with long streams
- [ ] Regex doesn't cause ReDoS
- [ ] Large buffer doesn't freeze UI
---
## Documentation Requirements
### User-Facing Docs
Create: `docs/nodes/data/stream-utilities.md`
```markdown
# Stream Utilities
Tools for working with streaming data from SSE, WebSocket, or chunked HTTP responses.
## Text Accumulator
Collect text chunks into complete messages:
```
[SSE] → chunks
[Text Accumulator] → complete messages
```
Use cases:
- AI chat streaming
- Log streaming
- Progress messages
## JSON Stream Parser
Parse streaming JSON in various formats:
- **NDJSON**: One JSON per line
- **Single**: Wait for complete object
- **Array**: Parse partial JSON arrays
## Pattern Extractor
Extract values using regex:
```
Text: "Status: 45% complete"
Pattern: "(\d+)%"
→ Match: "45"
```
Use cases:
- Extract progress percentages
- Parse structured logs
- Find specific values
## Stream Buffer
Batch frequent updates:
```
[Rapid Updates] → [Buffer] → [Batch Process]
```
Reduces processing overhead.
## Best Practices
1. **Set max lengths**: Prevent memory issues
2. **Handle parse errors**: JSON might be incomplete
3. **Use delimiters**: For message boundaries
4. **Batch when possible**: Reduce processing
```
---
## Success Criteria
1. ✅ Handles streaming data reliably
2. ✅ Parses NDJSON correctly
3. ✅ Regex extraction works
4. ✅ No memory leaks
5. ✅ Clear documentation
6. ✅ Works with AGENT-001 (SSE) for Erleah
---
## Future Enhancements
1. **XML Stream Parser** - Parse chunked XML
2. **CSV Stream Parser** - Parse CSV row-by-row
3. **Binary Parsers** - Protocol buffers, msgpack
4. **Compression** - Decompress gzip/deflate streams
5. **Encoding Detection** - Auto-detect UTF-8/UTF-16
---
## References
- [NDJSON Spec](http://ndjson.org/)
- [JSON Streaming](https://en.wikipedia.org/wiki/JSON_streaming)
---
## Dependencies
- None (pure utilities)
## Blocked By
- None (can be developed independently)
## Blocks
- None (but enhances AGENT-001, AGENT-002)
---
## Estimated Effort Breakdown
| Phase | Estimate | Description |
|-------|----------|-------------|
| Text Accumulator | 0.5 day | Basic chunking logic |
| JSON Parser | 1 day | NDJSON, single, array formats |
| Pattern Extractor | 0.5 day | Regex wrapper |
| Stream Buffer | 0.5 day | Time/size-based flushing |
| Testing | 0.5 day | Edge cases, performance |
| Documentation | 0.5 day | User docs, examples |
**Total: 3.5 days**
Buffer: None needed
**Final: 2-3 days**

View File

@@ -0,0 +1,826 @@
# Can Noodl Build the New Agentic Erleah?
## Strategic Analysis & Roadmap
**Date:** December 30, 2025
**Author:** Strategic Analysis
**Status:** Recommendation - YES, with Phase 3.5 additions
---
## Executive Summary
**TL;DR:** Yes, Noodl CAN build the new agentic Erleah, but it requires adding a focused "Phase 3.5: Real-Time Agentic UI" series of nodes and features. This is actually a PERFECT test case for Noodl's capabilities and would result in features that benefit the entire Noodl ecosystem.
**Key Insights:**
1. **Foundation is solid** - Phases 1 & 2 created a modern React 19 + TypeScript base
2. **Core patterns exist** - HTTP nodes, state management, and event systems are already there
3. **Missing pieces are specific** - SSE streams, optimistic updates, and action dispatching
4. **High ROI** - Building these features makes Noodl better for ALL modern web apps
5. **Validation opportunity** - If Noodl can build Erleah, it proves the platform's maturity
---
## Current Capabilities Assessment
### ✅ What Noodl ALREADY Has
#### 1. Modern Foundation (Phase 1 Complete)
- **React 19** in both editor and runtime
- **TypeScript 5** with full type inference
- **Modern tooling** - webpack 5, Storybook 8
- **Performance** - Build times improved, hot reload snappy
#### 2. HTTP & API Integration (Phase 2 In Progress)
```javascript
// Current HTTP Node capabilities:
- GET/POST/PUT/DELETE/PATCH methods
- Authentication presets (Bearer, Basic, API Key)
- JSONPath response mapping
- Header and query parameter management
- Form data and URL-encoded bodies
- Timeout configuration
- Cancel requests
```
#### 3. State Management
```javascript
- Variable nodes for local state
- Object/Array manipulation nodes
- Component Inputs/Outputs for prop drilling
- Send Event/Receive Event for pub-sub
- States node for state machines
```
#### 4. Visual Components
```javascript
- Full React component library
- Responsive breakpoints (planned in NODES-001)
- Visual states (hover, pressed, disabled)
- Conditional rendering
- Repeater for dynamic lists
```
#### 5. Event System
```javascript
- Signal-based event propagation
- EventDispatcher for pub-sub patterns
- Connection-based data flow
- Debounce/Delay nodes for timing
```
### ❌ What Noodl Is MISSING for Erleah
#### 1. **Server-Sent Events (SSE) Support**
**Current Gap:** HTTP node only does request-response, no streaming
**Erleah Needs:**
```javascript
// Chat messages streaming in real-time
AI Agent: "I'm searching attendees..." [streaming]
AI Agent: "Found 8 matches..." [streaming]
AI Agent: "Adding to your plan..." [streaming]
```
**What's Required:**
- SSE connection node
- Stream parsing (JSON chunks)
- Progressive message accumulation
- Automatic reconnection on disconnect
#### 2. **WebSocket Support**
**Current Gap:** No WebSocket node exists
**Erleah Needs:**
```javascript
// Real-time bidirectional communication
User Backend: "Add this to timeline"
Backend User: "Timeline updated" [instant]
Backend User: "Connection request accepted" [push]
```
#### 3. **Optimistic UI Updates**
**Current Gap:** No pattern for "update UI first, sync later"
**Erleah Needs:**
```javascript
// Click "Accept" → immediate UI feedback
// Then backend call → roll back if it fails
```
**What's Required:**
- Transaction/rollback state management
- Pending state indicators
- Error recovery patterns
#### 4. **Action Dispatcher Pattern**
**Current Gap:** No concept of backend-triggered UI actions
**Erleah Needs:**
```javascript
// Backend (AI Agent) sends:
{
type: "OPEN_VIEW",
view: "agenda",
id: "session-123"
}
// Frontend automatically navigates
```
**What's Required:**
- Action queue/processor
- UI action vocabulary
- Safe execution sandbox
#### 5. **State Synchronization Across Views**
**Current Gap:** Component state is isolated, no global reactive store
**Erleah Needs:**
```javascript
// Chat sidebar updates → Timeline view updates
// Timeline view updates → Parking Lot updates
// All views stay in sync automatically
```
**What's Required:**
- Global observable store (like Zustand)
- Subscription mechanism
- Selective re-rendering
---
## Gap Analysis: Erleah Requirements vs Noodl Capabilities
### Feature Comparison Matrix
| Erleah Feature | Noodl Today | Gap Size | Effort to Add |
|----------------|-------------|----------|---------------|
| **Timeline View** | ✅ Repeater + Cards | None | 0 days |
| **Chat Sidebar** | ✅ Components | None | 0 days |
| **Parking Lot Sidebar** | ✅ Components | None | 0 days |
| **Card Layouts** | ✅ Visual nodes | None | 0 days |
| **HTTP API Calls** | ✅ HTTP Node | None | 0 days |
| **Authentication** | ✅ Auth presets | None | 0 days |
| **SSE Streaming** | ❌ None | Large | 3-5 days |
| **WebSocket** | ❌ None | Large | 3-5 days |
| **Optimistic Updates** | ❌ None | Medium | 2-3 days |
| **Action Dispatcher** | ⚠️ Partial | Medium | 2-4 days |
| **Global State** | ⚠️ Workarounds | Small | 2-3 days |
| **State History** | ❌ None | Small | 1-2 days |
| **Real-time Preview** | ✅ Existing | None | 0 days |
**Total New Development:** ~15-24 days
---
## Proposed Phase 3.5: Real-Time Agentic UI
Insert this between current Phase 3 and the rest of the roadmap.
### Task Series: AGENT (AI Agent Integration)
**Total Estimated:** 15-24 days (3-4 weeks)
| Task ID | Name | Estimate | Description |
|---------|------|----------|-------------|
| **AGENT-001** | Server-Sent Events Node | 3-5 days | SSE connection, streaming, auto-reconnect |
| **AGENT-002** | WebSocket Node | 3-5 days | Bidirectional real-time communication |
| **AGENT-003** | Global State Store | 2-3 days | Observable store like Zustand, cross-component |
| **AGENT-004** | Optimistic Update Pattern | 2-3 days | Transaction wrapper, rollback support |
| **AGENT-005** | Action Dispatcher | 2-4 days | Backend-to-frontend command execution |
| **AGENT-006** | State History & Time Travel | 1-2 days | Undo/redo, state snapshots |
| **AGENT-007** | Stream Parser Utilities | 2-3 days | JSON streaming, chunk assembly |
### AGENT-001: Server-Sent Events Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/ssenode.js`
```javascript
var SSENode = {
name: 'net.noodl.SSE',
displayNodeName: 'Server-Sent Events',
docs: 'https://docs.noodl.net/nodes/data/sse',
category: 'Data',
color: 'data',
searchTags: ['sse', 'stream', 'server-sent', 'events', 'realtime'],
initialize: function() {
this._internal.eventSource = null;
this._internal.isConnected = false;
this._internal.messageBuffer = [];
},
inputs: {
url: {
type: 'string',
displayName: 'URL',
group: 'Connection',
set: function(value) {
this._internal.url = value;
}
},
connect: {
type: 'signal',
displayName: 'Connect',
group: 'Actions',
valueChangedToTrue: function() {
this.doConnect();
}
},
disconnect: {
type: 'signal',
displayName: 'Disconnect',
group: 'Actions',
valueChangedToTrue: function() {
this.doDisconnect();
}
},
autoReconnect: {
type: 'boolean',
displayName: 'Auto Reconnect',
group: 'Connection',
default: true
},
reconnectDelay: {
type: 'number',
displayName: 'Reconnect Delay (ms)',
group: 'Connection',
default: 3000
}
},
outputs: {
message: {
type: 'object',
displayName: 'Message',
group: 'Data'
},
data: {
type: '*',
displayName: 'Parsed Data',
group: 'Data'
},
connected: {
type: 'signal',
displayName: 'Connected',
group: 'Events'
},
disconnected: {
type: 'signal',
displayName: 'Disconnected',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events'
},
isConnected: {
type: 'boolean',
displayName: 'Is Connected',
group: 'Status',
getter: function() {
return this._internal.isConnected;
}
}
},
methods: {
doConnect: function() {
if (this._internal.eventSource) {
this.doDisconnect();
}
const url = this._internal.url;
if (!url) {
this.setError('URL is required');
return;
}
try {
const eventSource = new EventSource(url);
this._internal.eventSource = eventSource;
eventSource.onopen = () => {
this._internal.isConnected = true;
this.flagOutputDirty('isConnected');
this.sendSignalOnOutput('connected');
};
eventSource.onmessage = (event) => {
this.handleMessage(event);
};
eventSource.onerror = (error) => {
this._internal.isConnected = false;
this.flagOutputDirty('isConnected');
this.sendSignalOnOutput('disconnected');
if (this._internal.autoReconnect) {
setTimeout(() => {
if (!this._internal.eventSource || this._internal.eventSource.readyState === EventSource.CLOSED) {
this.doConnect();
}
}, this._internal.reconnectDelay || 3000);
}
};
} catch (e) {
this.setError(e.message);
}
},
doDisconnect: function() {
if (this._internal.eventSource) {
this._internal.eventSource.close();
this._internal.eventSource = null;
this._internal.isConnected = false;
this.flagOutputDirty('isConnected');
this.sendSignalOnOutput('disconnected');
}
},
handleMessage: function(event) {
try {
// Try to parse as JSON
const data = JSON.parse(event.data);
this._internal.message = event;
this._internal.data = data;
} catch (e) {
// Not JSON, use raw data
this._internal.message = event;
this._internal.data = event.data;
}
this.flagOutputDirty('message');
this.flagOutputDirty('data');
},
setError: function(message) {
this._internal.error = message;
this.flagOutputDirty('error');
},
_onNodeDeleted: function() {
this.doDisconnect();
}
}
};
module.exports = {
node: SSENode
};
```
### AGENT-003: Global State Store Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/globalstorenode.js`
```javascript
// Global store instance (singleton)
class GlobalStore {
constructor() {
this.stores = new Map();
this.subscribers = new Map();
}
createStore(name, initialState = {}) {
if (!this.stores.has(name)) {
this.stores.set(name, initialState);
this.subscribers.set(name, new Set());
}
return this.stores.get(name);
}
getState(name) {
return this.stores.get(name) || {};
}
setState(name, updates) {
const current = this.stores.get(name) || {};
const next = { ...current, ...updates };
this.stores.set(name, next);
this.notify(name, next);
}
subscribe(name, callback) {
if (!this.subscribers.has(name)) {
this.subscribers.set(name, new Set());
}
this.subscribers.get(name).add(callback);
// Return unsubscribe function
return () => {
this.subscribers.get(name).delete(callback);
};
}
notify(name, state) {
const subscribers = this.subscribers.get(name);
if (subscribers) {
subscribers.forEach(cb => cb(state));
}
}
}
const globalStoreInstance = new GlobalStore();
var GlobalStoreNode = {
name: 'net.noodl.GlobalStore',
displayNodeName: 'Global Store',
category: 'Data',
color: 'data',
initialize: function() {
this._internal.storeName = 'default';
this._internal.unsubscribe = null;
},
inputs: {
storeName: {
type: 'string',
displayName: 'Store Name',
default: 'default',
set: function(value) {
if (this._internal.unsubscribe) {
this._internal.unsubscribe();
}
this._internal.storeName = value;
this.setupSubscription();
}
},
set: {
type: 'signal',
displayName: 'Set',
valueChangedToTrue: function() {
this.doSet();
}
},
key: {
type: 'string',
displayName: 'Key'
},
value: {
type: '*',
displayName: 'Value'
}
},
outputs: {
state: {
type: 'object',
displayName: 'State',
getter: function() {
return globalStoreInstance.getState(this._internal.storeName);
}
},
stateChanged: {
type: 'signal',
displayName: 'State Changed'
}
},
methods: {
setupSubscription: function() {
const storeName = this._internal.storeName;
this._internal.unsubscribe = globalStoreInstance.subscribe(
storeName,
(newState) => {
this.flagOutputDirty('state');
this.sendSignalOnOutput('stateChanged');
}
);
// Trigger initial state
this.flagOutputDirty('state');
},
doSet: function() {
const key = this._internal.key;
const value = this._internal.value;
const storeName = this._internal.storeName;
if (key) {
globalStoreInstance.setState(storeName, { [key]: value });
}
},
_onNodeDeleted: function() {
if (this._internal.unsubscribe) {
this._internal.unsubscribe();
}
}
}
};
```
### AGENT-005: Action Dispatcher Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/actiondispatchernode.js`
```javascript
var ActionDispatcherNode = {
name: 'net.noodl.ActionDispatcher',
displayNodeName: 'Action Dispatcher',
category: 'Events',
color: 'purple',
initialize: function() {
this._internal.actionHandlers = new Map();
this._internal.pendingActions = [];
},
inputs: {
action: {
type: 'object',
displayName: 'Action',
set: function(value) {
this._internal.currentAction = value;
this.dispatch(value);
}
},
// Register handlers
registerHandler: {
type: 'signal',
displayName: 'Register Handler',
valueChangedToTrue: function() {
this.doRegisterHandler();
}
},
handlerType: {
type: 'string',
displayName: 'Handler Type'
},
handlerCallback: {
type: 'signal',
displayName: 'Handler Callback'
}
},
outputs: {
actionType: {
type: 'string',
displayName: 'Action Type'
},
actionData: {
type: 'object',
displayName: 'Action Data'
},
dispatched: {
type: 'signal',
displayName: 'Dispatched'
}
},
methods: {
dispatch: function(action) {
if (!action || !action.type) return;
this._internal.actionType = action.type;
this._internal.actionData = action.data || {};
this.flagOutputDirty('actionType');
this.flagOutputDirty('actionData');
this.sendSignalOnOutput('dispatched');
// Execute registered handlers
const handler = this._internal.actionHandlers.get(action.type);
if (handler) {
handler(action.data);
}
},
doRegisterHandler: function() {
const type = this._internal.handlerType;
if (!type) return;
this._internal.actionHandlers.set(type, (data) => {
this._internal.actionData = data;
this.flagOutputDirty('actionData');
this.sendSignalOnOutput('handlerCallback');
});
}
}
};
```
---
## Implementation Strategy: Phases 1-2-3.5-3-4-5
### Revised Roadmap
```
Phase 1: Foundation ✅ COMPLETE
├─ React 19 migration
├─ TypeScript 5 upgrade
└─ Storybook 8 migration
Phase 2: Core Features ⚙️ IN PROGRESS
├─ HTTP Node improvements ✅ COMPLETE
├─ Responsive breakpoints 🔄 ACTIVE
├─ Component migrations 🔄 ACTIVE
└─ EventDispatcher React bridge ⚠️ BLOCKED
Phase 3.5: Real-Time Agentic UI 🆕 PROPOSED
├─ AGENT-001: SSE Node (3-5 days)
├─ AGENT-002: WebSocket Node (3-5 days)
├─ AGENT-003: Global State Store (2-3 days)
├─ AGENT-004: Optimistic Updates (2-3 days)
├─ AGENT-005: Action Dispatcher (2-4 days)
├─ AGENT-006: State History (1-2 days)
└─ AGENT-007: Stream Utilities (2-3 days)
Total: 15-24 days (3-4 weeks)
Phase 3: Advanced Features
├─ Dashboard UX (DASH series)
├─ Git Integration (GIT series)
├─ Shared Components (COMP series)
├─ AI Features (AI series)
└─ Deployment (DEPLOY series)
```
### Critical Path for Erleah
To build Erleah, this is the minimum required path:
**Week 1-2: Phase 3.5 Core**
- AGENT-001 (SSE) - Absolutely critical for streaming AI responses
- AGENT-003 (Global Store) - Required for synchronized state
- AGENT-007 (Stream Utils) - Need to parse SSE JSON chunks
**Week 3: Phase 3.5 Enhancement**
- AGENT-004 (Optimistic Updates) - Better UX for user interactions
- AGENT-005 (Action Dispatcher) - AI agent can control UI
**Week 4: Erleah Development**
- Build Timeline view
- Build Chat sidebar with SSE
- Build Parking Lot sidebar
- Connect to backend
**Total:** 4 weeks to validated Erleah prototype in Noodl
---
## Why This Is GOOD for Noodl
### 1. **Validates Modern Architecture**
Building a complex, agentic UI proves that Noodl's React 19 + TypeScript migration was worth it. This is a real-world stress test.
### 2. **Features Benefit Everyone**
SSE, WebSocket, and Global Store aren't "Erleah-specific" - every modern web app needs these:
- Chat applications
- Real-time dashboards
- Collaborative tools
- Live notifications
- Streaming data visualization
### 3. **Competitive Advantage**
Flutterflow, Bubble, Webflow - none have agentic UI patterns built in. This would be a differentiator.
### 4. **Dogfooding**
Using Noodl to build a complex AI-powered app exposes UX issues and missing features that users face daily.
### 5. **Marketing Asset**
"Built with Noodl" becomes a powerful case study. Erleah is a sophisticated, modern web app that competes with pure-code solutions.
---
## Risks & Mitigations
### Risk 1: "We're adding too much complexity"
**Mitigation:** Phase 3.5 features are optional. Existing Noodl projects continue working. These are additive, not disruptive.
### Risk 2: "What if we hit a fundamental limitation?"
**Mitigation:** Start with Phase 3.5 AGENT-001 (SSE) as a proof-of-concept. If that works smoothly, continue. If it's a nightmare, reconsider.
### Risk 3: "We're delaying Phase 3 features"
**Mitigation:** Phase 3.5 is only 3-4 weeks. The learnings will inform Phase 3 (especially AI-001 AI Project Scaffolding).
### Risk 4: "SSE/WebSocket are complex to implement correctly"
**Mitigation:** Leverage existing libraries (EventSource is native, WebSocket is native). Focus on the Noodl integration layer, not reinventing protocols.
---
## Alternative: Hybrid Approach
If pure Noodl feels too risky, consider:
### Option A: Noodl Editor + React Runtime
- Build most of Erleah in Noodl
- Write 1-2 custom React components for SSE streaming in pure code
- Import as "Custom React Components" (already supported in Noodl)
**Pros:**
- Faster initial development
- No waiting for Phase 3.5
- Still validates Noodl for 90% of the app
**Cons:**
- Doesn't push Noodl forward
- Misses opportunity to build reusable features
### Option B: Erleah 1.0 in Code, Erleah 2.0 in Noodl
- Ship current Erleah version in pure React
- Use learnings to design Phase 3.5
- Rebuild Erleah 2.0 in Noodl with Phase 3.5 features
**Pros:**
- No business risk
- Informs Phase 3.5 design with real requirements
- Validates Noodl with second implementation
**Cons:**
- Slower validation loop
- Two separate codebases to maintain initially
---
## Recommendation
### ✅ Go Forward with Phase 3.5
**Rationale:**
1. **Timing is right** - Phases 1 & 2 created the foundation
2. **Scope is focused** - 7 tasks, 3-4 weeks, clear boundaries
3. **Value is high** - Erleah validates Noodl, features benefit everyone
4. **Risk is manageable** - Start with AGENT-001, can pivot if needed
### 📋 Action Plan
**Immediate (Next 2 weeks):**
1. Create AGENT-001 (SSE Node) task document
2. Implement SSE Node as proof-of-concept
3. Build simple streaming chat UI in Noodl to test
4. Evaluate: Did this feel natural? Were there blockers?
**If POC succeeds (Week 3-4):**
5. Complete AGENT-003 (Global Store)
6. Complete AGENT-007 (Stream Utils)
7. Build Erleah Timeline prototype in Noodl
**If POC struggles:**
8. Document specific pain points
9. Consider hybrid approach
10. Inform future node design
### 🎯 Success Criteria
Phase 3.5 is successful if:
- ✅ SSE Node can stream AI responses smoothly
- ✅ Global Store keeps views synchronized
- ✅ Building Erleah in Noodl feels productive, not painful
- ✅ The resulting app performs well (no visible lag)
- ✅ Code is maintainable (not a tangled node spaghetti)
---
## Conclusion
**Can Noodl build the new agentic Erleah?**
Yes - but only with Phase 3.5 additions. Without SSE, Global Store, and Action Dispatcher patterns, you'd be fighting the platform. With them, Noodl becomes a powerful tool for building modern, reactive, AI-powered web apps.
**Should you do it?**
Yes - this is a perfect validation moment. You've invested heavily in modernizing Noodl. Now prove it can build something cutting-edge. If Noodl struggles with Erleah, that's valuable feedback. If it succeeds, you have a compelling case study and a suite of new features.
**Timeline:**
- **Phase 3.5 Development:** 3-4 weeks
- **Erleah Prototype:** 1-2 weeks
- **Total to Validation:** 4-6 weeks
This is a strategic investment that pays dividends beyond just Erleah.
---
## Next Steps
1. **Review this document** with the team
2. **Decide on approach**: Full Phase 3.5, Hybrid, or Pure Code
3. **If Phase 3.5**: Start with AGENT-001 task creation
4. **If Hybrid**: Design the boundary between Noodl and custom React
5. **If Pure Code**: Document learnings for future Noodl improvements
**Question to answer:** What would prove to you that Noodl CAN'T build Erleah? Define failure criteria upfront so you can pivot quickly if needed.

View File

@@ -0,0 +1,135 @@
# TASK-001: Backend Services Panel
**Task ID:** TASK-001
**Phase:** 5 - Multi-Target Deployment
**Priority:** 🔴 Critical
**Estimated Duration:** 1 week
**Status:** ✅ Complete (Phase 1)
**Created:** 2025-12-29
**Completed:** 2025-12-29
**Branch:** `feature/byob-backend`
## Overview
Create a new "Backend Services" sidebar panel that allows users to configure external backend databases (Directus, Supabase, Pocketbase, or any custom REST API). This panel will sit alongside the existing "Cloud Services" panel, giving users flexibility to choose their data backend.
## Goals
1. **New Sidebar Panel** - "Backend Services" tab in the far left menu ✅
2. **Backend List** - Display configured backends with connection status ✅
3. **Add Backend Dialog** - Configure new backends with presets or custom ✅
4. **Edit/Delete** - Manage existing backend configurations ✅
5. **Schema Browser** - View tables/collections from connected backends ✅ (basic)
## What Was Implemented
### Model Layer
- `types.ts` - Complete TypeScript interfaces for BackendConfig, schemas, events
- `presets.ts` - Preset configurations for Directus, Supabase, Pocketbase, Custom REST
- `BackendServices.ts` - Singleton model with CRUD, connection testing, schema introspection
- `index.ts` - Clean exports
### UI Components
- `BackendServicesPanel.tsx` - Main panel with backend list and actions
- `BackendCard/BackendCard.tsx` - Individual backend card with status, actions
- `AddBackendDialog/AddBackendDialog.tsx` - Modal for adding new backends
- All components use proper design tokens (no hardcoded colors)
### Key Features
- **Preset Selection**: Easy setup for Directus, Supabase, Pocketbase
- **Custom REST API**: Full configurability for any REST API
- **Connection Testing**: Test button validates connectivity
- **Schema Introspection**: Fetches and caches table/field definitions
- **Active Backend**: One backend can be marked as active
- **Persistence**: Saves to project metadata
## Implementation Steps
### Step 1: Create Backend Model ✅
- [x] Create `BackendServices/types.ts` with TypeScript interfaces
- [x] Create `BackendServices/BackendServices.ts` singleton model
- [x] Create `BackendServices/presets.ts` for preset configurations
### Step 2: Create Panel UI ✅
- [x] Create `BackendServicesPanel.tsx` base panel
- [x] Create `BackendCard.tsx` for displaying a backend
- [x] Add panel to sidebar registry in `router.setup.ts`
### Step 3: Add Backend Dialog ✅
- [x] Create `AddBackendDialog.tsx` with preset selection
- [x] Create backend preset configurations (Directus, Supabase, etc.)
- [x] Implement connection testing
- [x] Implement save/cancel logic
### Step 4: Schema Introspection ✅ (Basic)
- [x] Implement Directus schema parsing
- [x] Implement Supabase schema parsing (OpenAPI)
- [x] Implement Pocketbase schema parsing
- [x] Cache schema in backend config
### Step 5: Integration ✅
- [x] Add "activeBackendId" to project settings
- [x] Emit events when backends change
## Files Created
```
packages/noodl-editor/src/editor/src/
├── models/BackendServices/
│ ├── types.ts # TypeScript interfaces
│ ├── presets.ts # Directus, Supabase, Pocketbase presets
│ ├── BackendServices.ts # Main singleton model
│ └── index.ts # Exports
└── views/panels/BackendServicesPanel/
├── BackendServicesPanel.tsx
├── BackendCard/
│ ├── BackendCard.tsx
│ └── BackendCard.module.scss
└── AddBackendDialog/
├── AddBackendDialog.tsx
└── AddBackendDialog.module.scss
```
## Files Modified
```
packages/noodl-editor/src/editor/src/router.setup.ts # Register panel
```
## Testing Status
- [x] Editor builds and runs without errors
- [ ] Manual testing of panel (pending user verification)
- [ ] Test with real Directus instance
- [ ] Test with real Supabase instance
## Next Steps (Future Tasks)
1. **TASK-002: Data Node Integration** - Update data nodes to use Backend Services
2. **SchemaViewer Component** - Dedicated component to browse schema
3. **Edit Backend Dialog** - Edit existing backends (currently only add/delete)
4. **Local Docker Wizard** - Spin up backends locally
## Notes
- Keep existing Cloud Services panel untouched (Option A from planning)
- Use design tokens for all styling (no hardcoded colors)
- Follow existing patterns from CloudServicePanel closely
- Panel icon: `RestApi` (Database icon didn't exist in IconName enum)
## Related Tasks
| Task | Name | Status |
| -------- | ------------------------------------------------------- | ----------- |
| TASK-002 | [Data Nodes Integration](../TASK-002-data-nodes/) | Not Started |
| TASK-003 | [Schema Viewer Component](../TASK-003-schema-viewer/) | Not Started |
| TASK-004 | [Edit Backend Dialog](../TASK-004-edit-backend-dialog/) | Not Started |
| TASK-005 | [Local Docker Wizard](../TASK-005-local-docker-wizard/) | Not Started |

View File

@@ -0,0 +1,205 @@
# Visual Filter Builder Specification
The Visual Filter Builder is the **hero feature** of TASK-002. It transforms the painful experience of writing Directus filter JSON into an intuitive visual interface.
## The Problem
Directus filters require complex nested JSON:
```json
{
"_and": [
{ "status": { "_eq": "published" } },
{ "author": { "name": { "_contains": "John" } } },
{ "_or": [{ "views": { "_gt": 100 } }, { "featured": { "_eq": true } }] }
]
}
```
This is error-prone and requires memorizing operator names.
## The Solution
A visual builder that generates this JSON automatically:
```
┌─────────────────────────────────────────────────────────────────────┐
│ FILTER CONDITIONS [+ Add Rule] │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─ AND ─────────────────────────────────────────────────────── [×] ─┐
│ │ │
│ │ [status ▾] [equals ▾] [published ▾] [×] │
│ │ │
│ │ [author.name ▾] [contains ▾] [John ] [×] │
│ │ │
│ │ ┌─ OR ───────────────────────────────────────────── [×] ─┐ │
│ │ │ [views ▾] [greater than ▾] [100 ] [×] │ │
│ │ │ [featured ▾] [equals ▾] [true ▾] [×] │ │
│ │ │ [+ Add Condition] │ │
│ │ └────────────────────────────────────────────────────────┘ │
│ │ │
│ │ [+ Add Condition] [+ Add Group] │
│ └───────────────────────────────────────────────────────────────────┘
│ │
│ ▶ Preview JSON │
└─────────────────────────────────────────────────────────────────────┘
```
## Key Features
### 1. Schema-Aware Field Dropdown
Fields populated from cached schema with:
- Nested relation traversal (`author.name`, `category.parent.name`)
- Field type icons
- Smart search/filtering
### 2. Type-Aware Operator Selection
| Field Type | Available Operators |
| ---------- | ---------------------------------------------------------------------------- |
| String | equals, not equals, contains, starts with, ends with, is empty, is not empty |
| Number | equals, not equals, greater than, less than, >=, <=, between |
| Boolean | equals, not equals |
| Date | equals, before, after, between, is empty |
| Enum | equals, not equals, in, not in |
| Relation | equals (ID), is empty, is not empty |
### 3. Type-Aware Value Input
| Field Type | Value UI |
| ---------- | ------------------------------------- |
| String | Text input |
| Number | Number input with validation |
| Boolean | Toggle or dropdown (true/false/null) |
| Date | Date picker |
| Enum | Dropdown with schema-defined values |
| Relation | Search/select from related collection |
### 4. AND/OR Grouping
- Drag-and-drop reordering
- Unlimited nesting depth
- Visual indentation
- Collapse/expand groups
### 5. JSON Preview
- Toggle to see generated Directus filter
- Syntax highlighted
- Copy button
- Edit JSON directly (advanced mode)
## Data Model
```typescript
interface FilterGroup {
id: string;
type: 'and' | 'or';
conditions: (FilterCondition | FilterGroup)[];
}
interface FilterCondition {
id: string;
field: string; // e.g., "status" or "author.name"
operator: FilterOperator;
value: any;
}
type FilterOperator =
| '_eq'
| '_neq' // equals, not equals
| '_gt'
| '_gte' // greater than (or equal)
| '_lt'
| '_lte' // less than (or equal)
| '_contains'
| '_ncontains'
| '_starts_with'
| '_ends_with'
| '_in'
| '_nin' // in array, not in array
| '_null'
| '_nnull' // is null, is not null
| '_between'; // between two values
```
## Implementation Approach
### Component Structure
```
FilterBuilder/
├── FilterBuilder.tsx # Main container
├── FilterBuilder.module.scss
├── FilterGroup.tsx # AND/OR group (recursive)
├── FilterCondition.tsx # Single condition row
├── FieldSelector.tsx # Schema-aware field dropdown
├── OperatorSelector.tsx # Type-aware operator dropdown
├── ValueInput.tsx # Type-aware value input
├── JsonPreview.tsx # Generated JSON preview
└── types.ts # TypeScript interfaces
```
### State Management
```typescript
// In the Query Records node property panel
const [filter, setFilter] = useState<FilterGroup>({
id: 'root',
type: 'and',
conditions: []
});
// Convert to Directus format on change
useEffect(() => {
const directusFilter = convertToDirectusFilter(filter);
node.setParameter('filter', directusFilter);
}, [filter]);
```
## Directus Filter Conversion
```typescript
function convertToDirectusFilter(group: FilterGroup): object {
const key = `_${group.type}`; // _and or _or
const conditions = group.conditions.map((item) => {
if ('type' in item) {
// Nested group
return convertToDirectusFilter(item);
} else {
// Single condition
return convertCondition(item);
}
});
return { [key]: conditions };
}
function convertCondition(cond: FilterCondition): object {
// Handle nested fields like "author.name"
const parts = cond.field.split('.');
let result: any = { [cond.operator]: cond.value };
// Build nested structure from inside out
for (let i = parts.length - 1; i >= 0; i--) {
result = { [parts[i]]: result };
}
return result;
}
```
## Success Criteria
- [ ] Users can build filters without knowing Directus JSON syntax
- [ ] Field dropdown shows all fields from schema
- [ ] Nested relations work (author.name)
- [ ] Operators change based on field type
- [ ] Value inputs match field type
- [ ] AND/OR grouping works with nesting
- [ ] Generated JSON is valid Directus filter
- [ ] JSON preview shows the output

View File

@@ -0,0 +1,213 @@
# Data Node Specifications
This document defines the four data nodes for BYOB backends.
## 1. Query Records Node
The primary node for fetching data from backends.
### Inputs
| Input | Type | Description |
| ---------- | ------------- | ----------------------------------------------- |
| Backend | dropdown | Select configured backend (or "Active Backend") |
| Collection | dropdown | Select table/collection from schema |
| Filter | FilterBuilder | Visual filter builder (see FILTER-BUILDER.md) |
| Sort Field | dropdown | Field to sort by |
| Sort Order | enum | Ascending / Descending |
| Limit | number | Max records to return |
| Offset | number | Records to skip (pagination) |
| Fields | multi-select | Fields to return (default: all) |
| Do | signal | Trigger the query |
### Outputs
| Output | Type | Description |
| ------------ | ------- | --------------------------------------- |
| Records | array | Array of record objects |
| First Record | object | First record (convenience) |
| Count | number | Number of records returned |
| Total Count | number | Total matching records (for pagination) |
| Loading | boolean | True while request in progress |
| Error | object | Error details if failed |
| Done | signal | Fires when query completes |
| Failed | signal | Fires on error |
### Property Panel UI
```
┌─────────────────────────────────────────────────────────────────────┐
│ Query Records │
├─────────────────────────────────────────────────────────────────────┤
│ BACKEND │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ○ Active Backend (Production Directus) │ │
│ │ ● Specific Backend: [Production Directus ▾] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ COLLECTION │
│ [posts ▾] │
│ │
│ ▼ FILTER │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ [Visual Filter Builder - see FILTER-BUILDER.md] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ▼ SORT │
│ [created_at ▾] [Descending ▾] │
│ │
│ ▼ PAGINATION │
│ Limit: [20 ] Offset: [0 ] │
│ │
│ ▶ FIELDS (optional) │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 2. Create Record Node
Creates a new record in a collection.
### Inputs
| Input | Type | Description |
| ---------- | -------- | --------------------------------------- |
| Backend | dropdown | Select configured backend |
| Collection | dropdown | Select table/collection |
| Data | object | Record data (dynamic ports from schema) |
| Do | signal | Trigger creation |
### Dynamic Inputs
When a collection is selected, ports are dynamically generated for each writable field:
- `field_title` (string)
- `field_content` (text)
- `field_author` (relation → user ID)
- etc.
### Outputs
| Output | Type | Description |
| ------- | ------ | -------------------- |
| Record | object | The created record |
| ID | string | ID of created record |
| Success | signal | Fires on success |
| Failed | signal | Fires on error |
| Error | object | Error details |
---
## 3. Update Record Node
Updates an existing record.
### Inputs
| Input | Type | Description |
| ---------- | -------- | -------------------------------- |
| Backend | dropdown | Select configured backend |
| Collection | dropdown | Select table/collection |
| Record ID | string | ID of record to update |
| Data | object | Fields to update (dynamic ports) |
| Do | signal | Trigger update |
### Dynamic Inputs
Same as Create Record - schema-driven field inputs.
### Outputs
| Output | Type | Description |
| ------- | ------ | ------------------ |
| Record | object | The updated record |
| Success | signal | Fires on success |
| Failed | signal | Fires on error |
| Error | object | Error details |
---
## 4. Delete Record Node
Deletes a record from a collection.
### Inputs
| Input | Type | Description |
| ---------- | -------- | ------------------------- |
| Backend | dropdown | Select configured backend |
| Collection | dropdown | Select table/collection |
| Record ID | string | ID of record to delete |
| Do | signal | Trigger deletion |
### Outputs
| Output | Type | Description |
| ------- | ------ | ---------------- |
| Success | signal | Fires on success |
| Failed | signal | Fires on error |
| Error | object | Error details |
---
## Implementation Files
```
packages/noodl-runtime/src/nodes/std-library/data/
├── directus/
│ ├── query-records.js # Query Records runtime
│ ├── create-record.js # Create Record runtime
│ ├── update-record.js # Update Record runtime
│ ├── delete-record.js # Delete Record runtime
│ └── utils.js # Shared utilities
packages/noodl-editor/src/editor/src/views/propertyeditor/
├── DataNodePropertyEditor/
│ ├── BackendSelector.tsx # Backend dropdown
│ ├── CollectionSelector.tsx # Collection dropdown
│ └── DynamicFieldInputs.tsx # Schema-driven field inputs
```
## Node Registration
```javascript
// In node index/registration
module.exports = {
node: QueryRecordsNode,
setup: function (context, graphModel) {
// Register dynamic ports based on schema
graphModel.on('editorImportComplete', () => {
// Set up schema-aware dropdowns
});
}
};
```
## HTTP Request Format (Directus)
### Query
```
GET /items/{collection}?filter={json}&sort={field}&limit={n}&offset={n}
```
### Create
```
POST /items/{collection}
Body: { field1: value1, field2: value2 }
```
### Update
```
PATCH /items/{collection}/{id}
Body: { field1: newValue }
```
### Delete
```
DELETE /items/{collection}/{id}
```

View File

@@ -0,0 +1,87 @@
# TASK-002: Data Nodes Integration
**Task ID:** TASK-002
**Phase:** 5 - Multi-Target Deployment (BYOB)
**Priority:** 🔴 Critical
**Difficulty:** 🔴 Hard
**Estimated Time:** 1-2 weeks
**Prerequisites:** TASK-001 (Backend Services Panel)
**Branch:** `feature/byob-data-nodes`
## Objective
Create visual data nodes (Query, Create, Update, Delete) that connect to the configured Backend Services, with a **Visual Filter Builder** as the hero feature.
## Background
Users can now configure backend connections (TASK-001), but there's no way to actually USE them. This task bridges that gap by creating data nodes that:
- Read from the cached schema to populate field dropdowns
- Execute queries against the configured backend
- Provide a visual way to build complex filters (the key differentiator)
The **Visual Filter Builder** eliminates the pain of writing Directus filter JSON manually.
## User Story
> As a Noodl user, I want to visually build queries against my Directus backend, so I don't have to learn the complex filter JSON syntax.
## Current State
- Backend Services Panel exists (TASK-001 ✅)
- Schema introspection works for Directus
- No data nodes exist for BYOB backends
- Users would have to use raw HTTP nodes
## Desired State
- Query Records node with visual filter builder
- Create/Update/Delete Record nodes
- All nodes populate dropdowns from cached schema
- Filters generate correct Directus JSON
## Scope
### In Scope
- Query Records node (most complex - has filter builder)
- Create Record node
- Update Record node
- Delete Record node
- Directus backend support (primary focus)
### Out of Scope
- Supabase/Pocketbase adapters (future task)
- Realtime subscriptions
- Batch operations
- File upload nodes
---
## Known Limitations (Dec 2025)
### Directus System Collections Not Supported
**Issue**: The Query Data node only uses `/items/{collection}` API endpoint, which doesn't work for Directus system tables.
**Affected Collections**:
- `directus_users` - User management (use `/users` endpoint)
- `directus_roles` - Role management (use `/roles` endpoint)
- `directus_files` - File management (use `/files` endpoint)
- `directus_folders` - Folder management (use `/folders` endpoint)
- `directus_activity` - Activity log (use `/activity` endpoint)
- `directus_permissions` - Permissions (use `/permissions` endpoint)
- And other `directus_*` system tables
**Current Behavior**: These collections may appear in the Collection dropdown (if schema introspection includes them), but queries will fail with 404 or forbidden errors.
**Future Enhancement**: Add an "API Path Type" dropdown to the Query node:
- **Items** (default) - Uses `/items/{collection}` for user collections
- **System** - Uses `/{collection_without_directus_prefix}` for system tables
**Alternative Workaround**: Use the HTTP Request node with manual endpoint construction for system table access.
**Related**: This limitation also affects Create/Update/Delete Record nodes (when implemented).

View File

@@ -0,0 +1,158 @@
# TASK-003: Schema Viewer Component
**Task ID:** TASK-003
**Phase:** 5 - Multi-Target Deployment (BYOB)
**Priority:** 🟡 Medium
**Difficulty:** 🟢 Easy
**Estimated Time:** 2-3 days
**Prerequisites:** TASK-001 (Backend Services Panel)
**Branch:** `feature/byob-schema-viewer`
## Objective
Create a dedicated Schema Viewer component that displays the cached schema from connected backends in an interactive, collapsible tree view.
## Background
TASK-001 implemented basic schema introspection, but the current UI only shows collection names in a simple list. Users need to:
- See all fields in each collection
- Understand field types and constraints
- Easily copy field names for use in nodes
- Refresh schema when backend changes
## User Story
> As a Noodl user, I want to browse my backend's data schema visually, so I can understand what data is available and use correct field names.
## Current State
- Schema is fetched and cached (TASK-001 ✅)
- Only collection names shown in Backend Services Panel
- No field details visible
- No way to copy field paths
## Desired State
- Expandable tree view of collections → fields
- Field type icons and badges
- Copy field path on click
- Relation indicators
- Search/filter collections
- Refresh button
## UI Design
```
┌─────────────────────────────────────────────────────────────────────┐
│ SCHEMA BROWSER [🔍 Search] [↻ Refresh] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ▼ users (12 fields) │
│ ├─ 🔑 id (uuid) PRIMARY │
│ ├─ 📧 email (email) REQUIRED UNIQUE │
│ ├─ 📝 name (string) │
│ ├─ 🖼️ avatar (image) │
│ ├─ 🔗 role → roles (relation-one) │
│ ├─ 📅 created_at (datetime) READ-ONLY │
│ └─ 📅 updated_at (datetime) READ-ONLY │
│ │
│ ▶ posts (8 fields) │
│ ▶ comments (6 fields) │
│ ▶ categories (4 fields) │
│ ▶ media (7 fields) │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Features
### 1. Collapsible Tree Structure
- Collections at root level
- Fields as children
- Expand/collapse with animation
- "Expand All" / "Collapse All" buttons
### 2. Field Information Display
Each field shows:
- Name
- Type (with icon)
- Constraints (REQUIRED, UNIQUE, PRIMARY)
- Default value (if set)
- Relation target (for relation fields)
### 3. Field Type Icons
| Type | Icon | Color |
| -------------- | ---- | ------- |
| string | 📝 | default |
| text | 📄 | default |
| number/integer | 🔢 | blue |
| boolean | ✅ | green |
| datetime | 📅 | purple |
| email | 📧 | blue |
| url | 🔗 | blue |
| image/file | 🖼️ | orange |
| relation | 🔗 | cyan |
| json | {} | gray |
| uuid | 🔑 | yellow |
### 4. Copy Field Path
- Click on field name → copies to clipboard
- For nested paths: `collection.field`
- Toast notification: "Copied: author.name"
### 5. Search/Filter
- Filter collections by name
- Filter fields within collections
- Highlight matching text
### 6. Refresh Schema
- Manual refresh button
- Shows last synced timestamp
- Loading indicator during refresh
## Implementation
### File Structure
```
packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/
├── SchemaViewer/
│ ├── SchemaViewer.tsx # Main component
│ ├── SchemaViewer.module.scss
│ ├── CollectionTree.tsx # Collection list
│ ├── FieldRow.tsx # Single field display
│ ├── FieldTypeIcon.tsx # Type icon component
│ └── types.ts
```
### Component API
```typescript
interface SchemaViewerProps {
backend: BackendConfig;
onRefresh: () => Promise<void>;
}
```
### Integration Point
The SchemaViewer should be embedded in the BackendServicesPanel, shown when a backend is selected or expanded.
## Success Criteria
- [ ] Tree view displays all collections from schema
- [ ] Fields expand/collapse per collection
- [ ] Field types shown with appropriate icons
- [ ] Constraints (REQUIRED, UNIQUE) visible
- [ ] Click to copy field path works
- [ ] Search filters collections and fields
- [ ] Refresh button fetches fresh schema
- [ ] Last synced timestamp displayed

View File

@@ -0,0 +1,183 @@
# TASK-004: Edit Backend Dialog
**Task ID:** TASK-004
**Phase:** 5 - Multi-Target Deployment (BYOB)
**Priority:** 🟡 Medium
**Difficulty:** 🟢 Easy
**Estimated Time:** 1-2 days
**Prerequisites:** TASK-001 (Backend Services Panel)
**Branch:** `feature/byob-edit-backend`
## Objective
Add the ability to edit existing backend configurations. Currently users can only add new backends or delete them - there's no way to update URL, credentials, or settings.
## Background
TASK-001 implemented Add Backend and Delete Backend functionality. Users frequently need to:
- Update API tokens when they rotate
- Change URLs between environments
- Modify authentication settings
- Rename backends for clarity
## User Story
> As a Noodl user, I want to edit my backend configurations, so I can update credentials or settings without recreating the entire configuration.
## Current State
- Add Backend Dialog exists (TASK-001 ✅)
- Delete backend works
- No edit capability - must delete and recreate
- `updateBackend()` method exists in model but no UI
## Desired State
- "Edit" button on BackendCard
- Opens pre-filled dialog with current values
- Save updates existing config
- Preserve schema cache and connection history
## UI Design
The Edit dialog reuses AddBackendDialog with modifications:
```
┌─────────────────────────────────────────────────────────────────────┐
│ Edit Backend [×] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ NAME │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Production Directus │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ TYPE │
│ [Directus ▾] ← Disabled (can't change type) │
│ │
│ URL │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ https://api.myapp.com │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ AUTHENTICATION │
│ Method: [Bearer Token ▾] │
│ Token: [••••••••••••••••••••••••••• ] [👁] [Update] │
│ │
│ ⚠️ Changing URL or credentials will require re-syncing schema │
│ │
│ [Cancel] [Test] [Save Changes] │
└─────────────────────────────────────────────────────────────────────┘
```
## Implementation
### Option A: Modify AddBackendDialog (Recommended)
Add an `editMode` prop to the existing dialog:
```typescript
interface AddBackendDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (backend: BackendConfig) => void;
editBackend?: BackendConfig; // If provided, dialog is in edit mode
}
```
### Option B: Create Separate EditBackendDialog
Create a new component specifically for editing. More code duplication but cleaner separation.
### Recommendation
**Option A** - Reusing AddBackendDialog is simpler since 90% of the UI is identical.
### Key Differences in Edit Mode
| Aspect | Add Mode | Edit Mode |
| -------------- | ----------------- | ---------------------------- |
| Title | "Add Backend" | "Edit Backend" |
| Type selector | Enabled | Disabled (can't change type) |
| Submit button | "Add Backend" | "Save Changes" |
| Initial values | Empty/defaults | Populated from editBackend |
| On submit | `createBackend()` | `updateBackend()` |
### Changes Required
1. **AddBackendDialog.tsx**
- Accept optional `editBackend` prop
- Initialize form with existing values
- Disable type selector in edit mode
- Change button text/behavior
2. **BackendCard.tsx**
- Add "Edit" button to actions
- Open dialog with `editBackend` prop
3. **BackendServicesPanel.tsx**
- Handle edit dialog state
- Pass selected backend to dialog
## Code Changes
### BackendCard Actions
```tsx
// Current
<IconButton icon={IconName.Trash} onClick={onDelete} />
// After
<IconButton icon={IconName.Pencil} onClick={onEdit} />
<IconButton icon={IconName.Trash} onClick={onDelete} />
```
### Dialog Mode Detection
```tsx
function AddBackendDialog({ editBackend, onSave, ... }) {
const isEditMode = !!editBackend;
const [name, setName] = useState(editBackend?.name || '');
const [url, setUrl] = useState(editBackend?.url || '');
// ... etc
const handleSubmit = async () => {
if (isEditMode) {
await BackendServices.instance.updateBackend({
id: editBackend.id,
name,
url,
auth
});
} else {
await BackendServices.instance.createBackend({ ... });
}
onSave();
};
return (
<Dialog title={isEditMode ? 'Edit Backend' : 'Add Backend'}>
{/* ... form fields ... */}
<Button
label={isEditMode ? 'Save Changes' : 'Add Backend'}
onClick={handleSubmit}
/>
</Dialog>
);
}
```
## Success Criteria
- [ ] Edit button appears on BackendCard
- [ ] Clicking Edit opens dialog with pre-filled values
- [ ] Backend type selector is disabled in edit mode
- [ ] URL can be changed
- [ ] Credentials can be updated
- [ ] Save calls `updateBackend()` method
- [ ] Schema cache is preserved after edit
- [ ] Connection status updates after credential change

View File

@@ -0,0 +1,270 @@
# TASK-005: Local Docker Backend Wizard
**Task ID:** TASK-005
**Phase:** 5 - Multi-Target Deployment (BYOB)
**Priority:** 🟢 Low
**Difficulty:** 🟡 Medium
**Estimated Time:** 3-5 days
**Prerequisites:** TASK-001, Docker installed on user's machine
**Branch:** `feature/byob-docker-wizard`
## Objective
Create a wizard that helps users spin up local backend instances (Directus, Pocketbase, Supabase) via Docker, and automatically configures the connection in Noodl.
## Background
Many users want to develop locally before deploying to production backends. Currently they must:
1. Manually install Docker
2. Find and run the correct Docker commands
3. Wait for the backend to start
4. Manually configure the connection in Noodl
This wizard automates steps 2-4.
## User Story
> As a Noodl developer, I want to quickly spin up a local backend for development, so I can start building without setting up cloud infrastructure.
## Desired State
- "Start Local Backend" button in Backend Services Panel
- Wizard to select backend type and configure ports
- One-click Docker container launch
- Automatic backend configuration after startup
- Status monitoring and stop/restart controls
## UI Design
### Step 1: Select Backend Type
```
┌─────────────────────────────────────────────────────────────────────┐
│ Start Local Backend [×] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Select a backend to run locally via Docker: │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ [Directus] │ │ [Pocketbase]│ │ [Supabase] │ │
│ │ │ │ │ │ │ │
│ │ Directus │ │ Pocketbase │ │ Supabase │ │
│ │ ● Selected │ │ │ │ (complex) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
Requires Docker to be installed and running │
│ │
│ [Cancel] [Next →] │
└─────────────────────────────────────────────────────────────────────┘
```
### Step 2: Configure Options
```
┌─────────────────────────────────────────────────────────────────────┐
│ Configure Directus [×] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ CONTAINER NAME │
│ [noodl-directus ] │
│ │
│ PORT │
│ [8055 ] (default: 8055) │
│ │
│ ADMIN CREDENTIALS │
│ Email: [admin@example.com ] │
│ Password: [•••••••• ] │
│ │
│ DATABASE │
│ ○ SQLite (simple, no extra setup) │
│ ● PostgreSQL (recommended for production parity) │
│ │
│ DATA PERSISTENCE │
│ ☑ Persist data between restarts (uses Docker volume) │
│ │
│ [← Back] [Start Backend] │
└─────────────────────────────────────────────────────────────────────┘
```
### Step 3: Starting / Progress
```
┌─────────────────────────────────────────────────────────────────────┐
│ Starting Directus... [×] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░] 45% │
│ │
│ ✅ Checking Docker... │
│ ✅ Pulling directus/directus:latest... │
│ ⏳ Starting container... │
│ ○ Waiting for health check... │
│ ○ Configuring connection... │
│ │
│ ───────────────────────────────────────────────────────────────────│
│ $ docker run -d --name noodl-directus -p 8055:8055 ... │
│ │
│ [Cancel] │
└─────────────────────────────────────────────────────────────────────┘
```
### Step 4: Success
```
┌─────────────────────────────────────────────────────────────────────┐
│ Backend Ready! 🎉 [×] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ Directus is running at http://localhost:8055 │
│ │
│ ADMIN PANEL │
│ URL: http://localhost:8055/admin │
│ Email: admin@example.com │
│ Password: (as configured) │
│ │
│ CONNECTION │
│ ✅ "Local Directus" backend added to your project │
│ ✅ Schema synced (0 collections - add some in admin panel) │
│ │
│ [Open Admin Panel] [Done] │
└─────────────────────────────────────────────────────────────────────┘
```
## Docker Commands
### Directus (SQLite)
```bash
docker run -d \
--name noodl-directus \
-p 8055:8055 \
-e KEY="noodl-directus-key" \
-e SECRET="noodl-directus-secret" \
-e ADMIN_EMAIL="admin@example.com" \
-e ADMIN_PASSWORD="password123" \
-e DB_CLIENT="sqlite3" \
-e DB_FILENAME="/directus/database/data.db" \
-v noodl-directus-data:/directus/database \
-v noodl-directus-uploads:/directus/uploads \
directus/directus:latest
```
### Pocketbase
```bash
docker run -d \
--name noodl-pocketbase \
-p 8090:8090 \
-v noodl-pocketbase-data:/pb_data \
ghcr.io/muchobien/pocketbase:latest
```
### Supabase (docker-compose required)
Supabase is more complex and requires multiple containers. Consider either:
- Linking to official Supabase local dev docs
- Providing a bundled docker-compose.yml
- Skipping Supabase for initial implementation
## Implementation
### File Structure
```
packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/
├── LocalDockerWizard/
│ ├── LocalDockerWizard.tsx # Main wizard component
│ ├── LocalDockerWizard.module.scss
│ ├── steps/
│ │ ├── SelectBackendStep.tsx
│ │ ├── ConfigureStep.tsx
│ │ ├── ProgressStep.tsx
│ │ └── SuccessStep.tsx
│ ├── docker/
│ │ ├── dockerCommands.ts # Docker command builders
│ │ ├── directus.ts # Directus-specific config
│ │ └── pocketbase.ts # Pocketbase-specific config
│ └── types.ts
```
### Docker Detection
```typescript
async function checkDockerAvailable(): Promise<boolean> {
try {
const { stdout } = await exec('docker --version');
return stdout.includes('Docker version');
} catch {
return false;
}
}
async function checkDockerRunning(): Promise<boolean> {
try {
await exec('docker info');
return true;
} catch {
return false;
}
}
```
### Container Management
```typescript
interface DockerContainer {
name: string;
image: string;
ports: Record<string, string>;
env: Record<string, string>;
volumes: string[];
}
async function startContainer(config: DockerContainer): Promise<void> {
const args = [
'run',
'-d',
'--name',
config.name,
...Object.entries(config.ports).flatMap(([h, c]) => ['-p', `${h}:${c}`]),
...Object.entries(config.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
...config.volumes.flatMap((v) => ['-v', v]),
config.image
];
await exec(`docker ${args.join(' ')}`);
}
async function waitForHealthy(url: string, timeout = 60000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const res = await fetch(url);
if (res.ok) return true;
} catch {}
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
```
## Success Criteria
- [ ] Docker availability check works
- [ ] Directus container can be started
- [ ] Pocketbase container can be started
- [ ] Health check waits for backend to be ready
- [ ] Backend config auto-created after startup
- [ ] Container name/port configurable
- [ ] Data persists with Docker volumes
- [ ] Error handling for common issues (port in use, etc.)
## Future Enhancements
- Container status in Backend Services Panel
- Stop/Restart/Delete buttons
- View container logs
- Supabase support (via docker-compose)
- Auto-start containers when project opens