@volter/editor-core 0.5.57
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.
- package/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- package/vite-plugin-ui-oid.ts +2106 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, dependency-light helpers for the editor server.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is side-effect free and unit-testable (see
|
|
5
|
+
* packages/editor/test/server-security.test.ts). The route handlers in
|
|
6
|
+
* editor-server.ts / asset-library-routes.ts delegate their security and
|
|
7
|
+
* correctness checks to these functions so the logic can be verified in
|
|
8
|
+
* isolation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { realpath } from 'node:fs/promises';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
15
|
+
import { type CommandResult, relayCommandTimeoutMs } from '@volter/editor-sdk/session/command-table';
|
|
16
|
+
import type { UnresolvedConsoleSummary } from './console-ledger';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Path containment (S1 / S5 / S6)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True if `child` resolves to the same path as `parent`, or to a path strictly
|
|
24
|
+
* inside it. Defends against `..` traversal and sibling-prefix attacks where a
|
|
25
|
+
* naive `startsWith` would accept `/a/public-secrets` as inside `/a/public`.
|
|
26
|
+
*/
|
|
27
|
+
export function isPathInside(parent: string, child: string): boolean {
|
|
28
|
+
const resolvedParent = resolve(parent);
|
|
29
|
+
const resolvedChild = resolve(child);
|
|
30
|
+
if (resolvedChild === resolvedParent) return true;
|
|
31
|
+
const parentWithSep = resolvedParent.endsWith(sep) ? resolvedParent : resolvedParent + sep;
|
|
32
|
+
return resolvedChild.startsWith(parentWithSep);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve both sides through the filesystem before accepting containment.
|
|
36
|
+
* Lexical containment alone lets an in-root symlink reach another worktree or
|
|
37
|
+
* an arbitrary host directory. Existing read targets must pass this check
|
|
38
|
+
* immediately before bytes are read. */
|
|
39
|
+
export async function isCanonicalPathInside(parent: string, child: string): Promise<boolean> {
|
|
40
|
+
if (!isPathInside(parent, child)) return false;
|
|
41
|
+
try {
|
|
42
|
+
const [canonicalParent, canonicalChild] = await Promise.all([
|
|
43
|
+
realpath(parent),
|
|
44
|
+
realpath(child),
|
|
45
|
+
]);
|
|
46
|
+
return isPathInside(canonicalParent, canonicalChild);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Validate the directory chain used to create or replace a file. The final
|
|
53
|
+
* leaf may not exist yet, so the nearest existing ancestor is canonicalized.
|
|
54
|
+
* This rejects an intermediate symlink that would carry an atomic temp-file
|
|
55
|
+
* write outside the project root. */
|
|
56
|
+
export async function isCanonicalWritePathInside(parent: string, child: string): Promise<boolean> {
|
|
57
|
+
if (!isPathInside(parent, child)) return false;
|
|
58
|
+
let ancestor = dirname(child);
|
|
59
|
+
for (;;) {
|
|
60
|
+
try {
|
|
61
|
+
const [canonicalParent, canonicalAncestor] = await Promise.all([
|
|
62
|
+
realpath(parent),
|
|
63
|
+
realpath(ancestor),
|
|
64
|
+
]);
|
|
65
|
+
return isPathInside(canonicalParent, canonicalAncestor);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false;
|
|
68
|
+
const next = dirname(ancestor);
|
|
69
|
+
if (next === ancestor) return false;
|
|
70
|
+
ancestor = next;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Asset source + host allowlists (S1 / S2)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export const ALLOWED_ASSET_SOURCES = ['polyhaven', 'ambientcg', 'local'] as const;
|
|
80
|
+
export type AllowedAssetSource = (typeof ALLOWED_ASSET_SOURCES)[number];
|
|
81
|
+
|
|
82
|
+
/** Allowlist the `source` segment so it can never escape the library directory. */
|
|
83
|
+
export function isAllowedAssetSource(source: unknown): source is AllowedAssetSource {
|
|
84
|
+
return (
|
|
85
|
+
typeof source === 'string' && (ALLOWED_ASSET_SOURCES as readonly string[]).includes(source)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Known CDN / API domains the server is permitted to fetch from. */
|
|
90
|
+
export const ASSET_HOST_ALLOWLIST = [
|
|
91
|
+
'polyhaven.com',
|
|
92
|
+
'polyhaven.org',
|
|
93
|
+
'ambientcg.com',
|
|
94
|
+
'struffelproduction.com', // ambientCG's download CDN
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True if `host` is an IPv4/IPv6 literal inside a private, loopback,
|
|
99
|
+
* link-local, or otherwise non-routable range. Used to block SSRF to metadata
|
|
100
|
+
* endpoints (e.g. 169.254.169.254) and internal services.
|
|
101
|
+
*/
|
|
102
|
+
export function isPrivateOrLinkLocalHost(host: string): boolean {
|
|
103
|
+
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
104
|
+
|
|
105
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
106
|
+
|
|
107
|
+
// IPv6 loopback / link-local / unique-local.
|
|
108
|
+
if (h === '::1' || h === '::') return true;
|
|
109
|
+
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
|
|
110
|
+
// IPv4-mapped IPv6, e.g. ::ffff:169.254.169.254
|
|
111
|
+
const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
112
|
+
if (mapped) return isPrivateOrLinkLocalHost(mapped[1]!);
|
|
113
|
+
|
|
114
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
115
|
+
if (!m) return false;
|
|
116
|
+
const a = Number(m[1]);
|
|
117
|
+
const b = Number(m[2]);
|
|
118
|
+
if (a === 0 || a === 10 || a === 127) return true; // 0.x, private, loopback
|
|
119
|
+
if (a === 169 && b === 254) return true; // link-local (cloud metadata)
|
|
120
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
121
|
+
if (a === 192 && b === 168) return true; // private
|
|
122
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
123
|
+
if (a >= 224) return true; // multicast / reserved
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Validate a body-supplied download URL: must be HTTPS, point at an allowlisted
|
|
129
|
+
* CDN host, and not resolve to a private / link-local literal IP. (S2)
|
|
130
|
+
*/
|
|
131
|
+
export function isAllowedAssetHost(urlString: string): boolean {
|
|
132
|
+
let u: URL;
|
|
133
|
+
try {
|
|
134
|
+
u = new URL(urlString);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (u.protocol !== 'https:') return false;
|
|
139
|
+
const host = u.hostname.toLowerCase();
|
|
140
|
+
if (isPrivateOrLinkLocalHost(host)) return false;
|
|
141
|
+
return ASSET_HOST_ALLOWLIST.some((d) => host === d || host.endsWith(`.${d}`));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// AppleScript escaping (S4)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
// Matches ASCII control characters (U+0000–U+001F).
|
|
149
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: this security boundary intentionally strips the full ASCII control range.
|
|
150
|
+
const CONTROL_CHARS = /[\u0000-\u001f]/g;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Escape a string for safe inclusion inside a double-quoted AppleScript literal.
|
|
154
|
+
* Backslashes must be doubled BEFORE quotes are escaped, otherwise an injected
|
|
155
|
+
* trailing backslash neutralises the closing quote. Control characters are
|
|
156
|
+
* stripped so they can't break out of the `-e` argument.
|
|
157
|
+
*/
|
|
158
|
+
export function escapeAppleScriptString(s: string): string {
|
|
159
|
+
return s.replace(CONTROL_CHARS, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Content-Disposition filename sanitization (S7)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
// Control chars, double-quote, and backslash are unsafe in a quoted filename.
|
|
167
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: content-disposition must reject the full ASCII control range.
|
|
168
|
+
const UNSAFE_FILENAME_CHARS = /[\u0000-\u001f"\\]/g;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reduce an arbitrary name to a safe `filename="..."` value: basename only, no
|
|
172
|
+
* path separators, quotes, backslashes, or control characters.
|
|
173
|
+
*/
|
|
174
|
+
export function sanitizeContentDispositionFilename(name: string): string {
|
|
175
|
+
const base = name.split(/[\\/]/).pop() ?? '';
|
|
176
|
+
const cleaned = base.replace(UNSAFE_FILENAME_CHARS, '').trim();
|
|
177
|
+
return cleaned || 'download';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// /@fs served-extension allowlist (S5)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export const SERVABLE_FS_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
185
|
+
'ts',
|
|
186
|
+
'tsx',
|
|
187
|
+
'js',
|
|
188
|
+
'jsx',
|
|
189
|
+
'mjs',
|
|
190
|
+
'cjs',
|
|
191
|
+
'json',
|
|
192
|
+
'wasm',
|
|
193
|
+
'css',
|
|
194
|
+
'glsl',
|
|
195
|
+
'vert',
|
|
196
|
+
'frag',
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
/** Restrict /@fs serving to source/asset file types (never .env, keys, etc.). */
|
|
200
|
+
export function isServableFsExtension(ext: string): boolean {
|
|
201
|
+
return SERVABLE_FS_EXTENSIONS.has(ext.toLowerCase());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `/@fs` is a module transport, not a general project-file browser. Keep the
|
|
205
|
+
* private editor/Git estates and dotenv variants out even when their final
|
|
206
|
+
* suffix (for example `.vgai/session.json` or `.env.production.json`) would
|
|
207
|
+
* otherwise pass the source-extension allowlist. */
|
|
208
|
+
export function isServableFsPath(path: string): boolean {
|
|
209
|
+
return !path.split(/[\\/]+/).some((segment) => {
|
|
210
|
+
const normalized = segment.toLowerCase();
|
|
211
|
+
return normalized === '.vgai' || normalized === '.git' || normalized.startsWith('.env');
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A path inside a Vite CACHE DIRECTORY (`node_modules/.vite`,
|
|
216
|
+
* `node_modules/.vite-editor/v2-<key>`, …), which holds the dependency
|
|
217
|
+
* optimizer's OUTPUT.
|
|
218
|
+
*
|
|
219
|
+
* Those files are written asynchronously: on a cold boot the browser requests
|
|
220
|
+
* `deps/react-dom_client.js?v=<hash>` while the optimizer is still bundling it,
|
|
221
|
+
* and Vite's own middleware is the owner that WAITS for the run to finish (and
|
|
222
|
+
* answers `504 Outdated Optimize Dep`, which makes the client reload, when the
|
|
223
|
+
* hash went stale). A `stat`-based "this module is missing" answer is therefore
|
|
224
|
+
* wrong here even though the file genuinely is not on disk yet — it races the
|
|
225
|
+
* optimizer and reports a transient absence as a permanent one.
|
|
226
|
+
*
|
|
227
|
+
* Measured 2026-08-20: the missing-module 404 below fired on
|
|
228
|
+
* `react_jsx-dev-runtime.js` and `react-dom_client.js` mid-optimization, so the
|
|
229
|
+
* editor shell never got React, the page sat on "Opening project…" forever, and
|
|
230
|
+
* `vgai doctor` waited out its whole budget for a play control that could never
|
|
231
|
+
* mount. */
|
|
232
|
+
export function isViteDepCachePath(path: string): boolean {
|
|
233
|
+
return path.split(/[\\/]+/).some((segment) => segment.toLowerCase().startsWith('.vite'));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** WRITABLE project-root `.vgai` paths — the provenance ledger only.
|
|
237
|
+
* Session ownership, collaboration persistence, catalogs and future private
|
|
238
|
+
* metadata must never become reachable merely because they share a directory.
|
|
239
|
+
*
|
|
240
|
+
* Deliberately NARROWER than {@link isReadableVgaiPath}: run evidence below is
|
|
241
|
+
* readable and not writable, because its project-owned Node helper files real
|
|
242
|
+
* invocations and letting the panel write would let it invent a run nothing
|
|
243
|
+
* ran. */
|
|
244
|
+
export function isPublicVgaiLedgerPath(path: string): boolean {
|
|
245
|
+
return path === '.vgai/provenance.json';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Project-root editor metadata/cache paths the browser may regenerate. */
|
|
249
|
+
export function isWritableVgaiEditorPath(path: string): boolean {
|
|
250
|
+
return (
|
|
251
|
+
isPublicVgaiLedgerPath(path) ||
|
|
252
|
+
path === '.vgai/thumbnails.json' ||
|
|
253
|
+
/^\.vgai\/cache\/document-previews\/[a-f0-9]{64}\.png$/.test(path)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** READABLE project-root `.vgai` paths — the public ledger only.
|
|
258
|
+
*
|
|
259
|
+
* Every other `.vgai/` file stays unreachable, which is why this is an
|
|
260
|
+
* allowlist of literals rather than a prefix. */
|
|
261
|
+
export function isReadableVgaiPath(path: string): boolean {
|
|
262
|
+
return isWritableVgaiEditorPath(path);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const PROJECT_RESOURCE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
266
|
+
'avif',
|
|
267
|
+
'bin',
|
|
268
|
+
'bmp',
|
|
269
|
+
'csv',
|
|
270
|
+
'cube',
|
|
271
|
+
'dds',
|
|
272
|
+
'exr',
|
|
273
|
+
'fbx',
|
|
274
|
+
'flac',
|
|
275
|
+
'frag',
|
|
276
|
+
'gif',
|
|
277
|
+
'glb',
|
|
278
|
+
'gltf',
|
|
279
|
+
'glsl',
|
|
280
|
+
'hdr',
|
|
281
|
+
'jpeg',
|
|
282
|
+
'jpg',
|
|
283
|
+
'json',
|
|
284
|
+
'ktx2',
|
|
285
|
+
'm4a',
|
|
286
|
+
'mp3',
|
|
287
|
+
'mp4',
|
|
288
|
+
'mtl',
|
|
289
|
+
'obj',
|
|
290
|
+
'ogg',
|
|
291
|
+
'ply',
|
|
292
|
+
'spv',
|
|
293
|
+
'stl',
|
|
294
|
+
'svg',
|
|
295
|
+
'tga',
|
|
296
|
+
'tmj',
|
|
297
|
+
'txt',
|
|
298
|
+
'vert',
|
|
299
|
+
'wasm',
|
|
300
|
+
'wav',
|
|
301
|
+
'webm',
|
|
302
|
+
'webp',
|
|
303
|
+
'yaml',
|
|
304
|
+
'yml',
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Project-owned Asset Lab documents may span ordinary authored resources, but
|
|
309
|
+
* they may not turn the document serializer into a second source-code or
|
|
310
|
+
* project-control writer. Keep this route below the two estates where authored
|
|
311
|
+
* resources live and leave executable source to the checksum-guarded
|
|
312
|
+
* `/__ui-source/*` seam.
|
|
313
|
+
*/
|
|
314
|
+
export function isWritableProjectResourcePath(path: string): boolean {
|
|
315
|
+
if (path === '.vgai/thumbnails.json') return true;
|
|
316
|
+
if (!path || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false;
|
|
317
|
+
const segments = path.split('/');
|
|
318
|
+
if (
|
|
319
|
+
segments.some((segment) => {
|
|
320
|
+
const normalized = segment.toLowerCase();
|
|
321
|
+
return (
|
|
322
|
+
!segment ||
|
|
323
|
+
segment === '.' ||
|
|
324
|
+
segment === '..' ||
|
|
325
|
+
segment.startsWith('.') ||
|
|
326
|
+
normalized === 'node_modules' ||
|
|
327
|
+
normalized === 'vendor'
|
|
328
|
+
);
|
|
329
|
+
})
|
|
330
|
+
) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (segments[0] !== 'src' && segments[0] !== 'public') return false;
|
|
334
|
+
const filename = segments.at(-1) ?? '';
|
|
335
|
+
const extension = filename.includes('.') ? (filename.split('.').at(-1) ?? '').toLowerCase() : '';
|
|
336
|
+
return PROJECT_RESOURCE_EXTENSIONS.has(extension);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Origin allowlist for mutating editor routes (S3)
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The origin of the Code-OSS DESKTOP workbench page — Electron's privileged
|
|
345
|
+
* `vscode-file` scheme with the fixed authority it mints for the app root. It is
|
|
346
|
+
* the ONE origin other than this server's own that ever hosts the editor
|
|
347
|
+
* (docs/CODE-OSS.md §Desktop): on desktop the workbench is loaded off disk by
|
|
348
|
+
* Electron and the session stays on loopback http, so the two cannot be made one
|
|
349
|
+
* the way the web shape's proxy makes them one.
|
|
350
|
+
*
|
|
351
|
+
* Allowing it where a loopback origin is allowed is not a widening of the
|
|
352
|
+
* drive-by surface this guard exists for: no web page can be served from
|
|
353
|
+
* `vscode-file://vscode-app` — the scheme is registered by the Electron main
|
|
354
|
+
* process and its handler reads the app's own files — so an attacker's page can
|
|
355
|
+
* never carry this Origin.
|
|
356
|
+
*/
|
|
357
|
+
export const DESKTOP_FRAME_ORIGIN = 'vscode-file://vscode-app';
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* THE SCRIPTS A CROSS-ORIGIN-ISOLATED FRAME LOADS **NO-CORS** FROM THIS
|
|
361
|
+
* SESSION, and the one header that lets it: `Cross-Origin-Resource-Policy`.
|
|
362
|
+
*
|
|
363
|
+
* The Code-OSS desktop frame runs cross-origin-isolated (`--enable-coi`, which
|
|
364
|
+
* the Blender worker needs for `SharedArrayBuffer`), and such a realm refuses
|
|
365
|
+
* every cross-origin subresource fetched in `no-cors` mode unless the response
|
|
366
|
+
* says out loud that it may be embedded. CORS does not cover these: `fetch`
|
|
367
|
+
* and a module `import` are cors-mode and already pass, but `importScripts`
|
|
368
|
+
* inside a worker is not, and that is exactly how two of this session's
|
|
369
|
+
* scripts are loaded on that page:
|
|
370
|
+
*
|
|
371
|
+
* - `/__editor/tab-heartbeat.js` — the tab's heartbeat worker, loaded through
|
|
372
|
+
* the frame's `blob:` doorway (docs/CODE-OSS.md §Boot, DESKTOP), and
|
|
373
|
+
* - `/__editor/blender-wasm/blender_browser.js` — the Emscripten glue, which
|
|
374
|
+
* Blender's own pthreads load as a CLASSIC worker from this same URL.
|
|
375
|
+
*
|
|
376
|
+
* Both were measured failing on 2026-09-19: the heartbeat worker died on
|
|
377
|
+
* `net::ERR_BLOCKED_BY_RESPONSE` before its first line (the VS Code window sat
|
|
378
|
+
* blessed, answering commands, and never beating), and the pthread that runs
|
|
379
|
+
* Blender's `main()` never started, so the first bpy call never answered and
|
|
380
|
+
* nothing anywhere reported an error.
|
|
381
|
+
*
|
|
382
|
+
* `cross-origin` and not `same-site`: `vscode-file://vscode-app` is a scheme of
|
|
383
|
+
* its own, so nothing narrower can name it. It is safe on exactly these
|
|
384
|
+
* responses — plain, secret-free program text this session serves a frame on
|
|
385
|
+
* purpose — and is set nowhere else.
|
|
386
|
+
*/
|
|
387
|
+
export function allowCrossOriginFrameEmbedding(res: {
|
|
388
|
+
setHeader(name: string, value: string): void;
|
|
389
|
+
}): void {
|
|
390
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** True for loopback hostnames (localhost / 127.0.0.0/8 / ::1). */
|
|
394
|
+
export function isLoopbackHostname(host: string): boolean {
|
|
395
|
+
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
396
|
+
if (h === 'localhost' || h === '::1') return true;
|
|
397
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* CSRF / drive-by defense for mutating `/__editor/*` routes. A browser always
|
|
402
|
+
* sends an `Origin` on cross-site POSTs; if present it must be a loopback origin,
|
|
403
|
+
* the Code-OSS desktop frame ({@link DESKTOP_FRAME_ORIGIN}), or an
|
|
404
|
+
* explicitly-allowed host. Non-browser clients (the CLI/SDK using Node `fetch`)
|
|
405
|
+
* send no `Origin` and are allowed.
|
|
406
|
+
*/
|
|
407
|
+
export function isAllowedEditorOrigin(
|
|
408
|
+
origin: string | undefined,
|
|
409
|
+
extraAllowedHosts: string[] = [],
|
|
410
|
+
): boolean {
|
|
411
|
+
if (!origin) return true;
|
|
412
|
+
if (origin === DESKTOP_FRAME_ORIGIN) return true;
|
|
413
|
+
let u: URL;
|
|
414
|
+
try {
|
|
415
|
+
u = new URL(origin);
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
const host = u.hostname.toLowerCase();
|
|
420
|
+
if (isLoopbackHostname(host)) return true;
|
|
421
|
+
return extraAllowedHosts.some((h) => h.toLowerCase() === host);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Installed-package source resolution (B1b, packaging plan §3(b)1)
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Resolve `packageName`'s own `src/` directory as INSTALLED in `fromDir`'s
|
|
430
|
+
* `node_modules` — Node's own resolution algorithm, rooted at `fromDir` (a
|
|
431
|
+
* project directory), not at wherever this server process's own code lives.
|
|
432
|
+
*
|
|
433
|
+
* Why: a project that pins `@vgai/game-runtime@0.3.0` must be served ITS 0.3.0
|
|
434
|
+
* source, never a copy baked into whatever package happens to be running the
|
|
435
|
+
* editor server — otherwise the exact version-skew class the pin exists to
|
|
436
|
+
* kill (an editor silently serving a different engine version than the one
|
|
437
|
+
* the project declares) reappears one layer down, inside script-serving.
|
|
438
|
+
* `packageName`'s `exports` map must publish a `./package.json` entry for
|
|
439
|
+
* `require.resolve` to find it.
|
|
440
|
+
*
|
|
441
|
+
* Returns `null` (never throws) when `packageName` isn't resolvable from
|
|
442
|
+
* `fromDir` — no project open, a pre-Phase-B project with no such
|
|
443
|
+
* dependency, or a project that hasn't `npm install`ed yet. Callers fall back
|
|
444
|
+
* to a checkout-relative default in that case.
|
|
445
|
+
*/
|
|
446
|
+
export function resolveInstalledPackageSrcDir(fromDir: string, packageName: string): string | null {
|
|
447
|
+
try {
|
|
448
|
+
const req = createRequire(join(fromDir, 'package.json'));
|
|
449
|
+
const pkgJsonPath = req.resolve(`${packageName}/package.json`);
|
|
450
|
+
return join(dirname(pkgJsonPath), 'src');
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Bind host + listen-error helpers (S3 / SC5)
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolve the interface to bind to. Native hosts default to loopback. WSL is
|
|
462
|
+
* the exception: the editor process runs in WSL while the browser runs on
|
|
463
|
+
* Windows, and Windows cannot reach a listener bound only to WSL's loopback
|
|
464
|
+
* interface. Binding the WSL listener on all of its interfaces lets Windows'
|
|
465
|
+
* localhost forwarding reach it; the editor's origin checks still protect
|
|
466
|
+
* mutating routes. An explicit host always wins.
|
|
467
|
+
*/
|
|
468
|
+
export function resolveBindHost(
|
|
469
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
470
|
+
platform: NodeJS.Platform = process.platform,
|
|
471
|
+
): string {
|
|
472
|
+
const explicitHost = env['VGAI_EDITOR_HOST'] || env['EDITOR_HOST'];
|
|
473
|
+
if (explicitHost) return explicitHost;
|
|
474
|
+
|
|
475
|
+
const isWsl = platform === 'linux' && Boolean(env['WSL_INTEROP'] || env['WSL_DISTRO_NAME']);
|
|
476
|
+
return isWsl ? '0.0.0.0' : '127.0.0.1';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Format a friendly message for a server `listen` error (notably EADDRINUSE).
|
|
481
|
+
*
|
|
482
|
+
* `portEnvVar` is REQUIRED because the editor servers do not read the
|
|
483
|
+
* same variable, and the message used to hardcode the wrong one. `dev.ts` (what
|
|
484
|
+
* `npm run dev` runs) honours only `VGAI_EDITOR_PORT`;
|
|
485
|
+
* `packaged.ts` honours `PORT` then `VGAI_EDITOR_PORT`. The old text
|
|
486
|
+
* said "set PORT / VGAI_EDITOR_HOST" for both — so on the most common path
|
|
487
|
+
* it named a variable `dev.ts` ignores, and offered a HOST variable as the way
|
|
488
|
+
* out of a PORT conflict. Following it verbatim reproduces the exact error it
|
|
489
|
+
* was printed to resolve (confirmed 2026-07-30: `PORT=5311 npm run dev` bound
|
|
490
|
+
* the built-in default again and died the same way).
|
|
491
|
+
*/
|
|
492
|
+
export function friendlyListenError(
|
|
493
|
+
err: NodeJS.ErrnoException,
|
|
494
|
+
port: number,
|
|
495
|
+
host: string,
|
|
496
|
+
portEnvVar: string,
|
|
497
|
+
): string {
|
|
498
|
+
if (err.code === 'EADDRINUSE') {
|
|
499
|
+
return (
|
|
500
|
+
`Port ${port} is already in use on ${host}. ` +
|
|
501
|
+
`Another editor instance may already be running — stop it, or set ${portEnvVar} ` +
|
|
502
|
+
`to a free port (or VGAI_EDITOR_HOST to a different address) and try again.`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (err.code === 'EACCES') {
|
|
506
|
+
return `Permission denied binding to ${host}:${port}. Try a port above 1024.`;
|
|
507
|
+
}
|
|
508
|
+
return `Failed to start server on ${host}:${port}: ${err.message}`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// File-move disambiguation (SC2)
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
export interface MoveCandidate {
|
|
516
|
+
hash: string;
|
|
517
|
+
size: number;
|
|
518
|
+
timestamp: number;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* A delete+create pair is treated as a move (and triggers scene-ref rewriting)
|
|
523
|
+
* only when it is unambiguous: the added file must match a pending unlink by
|
|
524
|
+
* BOTH content hash AND byte size, that match must be unique, and it must fall
|
|
525
|
+
* within the move window. Hash-only matching risks rewriting scene files on an
|
|
526
|
+
* unrelated delete+create that happens to collide. (SC2)
|
|
527
|
+
*/
|
|
528
|
+
export function findUniqueMoveMatch<T extends MoveCandidate>(
|
|
529
|
+
candidates: readonly T[],
|
|
530
|
+
addedHash: string,
|
|
531
|
+
addedSize: number,
|
|
532
|
+
now: number,
|
|
533
|
+
windowMs: number,
|
|
534
|
+
): T | null {
|
|
535
|
+
const matches = candidates.filter(
|
|
536
|
+
(c) => c.hash === addedHash && c.size === addedSize && now - c.timestamp < windowMs,
|
|
537
|
+
);
|
|
538
|
+
return matches.length === 1 ? matches[0]! : null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// Manifest write validation (A4, D8) — POST /__editor/manifest
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
export interface ManifestWriteBody {
|
|
546
|
+
path?: unknown;
|
|
547
|
+
content?: unknown;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export type ManifestWriteValidation =
|
|
551
|
+
| { ok: true; content: string }
|
|
552
|
+
| { ok: false; status: number; error: string };
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Validate a `POST /__editor/manifest` request body. The route HARD-CODES the
|
|
556
|
+
* write destination (`join(projectRoot, 'vgai.project.json')`) — this function
|
|
557
|
+
* never returns a path, only a green light + the (already-string, already-
|
|
558
|
+
* valid-JSON) content to write, so there is no way for a caller to derive the
|
|
559
|
+
* destination from user input even by accident. Rejects: no project open,
|
|
560
|
+
* any `path` other than the literal `'vgai.project.json'` (traversal, a
|
|
561
|
+
* different filename, an absolute path — all rejected identically), a
|
|
562
|
+
* non-string `content`, and content that fails to `JSON.parse`.
|
|
563
|
+
*/
|
|
564
|
+
export function validateManifestWrite(
|
|
565
|
+
projectRoot: string,
|
|
566
|
+
engineRoot: string,
|
|
567
|
+
body: ManifestWriteBody,
|
|
568
|
+
): ManifestWriteValidation {
|
|
569
|
+
if (projectRoot === engineRoot) {
|
|
570
|
+
return { ok: false, status: 400, error: 'No project open.' };
|
|
571
|
+
}
|
|
572
|
+
if (body.path !== undefined && body.path !== 'vgai.project.json') {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
status: 400,
|
|
576
|
+
error: "Invalid path — only 'vgai.project.json' may be written.",
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (typeof body.content !== 'string') {
|
|
580
|
+
return { ok: false, status: 400, error: 'Invalid content — expected a JSON string.' };
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
JSON.parse(body.content);
|
|
584
|
+
} catch {
|
|
585
|
+
return { ok: false, status: 400, error: 'Content is not valid JSON.' };
|
|
586
|
+
}
|
|
587
|
+
return { ok: true, content: body.content };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// Project src/ watcher classification (W6a)
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The naming convention that MAKES a module an editor contribution.
|
|
596
|
+
*
|
|
597
|
+
* Nothing enumerates contribution modules any more, so the filename is the
|
|
598
|
+
* whole declaration that a file is one. Each suffix names its contribution
|
|
599
|
+
* point (`workspace.document`, `selection.inspector`, `asset.inspector`,
|
|
600
|
+
* `generation.result`, `workspace.utility`, `workspace.analytics`) — the module still exports the
|
|
601
|
+
* authoritative `point`; this only decides what gets looked at.
|
|
602
|
+
*
|
|
603
|
+
* The convention has to be exact, because a file that matches but exports no
|
|
604
|
+
* component is a LOUD error, not a silent skip. `builder-document.tsx` (the
|
|
605
|
+
* shared `createBuilderDocument` helper that ships beside real documents) is
|
|
606
|
+
* the live proof: a `-document` suffix would sweep it in. Only the dotted form
|
|
607
|
+
* counts.
|
|
608
|
+
*/
|
|
609
|
+
// The convention itself lives in `@volter/editor-sdk/session/
|
|
610
|
+
// tool-contribution-convention` so every program that asks shares the ONE
|
|
611
|
+
// definition; re-exported here so server importers keep their import site.
|
|
612
|
+
export {
|
|
613
|
+
isToolContributionModule,
|
|
614
|
+
TOOL_CONTRIBUTION_SUFFIXES,
|
|
615
|
+
} from '@volter/editor-sdk/session/tool-contribution-convention';
|
|
616
|
+
|
|
617
|
+
import {
|
|
618
|
+
isEditorLanePath,
|
|
619
|
+
isToolContributionModule,
|
|
620
|
+
} from '@volter/editor-sdk/session/tool-contribution-convention';
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Which editor list a `src/**` add/unlink invalidates, or `null` if it's
|
|
624
|
+
* irrelevant. Feeds the editor-server's second `src/`-scoped chokidar
|
|
625
|
+
* watcher (`startWatcher()` in editor-server.ts) so a NEW registered tool
|
|
626
|
+
* module shows up in the dock without a full editor reload (spec §7 W4 field
|
|
627
|
+
* note c). `stories` (C3, spec §9) is the same physics for a new/removed
|
|
628
|
+
* `*.stories.tsx`/`*.stories.ts` anywhere under `src/` — the Stories tab.
|
|
629
|
+
*
|
|
630
|
+
* Data assets are deliberately NOT classified here: the data capability
|
|
631
|
+
* rescans on mount, on activation and on focus, which is the same rescan a
|
|
632
|
+
* watcher event would have triggered.
|
|
633
|
+
*
|
|
634
|
+
* `relPath` is project-root-relative with forward slashes (same convention
|
|
635
|
+
* `/__editor/data-files` already uses).
|
|
636
|
+
*/
|
|
637
|
+
export function classifyProjectSrcPath(relPath: string): 'tools' | 'stories' | null {
|
|
638
|
+
if (
|
|
639
|
+
isEditorLanePath(relPath) &&
|
|
640
|
+
(relPath.endsWith('.tool.ts') ||
|
|
641
|
+
relPath.endsWith('.tool.js') ||
|
|
642
|
+
isToolContributionModule(relPath))
|
|
643
|
+
)
|
|
644
|
+
return 'tools';
|
|
645
|
+
// C3 (spec §9) — conventional CSF story files, colocated anywhere under one
|
|
646
|
+
// of the project's STORY SOURCE DIRS ({@link projectStorySourceDirs}), not
|
|
647
|
+
// confined to one subfolder like tools/data. `src/…` is the in-project form;
|
|
648
|
+
// a leading `../` is the out-of-project one, which arises only for a project
|
|
649
|
+
// whose manifest declares a root entry outside its own folder (a
|
|
650
|
+
// repo-vendored game). The caller's watcher is rooted at exactly those dirs,
|
|
651
|
+
// so a `../`-prefixed path here is under one by construction.
|
|
652
|
+
if (
|
|
653
|
+
(relPath.startsWith('src/') || relPath.startsWith('../')) &&
|
|
654
|
+
(relPath.endsWith('.stories.tsx') || relPath.endsWith('.stories.ts'))
|
|
655
|
+
) {
|
|
656
|
+
return 'stories';
|
|
657
|
+
}
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// Where a project's authored source actually lives
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Every directory tree that holds the open project's own authored source —
|
|
667
|
+
* what `/__editor/story-files` scans for `*.stories.tsx`/`*.stories.ts`.
|
|
668
|
+
*
|
|
669
|
+
* `<projectRoot>/src` is the answer for every project whose roots live inside
|
|
670
|
+
* its own folder, which is every scaffolded project and every `examples/<id>`.
|
|
671
|
+
* It is NOT the answer in general, and assuming it was is what left a whole
|
|
672
|
+
* class of project storyless by construction: a manifest may declare a root
|
|
673
|
+
* whose `entry` resolves OUTSIDE the project folder — a repo-vendored game is
|
|
674
|
+
* exactly this shape (`packages/editor/src/ingest/games/<id>/` holds the
|
|
675
|
+
* manifest and the host shim; the game's source is
|
|
676
|
+
* `vendor/games/<id>/src/`). Stories colocated with those components sat on
|
|
677
|
+
* disk and no scan ever looked at them.
|
|
678
|
+
*
|
|
679
|
+
* So the scan set is DERIVED FROM THE MANIFEST: `<projectRoot>/src`, plus, for
|
|
680
|
+
* every `entry` any root declares that lands outside `projectRoot`, that
|
|
681
|
+
* entry's own source tree — its nearest ancestor named `src` when it has one
|
|
682
|
+
* (so the whole game's source is covered, not just the folder its entry file
|
|
683
|
+
* happens to sit in), else the entry's own directory. Entries INSIDE the
|
|
684
|
+
* project add nothing: `<projectRoot>/src` already covers them.
|
|
685
|
+
*
|
|
686
|
+
* Pure over an already-parsed manifest so it is unit-testable with no disk;
|
|
687
|
+
* the caller reads and parses `vgai.project.json` itself. Every returned path
|
|
688
|
+
* is absolute and de-duplicated, and a nested directory is dropped when an
|
|
689
|
+
* ancestor is already in the set so nothing is scanned twice.
|
|
690
|
+
*/
|
|
691
|
+
export function projectStorySourceDirs(projectRoot: string, manifest: unknown): string[] {
|
|
692
|
+
const root = resolve(projectRoot);
|
|
693
|
+
const dirs = [join(root, 'src')];
|
|
694
|
+
|
|
695
|
+
for (const entry of declaredRootEntries(manifest)) {
|
|
696
|
+
const absolute = resolve(root, entry);
|
|
697
|
+
if (isPathInside(root, absolute)) continue;
|
|
698
|
+
dirs.push(sourceTreeOf(absolute));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const unique: string[] = [];
|
|
702
|
+
for (const dir of dirs) {
|
|
703
|
+
if (unique.some((kept) => kept === dir || isPathInside(kept, dir))) continue;
|
|
704
|
+
unique.push(dir);
|
|
705
|
+
}
|
|
706
|
+
return unique;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Every `entry` string declared anywhere under the manifest's `roots` — a
|
|
710
|
+
* root's own `entry`, and the nested ones an adapter descriptor carries (an
|
|
711
|
+
* root's `world.entry`). Walked generically rather than
|
|
712
|
+
* by a fixed path list: `entry` means the same thing at every depth, and a
|
|
713
|
+
* hard-coded shape here would go stale the next time the adapter descriptor
|
|
714
|
+
* grows a level. */
|
|
715
|
+
function declaredRootEntries(manifest: unknown): string[] {
|
|
716
|
+
const out: string[] = [];
|
|
717
|
+
const roots = (manifest as { roots?: unknown } | null)?.roots;
|
|
718
|
+
if (!Array.isArray(roots)) return out;
|
|
719
|
+
const visit = (node: unknown): void => {
|
|
720
|
+
if (Array.isArray(node)) {
|
|
721
|
+
for (const item of node) visit(item);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (!node || typeof node !== 'object') return;
|
|
725
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
726
|
+
if (key === 'entry' && typeof value === 'string' && value.length > 0) out.push(value);
|
|
727
|
+
else visit(value);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
visit(roots);
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** The source TREE an out-of-project entry belongs to: its nearest ancestor
|
|
735
|
+
* directory named `src`, else the directory the entry file sits in. */
|
|
736
|
+
function sourceTreeOf(entryPath: string): string {
|
|
737
|
+
let dir = dirname(entryPath);
|
|
738
|
+
let parent = dirname(dir);
|
|
739
|
+
while (parent !== dir) {
|
|
740
|
+
if (dir.endsWith(`${sep}src`)) return dir;
|
|
741
|
+
dir = parent;
|
|
742
|
+
parent = dirname(dir);
|
|
743
|
+
}
|
|
744
|
+
return dirname(entryPath);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// Watcher polling resolution (#131)
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Chokidar options fragment deciding whether an editor-server watcher polls.
|
|
753
|
+
*
|
|
754
|
+
* #131: on WSL drvfs mounts (`/mnt/<drive>/…`) inotify never fires, so every
|
|
755
|
+
* SSE broadcast the editor UI lives on (`assets-changed`, `asset-moved`,
|
|
756
|
+
* `tool/data/story-files-changed`, manifest validation) was structurally dead
|
|
757
|
+
* there — the owner watched an agent author a scene and the open editor never
|
|
758
|
+
* repainted until a manual browser refresh. dev.ts already auto-polls VITE's
|
|
759
|
+
* watcher on drvfs (`startProjectPollWatcher`) but deliberately never feeds
|
|
760
|
+
* these watchers (see the decoupling note at the srcWatcher construction), so
|
|
761
|
+
* they need their own decision. Chokidar's scoped `usePolling` is safe here
|
|
762
|
+
* where Vite-wide polling was not: these watchers cover only a game project's
|
|
763
|
+
* `public/`+`src/` trees and one manifest file, not the whole engine tree
|
|
764
|
+
* that made drvfs stat-flood starve the event loop (dev.ts field note
|
|
765
|
+
* 2026-07-10).
|
|
766
|
+
*
|
|
767
|
+
* Same env contract as dev.ts's poller: `VGAI_WATCH_POLL=0` forces off
|
|
768
|
+
* anywhere, `=1` forces on (default 1000ms interval), `=<ms>` forces on with
|
|
769
|
+
* that interval. Unset: auto-enable iff `root` sits on a drvfs mount
|
|
770
|
+
* (linux + `/mnt/<drive>/`). Pass `root: null` to opt out of auto-detection
|
|
771
|
+
* (engine-repo-rooted watchers, where `public/` includes the vendored-game
|
|
772
|
+
* trees and polling would be needlessly broad).
|
|
773
|
+
*/
|
|
774
|
+
export function resolveWatcherPollOptions(
|
|
775
|
+
root: string | null,
|
|
776
|
+
env: Record<string, string | undefined> = process.env,
|
|
777
|
+
platform: NodeJS.Platform = process.platform,
|
|
778
|
+
): { usePolling: true; interval: number } | Record<string, never> {
|
|
779
|
+
const raw = env['VGAI_WATCH_POLL'];
|
|
780
|
+
if (raw !== undefined && raw !== '') {
|
|
781
|
+
if (raw === '0') return {};
|
|
782
|
+
const ms = Number(raw);
|
|
783
|
+
return { usePolling: true, interval: Number.isFinite(ms) && ms > 1 ? ms : 1000 };
|
|
784
|
+
}
|
|
785
|
+
const onDrvfs = root !== null && platform === 'linux' && /^\/mnt\/[a-z]\//i.test(root);
|
|
786
|
+
return onDrvfs ? { usePolling: true, interval: 1000 } : {};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
// Command-relay response shaping (SC1)
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* A relay command's SETTLED result: the tab's own answer
|
|
795
|
+
* ({@link CommandResult}, declared once in `src/command-table.ts` and imported
|
|
796
|
+
* by both ends of the wire), or the server's verdict for a command no tab
|
|
797
|
+
* answered.
|
|
798
|
+
*
|
|
799
|
+
* `timedOut` is the one field the server adds, because the server is the only
|
|
800
|
+
* thing that can produce it — no browser handler has a concept of "nobody
|
|
801
|
+
* answered". Every site that sets it (`control-plane.ts`) pairs it with
|
|
802
|
+
* `ok: false`, and {@link commandResponseFor} reads it only after `ok` is
|
|
803
|
+
* false, which is why it can be an optional marker on top of a REQUIRED `ok`
|
|
804
|
+
* rather than a second half-truth beside an optional one.
|
|
805
|
+
*/
|
|
806
|
+
export interface RelayedCommandResult extends CommandResult {
|
|
807
|
+
timedOut?: boolean;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The relay's per-command WORK budget and the ANSWER SHAPE both live in
|
|
812
|
+
* `src/command-table.ts`, beside the vocabulary they are columns of —
|
|
813
|
+
* re-exported here because the server's relay is their only reader and this is
|
|
814
|
+
* where that reader looks. The delivery budget below is a different clock and
|
|
815
|
+
* stays here.
|
|
816
|
+
*/
|
|
817
|
+
export { type CommandResult, relayCommandTimeoutMs };
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* How long the relay waits for the editor tab to say it RECEIVED a command,
|
|
821
|
+
* as opposed to finishing it.
|
|
822
|
+
*
|
|
823
|
+
* The long budgets above are for the WORK — a game's async `setup()`, a
|
|
824
|
+
* teardown chain, a sheet of rasterized stories. They were also, silently,
|
|
825
|
+
* the budget for DELIVERY: a tab that stopped answering the SSE command
|
|
826
|
+
* channel altogether made `vgai play` sit for the full 120 seconds and then
|
|
827
|
+
* report "Command timed out — editor connected but did not respond", a
|
|
828
|
+
* sentence that names no cause and no remedy (this file's own note above
|
|
829
|
+
* records the same message sending a real investigation looking for a dead
|
|
830
|
+
* editor). Measured on a hidden tab: a healthy play acks in ~350 ms with
|
|
831
|
+
* `requestAnimationFrame` fully parked and timers clamped to a minute, so a
|
|
832
|
+
* play still silent after this window is not a slow boot — it is a tab that
|
|
833
|
+
* is not running its command listener.
|
|
834
|
+
*
|
|
835
|
+
* The receipt (`command-listener.ts` posts it the instant the SSE event
|
|
836
|
+
* arrives, BEFORE it starts the work) separates the two: acknowledged, and
|
|
837
|
+
* the command's full budget stands untouched; unacknowledged, and the caller
|
|
838
|
+
* learns in seconds with the facts the server already holds.
|
|
839
|
+
*
|
|
840
|
+
* The "not a slow boot" inference above holds only when the tab's SSE
|
|
841
|
+
* socket is GONE. Measured 2026-08-09 on a loaded box (load ~19, cold
|
|
842
|
+
* editor graph): a live tab's main thread blocked for 60-100s mid-boot, the
|
|
843
|
+
* SSE command event sat queued the whole time, and every command executed
|
|
844
|
+
* the moment the thread freed — receipts included. So a live socket EXTENDS
|
|
845
|
+
* the wait (up to {@link RELAY_DELIVERY_MAX_WAIT_MS}) instead of failing at
|
|
846
|
+
* this first window; only a dead socket or the max wait ends it.
|
|
847
|
+
*/
|
|
848
|
+
export const RELAY_DELIVERY_ACK_MS = 8000;
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Ceiling for the receipt wait while the controller tab's SSE socket stays
|
|
852
|
+
* open. A blocked-but-alive main thread (a cold editor boot under machine
|
|
853
|
+
* load) holds its socket and runs the queued command when it unblocks —
|
|
854
|
+
* measured at 60-100s on 2026-08-09, when five consecutive 8s windows
|
|
855
|
+
* false-failed a play that then executed anyway (and the caller's blind
|
|
856
|
+
* resends, queued behind it, stopped the game it had just started). 45s
|
|
857
|
+
* outlasts an ordinary cold-boot stall while still naming a genuine zombie
|
|
858
|
+
* tab (socket open, listener never installed) in under a minute instead of
|
|
859
|
+
* the command's 120s budget.
|
|
860
|
+
*
|
|
861
|
+
* A CEILING, not a fixed wait: the caller clamps it to the type's own
|
|
862
|
+
* {@link relayCommandTimeoutMs}, because `stop` (30s),
|
|
863
|
+
* `capture-asset-preview` (30s) and `bridge-screenshot` (15s) are all
|
|
864
|
+
* shorter. Unclamped, their own timer would always win and those callers
|
|
865
|
+
* would get the generic "editor connected but did not respond" — the very
|
|
866
|
+
* message the receipt exists to replace.
|
|
867
|
+
*/
|
|
868
|
+
export const RELAY_DELIVERY_MAX_WAIT_MS = 45_000;
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* The delivery-acknowledgement window for one command type, or `null` when
|
|
872
|
+
* the type has none.
|
|
873
|
+
*
|
|
874
|
+
* Only types whose own budget EXCEEDS {@link RELAY_DELIVERY_ACK_MS} get one:
|
|
875
|
+
* an ordinary 5s command already fails faster than any ack timer could, so
|
|
876
|
+
* arming one would add a second timer that can never be the informative one —
|
|
877
|
+
* the same "keep the informative timer the one that fires" rule
|
|
878
|
+
* {@link relayCommandTimeoutMs} states for its client-side counterparts.
|
|
879
|
+
*/
|
|
880
|
+
export function relayCommandAckDeadlineMs(type: unknown): number | null {
|
|
881
|
+
return relayCommandTimeoutMs(type) > RELAY_DELIVERY_ACK_MS ? RELAY_DELIVERY_ACK_MS : null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The refusal for a command whose controlling tab went away before answering.
|
|
886
|
+
*
|
|
887
|
+
* ONE constant because two different paths emit it — the SSE `close`
|
|
888
|
+
* handler's `failCommandsOwnedBy`, and the receipt window when it finds the
|
|
889
|
+
* socket already gone — and because a THIRD place, in another package, reads
|
|
890
|
+
* it: the CLI decides a play is safe to resend by matching a fragment of this
|
|
891
|
+
* sentence (`vgai-cli/src/play-retry.ts`, `TRANSIENT_RELAY_ERRORS`). Drift
|
|
892
|
+
* between copies would not fail a build or a type check; it would silently
|
|
893
|
+
* turn a retryable disconnect into a dead end. `play-retry.test.ts` pins the
|
|
894
|
+
* cross-package half by running this exact string through that matcher.
|
|
895
|
+
*/
|
|
896
|
+
export const CONTROLLER_DISCONNECTED_MESSAGE =
|
|
897
|
+
'The editor tab controlling this command disconnected before reporting a result.';
|
|
898
|
+
|
|
899
|
+
/** What the server knows about the tab it handed a command to. Every field is
|
|
900
|
+
* already in the `/__editor/state` snapshot that tab POSTs. */
|
|
901
|
+
export interface UnacknowledgedCommandContext {
|
|
902
|
+
/** The command type that went unacknowledged. */
|
|
903
|
+
readonly type: unknown;
|
|
904
|
+
/** Page visibility last reported by that tab, or `null` if it never reported. */
|
|
905
|
+
readonly visibility: 'visible' | 'hidden' | null;
|
|
906
|
+
/** Whether that tab last reported window focus; `null` if it never reported. */
|
|
907
|
+
readonly focused: boolean | null;
|
|
908
|
+
/** Milliseconds since that tab last POSTed anything, or `null` if never. */
|
|
909
|
+
readonly silentForMs: number | null;
|
|
910
|
+
/** How long the relay actually waited for the receipt before giving up —
|
|
911
|
+
* the type's clamped ceiling, so it varies by command. */
|
|
912
|
+
readonly waitedMs: number;
|
|
913
|
+
/**
|
|
914
|
+
* Age of the last protocol pong on that tab's control socket, or `null`
|
|
915
|
+
* when the transport has none (the SSE stream and the share tunnel's
|
|
916
|
+
* bridge). A pong is answered by the browser's NETWORK stack, so a fresh
|
|
917
|
+
* one alongside app-level silence is positive evidence of the blocked-main-
|
|
918
|
+
* thread case rather than an inference from it.
|
|
919
|
+
*/
|
|
920
|
+
readonly lastPongAgeMs?: number | null;
|
|
921
|
+
/**
|
|
922
|
+
* What the main-thread echo said. `'unanswered'` means the page's inline
|
|
923
|
+
* bootstrap responder — attached before any module loads — did not reply,
|
|
924
|
+
* so the thread really is blocked. `'answered'` is the opposite and much
|
|
925
|
+
* worse news: the thread is free and still did not pick the command up, so
|
|
926
|
+
* the tab's command listener is not running. `'unavailable'` is the SSE /
|
|
927
|
+
* tunnel transport, which cannot ask.
|
|
928
|
+
*/
|
|
929
|
+
readonly mainThreadEcho?: 'answered' | 'unanswered' | 'unavailable';
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* The refusal for a command the controlling tab never acknowledged.
|
|
934
|
+
*
|
|
935
|
+
* Names the condition (how long that tab has been silent, what it was
|
|
936
|
+
* showing) and the remedy, because the generic timeout named neither. It
|
|
937
|
+
* deliberately does NOT blame page visibility: a hidden tab runs commands
|
|
938
|
+
* fine (measured — see {@link RELAY_DELIVERY_ACK_MS}), so "your tab is
|
|
939
|
+
* hidden" would send the reader to foreground a tab that was never the
|
|
940
|
+
* problem. Visibility is reported as one observation beside the others.
|
|
941
|
+
*
|
|
942
|
+
* What it must NOT claim is that nothing was started. The old wording did,
|
|
943
|
+
* and it was false: a main thread blocked by a cold boot under load holds
|
|
944
|
+
* its socket, keeps the SSE event queued, and runs the command when it
|
|
945
|
+
* unblocks (measured 2026-08-09 — five refusals in a row, each of whose
|
|
946
|
+
* plays later executed, and the caller's blind resends then stopped the
|
|
947
|
+
* game). So this says the command is queued and warns AGAINST resending.
|
|
948
|
+
*
|
|
949
|
+
* There is deliberately no second, tab-is-gone ending here: a controller
|
|
950
|
+
* whose socket dies is settled by `failCommandsOwnedBy` on the `close`
|
|
951
|
+
* handler, with its own message, before this window can expire. "Its
|
|
952
|
+
* connection is still open" is therefore provable rather than assumed.
|
|
953
|
+
*
|
|
954
|
+
* On the duplex control socket the diagnosis stops being an inference. The
|
|
955
|
+
* pong age proves the transport is live, and the main-thread echo separates
|
|
956
|
+
* the two endings the old single sentence had to merge: `'unanswered'` is a
|
|
957
|
+
* genuinely blocked thread (queued, will run, do not resend), `'answered'` is
|
|
958
|
+
* a responsive tab whose command listener is not running (queued behind
|
|
959
|
+
* nothing — a reload is the actual remedy). Both stay NON-transient for
|
|
960
|
+
* `vgai-cli`'s resend matcher: neither is a lost command.
|
|
961
|
+
*/
|
|
962
|
+
export function unacknowledgedCommandMessage(context: UnacknowledgedCommandContext): string {
|
|
963
|
+
const waited = (context.waitedMs / 1000).toFixed(0);
|
|
964
|
+
const silence =
|
|
965
|
+
context.silentForMs === null
|
|
966
|
+
? 'has never reported any state'
|
|
967
|
+
: `last reported state ${(context.silentForMs / 1000).toFixed(1)}s ago`;
|
|
968
|
+
const presence =
|
|
969
|
+
context.visibility === null
|
|
970
|
+
? 'presence unknown'
|
|
971
|
+
: `page ${context.visibility}, ${context.focused ? 'focused' : 'unfocused'}`;
|
|
972
|
+
const pong =
|
|
973
|
+
typeof context.lastPongAgeMs === 'number'
|
|
974
|
+
? `, socket pong ${(context.lastPongAgeMs / 1000).toFixed(1)}s ago`
|
|
975
|
+
: '';
|
|
976
|
+
const opening =
|
|
977
|
+
`The editor tab did not pick up "${String(context.type)}" within ${waited}s. Its connection ` +
|
|
978
|
+
`is still open (${presence}${pong}) and it ${silence}`;
|
|
979
|
+
if (context.mainThreadEcho === 'answered') {
|
|
980
|
+
return (
|
|
981
|
+
`${opening} — but its main thread ANSWERED a liveness echo, so the thread is not blocked ` +
|
|
982
|
+
`and the tab simply is not running a command listener. The command stays queued there and ` +
|
|
983
|
+
`will not run by itself, so do NOT resend it blindly: reload the tab, or re-run ` +
|
|
984
|
+
`\`volter-editor edit\` (which reuses the session and self-heals the tab).`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
const evidence =
|
|
988
|
+
context.mainThreadEcho === 'unanswered'
|
|
989
|
+
? ` — its main thread did not answer a liveness echo either; the cause has not been established`
|
|
990
|
+
: ` — main-thread liveness was not measured`;
|
|
991
|
+
return (
|
|
992
|
+
`${opening}${evidence}. The command is queued in that tab and may still run when it ` +
|
|
993
|
+
`unblocks, so do NOT resend it blindly. Watch \`volter-editor status\` for fresh state; if the tab ` +
|
|
994
|
+
`stays silent, reload it or re-run \`volter-editor edit\` (which reuses the session and self-heals ` +
|
|
995
|
+
`the tab).`
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
// Command-listener health (P36)
|
|
1001
|
+
// ---------------------------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* What the server has MEASURED about one page-load's command listener.
|
|
1005
|
+
*
|
|
1006
|
+
* The gap this closes: presence is owned by the tiny pre-React entry
|
|
1007
|
+
* (`src/early-editor-presence.ts`), which opens the control channel before any
|
|
1008
|
+
* module loads, while the listener that actually executes commands attaches
|
|
1009
|
+
* much later (`connectCommandListener`, once the whole React graph is up). So a
|
|
1010
|
+
* page can beat, hold a live socket, count as PRESENT and BLESSED — and be
|
|
1011
|
+
* unable to run anything. During the incident this comes from, `vgai status`
|
|
1012
|
+
* answered happily for eight minutes about a session in exactly that state; the
|
|
1013
|
+
* only way to learn the truth was to issue a command and watch it hang.
|
|
1014
|
+
*
|
|
1015
|
+
* Every field is a timestamp the server already stamps for its own reasons. No
|
|
1016
|
+
* field is inferred, and nothing here guesses when one is absent.
|
|
1017
|
+
*/
|
|
1018
|
+
export interface CommandListenerFacts {
|
|
1019
|
+
/** When this page-load reported its listener ATTACHED, or `null` if it never
|
|
1020
|
+
* has. Cleared when the page reports it detaching. */
|
|
1021
|
+
readonly attachedAt: number | null;
|
|
1022
|
+
/** When the relay last handed this page a command, or `null`. */
|
|
1023
|
+
readonly lastRelayAt: number | null;
|
|
1024
|
+
/** When this page last acknowledged RECEIPT of a relayed command, or `null`. */
|
|
1025
|
+
readonly lastReceiptAt: number | null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** The standing health verdict `vgai status` prints per tab. */
|
|
1029
|
+
export type CommandListenerHealth = 'ready' | 'not attached' | `silent since ${string}`;
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* The verdict, from those facts alone.
|
|
1033
|
+
*
|
|
1034
|
+
* `not attached` is the zombie page — the one the incident hid. `silent since`
|
|
1035
|
+
* is the other half: a listener that DID attach and has since stopped taking
|
|
1036
|
+
* commands, which the receipt path proves without asking the page anything (a
|
|
1037
|
+
* relay went out, the ack window passed, no receipt came back). `ready` is the
|
|
1038
|
+
* only remaining case, and it is a positive report rather than the absence of
|
|
1039
|
+
* evidence.
|
|
1040
|
+
*
|
|
1041
|
+
* The grace before "silent" is {@link RELAY_DELIVERY_ACK_MS}, deliberately the
|
|
1042
|
+
* same window the relay itself waits for a receipt: a command relayed 50ms ago
|
|
1043
|
+
* has not had time to be acknowledged, and calling that silence would make the
|
|
1044
|
+
* field cry wolf on every healthy `vgai play`.
|
|
1045
|
+
*/
|
|
1046
|
+
export function commandListenerHealth(
|
|
1047
|
+
facts: CommandListenerFacts,
|
|
1048
|
+
now: number,
|
|
1049
|
+
): CommandListenerHealth {
|
|
1050
|
+
if (facts.attachedAt === null) return 'not attached';
|
|
1051
|
+
const { lastRelayAt, lastReceiptAt } = facts;
|
|
1052
|
+
const unanswered =
|
|
1053
|
+
lastRelayAt !== null &&
|
|
1054
|
+
(lastReceiptAt === null || lastReceiptAt < lastRelayAt) &&
|
|
1055
|
+
now - lastRelayAt >= RELAY_DELIVERY_ACK_MS;
|
|
1056
|
+
if (!unanswered) return 'ready';
|
|
1057
|
+
return `silent since ${((now - lastRelayAt) / 1000).toFixed(1)}s ago`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Map a relayed-command result to an HTTP status + body. A timeout (no editor
|
|
1061
|
+
* connected) is a distinct 504 error rather than a fake success. (SC1)
|
|
1062
|
+
* `data` is spread into the response body at the top level — alongside
|
|
1063
|
+
* `ok: true` on success (so `HttpEditorTransport.sendCommand` sees it
|
|
1064
|
+
* without a second field of indirection) and alongside `ok: false`/`error`
|
|
1065
|
+
* on the refusal path.
|
|
1066
|
+
*
|
|
1067
|
+
* A REFUSAL travels as HTTP 200. It is a first-class ANSWER from the game —
|
|
1068
|
+
* a steer verb racing its run's end, a poll of a run-scoped provider that is
|
|
1069
|
+
* legitimately absent — and every first-party caller reads `body.ok`, never
|
|
1070
|
+
* the status. Encoding it as 400 had a real cost: Chromium prints an
|
|
1071
|
+
* unsuppressable "Failed to load resource: 400" console error for every
|
|
1072
|
+
* non-2xx subresource, so an editor panel's 700 ms `bot.status` poll flooded
|
|
1073
|
+
* the editor console (~1.4 lines/s, all play long) — the exact channel the
|
|
1074
|
+
* dev-tools warning backstop and `vgai status` report from — and no
|
|
1075
|
+
* try/catch on the caller can silence the browser's own network log. The
|
|
1076
|
+
* timeout stays 504: no-editor/no-answer is a genuine gateway condition,
|
|
1077
|
+
* and rare enough that its console line is signal. */
|
|
1078
|
+
export function commandResponseFor(
|
|
1079
|
+
result: RelayedCommandResult,
|
|
1080
|
+
unresolvedConsole?: UnresolvedConsoleSummary,
|
|
1081
|
+
): {
|
|
1082
|
+
status: number;
|
|
1083
|
+
body: Record<string, unknown>;
|
|
1084
|
+
} {
|
|
1085
|
+
// THE CHOKE POINT for console loudness. Every relayed command — `play`,
|
|
1086
|
+
// `eval`, `screenshot`, every debug-plane read, everything the CLI and the
|
|
1087
|
+
// editor SDK ever ask a tab to do — comes back through this one function, so
|
|
1088
|
+
// the unresolved-console counts are attached here ONCE instead of in each of
|
|
1089
|
+
// the CLI's ~35 output sites. It is the FIRST key of every envelope by
|
|
1090
|
+
// deliberate choice: a structured reader slicing the first field, and a human
|
|
1091
|
+
// eyeballing a pretty-printed body, both hit it before the answer they came
|
|
1092
|
+
// for. Omitted entirely (not zero-filled) when the caller has no ledger, so
|
|
1093
|
+
// "this server predates the contract" and "this session is clean" stay
|
|
1094
|
+
// distinguishable.
|
|
1095
|
+
const head = unresolvedConsole === undefined ? {} : { unresolvedConsole };
|
|
1096
|
+
if (result.ok) return { status: 200, body: { ...head, ok: true, ...(result.data ?? {}) } };
|
|
1097
|
+
if (result.timedOut) {
|
|
1098
|
+
return {
|
|
1099
|
+
status: 504,
|
|
1100
|
+
body: {
|
|
1101
|
+
...head,
|
|
1102
|
+
ok: false,
|
|
1103
|
+
error: result.error ?? 'Command timed out — no editor connected.',
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
status: 200,
|
|
1109
|
+
body: { ...head, ok: false, error: result.error, ...(result.data ?? {}) },
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
// Runtime/scaffold distribution classification
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* True iff a root is a monorepo checkout rather than an installed package
|
|
1119
|
+
* distribution. The result selects link-vs-registry scaffold dependencies and
|
|
1120
|
+
* the packaged React runtime path; both distributions can create projects.
|
|
1121
|
+
*
|
|
1122
|
+
* A packaged (`node_modules`-installed) `@vgai/editor`'s `engineRoot` is the
|
|
1123
|
+
* EDITOR PACKAGE's own root instead of a monorepo checkout root (see
|
|
1124
|
+
* `packaged.ts` is constructed with `engineRoot: editorPackageRoot`; its
|
|
1125
|
+
* separate `scaffoldRoot` points at the npm installation that contains the
|
|
1126
|
+
* shipped template and release train.
|
|
1127
|
+
*/
|
|
1128
|
+
export function isMonorepoScaffoldRoot(engineRoot: string): boolean {
|
|
1129
|
+
return existsSync(join(engineRoot, 'packages', 'editor', 'template'));
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
// Asset listing (S-8 — ingest-boot 404 noise)
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* What `GET /__editor/assets` should answer when `readdir` failed.
|
|
1138
|
+
*
|
|
1139
|
+
* S-8 (the SimCity ingest ledger): the asset browser lists the project's asset
|
|
1140
|
+
* ROOT (`<project>/public`) at boot, three times. A project that simply has no
|
|
1141
|
+
* such folder — every source-mounted foreign game, and micropolisJS keeps its
|
|
1142
|
+
* own assets at `src/public` — answered 404 three times before the editor had
|
|
1143
|
+
* finished booting, which is exactly the noise that makes a real failure hard
|
|
1144
|
+
* to see.
|
|
1145
|
+
*
|
|
1146
|
+
* The rule, and it is a correctness fix rather than a suppression: "list the
|
|
1147
|
+
* asset root of a project that has no asset root" has a true answer, and it is
|
|
1148
|
+
* the EMPTY LISTING, not an error. Nothing is being fabricated — an absent
|
|
1149
|
+
* directory contains no assets. A named SUBdirectory is the opposite case: the
|
|
1150
|
+
* caller asserted a path that does not exist, and 404 is the honest reply.
|
|
1151
|
+
*/
|
|
1152
|
+
/**
|
|
1153
|
+
* Where Content lists media. A first-party project owns `<project>/public`.
|
|
1154
|
+
* A source-mounted ingest fixture often has no such folder — its bytes live
|
|
1155
|
+
* in `vendor/games/<id>/public`. Listing that tree is not fabrication: those
|
|
1156
|
+
* are the game's images and audio. Writes still target the project public/.
|
|
1157
|
+
*/
|
|
1158
|
+
/**
|
|
1159
|
+
* Where Content lists one named root. `public` keeps the rule below verbatim;
|
|
1160
|
+
* `references` is the project's own reference-material folder — ordinary files
|
|
1161
|
+
* beside `public/`, never vendored and never substituted, because reference
|
|
1162
|
+
* material belongs to the project someone opened and nothing else.
|
|
1163
|
+
*
|
|
1164
|
+
* An unknown root name resolves to the public root rather than anywhere else:
|
|
1165
|
+
* the listing routes validate names, and a silent fallback to the safest root
|
|
1166
|
+
* is the only wrong answer that cannot read a directory nobody asked for.
|
|
1167
|
+
*/
|
|
1168
|
+
export function resolveListedAssetRoot(
|
|
1169
|
+
projectRoot: string,
|
|
1170
|
+
engineRoot: string,
|
|
1171
|
+
root: string,
|
|
1172
|
+
): string {
|
|
1173
|
+
if (root === 'references') return resolve(projectRoot, 'references');
|
|
1174
|
+
return resolveListedPublicRoot(projectRoot, engineRoot);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
export function resolveListedPublicRoot(projectRoot: string, engineRoot: string): string {
|
|
1178
|
+
const projectPublic = resolve(projectRoot, 'public');
|
|
1179
|
+
if (existsSync(projectPublic)) return projectPublic;
|
|
1180
|
+
const id = projectRoot.split(sep).filter(Boolean).at(-1);
|
|
1181
|
+
if (!id) return projectPublic;
|
|
1182
|
+
const vendorPublic = resolve(engineRoot, 'vendor', 'games', id, 'public');
|
|
1183
|
+
return existsSync(vendorPublic) ? vendorPublic : projectPublic;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function assetListingErrorResponse(
|
|
1187
|
+
dir: string,
|
|
1188
|
+
errorCode: string | undefined,
|
|
1189
|
+
): { kind: 'empty' } | { kind: 'error'; status: 404 | 500; message: string } {
|
|
1190
|
+
const missing = errorCode === 'ENOENT' || errorCode === 'ENOTDIR';
|
|
1191
|
+
if (!missing) return { kind: 'error', status: 500, message: 'Internal server error.' };
|
|
1192
|
+
if (dir === '') return { kind: 'empty' };
|
|
1193
|
+
return { kind: 'error', status: 404, message: 'Directory not found.' };
|
|
1194
|
+
}
|