stream_weaver 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sinatra/base'
|
|
4
|
+
require 'sinatra/streaming'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'socket'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
require 'uri'
|
|
10
|
+
require_relative 'css'
|
|
11
|
+
|
|
12
|
+
module StreamWeaver
|
|
13
|
+
# Multi-app service that renders StreamWeaver apps without per-app process management.
|
|
14
|
+
# CLI spawns this service if needed, then sends commands to load apps.
|
|
15
|
+
class Service < Sinatra::Base
|
|
16
|
+
helpers Sinatra::Streaming
|
|
17
|
+
|
|
18
|
+
enable :sessions
|
|
19
|
+
set :session_secret, ENV.fetch('SESSION_SECRET') {
|
|
20
|
+
'stream-weaver-service-development-secret-key-minimum-64-characters-for-security'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
set :logging, false
|
|
24
|
+
set :show_exceptions, :after_handler
|
|
25
|
+
set :server, :puma
|
|
26
|
+
|
|
27
|
+
DEFAULT_PORT = 4567
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# { app_id => { app: StreamWeaver::App, path: file_path, loaded_at: Time } }
|
|
31
|
+
attr_accessor :apps
|
|
32
|
+
|
|
33
|
+
def apps
|
|
34
|
+
@apps ||= {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# =========================================
|
|
38
|
+
# Live Sessions (polling-based update-in-place)
|
|
39
|
+
# =========================================
|
|
40
|
+
|
|
41
|
+
def live_sessions
|
|
42
|
+
@live_sessions ||= {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# { slug => app_id } registry for human-readable /apps/:slug URLs
|
|
46
|
+
def slug_registry
|
|
47
|
+
@slug_registry ||= {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_or_create_live_session(name)
|
|
51
|
+
live_sessions[name] ||= {
|
|
52
|
+
content: {}, # { target => { html:, action:, timestamp: } }
|
|
53
|
+
submissions: [], # Array of form submissions waiting for Claude to read
|
|
54
|
+
created_at: Time.now,
|
|
55
|
+
last_push: nil
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Store a form submission for Claude to retrieve
|
|
60
|
+
def add_live_submission(name, data)
|
|
61
|
+
session = live_sessions[name]
|
|
62
|
+
return false unless session
|
|
63
|
+
session[:submissions] << {
|
|
64
|
+
data: data,
|
|
65
|
+
timestamp: (Time.now.to_f * 1000).to_i
|
|
66
|
+
}
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get pending submissions (Claude polls this)
|
|
71
|
+
def get_live_submissions(name, clear: true)
|
|
72
|
+
session = live_sessions[name]
|
|
73
|
+
return [] unless session
|
|
74
|
+
submissions = session[:submissions].dup
|
|
75
|
+
session[:submissions].clear if clear
|
|
76
|
+
submissions
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def push_to_live_session(name, target:, content:, action: :replace)
|
|
80
|
+
session = live_sessions[name]
|
|
81
|
+
return false unless session
|
|
82
|
+
|
|
83
|
+
session[:content][target] = {
|
|
84
|
+
html: content,
|
|
85
|
+
action: action,
|
|
86
|
+
timestamp: (Time.now.to_f * 1000).to_i
|
|
87
|
+
}
|
|
88
|
+
session[:last_push] = Time.now
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def remove_live_session(name)
|
|
93
|
+
live_sessions.delete(name) ? true : false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def list_live_sessions
|
|
97
|
+
live_sessions.map do |name, session|
|
|
98
|
+
{
|
|
99
|
+
name: name,
|
|
100
|
+
created_at: session[:created_at],
|
|
101
|
+
last_push: session[:last_push]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Load an app from a file path and register it
|
|
107
|
+
# Returns the app_id for the newly loaded app
|
|
108
|
+
#
|
|
109
|
+
# Works with existing examples that use the pattern:
|
|
110
|
+
# App = app "Title" do ... end
|
|
111
|
+
# App.run! if __FILE__ == $0
|
|
112
|
+
#
|
|
113
|
+
# When loaded from service, __FILE__ != $0 so run! is skipped,
|
|
114
|
+
# and we capture the SinatraApp from StreamWeaver.last_generated_app
|
|
115
|
+
#
|
|
116
|
+
# @param file_path [String] Path to the Ruby file
|
|
117
|
+
# @param name [String, nil] Optional custom name for the app
|
|
118
|
+
# @param source [String, nil] Optional source identifier (e.g., "examples_browser", "tutorial")
|
|
119
|
+
# @return [String] The app_id
|
|
120
|
+
def load_app(file_path, name: nil, source: nil)
|
|
121
|
+
app_id = SecureRandom.hex(4)
|
|
122
|
+
expanded_path = File.expand_path(file_path)
|
|
123
|
+
|
|
124
|
+
raise "File not found: #{expanded_path}" unless File.exist?(expanded_path)
|
|
125
|
+
|
|
126
|
+
# Clear any previous captured app
|
|
127
|
+
StreamWeaver.last_generated_app = nil
|
|
128
|
+
|
|
129
|
+
# Load the file - the global app() helper captures the result.
|
|
130
|
+
# service_loading makes any trailing `end.run!` in the file a
|
|
131
|
+
# warn-and-no-op instead of booting a second server inside us.
|
|
132
|
+
StreamWeaver.service_loading = true
|
|
133
|
+
begin
|
|
134
|
+
load expanded_path
|
|
135
|
+
ensure
|
|
136
|
+
StreamWeaver.service_loading = false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get the captured app
|
|
140
|
+
sinatra_app = StreamWeaver.last_generated_app
|
|
141
|
+
raise "No app found. File should use: app \"Title\" do ... end" unless sinatra_app
|
|
142
|
+
|
|
143
|
+
# Extract the StreamWeaver::App from the SinatraApp
|
|
144
|
+
streamlit_app = sinatra_app.settings.streamlit_app
|
|
145
|
+
|
|
146
|
+
# Derive name from: explicit name > app title > filename
|
|
147
|
+
app_name = name || streamlit_app.title || File.basename(expanded_path, '.rb')
|
|
148
|
+
slug = assign_slug(app_id, app_name, expanded_path)
|
|
149
|
+
|
|
150
|
+
apps[app_id] = {
|
|
151
|
+
app: streamlit_app,
|
|
152
|
+
path: expanded_path,
|
|
153
|
+
name: app_name,
|
|
154
|
+
source: source,
|
|
155
|
+
slug: slug,
|
|
156
|
+
loaded_at: Time.now,
|
|
157
|
+
last_accessed: Time.now
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
app_id
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Convert a string into a URL-safe slug: lowercase, non-alphanumerics
|
|
164
|
+
# collapsed to hyphens, leading/trailing hyphens stripped.
|
|
165
|
+
# @param str [String]
|
|
166
|
+
# @return [String]
|
|
167
|
+
def slugify(str)
|
|
168
|
+
str.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Assign (or reuse) a human-readable slug for an app_id, derived from the
|
|
172
|
+
# app's declared name with fallback to the script filename.
|
|
173
|
+
#
|
|
174
|
+
# Re-loading the same file reuses its previous slug. A different file
|
|
175
|
+
# whose derived slug collides with an existing one gets a numeric
|
|
176
|
+
# suffix (-2, -3, ...).
|
|
177
|
+
#
|
|
178
|
+
# @param app_id [String] The app_id being assigned a slug
|
|
179
|
+
# @param app_name [String] The app's declared/derived name
|
|
180
|
+
# @param expanded_path [String] Absolute path to the app's file
|
|
181
|
+
# @return [String] The assigned slug
|
|
182
|
+
def assign_slug(app_id, app_name, expanded_path)
|
|
183
|
+
base = slugify(app_name)
|
|
184
|
+
base = slugify(File.basename(expanded_path, '.rb')) if base.empty?
|
|
185
|
+
base = 'app' if base.empty?
|
|
186
|
+
|
|
187
|
+
slug = base
|
|
188
|
+
suffix = 2
|
|
189
|
+
loop do
|
|
190
|
+
holder_id = slug_registry[slug]
|
|
191
|
+
holder_entry = holder_id && apps[holder_id]
|
|
192
|
+
break if holder_entry.nil? || holder_entry[:path] == expanded_path
|
|
193
|
+
slug = "#{base}-#{suffix}"
|
|
194
|
+
suffix += 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
slug_registry[slug] = app_id
|
|
198
|
+
slug
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Resolve a hex app_id or a human-readable slug to the canonical app_id
|
|
202
|
+
# @param id_or_slug [String]
|
|
203
|
+
# @return [String, nil] The canonical app_id, or nil if not found
|
|
204
|
+
def resolve_app_id(id_or_slug)
|
|
205
|
+
return id_or_slug if apps.key?(id_or_slug)
|
|
206
|
+
slug_registry[id_or_slug]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Remove an app by ID
|
|
210
|
+
# @param app_id [String] The app ID to remove
|
|
211
|
+
# @return [Boolean] true if removed, false if not found
|
|
212
|
+
def remove_app(app_id)
|
|
213
|
+
return false unless apps.key?(app_id)
|
|
214
|
+
apps.delete(app_id)
|
|
215
|
+
true
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Remove all apps
|
|
219
|
+
# @return [Integer] Number of apps removed
|
|
220
|
+
def clear_apps
|
|
221
|
+
count = apps.size
|
|
222
|
+
@apps = {}
|
|
223
|
+
@slug_registry = {}
|
|
224
|
+
count
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Remove all apps from a specific source
|
|
228
|
+
# @param source [String] The source identifier
|
|
229
|
+
# @return [Integer] Number of apps removed
|
|
230
|
+
def remove_apps_by_source(source)
|
|
231
|
+
return 0 if source.nil? || source.empty?
|
|
232
|
+
ids_to_remove = apps.select { |_id, entry| entry[:source] == source }.keys
|
|
233
|
+
ids_to_remove.each { |id| apps.delete(id) }
|
|
234
|
+
ids_to_remove.size
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Find app by source and name (for aliased routes)
|
|
238
|
+
# @param source [String] The source identifier
|
|
239
|
+
# @param name [String] The app name (partial match OK)
|
|
240
|
+
# @return [Array<String, Hash>] [app_id, entry] or [nil, nil]
|
|
241
|
+
def find_app_by_source_and_name(source, name)
|
|
242
|
+
apps.find do |_id, entry|
|
|
243
|
+
entry[:source] == source && entry[:name]&.include?(name)
|
|
244
|
+
end || [nil, nil]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Generate aliased route path for an app
|
|
248
|
+
# @param app_id [String] The app ID
|
|
249
|
+
# @return [String, nil] The aliased path or nil if no source
|
|
250
|
+
def aliased_path_for(app_id)
|
|
251
|
+
entry = apps[app_id]
|
|
252
|
+
return nil unless entry && entry[:source]
|
|
253
|
+
# Use custom name if provided, otherwise fall back to filename
|
|
254
|
+
name_part = entry[:name] || File.basename(entry[:path], '.rb')
|
|
255
|
+
# Handle names that might include source prefix (e.g., "tutorial/philosophy")
|
|
256
|
+
name_part = name_part.sub(%r{^#{entry[:source]}/}, '')
|
|
257
|
+
"/#{entry[:source]}/#{name_part}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# PID file management
|
|
261
|
+
def pid_file_path
|
|
262
|
+
File.expand_path('~/.streamweaver/server.pid')
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def write_pid_file(port)
|
|
266
|
+
FileUtils.mkdir_p(File.dirname(pid_file_path))
|
|
267
|
+
File.write(pid_file_path, "port=#{port}\npid=#{Process.pid}\n")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def delete_pid_file
|
|
271
|
+
File.delete(pid_file_path) if File.exist?(pid_file_path)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def read_pid_file
|
|
275
|
+
return nil unless File.exist?(pid_file_path)
|
|
276
|
+
content = File.read(pid_file_path)
|
|
277
|
+
port = content[/port=(\d+)/, 1]&.to_i
|
|
278
|
+
pid = content[/pid=(\d+)/, 1]&.to_i
|
|
279
|
+
{ port: port, pid: pid }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Check if a service is already running (named to avoid shadowing Sinatra's running?)
|
|
283
|
+
def service_running?
|
|
284
|
+
info = read_pid_file
|
|
285
|
+
return false unless info && info[:pid]
|
|
286
|
+
|
|
287
|
+
# Check if process exists
|
|
288
|
+
Process.kill(0, info[:pid])
|
|
289
|
+
true
|
|
290
|
+
rescue Errno::ESRCH
|
|
291
|
+
# Process not found, clean up stale PID file
|
|
292
|
+
delete_pid_file
|
|
293
|
+
false
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def find_available_port(start_port = DEFAULT_PORT)
|
|
297
|
+
port = start_port
|
|
298
|
+
loop do
|
|
299
|
+
begin
|
|
300
|
+
# Check both 127.0.0.1 and 0.0.0.0 to avoid conflicts where
|
|
301
|
+
# a wildcard listener already holds the port (macOS routes to it)
|
|
302
|
+
s1 = TCPServer.new('127.0.0.1', port)
|
|
303
|
+
s1.close
|
|
304
|
+
s2 = TCPServer.new('0.0.0.0', port)
|
|
305
|
+
s2.close
|
|
306
|
+
return port
|
|
307
|
+
rescue Errno::EADDRINUSE
|
|
308
|
+
port += 1
|
|
309
|
+
raise "No available ports found" if port > start_port + 100
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Launch service in background using spawn
|
|
315
|
+
def launch_background(port: nil)
|
|
316
|
+
port ||= find_available_port
|
|
317
|
+
|
|
318
|
+
# Ensure directory exists
|
|
319
|
+
FileUtils.mkdir_p(File.dirname(pid_file_path))
|
|
320
|
+
|
|
321
|
+
# Log file for debugging
|
|
322
|
+
log_file = File.join(File.dirname(pid_file_path), 'server.log')
|
|
323
|
+
|
|
324
|
+
# Get the lib path for stream_weaver
|
|
325
|
+
lib_path = File.expand_path('../..', __FILE__)
|
|
326
|
+
|
|
327
|
+
# Create a simple server script
|
|
328
|
+
server_script = <<~RUBY
|
|
329
|
+
$stdout.sync = true
|
|
330
|
+
$stderr.sync = true
|
|
331
|
+
puts "Starting StreamWeaver service..."
|
|
332
|
+
|
|
333
|
+
$LOAD_PATH.unshift('#{lib_path}')
|
|
334
|
+
require 'stream_weaver'
|
|
335
|
+
|
|
336
|
+
puts "Setting port to #{port}..."
|
|
337
|
+
StreamWeaver::Service.set :port, #{port}
|
|
338
|
+
StreamWeaver::Service.set :bind, '127.0.0.1'
|
|
339
|
+
|
|
340
|
+
# Configure Puma for SSE (more threads to handle long-lived connections)
|
|
341
|
+
StreamWeaver::Service.set :server_settings, {
|
|
342
|
+
:Threads => '5:20' # Min 5, max 20 threads for SSE support
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Write PID file
|
|
346
|
+
puts "Writing PID file..."
|
|
347
|
+
File.write('#{pid_file_path}', "port=#{port}\\npid=\#{Process.pid}\\n")
|
|
348
|
+
|
|
349
|
+
# Clean up on exit
|
|
350
|
+
at_exit { File.delete('#{pid_file_path}') rescue nil }
|
|
351
|
+
|
|
352
|
+
puts "Calling run!..."
|
|
353
|
+
StreamWeaver::Service.run!
|
|
354
|
+
RUBY
|
|
355
|
+
|
|
356
|
+
# Write script to temp file
|
|
357
|
+
script_file = File.join(File.dirname(pid_file_path), 'server_start.rb')
|
|
358
|
+
File.write(script_file, server_script)
|
|
359
|
+
|
|
360
|
+
# Spawn the server process
|
|
361
|
+
pid = spawn(
|
|
362
|
+
RbConfig.ruby, script_file,
|
|
363
|
+
out: [log_file, 'a'],
|
|
364
|
+
err: [log_file, 'a'],
|
|
365
|
+
pgroup: true # New process group
|
|
366
|
+
)
|
|
367
|
+
Process.detach(pid)
|
|
368
|
+
|
|
369
|
+
# Wait for PID file to be written (server started)
|
|
370
|
+
10.times do
|
|
371
|
+
if File.exist?(pid_file_path)
|
|
372
|
+
sleep 0.5 # Extra time for server to be ready
|
|
373
|
+
return { port: port, pid: pid }
|
|
374
|
+
end
|
|
375
|
+
sleep 0.5
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
{ port: port, pid: pid }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def stop
|
|
382
|
+
info = read_pid_file
|
|
383
|
+
return false unless info && info[:pid]
|
|
384
|
+
|
|
385
|
+
Process.kill('TERM', info[:pid])
|
|
386
|
+
sleep 1
|
|
387
|
+
delete_pid_file
|
|
388
|
+
true
|
|
389
|
+
rescue Errno::ESRCH
|
|
390
|
+
delete_pid_file
|
|
391
|
+
true
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
helpers do
|
|
396
|
+
# Get app-specific state from session
|
|
397
|
+
def app_state(app_id)
|
|
398
|
+
session[:app_states] ||= {}
|
|
399
|
+
session[:app_states][app_id] ||= {}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Service-mode parity with SessionStore::Base#filter (flash-prg.md §5):
|
|
403
|
+
# flash is one-shot, so it must never be the value persisted for the
|
|
404
|
+
# *next* request. Storing a stripped copy -- not mutating `state` in
|
|
405
|
+
# place -- leaves the caller's live `state` object (already used, or
|
|
406
|
+
# about to be used, for *this* request's render) untouched.
|
|
407
|
+
def set_app_state(app_id, state)
|
|
408
|
+
session[:app_states] ||= {}
|
|
409
|
+
session[:app_states][app_id] = state.reject { |k, _| k == :_flash }
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def action_generation(app_id)
|
|
413
|
+
session[:action_generations] ||= {}
|
|
414
|
+
session[:action_generations][app_id] ||= SecureRandom.hex(12)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def action_manifest(app_id)
|
|
418
|
+
session[:action_manifests] ||= {}
|
|
419
|
+
session[:action_manifests][app_id] ||= []
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def set_action_manifest(app_id, tokens)
|
|
423
|
+
session[:action_manifests] ||= {}
|
|
424
|
+
session[:action_manifests][app_id] = tokens.to_a
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def state_version(app_id)
|
|
428
|
+
(session[:state_versions] || {})[app_id] || 0
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def set_state_version(app_id, version)
|
|
432
|
+
session[:state_versions] ||= {}
|
|
433
|
+
session[:state_versions][app_id] = version
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Multi-tab route_with fix (2026-08-31) -- service-mode counterpart to
|
|
437
|
+
# server.rb's `seed_route_state!`/`reseed_route_state!` (see that
|
|
438
|
+
# file's comment for the full rationale). Mount-prefix aware: an
|
|
439
|
+
# app hosted at /apps/:app_id needs the prefix stripped from
|
|
440
|
+
# HX-Current-URL before it matches what `state_for_path` expects, the
|
|
441
|
+
# same way the `after '/apps/:app_id/*'` hook re-applies it on the way
|
|
442
|
+
# out. A stale HX-Current-URL from a DIFFERENT mounted app (a session
|
|
443
|
+
# can hold many) is skipped rather than misapplied to this one.
|
|
444
|
+
def seed_route_state!(streamlit_app, state, path)
|
|
445
|
+
return if path.to_s.empty?
|
|
446
|
+
return unless streamlit_app.routable?
|
|
447
|
+
|
|
448
|
+
streamlit_app.state_for_path(path)&.each { |k, v| state[k] = v }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def reseed_route_state!(app_id, streamlit_app, state)
|
|
452
|
+
current_url = request.env['HTTP_HX_CURRENT_URL']
|
|
453
|
+
return unless current_url
|
|
454
|
+
|
|
455
|
+
path = begin
|
|
456
|
+
URI.parse(current_url).path
|
|
457
|
+
rescue URI::Error
|
|
458
|
+
nil
|
|
459
|
+
end
|
|
460
|
+
return unless path
|
|
461
|
+
|
|
462
|
+
prefix = "/apps/#{app_id}"
|
|
463
|
+
return unless path == prefix || path.start_with?("#{prefix}/")
|
|
464
|
+
|
|
465
|
+
suffix = path.sub(prefix, "")
|
|
466
|
+
suffix = "/" if suffix.empty?
|
|
467
|
+
seed_route_state!(streamlit_app, state, suffix)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Sync form params to state hash (mirrors server.rb's helper of the same name)
|
|
471
|
+
def sync_params_to_state(state, excluded_keys: [])
|
|
472
|
+
excluded = App::ROUTE_OWNED_PARAMS + excluded_keys.map(&:to_s)
|
|
473
|
+
params.each do |key, value|
|
|
474
|
+
next if excluded.include?(key)
|
|
475
|
+
state[key.to_sym] = coerce_param_value(value, state[key.to_sym])
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def coerce_param_value(value, current_value)
|
|
480
|
+
case
|
|
481
|
+
when value.is_a?(Array) then value
|
|
482
|
+
when value == "on" || value == "true" then true
|
|
483
|
+
when value == "false" then false
|
|
484
|
+
when current_value.is_a?(Array) then Array(value)
|
|
485
|
+
else value
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def render_error(message, error = nil)
|
|
490
|
+
status 500
|
|
491
|
+
content_type 'text/html'
|
|
492
|
+
backtrace = error&.backtrace&.first(10)&.join("\n") || ""
|
|
493
|
+
<<~HTML
|
|
494
|
+
<div style="color: red; padding: 1rem; border: 1px solid red; margin: 1rem; font-family: monospace;">
|
|
495
|
+
<h3>Error</h3>
|
|
496
|
+
<p><strong>#{Rack::Utils.escape_html(message)}</strong></p>
|
|
497
|
+
#{"<pre>#{Rack::Utils.escape_html(backtrace)}</pre>" unless backtrace.empty?}
|
|
498
|
+
</div>
|
|
499
|
+
HTML
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# =========================================
|
|
505
|
+
# Service Management Routes
|
|
506
|
+
# =========================================
|
|
507
|
+
|
|
508
|
+
# Status endpoint for CLI detection
|
|
509
|
+
get '/api/status' do
|
|
510
|
+
content_type :json
|
|
511
|
+
{
|
|
512
|
+
app: 'streamweaver',
|
|
513
|
+
version: StreamWeaver::VERSION,
|
|
514
|
+
port: settings.port,
|
|
515
|
+
apps: self.class.apps.keys
|
|
516
|
+
}.to_json
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Load an app from file path
|
|
520
|
+
post '/load-app' do
|
|
521
|
+
content_type :json
|
|
522
|
+
file_path = params[:file_path]
|
|
523
|
+
name = params[:name] # Optional custom name
|
|
524
|
+
source = params[:source] # Optional source identifier (e.g., "examples_browser")
|
|
525
|
+
|
|
526
|
+
begin
|
|
527
|
+
app_id = self.class.load_app(file_path, name: name, source: source)
|
|
528
|
+
app_entry = self.class.apps[app_id]
|
|
529
|
+
aliased = self.class.aliased_path_for(app_id)
|
|
530
|
+
{
|
|
531
|
+
success: true,
|
|
532
|
+
app_id: app_id,
|
|
533
|
+
slug: app_entry[:slug],
|
|
534
|
+
name: app_entry[:name],
|
|
535
|
+
source: app_entry[:source],
|
|
536
|
+
url: "/apps/#{app_entry[:slug]}",
|
|
537
|
+
canonical_url: "/apps/#{app_id}",
|
|
538
|
+
aliased_url: aliased
|
|
539
|
+
}.to_json
|
|
540
|
+
rescue => e
|
|
541
|
+
status 400
|
|
542
|
+
{ success: false, error: e.message }.to_json
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Remove a specific app
|
|
547
|
+
delete '/apps/:app_id' do
|
|
548
|
+
content_type :json
|
|
549
|
+
app_id = params[:app_id]
|
|
550
|
+
|
|
551
|
+
if self.class.remove_app(app_id)
|
|
552
|
+
{ success: true, message: "App #{app_id} removed" }.to_json
|
|
553
|
+
else
|
|
554
|
+
status 404
|
|
555
|
+
{ success: false, error: "App not found: #{app_id}" }.to_json
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Also support POST for clients that don't support DELETE
|
|
560
|
+
post '/remove-app' do
|
|
561
|
+
content_type :json
|
|
562
|
+
app_id = params[:app_id]
|
|
563
|
+
|
|
564
|
+
if self.class.remove_app(app_id)
|
|
565
|
+
{ success: true, message: "App #{app_id} removed" }.to_json
|
|
566
|
+
else
|
|
567
|
+
status 404
|
|
568
|
+
{ success: false, error: "App not found: #{app_id}" }.to_json
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Clear all apps
|
|
573
|
+
post '/clear-apps' do
|
|
574
|
+
content_type :json
|
|
575
|
+
count = self.class.clear_apps
|
|
576
|
+
{ success: true, message: "Removed #{count} app(s)" }.to_json
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Remove all apps from a specific source
|
|
580
|
+
post '/clear-source' do
|
|
581
|
+
content_type :json
|
|
582
|
+
source = params[:source]
|
|
583
|
+
count = self.class.remove_apps_by_source(source)
|
|
584
|
+
{ success: true, message: "Removed #{count} app(s) from #{source}", count: count }.to_json
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# List apps with details (JSON API)
|
|
588
|
+
get '/api/apps' do
|
|
589
|
+
content_type :json
|
|
590
|
+
apps_list = self.class.apps.map do |id, entry|
|
|
591
|
+
{
|
|
592
|
+
id: id,
|
|
593
|
+
name: entry[:name],
|
|
594
|
+
path: entry[:path],
|
|
595
|
+
source: entry[:source],
|
|
596
|
+
title: entry[:app].title,
|
|
597
|
+
url: "/apps/#{id}",
|
|
598
|
+
aliased_url: self.class.aliased_path_for(id),
|
|
599
|
+
loaded_at: entry[:loaded_at].iso8601,
|
|
600
|
+
last_accessed: entry[:last_accessed].iso8601,
|
|
601
|
+
age_seconds: (Time.now - entry[:loaded_at]).to_i,
|
|
602
|
+
idle_seconds: (Time.now - entry[:last_accessed]).to_i
|
|
603
|
+
}
|
|
604
|
+
end
|
|
605
|
+
{ apps: apps_list }.to_json
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# =========================================
|
|
609
|
+
# Live Sessions (SSE-based update-in-place)
|
|
610
|
+
# =========================================
|
|
611
|
+
|
|
612
|
+
# Create or get a live session and serve the live page
|
|
613
|
+
get '/live/:session_name' do
|
|
614
|
+
session_name = params[:session_name]
|
|
615
|
+
theme = (params[:theme] || 'default').to_s
|
|
616
|
+
layout = (params[:layout] || 'default').to_s
|
|
617
|
+
self.class.get_or_create_live_session(session_name)
|
|
618
|
+
|
|
619
|
+
# Validate theme
|
|
620
|
+
valid_themes = %w[default dashboard document]
|
|
621
|
+
theme = 'default' unless valid_themes.include?(theme)
|
|
622
|
+
|
|
623
|
+
content_type 'text/html'
|
|
624
|
+
<<~HTML
|
|
625
|
+
<!DOCTYPE html>
|
|
626
|
+
<html>
|
|
627
|
+
<head>
|
|
628
|
+
<title>StreamWeaver Live: #{Rack::Utils.escape_html(session_name)}</title>
|
|
629
|
+
#{StreamWeaver::CSS.cdn_scripts_html}
|
|
630
|
+
#{StreamWeaver::CSS.google_fonts_html}
|
|
631
|
+
<style>
|
|
632
|
+
#{StreamWeaver::CSS.full_stylesheet}
|
|
633
|
+
#{StreamWeaver::CSS.animation_css}
|
|
634
|
+
|
|
635
|
+
/* Live session header overlay */
|
|
636
|
+
.sw-live-header {
|
|
637
|
+
position: fixed;
|
|
638
|
+
top: 0;
|
|
639
|
+
left: 0;
|
|
640
|
+
right: 0;
|
|
641
|
+
display: flex;
|
|
642
|
+
justify-content: space-between;
|
|
643
|
+
align-items: center;
|
|
644
|
+
padding: 0.5rem 1rem;
|
|
645
|
+
background: rgba(0, 0, 0, 0.85);
|
|
646
|
+
backdrop-filter: blur(8px);
|
|
647
|
+
color: #ffffff;
|
|
648
|
+
font-size: 0.75rem;
|
|
649
|
+
z-index: 9999;
|
|
650
|
+
font-family: var(--sw-font-body);
|
|
651
|
+
}
|
|
652
|
+
.sw-live-status {
|
|
653
|
+
display: flex;
|
|
654
|
+
align-items: center;
|
|
655
|
+
gap: 0.5rem;
|
|
656
|
+
}
|
|
657
|
+
.sw-live-dot {
|
|
658
|
+
width: 8px;
|
|
659
|
+
height: 8px;
|
|
660
|
+
border-radius: 50%;
|
|
661
|
+
background: var(--sw-color-primary);
|
|
662
|
+
animation: sw-pulse 2s infinite;
|
|
663
|
+
}
|
|
664
|
+
@keyframes sw-pulse {
|
|
665
|
+
0%, 100% { opacity: 1; }
|
|
666
|
+
50% { opacity: 0.4; }
|
|
667
|
+
}
|
|
668
|
+
.sw-live-content {
|
|
669
|
+
padding-top: 2.5rem; /* Space for fixed header */
|
|
670
|
+
}
|
|
671
|
+
/* Theme selector in header */
|
|
672
|
+
.sw-live-theme-select {
|
|
673
|
+
background: rgba(255,255,255,0.1);
|
|
674
|
+
border: 1px solid rgba(255,255,255,0.2);
|
|
675
|
+
color: #ffffff;
|
|
676
|
+
padding: 0.25rem 0.5rem;
|
|
677
|
+
border-radius: 4px;
|
|
678
|
+
font-size: 0.75rem;
|
|
679
|
+
cursor: pointer;
|
|
680
|
+
}
|
|
681
|
+
.sw-live-theme-select:hover {
|
|
682
|
+
background: rgba(255,255,255,0.2);
|
|
683
|
+
}
|
|
684
|
+
</style>
|
|
685
|
+
</head>
|
|
686
|
+
<body class="sw-theme-#{Rack::Utils.escape_html(theme)} sw-layout-#{Rack::Utils.escape_html(layout)}">
|
|
687
|
+
<div class="sw-live-header">
|
|
688
|
+
<div class="sw-live-status">
|
|
689
|
+
<span class="sw-live-dot"></span>
|
|
690
|
+
<span>Live: #{Rack::Utils.escape_html(session_name)}</span>
|
|
691
|
+
</div>
|
|
692
|
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
693
|
+
<select class="sw-live-theme-select" onchange="switchTheme(this.value)">
|
|
694
|
+
<option value="default" #{theme == 'default' ? 'selected' : ''}>Default</option>
|
|
695
|
+
<option value="dashboard" #{theme == 'dashboard' ? 'selected' : ''}>Dashboard</option>
|
|
696
|
+
<option value="document" #{theme == 'document' ? 'selected' : ''}>Document</option>
|
|
697
|
+
</select>
|
|
698
|
+
<span id="sw-connection-status">Connecting...</span>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
<div class="sw-live-content">
|
|
702
|
+
<div id="app-container">
|
|
703
|
+
<div id="main">
|
|
704
|
+
<p style="color: var(--sw-color-text-muted);">Waiting for content...</p>
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
<script>
|
|
710
|
+
// Theme switching
|
|
711
|
+
function switchTheme(theme) {
|
|
712
|
+
const body = document.body;
|
|
713
|
+
body.className = body.className.replace(/sw-theme-\\w+/, 'sw-theme-' + theme);
|
|
714
|
+
// Update URL without reload
|
|
715
|
+
const url = new URL(window.location);
|
|
716
|
+
url.searchParams.set('theme', theme);
|
|
717
|
+
window.history.replaceState({}, '', url);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Polling for updates
|
|
721
|
+
(function() {
|
|
722
|
+
const sessionName = #{session_name.to_json};
|
|
723
|
+
const statusEl = document.getElementById('sw-connection-status');
|
|
724
|
+
let lastTs = 0;
|
|
725
|
+
|
|
726
|
+
async function poll() {
|
|
727
|
+
try {
|
|
728
|
+
const r = await fetch('/live/' + encodeURIComponent(sessionName) + '/poll?since=' + lastTs);
|
|
729
|
+
const d = await r.json();
|
|
730
|
+
statusEl.textContent = 'Connected';
|
|
731
|
+
statusEl.style.color = '#22c55e';
|
|
732
|
+
d.updates.forEach(u => {
|
|
733
|
+
const el = document.querySelector(u.target);
|
|
734
|
+
if (el) {
|
|
735
|
+
// Add animation class
|
|
736
|
+
el.classList.remove('sw-fade-in');
|
|
737
|
+
void el.offsetWidth; // Trigger reflow
|
|
738
|
+
el.classList.add('sw-fade-in');
|
|
739
|
+
|
|
740
|
+
if (u.action === 'replace') el.innerHTML = u.html;
|
|
741
|
+
else if (u.action === 'append') el.insertAdjacentHTML('beforeend', u.html);
|
|
742
|
+
else if (u.action === 'prepend') el.insertAdjacentHTML('afterbegin', u.html);
|
|
743
|
+
// Tell HTMX to process new content
|
|
744
|
+
if (typeof htmx !== 'undefined') htmx.process(el);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
lastTs = d.timestamp;
|
|
748
|
+
} catch(e) {
|
|
749
|
+
statusEl.textContent = 'Reconnecting...';
|
|
750
|
+
statusEl.style.color = '#eab308';
|
|
751
|
+
}
|
|
752
|
+
setTimeout(poll, 300);
|
|
753
|
+
}
|
|
754
|
+
poll();
|
|
755
|
+
})();
|
|
756
|
+
</script>
|
|
757
|
+
</body>
|
|
758
|
+
</html>
|
|
759
|
+
HTML
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# Polling endpoint for live session updates
|
|
763
|
+
get '/live/:session_name/poll' do
|
|
764
|
+
content_type :json
|
|
765
|
+
session_name = params[:session_name]
|
|
766
|
+
since = params[:since]&.to_i || 0
|
|
767
|
+
|
|
768
|
+
session = self.class.get_or_create_live_session(session_name)
|
|
769
|
+
|
|
770
|
+
updates = []
|
|
771
|
+
session[:content].each do |target, data|
|
|
772
|
+
updates << { target: target, action: data[:action], html: data[:html] } if data[:timestamp] > since
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
{ updates: updates, timestamp: (Time.now.to_f * 1000).to_i }.to_json
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# Push content to a live session
|
|
779
|
+
post '/live/:session_name/push' do
|
|
780
|
+
content_type :json
|
|
781
|
+
session_name = params[:session_name]
|
|
782
|
+
target = params[:target] || '#main'
|
|
783
|
+
action = (params[:action] || 'replace').to_sym
|
|
784
|
+
html_content = params[:content] || request.body.read
|
|
785
|
+
|
|
786
|
+
# Ensure session exists
|
|
787
|
+
self.class.get_or_create_live_session(session_name)
|
|
788
|
+
|
|
789
|
+
if self.class.push_to_live_session(session_name, target: target, content: html_content, action: action)
|
|
790
|
+
{ success: true, session: session_name, target: target, action: action }.to_json
|
|
791
|
+
else
|
|
792
|
+
status 404
|
|
793
|
+
{ success: false, error: "Session not found: #{session_name}" }.to_json
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# List live sessions
|
|
798
|
+
get '/api/live' do
|
|
799
|
+
content_type :json
|
|
800
|
+
{ sessions: self.class.list_live_sessions }.to_json
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Update from live session (text field changes, etc.)
|
|
804
|
+
post '/live/:session_name/update' do
|
|
805
|
+
# Return 204 No Content - tells HTMX to not swap anything
|
|
806
|
+
status 204
|
|
807
|
+
''
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Button action from live session
|
|
811
|
+
post '/live/:session_name/action/:button_id' do
|
|
812
|
+
content_type 'text/html'
|
|
813
|
+
session_name = params[:session_name]
|
|
814
|
+
button_id = params[:button_id]
|
|
815
|
+
|
|
816
|
+
# Collect all form data
|
|
817
|
+
excluded = %w[splat captures session_name button_id]
|
|
818
|
+
form_data = { _button: button_id }
|
|
819
|
+
params.each do |key, value|
|
|
820
|
+
next if excluded.include?(key)
|
|
821
|
+
form_data[key.to_sym] = case value
|
|
822
|
+
when "on", "true" then true
|
|
823
|
+
when "false" then false
|
|
824
|
+
else value
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
self.class.add_live_submission(session_name, form_data)
|
|
829
|
+
|
|
830
|
+
# Return HTML with "thinking" indicator - Claude will push real content when ready
|
|
831
|
+
<<~HTML
|
|
832
|
+
<div id="main" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; gap: 1rem;">
|
|
833
|
+
<div class="sw-spinner" style="width: 40px; height: 40px; border: 3px solid var(--sw-color-border, #e0e0e0); border-top-color: var(--sw-color-primary, #c2410c); border-radius: 50%; animation: sw-spin 0.8s linear infinite;"></div>
|
|
834
|
+
<p style="color: var(--sw-color-text-muted, #666); font-size: 1rem;">Claude is processing your response...</p>
|
|
835
|
+
<style>@keyframes sw-spin { to { transform: rotate(360deg); } }</style>
|
|
836
|
+
</div>
|
|
837
|
+
HTML
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# Submit form data from live session (user interactions)
|
|
841
|
+
post '/live/:session_name/submit' do
|
|
842
|
+
content_type :json
|
|
843
|
+
session_name = params[:session_name]
|
|
844
|
+
|
|
845
|
+
# Collect all form data
|
|
846
|
+
excluded = %w[splat captures session_name]
|
|
847
|
+
form_data = {}
|
|
848
|
+
params.each do |key, value|
|
|
849
|
+
next if excluded.include?(key)
|
|
850
|
+
form_data[key.to_sym] = case value
|
|
851
|
+
when "on", "true" then true
|
|
852
|
+
when "false" then false
|
|
853
|
+
else value
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
self.class.add_live_submission(session_name, form_data)
|
|
858
|
+
|
|
859
|
+
# Return confirmation and push a "submitted" message
|
|
860
|
+
self.class.push_to_live_session(
|
|
861
|
+
session_name,
|
|
862
|
+
target: '#main',
|
|
863
|
+
content: '<p style="color: #22c55e; padding: 1rem;">Submitted! Waiting for response...</p>',
|
|
864
|
+
action: :replace
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
{ success: true, data: form_data }.to_json
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Get pending submissions (Claude polls this)
|
|
871
|
+
get '/live/:session_name/submissions' do
|
|
872
|
+
content_type :json
|
|
873
|
+
session_name = params[:session_name]
|
|
874
|
+
clear = params[:clear] != 'false'
|
|
875
|
+
|
|
876
|
+
submissions = self.class.get_live_submissions(session_name, clear: clear)
|
|
877
|
+
{ submissions: submissions }.to_json
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Remove a live session
|
|
881
|
+
delete '/live/:session_name' do
|
|
882
|
+
content_type :json
|
|
883
|
+
session_name = params[:session_name]
|
|
884
|
+
|
|
885
|
+
if self.class.remove_live_session(session_name)
|
|
886
|
+
{ success: true, message: "Session '#{session_name}' removed" }.to_json
|
|
887
|
+
else
|
|
888
|
+
status 404
|
|
889
|
+
{ success: false, error: "Session not found: #{session_name}" }.to_json
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# =========================================
|
|
894
|
+
# Admin Dashboard
|
|
895
|
+
# =========================================
|
|
896
|
+
|
|
897
|
+
# Admin dashboard - StreamWeaver app managing itself
|
|
898
|
+
get '/admin' do
|
|
899
|
+
admin_app = Admin.create_app
|
|
900
|
+
state = app_state('admin')
|
|
901
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/admin", deck_server: false)
|
|
902
|
+
|
|
903
|
+
admin_app.with_render_lock do
|
|
904
|
+
admin_app.rebuild_with_state(state, generation: action_generation('admin'),
|
|
905
|
+
state_version: state_version('admin'), url_params: params)
|
|
906
|
+
set_action_manifest('admin', admin_app.render_state.action_tokens)
|
|
907
|
+
set_app_state('admin', state)
|
|
908
|
+
|
|
909
|
+
Views::AppView.new(admin_app, state, adapter, false).call
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
post '/admin/update' do
|
|
914
|
+
admin_app = Admin.create_app
|
|
915
|
+
state = app_state('admin')
|
|
916
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/admin", deck_server: false)
|
|
917
|
+
|
|
918
|
+
InteractionRunner.new(
|
|
919
|
+
app: admin_app, state: state, params: params,
|
|
920
|
+
interaction: :update, adapter: adapter,
|
|
921
|
+
persist: ->(value) { set_app_state('admin', value) },
|
|
922
|
+
response_headers: ->(values) { headers values },
|
|
923
|
+
state_version: state_version('admin'), persist_state_version: ->(version) { set_state_version('admin', version) }
|
|
924
|
+
).call
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
post '/admin/action/:button_id' do
|
|
928
|
+
button_id = params[:button_id]
|
|
929
|
+
admin_app = Admin.create_app
|
|
930
|
+
state = app_state('admin')
|
|
931
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/admin", deck_server: false)
|
|
932
|
+
|
|
933
|
+
InteractionRunner.new(
|
|
934
|
+
app: admin_app, state: state, params: params,
|
|
935
|
+
interaction: :action, target: button_id, adapter: adapter,
|
|
936
|
+
persist: ->(value) { set_app_state('admin', value) },
|
|
937
|
+
action_manifest: Set.new(action_manifest('admin')),
|
|
938
|
+
generation: action_generation('admin'),
|
|
939
|
+
persist_manifest: ->(tokens) { set_action_manifest('admin', tokens) },
|
|
940
|
+
response_headers: ->(values) { headers values },
|
|
941
|
+
state_version: state_version('admin'), persist_state_version: ->(version) { set_state_version('admin', version) }
|
|
942
|
+
).call
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Aliased route: /:source/:name -> /apps/:app_id
|
|
946
|
+
# Allows memorable URLs like /examples_browser/hello_world
|
|
947
|
+
get '/:source/:name' do
|
|
948
|
+
source = params[:source]
|
|
949
|
+
name = params[:name]
|
|
950
|
+
|
|
951
|
+
# Skip if this looks like a system route
|
|
952
|
+
pass if %w[apps api admin].include?(source)
|
|
953
|
+
|
|
954
|
+
app_id, _entry = self.class.find_app_by_source_and_name(source, name)
|
|
955
|
+
halt 404, "App not found: #{source}/#{name}" unless app_id
|
|
956
|
+
|
|
957
|
+
redirect "/apps/#{app_id}"
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# List all loaded apps
|
|
961
|
+
get '/' do
|
|
962
|
+
content_type 'text/html'
|
|
963
|
+
apps = self.class.apps
|
|
964
|
+
|
|
965
|
+
if apps.empty?
|
|
966
|
+
<<~HTML
|
|
967
|
+
<!DOCTYPE html>
|
|
968
|
+
<html>
|
|
969
|
+
<head><title>StreamWeaver Service</title></head>
|
|
970
|
+
<body style="font-family: system-ui; padding: 2rem;">
|
|
971
|
+
<h1>StreamWeaver Service</h1>
|
|
972
|
+
<p>No apps loaded yet.</p>
|
|
973
|
+
<p>Use: <code>streamweaver run <file.rb></code></p>
|
|
974
|
+
</body>
|
|
975
|
+
</html>
|
|
976
|
+
HTML
|
|
977
|
+
else
|
|
978
|
+
app_list = apps.map do |id, entry|
|
|
979
|
+
"<li><a href='/apps/#{id}'>#{entry[:app].title}</a> (#{File.basename(entry[:path])})</li>"
|
|
980
|
+
end.join("\n")
|
|
981
|
+
|
|
982
|
+
<<~HTML
|
|
983
|
+
<!DOCTYPE html>
|
|
984
|
+
<html>
|
|
985
|
+
<head><title>StreamWeaver Service</title></head>
|
|
986
|
+
<body style="font-family: system-ui; padding: 2rem;">
|
|
987
|
+
<h1>StreamWeaver Service</h1>
|
|
988
|
+
<h2>Loaded Apps</h2>
|
|
989
|
+
<ul>#{app_list}</ul>
|
|
990
|
+
</body>
|
|
991
|
+
</html>
|
|
992
|
+
HTML
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# =========================================
|
|
997
|
+
# App Rendering Routes
|
|
998
|
+
# =========================================
|
|
999
|
+
|
|
1000
|
+
# Render app main page
|
|
1001
|
+
# Accepts either the canonical hex app_id or its human-readable slug
|
|
1002
|
+
get '/apps/:app_id' do
|
|
1003
|
+
requested_id = params[:app_id]
|
|
1004
|
+
app_id = self.class.resolve_app_id(requested_id)
|
|
1005
|
+
app_entry = app_id && self.class.apps[app_id]
|
|
1006
|
+
halt 404, "App not found: #{requested_id}" unless app_entry
|
|
1007
|
+
|
|
1008
|
+
# Track last access time
|
|
1009
|
+
app_entry[:last_accessed] = Time.now
|
|
1010
|
+
|
|
1011
|
+
streamlit_app = app_entry[:app]
|
|
1012
|
+
state = app_state(app_id)
|
|
1013
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
|
|
1014
|
+
|
|
1015
|
+
# Seed state from URL routing (e.g., `page :home, '/'` registers a parser for
|
|
1016
|
+
# '/') -- mirrors the standalone `GET /` seeding so a routed app's default view
|
|
1017
|
+
# doesn't depend on how it's hosted (stream_weaver-oow).
|
|
1018
|
+
seed_route_state!(streamlit_app, state, '/')
|
|
1019
|
+
|
|
1020
|
+
streamlit_app.with_render_lock do
|
|
1021
|
+
# Only a URL decides a `url: true` tabs group's index (App#tab_index_source).
|
|
1022
|
+
# No `request.get?` guard is needed here as in server.rb's shared render_app:
|
|
1023
|
+
# the POST routes below render through InteractionRunner, which passes none.
|
|
1024
|
+
streamlit_app.rebuild_with_state(state, generation: action_generation(app_id),
|
|
1025
|
+
state_version: state_version(app_id), url_params: params)
|
|
1026
|
+
set_action_manifest(app_id, streamlit_app.render_state.action_tokens)
|
|
1027
|
+
set_app_state(app_id, state)
|
|
1028
|
+
|
|
1029
|
+
Views::AppView.new(streamlit_app, state, adapter, false).call
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# URL routing: seed state from the path suffix under /apps/:app_id/* -- the
|
|
1034
|
+
# service-mode counterpart to the standalone catch-all GET '/*' (server.rb). The
|
|
1035
|
+
# mount prefix is already stripped by Sinatra's splat capture, so `path` below is
|
|
1036
|
+
# the same suffix state_for_path expects from a standalone app (stream_weaver-oow).
|
|
1037
|
+
get '/apps/:app_id/*' do
|
|
1038
|
+
app_id = self.class.resolve_app_id(params[:app_id])
|
|
1039
|
+
app_entry = app_id && self.class.apps[app_id]
|
|
1040
|
+
pass unless app_entry
|
|
1041
|
+
|
|
1042
|
+
streamlit_app = app_entry[:app]
|
|
1043
|
+
pass unless streamlit_app.routable?
|
|
1044
|
+
|
|
1045
|
+
path = "/#{params['splat'].first}"
|
|
1046
|
+
route_state = streamlit_app.state_for_path(path)
|
|
1047
|
+
pass unless route_state
|
|
1048
|
+
|
|
1049
|
+
app_entry[:last_accessed] = Time.now
|
|
1050
|
+
|
|
1051
|
+
state = app_state(app_id)
|
|
1052
|
+
route_state.each { |k, v| state[k] = v }
|
|
1053
|
+
sync_params_to_state(state)
|
|
1054
|
+
set_app_state(app_id, state)
|
|
1055
|
+
|
|
1056
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
|
|
1057
|
+
is_htmx = request.env.key?('HTTP_HX_REQUEST')
|
|
1058
|
+
streamlit_app.with_render_lock do
|
|
1059
|
+
streamlit_app.rebuild_with_state(state, generation: action_generation(app_id),
|
|
1060
|
+
state_version: state_version(app_id), url_params: params)
|
|
1061
|
+
set_action_manifest(app_id, streamlit_app.render_state.action_tokens)
|
|
1062
|
+
if is_htmx
|
|
1063
|
+
Views::AppContentView.new(streamlit_app, state, adapter, false).call
|
|
1064
|
+
else
|
|
1065
|
+
Views::AppView.new(streamlit_app, state, adapter, false).call
|
|
1066
|
+
end
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# Multi-tab route_with fix (2026-08-31) -- service-mode counterpart to
|
|
1071
|
+
# server.rb's `before` filter. A path-scoped filter, not a call at each
|
|
1072
|
+
# POST route, so a route #5 under /apps/:app_id/* can't be added without
|
|
1073
|
+
# it -- see `reseed_route_state!` above for the full rationale.
|
|
1074
|
+
before '/apps/:app_id/*' do
|
|
1075
|
+
next unless request.post?
|
|
1076
|
+
|
|
1077
|
+
app_id = params[:app_id]
|
|
1078
|
+
app_entry = self.class.apps[app_id]
|
|
1079
|
+
next unless app_entry
|
|
1080
|
+
|
|
1081
|
+
reseed_route_state!(app_id, app_entry[:app], app_state(app_id))
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# URL routing: push URL on POST responses when route state changes -- the
|
|
1085
|
+
# service-mode counterpart to the standalone `after` hook (server.rb), with the
|
|
1086
|
+
# app's /apps/:app_id mount prefix re-applied so the pushed URL stays inside the
|
|
1087
|
+
# mounted app (stream_weaver-oow).
|
|
1088
|
+
after '/apps/:app_id/*' do
|
|
1089
|
+
next unless request.post?
|
|
1090
|
+
|
|
1091
|
+
app_id = self.class.resolve_app_id(params[:app_id])
|
|
1092
|
+
app_entry = app_id && self.class.apps[app_id]
|
|
1093
|
+
next unless app_entry
|
|
1094
|
+
|
|
1095
|
+
streamlit_app = app_entry[:app]
|
|
1096
|
+
next unless streamlit_app.routable?
|
|
1097
|
+
|
|
1098
|
+
new_path = streamlit_app.path_for_state(app_state(app_id))
|
|
1099
|
+
headers['HX-Push-Url'] = "/apps/#{app_id}#{new_path}" if new_path
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# Update state from form inputs
|
|
1103
|
+
post '/apps/:app_id/update' do
|
|
1104
|
+
app_id = params[:app_id]
|
|
1105
|
+
app_entry = self.class.apps[app_id]
|
|
1106
|
+
halt 404, "App not found" unless app_entry
|
|
1107
|
+
|
|
1108
|
+
streamlit_app = app_entry[:app]
|
|
1109
|
+
state = app_state(app_id)
|
|
1110
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
|
|
1111
|
+
|
|
1112
|
+
InteractionRunner.new(
|
|
1113
|
+
app: streamlit_app, state: state, params: params,
|
|
1114
|
+
interaction: :update, adapter: adapter,
|
|
1115
|
+
persist: ->(value) { set_app_state(app_id, value) },
|
|
1116
|
+
response_headers: ->(values) { headers values },
|
|
1117
|
+
state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
|
|
1118
|
+
).call
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
# Button actions
|
|
1122
|
+
post '/apps/:app_id/action/:button_id' do
|
|
1123
|
+
app_id = params[:app_id]
|
|
1124
|
+
button_id = params[:button_id]
|
|
1125
|
+
app_entry = self.class.apps[app_id]
|
|
1126
|
+
halt 404, "App not found" unless app_entry
|
|
1127
|
+
|
|
1128
|
+
streamlit_app = app_entry[:app]
|
|
1129
|
+
state = app_state(app_id)
|
|
1130
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
|
|
1131
|
+
|
|
1132
|
+
InteractionRunner.new(
|
|
1133
|
+
app: streamlit_app, state: state, params: params,
|
|
1134
|
+
interaction: :action, target: button_id, adapter: adapter,
|
|
1135
|
+
persist: ->(value) { set_app_state(app_id, value) },
|
|
1136
|
+
action_manifest: Set.new(action_manifest(app_id)),
|
|
1137
|
+
generation: action_generation(app_id),
|
|
1138
|
+
persist_manifest: ->(tokens) { set_action_manifest(app_id, tokens) },
|
|
1139
|
+
response_headers: ->(values) { headers values },
|
|
1140
|
+
state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
|
|
1141
|
+
).call
|
|
1142
|
+
rescue StaleActionDefinition
|
|
1143
|
+
status 409
|
|
1144
|
+
headers 'HX-Retarget' => '#app-container'
|
|
1145
|
+
streamlit_app.with_render_lock do
|
|
1146
|
+
streamlit_app.rebuild_with_state(state, generation: action_generation(app_id), state_version: state_version(app_id))
|
|
1147
|
+
set_action_manifest(app_id, streamlit_app.render_state.action_tokens)
|
|
1148
|
+
Views::AppContentView.new(streamlit_app, state, adapter, false).call
|
|
1149
|
+
end
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
# Event callback endpoint
|
|
1153
|
+
post '/apps/:app_id/event/:key' do
|
|
1154
|
+
app_id = params[:app_id]
|
|
1155
|
+
key = params[:key].to_sym
|
|
1156
|
+
app_entry = self.class.apps[app_id]
|
|
1157
|
+
halt 404, "App not found" unless app_entry
|
|
1158
|
+
|
|
1159
|
+
streamlit_app = app_entry[:app]
|
|
1160
|
+
state = app_state(app_id)
|
|
1161
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
|
|
1162
|
+
|
|
1163
|
+
InteractionRunner.new(
|
|
1164
|
+
app: streamlit_app, state: state, params: params,
|
|
1165
|
+
interaction: :event, target: key, adapter: adapter,
|
|
1166
|
+
persist: ->(value) { set_app_state(app_id, value) },
|
|
1167
|
+
response_headers: ->(values) { headers values },
|
|
1168
|
+
state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
|
|
1169
|
+
).call
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
# Form submission endpoint
|
|
1173
|
+
post '/apps/:app_id/form/:form_name' do
|
|
1174
|
+
app_id = params[:app_id]
|
|
1175
|
+
form_name = params[:form_name].to_sym
|
|
1176
|
+
app_entry = self.class.apps[app_id]
|
|
1177
|
+
halt 404, "App not found" unless app_entry
|
|
1178
|
+
|
|
1179
|
+
streamlit_app = app_entry[:app]
|
|
1180
|
+
state = app_state(app_id)
|
|
1181
|
+
adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
|
|
1182
|
+
|
|
1183
|
+
InteractionRunner.new(
|
|
1184
|
+
app: streamlit_app, state: state, params: params,
|
|
1185
|
+
interaction: :form, target: form_name, adapter: adapter,
|
|
1186
|
+
persist: ->(value) { set_app_state(app_id, value) },
|
|
1187
|
+
response_headers: ->(values) { headers values },
|
|
1188
|
+
state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
|
|
1189
|
+
).call
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
# Theme switching
|
|
1193
|
+
post '/apps/:app_id/theme/:theme_name' do
|
|
1194
|
+
app_id = params[:app_id]
|
|
1195
|
+
theme = params[:theme_name].to_sym
|
|
1196
|
+
app_entry = self.class.apps[app_id]
|
|
1197
|
+
halt 404, "App not found" unless app_entry
|
|
1198
|
+
|
|
1199
|
+
if StreamWeaver.theme_exists?(theme)
|
|
1200
|
+
session[:theme_overrides] ||= {}
|
|
1201
|
+
session[:theme_overrides][app_id] = theme
|
|
1202
|
+
status 200
|
|
1203
|
+
"sw-theme-#{theme} sw-layout-#{app_entry[:app].layout}"
|
|
1204
|
+
else
|
|
1205
|
+
status 400
|
|
1206
|
+
"Invalid theme"
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
# =========================================
|
|
1211
|
+
# Custom user-defined HTTP endpoints (App#endpoint DSL) — service mode
|
|
1212
|
+
# =========================================
|
|
1213
|
+
# Scoped under /apps/:app_id/* so multiple apps' endpoints can't collide with each
|
|
1214
|
+
# other. Defined AFTER every fixed /apps/:app_id/... route above, so on a path
|
|
1215
|
+
# collision the internal route always wins (Sinatra dispatches to the first route
|
|
1216
|
+
# that matches; `endpoint` also warns at registration time about known collisions).
|
|
1217
|
+
%i[get post put patch delete].each do |verb|
|
|
1218
|
+
send(verb, '/apps/:app_id/*') do
|
|
1219
|
+
# Resolve slugs the same way the page route does — endpoints must be
|
|
1220
|
+
# reachable at /apps/my-dashboard/api/... as well as the hex id.
|
|
1221
|
+
app_id = self.class.resolve_app_id(params[:app_id])
|
|
1222
|
+
app_entry = app_id && self.class.apps[app_id]
|
|
1223
|
+
pass unless app_entry
|
|
1224
|
+
|
|
1225
|
+
streamlit_app = app_entry[:app]
|
|
1226
|
+
path = "/#{params['splat'].first}"
|
|
1227
|
+
ep = streamlit_app.find_endpoint(verb, path)
|
|
1228
|
+
pass unless ep
|
|
1229
|
+
|
|
1230
|
+
status, headers, body = SinatraApp.normalize_endpoint_result(ep[:block].call(request))
|
|
1231
|
+
halt status, headers, body
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
# Module-level helper for apps to register with the service
|
|
1237
|
+
def self.register_service_app(app)
|
|
1238
|
+
Service.register_app(app)
|
|
1239
|
+
end
|
|
1240
|
+
end
|