diff --git a/dev-docs/future-projects/SSR-SUPPORT.md b/dev-docs/future-projects/SSR-SUPPORT.md new file mode 100644 index 0000000..9de48d4 --- /dev/null +++ b/dev-docs/future-projects/SSR-SUPPORT.md @@ -0,0 +1,341 @@ +# Future: Server-Side Rendering (SSR) Support + +> **Status**: Planning +> **Priority**: Medium +> **Complexity**: High +> **Prerequisites**: React 19 migration, HTTP node implementation + +## Executive Summary + +OpenNoodl has substantial existing SSR infrastructure that was developed but never shipped by the original Noodl team. This document outlines a path to completing and exposing SSR as a user-facing feature, giving users the choice between client-side rendering (CSR), server-side rendering (SSR), and static site generation (SSG). + +## Why SSR Matters + +### The Problem with Pure CSR + +Currently, Noodl apps are entirely client-side rendered: + +1. **SEO Limitations**: Search engine crawlers see an empty `
` until JavaScript executes +2. **Social Sharing**: Link previews on Twitter, Facebook, Slack, etc. show blank or generic content +3. **First Paint Performance**: Users see a blank screen while the runtime loads and initializes +4. **Core Web Vitals**: Poor Largest Contentful Paint (LCP) scores affect search rankings + +### What SSR Provides + +| Metric | CSR | SSR | SSG | +|--------|-----|-----|-----| +| SEO | Poor | Excellent | Excellent | +| Social Previews | Broken | Working | Working | +| First Paint | Slow | Fast | Fastest | +| Hosting Requirements | Static | Node.js Server | Static | +| Dynamic Content | Real-time | Real-time | Build-time | +| Build Complexity | Low | Medium | Medium | + +## Current State in Codebase + +### What Already Exists + +The original Noodl team built significant SSR infrastructure: + +**SSR Server (`packages/noodl-viewer-react/static/ssr/`)** +- Express server with route handling +- `ReactDOMServer.renderToString()` integration +- Browser API polyfills (localStorage, fetch, XMLHttpRequest, requestAnimationFrame) +- Result caching via `node-cache` +- Graceful fallback to CSR on errors + +**SEO API (`Noodl.SEO`)** +- `setTitle(value)` - Update document title +- `setMeta(key, value)` - Set meta tags +- `getMeta(key)` / `clearMeta()` - Manage meta tags +- Designed specifically for SSR (no direct window access) + +**Deploy Infrastructure** +- `runtimeType` parameter supports `'ssr'` value +- Separate deploy index for SSR files (`ssr/index.json`) +- Commented-out UI code showing intended deployment flow + +**Build Scripts** +- `getPages()` API returns all routes with metadata +- `createIndexPage()` generates HTML with custom meta tags +- `expandPaths()` for dynamic route expansion +- Sitemap generation support + +### What's Incomplete + +- SEO meta injection not implemented (`// TODO: Inject Noodl.SEO.meta`) +- Page router issues (`// TODO: Maybe fix page router`) +- No UI for selecting SSR deployment +- No documentation or user guidance +- Untested with modern component library +- No hydration verification + +## Proposed User Experience + +### Option 1: Project-Level Setting + +Add rendering mode selection in Project Settings: + +``` +Rendering Mode: + ○ Client-Side (CSR) - Default, works with any static host + ○ Server-Side (SSR) - Better SEO, requires Node.js hosting + ○ Static Generation (SSG) - Best performance, pre-renders at build time +``` + +**Pros**: Simple mental model, single source of truth +**Cons**: All-or-nothing, can't mix approaches + +### Option 2: Deploy-Time Selection + +Add rendering mode choice in Deploy popup: + +``` +Deploy Target: + [Static Files (CSR)] [Node.js Server (SSR)] [Pre-rendered (SSG)] +``` + +**Pros**: Flexible, same project can deploy differently +**Cons**: Could be confusing, settings disconnect + +### Option 3: Page-Level Configuration (Recommended) + +Add per-page rendering configuration in Page Router settings: + +``` +Page: /blog/{slug} + Rendering: [SSR ▼] + +Page: /dashboard + Rendering: [CSR ▼] + +Page: /about + Rendering: [SSG ▼] +``` + +**Pros**: Maximum flexibility, matches real-world needs +**Cons**: More complex, requires smarter build system + +### Recommended Approach + +**Phase 1**: Start with Option 2 (Deploy-Time Selection) - simplest to implement +**Phase 2**: Add Option 1 (Project Setting) for default behavior +**Phase 3**: Consider Option 3 (Page-Level) based on user demand + +## Technical Implementation + +### Phase 1: Complete Existing SSR Infrastructure + +**1.1 Fix Page Router for SSR** +- Ensure `globalThis.location` properly simulates browser location +- Handle query parameters and hash fragments +- Support Page Router navigation events + +**1.2 Implement SEO Meta Injection** +```javascript +// In ssr/index.js buildPage() +const result = htmlData + .replace('
', `
${output1}
`) + .replace('', `${generateMetaTags(noodlRuntime.SEO.meta)}`); +``` + +**1.3 Polyfill Audit** +- Test all visual nodes in SSR context +- Identify browser-only APIs that need polyfills +- Create SSR compatibility matrix for nodes + +### Phase 2: Deploy UI Integration + +**2.1 Add SSR Option to Deploy Popup** +```typescript +// DeployToFolderTab.tsx + + ); + }; + + return ( +
+ +
+ {renderInput()} + {showExpressionToggle && ( + + )} +
+
+ ); +} +``` + +### Step 5: Wire Up to Property Editor + +**Modify:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts` + +This is where the connection between the model and property panel happens. Add expression support: + +```typescript +// In the render or value handling logic: + +// Check if current parameter value is an expression +const paramValue = parent.model.parameters[port.name]; +const isExpressionMode = isExpressionParameter(paramValue); + +// When mode changes: +onExpressionModeChange(mode) { + if (mode === 'expression') { + // Convert current value to expression parameter + const currentValue = parent.model.parameters[port.name]; + parent.model.setParameter(port.name, { + mode: 'expression', + expression: String(currentValue || ''), + fallback: currentValue, + version: ExpressionEvaluator.EXPRESSION_VERSION + }); + } else { + // Convert back to fixed value + const param = parent.model.parameters[port.name]; + const fixedValue = isExpressionParameter(param) ? param.fallback : param; + parent.model.setParameter(port.name, fixedValue); + } +} + +// When expression changes: +onExpressionChange(expression) { + const param = parent.model.parameters[port.name]; + if (isExpressionParameter(param)) { + parent.model.setParameter(port.name, { + ...param, + expression + }); + } +} +``` + +### Step 6: Runtime Expression Evaluation + +**Modify:** `packages/noodl-runtime/src/node.js` + +Add expression evaluation to the parameter update flow: + +```javascript +// In Node.prototype._onNodeModelParameterUpdated or similar: + +Node.prototype._evaluateExpressionParameter = function(paramName, paramValue) { + const ExpressionEvaluator = require('./expression-evaluator'); + + if (!paramValue || paramValue.mode !== 'expression') { + return paramValue; + } + + // Compile and evaluate + const compiled = ExpressionEvaluator.compileExpression(paramValue.expression); + if (!compiled) { + return paramValue.fallback; + } + + const result = ExpressionEvaluator.evaluateExpression(compiled, this.context?.modelScope); + + // Set up reactive subscription if not already + if (!this._expressionSubscriptions) { + this._expressionSubscriptions = {}; + } + + if (!this._expressionSubscriptions[paramName]) { + const deps = ExpressionEvaluator.detectDependencies(paramValue.expression); + if (deps.variables.length > 0 || deps.objects.length > 0 || deps.arrays.length > 0) { + this._expressionSubscriptions[paramName] = ExpressionEvaluator.subscribeToChanges( + deps, + () => { + // Re-evaluate and update + const newResult = ExpressionEvaluator.evaluateExpression(compiled, this.context?.modelScope); + this.queueInput(paramName, newResult); + }, + this.context?.modelScope + ); + } + } + + return result !== undefined ? result : paramValue.fallback; +}; + +// Clean up subscriptions on delete +Node.prototype._onNodeDeleted = function() { + // ... existing cleanup ... + + // Clean up expression subscriptions + if (this._expressionSubscriptions) { + for (const unsub of Object.values(this._expressionSubscriptions)) { + if (typeof unsub === 'function') unsub(); + } + this._expressionSubscriptions = null; + } +}; +``` + +### Step 7: Expression Builder Modal (Optional Enhancement) + +**Create file:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionBuilder/ExpressionBuilder.tsx` + +A full-featured modal for complex expression editing: + +```tsx +import React, { useState, useEffect, useMemo } from 'react'; +import { Modal } from '@noodl-core-ui/components/layout/Modal'; +import { MonacoEditor } from '@noodl-core-ui/components/inputs/MonacoEditor'; +import { TreeView } from '@noodl-core-ui/components/tree/TreeView'; +import css from './ExpressionBuilder.module.scss'; + +interface ExpressionBuilderProps { + isOpen: boolean; + expression: string; + expectedType?: string; + onApply: (expression: string) => void; + onCancel: () => void; +} + +export function ExpressionBuilder({ + isOpen, + expression: initialExpression, + expectedType, + onApply, + onCancel +}: ExpressionBuilderProps) { + const [expression, setExpression] = useState(initialExpression); + const [preview, setPreview] = useState<{ result: any; error?: string }>({ result: null }); + + // Build available completions tree + const completionsTree = useMemo(() => { + // This would be populated from actual project data + return [ + { + label: 'Noodl', + children: [ + { + label: 'Variables', + children: [] // Populated from Noodl.Variables + }, + { + label: 'Objects', + children: [] // Populated from known Object IDs + }, + { + label: 'Arrays', + children: [] // Populated from known Array IDs + } + ] + }, + { + label: 'Math', + children: [ + { label: 'min(a, b)', insertText: 'min()' }, + { label: 'max(a, b)', insertText: 'max()' }, + { label: 'round(n)', insertText: 'round()' }, + { label: 'floor(n)', insertText: 'floor()' }, + { label: 'ceil(n)', insertText: 'ceil()' }, + { label: 'abs(n)', insertText: 'abs()' }, + { label: 'sqrt(n)', insertText: 'sqrt()' }, + { label: 'pow(base, exp)', insertText: 'pow()' }, + { label: 'pi', insertText: 'pi' }, + { label: 'random()', insertText: 'random()' } + ] + } + ]; + }, []); + + // Live preview + useEffect(() => { + const ExpressionEvaluator = require('@noodl/runtime/src/expression-evaluator'); + const validation = ExpressionEvaluator.validateExpression(expression); + + if (!validation.valid) { + setPreview({ result: null, error: validation.error }); + return; + } + + const compiled = ExpressionEvaluator.compileExpression(expression); + if (compiled) { + const result = ExpressionEvaluator.evaluateExpression(compiled); + setPreview({ result, error: undefined }); + } + }, [expression]); + + return ( + +
+
+ +
+ +
+
+

Available

+ { + // Insert at cursor + if (item.insertText) { + setExpression(prev => prev + item.insertText); + } + }} + /> +
+ +
+

Preview

+ {preview.error ? ( +
{preview.error}
+ ) : ( +
+
Result:
+
+ {JSON.stringify(preview.result)} +
+
+ Type: {typeof preview.result} +
+
+ )} +
+
+ +
+ + +
+
+
+ ); +} +``` + +### Step 8: Add Keyboard Shortcuts + +**Modify:** `packages/noodl-editor/src/editor/src/constants/Keybindings.ts` + +```typescript +export namespace Keybindings { + // ... existing keybindings ... + + // Expression shortcuts (new) + export const TOGGLE_EXPRESSION_MODE = new Keybinding(KeyMod.CtrlCmd, KeyCode.KEY_E); + export const OPEN_EXPRESSION_BUILDER = new Keybinding(KeyMod.CtrlCmd, KeyMod.Shift, KeyCode.KEY_E); +} +``` + +### Step 9: Handle Property Types + +Different property types need type-appropriate expression handling: + +| Property Type | Expression Returns | Coercion | +|--------------|-------------------|----------| +| `string` | Any → String | `String(result)` | +| `number` | Number | `Number(result) \|\| fallback` | +| `boolean` | Truthy/Falsy | `!!result` | +| `color` | Hex/RGB string | Validate format | +| `enum` | Enum value string | Validate against options | +| `component` | Component name | Validate exists | + +**Create file:** `packages/noodl-runtime/src/expression-type-coercion.js` + +```javascript +/** + * Coerce expression result to expected property type + */ +function coerceToType(value, expectedType, fallback, enumOptions) { + if (value === undefined || value === null) { + return fallback; + } + + switch (expectedType) { + case 'string': + return String(value); + + case 'number': + const num = Number(value); + return isNaN(num) ? fallback : num; + + case 'boolean': + return !!value; + + case 'color': + const str = String(value); + // Basic validation for hex or rgb + if (/^#[0-9A-Fa-f]{6}$/.test(str) || /^rgba?\(/.test(str)) { + return str; + } + return fallback; + + case 'enum': + const enumVal = String(value); + if (enumOptions && enumOptions.some(opt => + opt === enumVal || opt.value === enumVal + )) { + return enumVal; + } + return fallback; + + default: + return value; + } +} + +module.exports = { coerceToType }; +``` + +--- + +## Success Criteria + +### Functional Requirements + +- [ ] Expression toggle button appears on supported property types +- [ ] Toggle switches between fixed and expression modes +- [ ] Expression mode shows `fx` badge and code-style input +- [ ] Expression evaluates correctly at runtime +- [ ] Expression re-evaluates when dependencies change +- [ ] Connected ports (via cables) disable expression mode +- [ ] Type coercion works for each property type +- [ ] Invalid expressions show error state +- [ ] Copy/paste expressions works +- [ ] Expression builder modal opens (Cmd+Shift+E) +- [ ] Undo/redo works for expression changes + +### Property Types Supported + +- [ ] String (`PropertyPanelTextInput`) +- [ ] Number (`PropertyPanelNumberInput`) +- [ ] Number with units (`PropertyPanelLengthUnitInput`) +- [ ] Boolean (`PropertyPanelCheckbox`) +- [ ] Select/Enum (`PropertyPanelSelectInput`) +- [ ] Slider (`PropertyPanelSliderInput`) +- [ ] Color (`ColorType` / color picker) + +### Non-Functional Requirements + +- [ ] No performance regression in property panel rendering +- [ ] Expressions compile once, evaluate efficiently +- [ ] Memory cleanup when nodes are deleted +- [ ] Backward compatibility with existing projects + +--- + +## Testing Checklist + +### Manual Testing + +1. **Basic Toggle** + - Select a Group node + - Find the "Margin Left" property + - Click expression toggle button + - Verify UI changes to expression mode + - Toggle back to fixed mode + - Verify original value is preserved + +2. **Expression Evaluation** + - Set a Group's margin to expression mode + - Enter: `Noodl.Variables.spacing || 16` + - Set `Noodl.Variables.spacing = 32` in a Function node + - Verify margin updates to 32 + +3. **Reactive Updates** + - Create expression: `Noodl.Variables.isExpanded ? 200 : 50` + - Add button that toggles `Noodl.Variables.isExpanded` + - Click button, verify property updates + +4. **Connected Port Behavior** + - Connect an output to a property input + - Verify expression toggle is disabled/hidden + - Disconnect + - Verify toggle is available again + +5. **Type Coercion** + - Number property with expression returning string "42" + - Verify it coerces to number 42 + - Boolean property with expression returning "yes" + - Verify it coerces to true + +6. **Error Handling** + - Enter invalid expression: `1 +` + - Verify error indicator appears + - Verify property uses fallback value + - Fix expression + - Verify error clears + +7. **Undo/Redo** + - Change property to expression mode + - Undo (Cmd+Z) + - Verify returns to fixed mode + - Redo + - Verify returns to expression mode + +8. **Project Save/Load** + - Create property with expression + - Save project + - Close and reopen project + - Verify expression is preserved and working + +### Property Type Coverage + +- [ ] Text input with expression +- [ ] Number input with expression +- [ ] Number with units (px, %, etc.) with expression +- [ ] Checkbox/boolean with expression +- [ ] Dropdown/select with expression +- [ ] Color picker with expression +- [ ] Slider with expression + +### Edge Cases + +- [ ] Expression referencing non-existent variable +- [ ] Expression with runtime error (division by zero) +- [ ] Very long expression +- [ ] Expression with special characters +- [ ] Expression in visual state parameter +- [ ] Expression in variant parameter + +--- + +## Migration Considerations + +### Existing Projects + +- Existing projects have simple parameter values +- These continue to work as-is (backward compatible) +- No automatic migration needed + +### Future Expression Version Changes + +If we need to change the expression context in the future: +1. Increment `EXPRESSION_VERSION` in expression-evaluator.js +2. Add migration logic to handle old version expressions +3. Show warning for expressions with old version + +--- + +## Notes for Implementer + +### Important Patterns + +1. **Model-View Separation** + - Property panel is the view + - NodeGraphNode.parameters is the model + - Changes go through `setParameter()` for undo support + +2. **Port Connection Priority** + - Connected ports take precedence over expressions + - Connected ports take precedence over fixed values + - This is existing behavior, preserve it + +3. **Visual States** + - Visual state parameters use `paramName_stateName` pattern + - Expression parameters in visual states need same pattern + - Example: `marginLeft_hover` could be an expression + +### Edge Cases to Handle + +1. **Expression references port that's also connected** + - Expression should still work + - Connected value might be available via `this.inputs.X` + +2. **Circular expressions** + - Expression A references Variable that's set by Expression B + - Shouldn't cause infinite loop (dependency tracking prevents) + +3. **Expressions in cloud runtime** + - Cloud uses different Noodl.js API + - Ensure expression-evaluator works in both contexts + +### Questions to Resolve + +1. **Which property types should NOT support expressions?** + - Recommendation: component picker, image picker + - These need special UI that doesn't fit expression pattern + +2. **Should expressions work in style properties?** + - Recommendation: Yes, if using inputCss pattern + - CSS values often need to be dynamic + +3. **Mobile/responsive expressions?** + - Recommendation: Expressions can reference `Noodl.Variables.screenWidth` + - Combine with existing variants system + +--- + +## Files Created/Modified Summary + +### New Files +- `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.tsx` +- `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.module.scss` +- `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.tsx` +- `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.module.scss` +- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionBuilder/ExpressionBuilder.tsx` +- `packages/noodl-runtime/src/expression-type-coercion.js` + +### Modified Files +- `packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx` +- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts` +- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BooleanType.ts` +- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ColorType.ts` +- `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` +- `packages/noodl-runtime/src/node.js` +- `packages/noodl-editor/src/editor/src/constants/Keybindings.ts` diff --git a/packages/noodl-editor/src/main/src/web-server.js b/packages/noodl-editor/src/main/src/web-server.js index 7acc72e..eff4a45 100644 --- a/packages/noodl-editor/src/main/src/web-server.js +++ b/packages/noodl-editor/src/main/src/web-server.js @@ -143,15 +143,67 @@ function startServer(app, projectGetSettings, projectGetInfo, projectGetComponen //by this point it must be a static file in either the viewer folder or the project //check if it's a viewer file const viewerFilePath = appPath + '/src/external/viewer/' + path; + + // Debug: Log ALL font requests regardless of where they're served from + const fontExtensions = ['.ttf', '.otf', '.woff', '.woff2']; + const ext = (path.match(/\.[^.]+$/) || [''])[0].toLowerCase(); + const isFont = fontExtensions.includes(ext); + + if (isFont) { + console.log(`\n======= FONT REQUEST =======`); + console.log(`[Font] Requested path: ${path}`); + console.log(`[Font] Checking viewer path: ${viewerFilePath}`); + } + if (fs.existsSync(viewerFilePath)) { + if (isFont) console.log(`[Font] SERVED from viewer folder`); serveFile(viewerFilePath, request, response); } else { // Check if file exists in project directory projectGetInfo((info) => { const projectPath = info.projectDirectory + path; + + if (isFont) { + console.log(`[Font] Project dir: ${info.projectDirectory}`); + console.log(`[Font] Checking project path: ${projectPath}`); + console.log(`[Font] Exists at project path: ${fs.existsSync(projectPath)}`); + } + if (fs.existsSync(projectPath)) { + if (isFont) console.log(`[Font] SERVED from project folder`); serveFile(projectPath, request, response); } else { + // For fonts, try common fallback locations + // Legacy projects may store fonts without folder prefix, or in different locations + const fontExtensions = ['.ttf', '.otf', '.woff', '.woff2']; + const ext = (path.match(/\.[^.]+$/) || [''])[0].toLowerCase(); + + if (fontExtensions.includes(ext)) { + console.log(`[Font Debug] Request: ${path}`); + console.log(`[Font Debug] Project dir: ${info.projectDirectory}`); + console.log(`[Font Debug] Primary path NOT found: ${projectPath}`); + + const filename = path.split('/').pop(); + const fallbackPaths = [ + info.projectDirectory + '/fonts' + path, // /fonts/filename.ttf + info.projectDirectory + '/fonts/' + filename, // /fonts/filename.ttf (when path has no subfolder) + info.projectDirectory + '/' + filename, // /filename.ttf (root level) + info.projectDirectory + '/assets/fonts/' + filename // /assets/fonts/filename.ttf + ]; + + console.log(`[Font Debug] Trying fallback paths:`); + for (const fallbackPath of fallbackPaths) { + const exists = fs.existsSync(fallbackPath); + console.log(`[Font Debug] ${exists ? '✓' : '✗'} ${fallbackPath}`); + if (exists) { + console.log(`[Font Debug] SUCCESS - serving from fallback`); + serveFile(fallbackPath, request, response); + return; + } + } + console.log(`[Font Debug] FAILED - no fallback found`); + } + serve404(response); } }); @@ -184,7 +236,7 @@ function startServer(app, projectGetSettings, projectGetInfo, projectGetComponen }); }); - server.on('listening', (e) => { + server.on('listening', () => { console.log('webserver hustling bytes on port', port); process.env.NOODLPORT = port;