@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,2106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite plugin: live OID instrumentation + source-write endpoints (C1/C2/C7/C8).
|
|
3
|
+
*
|
|
4
|
+
* The visual-edit "trick" made live for hand-authored React UI components: at dev
|
|
5
|
+
* time it stamps `data-oid` on every JSX element in files matched by `include` (a
|
|
6
|
+
* predicate, so the rest of the editor build is untouched), builds an OID→source
|
|
7
|
+
* index, and serves:
|
|
8
|
+
* GET /__ui-source/index → the whole OID index
|
|
9
|
+
* POST /__ui-source/write {oid,prop,value} → surgical inline-style edit to source
|
|
10
|
+
* POST /__ui-source/struct {oid,op} → structural source edit (delete)
|
|
11
|
+
* Edits run the SAME `writer.ts` used in unit tests, server-side, then Vite HMR
|
|
12
|
+
* reloads. `data-oid` exists only in transform output, never on disk.
|
|
13
|
+
*
|
|
14
|
+
* `include` widening (T6.2 slice 2): the original
|
|
15
|
+
* scope was ONLY `ui-editor/editable-components/*.tsx` (the visual-edit fixture dir,
|
|
16
|
+
* for `UIAuthoringAdapter`/`SourceEditPanel`). A react WORLD's entry graph is a real
|
|
17
|
+
* project's `.tsx` source — the mounted template project (`packages/editor/template/
|
|
18
|
+
* src/**`) or an externally-scaffolded project directory reached via the SAME
|
|
19
|
+
* dev-server `fs.allow` pipeline `server/dev.ts` already builds for project scripts
|
|
20
|
+
* (T3.3) — so `ReactRootAuthoringAdapter` needs those files instrumented too.
|
|
21
|
+
* `defaultProjectScopeInclude` widens to PROJECT scope, not repo-global: any `.tsx`
|
|
22
|
+
* file outside `node_modules`, EXCEPT the vgai tooling/engine source trees this repo
|
|
23
|
+
* itself is built from (`packages/engine/src`, `packages/create-vgai-project`,
|
|
24
|
+
* `packages/vgai-cli`, `packages/editor-sdk`, and `packages/editor/src` generally —
|
|
25
|
+
* carving OUT `ui-editor/editable-components` so the existing UI-edit-mode surface
|
|
26
|
+
* keeps working unchanged). This does NOT depend on which project happens to be
|
|
27
|
+
* open — `fs.allow` already bounds what Vite can even reach, and this predicate
|
|
28
|
+
* additionally keeps the vgai app's OWN react source un-instrumented.
|
|
29
|
+
*
|
|
30
|
+
* Vendored trees: a vendored GAME's source is STAMPED; every other `/vendor/`
|
|
31
|
+
* tree is not. `data-oid` stamping is what makes a `.tsx` file addressable —
|
|
32
|
+
* and, through `/__ui-source/write`, writable — so this predicate is where the
|
|
33
|
+
* question "may a vendored game be authored?" is actually decided.
|
|
34
|
+
*
|
|
35
|
+
* It used to answer no, for every path under a `/vendor/` segment, under the
|
|
36
|
+
* never-modify-vendored-source rule. The owner's code-or-data ruling
|
|
37
|
+
* superseded that rule (ARCHITECTURE-CORE §Editor "Every edit edits the game's
|
|
38
|
+
* own CODE or DATA", §Rules "there is no unwritable base"): ownership decides
|
|
39
|
+
* the RECORDER, not whether the edit may happen. A user's folder records in
|
|
40
|
+
* their git; a repo-vendored copy records in its own `UPSTREAM.lock`, written
|
|
41
|
+
* in the SAME gesture as the file by `server/vendored-lock-recorder.ts` and
|
|
42
|
+
* routed through {@link writeEditableSource} below. `verify-unaltered.mjs`'s
|
|
43
|
+
* bar was never zero diff — it is zero UNRECORDED diff — so it stays green
|
|
44
|
+
* with an authored edit present, as a patch that reverse-applies to upstream.
|
|
45
|
+
*
|
|
46
|
+
* What stays excluded is everything under `/vendor/` that is a DEPENDENCY
|
|
47
|
+
* rather than a game: `packages/threejs-runtime/vendor/realism-effects` (a vendored
|
|
48
|
+
* library) and the import analyzers' `vendor/` API dumps. See
|
|
49
|
+
* {@link VENDORED_GAME_SRC_RE} for the measured characterization behind that
|
|
50
|
+
* split.
|
|
51
|
+
*
|
|
52
|
+
* D-Y4 (wave 2, slice S3) —
|
|
53
|
+
* the write-back ban becomes IDENTITY-scoped, not just location-scoped: the
|
|
54
|
+
* `/vendor/` regex above only protects the repo's OWN vendored trees, but
|
|
55
|
+
* wave 2 lets an `ingest-react` world live in ANY external, user-owned
|
|
56
|
+
* folder (`vgai edit <folder>`) — an ingested-as-is foreign game there would
|
|
57
|
+
* otherwise be silently stamped/writable, reopening the never-modify-game-
|
|
58
|
+
* source rule by location rather than by identity. `nearestManifestExcludesIngestReact`
|
|
59
|
+
* walks up from a candidate `.tsx`'s own directory to the NEAREST
|
|
60
|
+
* `vgai.project.json`; if that manifest declares an `{ ingest }` react world,
|
|
61
|
+
* the whole manifest folder's subtree is excluded — exact, not coarse:
|
|
62
|
+
* `ingest-react` is single-world by construction (`binding-resolver.ts`'s
|
|
63
|
+
* `MULTI_WORLD_ALLOWED_IDENTITIES` exclusion), so "the manifest folder" IS
|
|
64
|
+
* the foreign game's whole tree, and D-N8 ships no authoring for it anyway —
|
|
65
|
+
* excluding it from stamping loses nothing. Cached per directory (hit AND
|
|
66
|
+
* miss, R-Y3) so a project with no manifest anywhere up the tree pays the
|
|
67
|
+
* walk cost once, ever. Malformed JSON at the manifest path is treated as
|
|
68
|
+
* "no exclusion" (never crash the transform hook over a broken file); a
|
|
69
|
+
* genuine fs error OTHER than "not found" (e.g. a permission error) is not
|
|
70
|
+
* swallowed — it propagates, same honesty bar the rest of this file holds.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { createHash } from 'node:crypto';
|
|
74
|
+
import {
|
|
75
|
+
existsSync,
|
|
76
|
+
mkdirSync,
|
|
77
|
+
readdirSync,
|
|
78
|
+
readFileSync,
|
|
79
|
+
realpathSync,
|
|
80
|
+
writeFileSync,
|
|
81
|
+
} from 'node:fs';
|
|
82
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
83
|
+
import { fileURLToPath } from 'node:url';
|
|
84
|
+
import { Worker } from 'node:worker_threads';
|
|
85
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
86
|
+
import { resolveManifestPath } from '@volter/editor-project/manifest/locate';
|
|
87
|
+
import { CollaborationConflictError, collaborationSession } from './server/collaboration-session';
|
|
88
|
+
import {
|
|
89
|
+
importersFromModuleGraph,
|
|
90
|
+
reportOidSurfaceDiagnostics,
|
|
91
|
+
resolveOidSurface,
|
|
92
|
+
} from './server/project-root-surface';
|
|
93
|
+
import {
|
|
94
|
+
editorChromeStampBoundary,
|
|
95
|
+
type HmrInvalidationGraph,
|
|
96
|
+
staleModuleWarning,
|
|
97
|
+
stampHmrInvalidation,
|
|
98
|
+
} from './server/project-script-hmr';
|
|
99
|
+
import {
|
|
100
|
+
findVendoredTarget,
|
|
101
|
+
settleVendoredWrite,
|
|
102
|
+
writeRecordedVendoredFile,
|
|
103
|
+
} from './server/vendored-lock-recorder';
|
|
104
|
+
import { cssTextForStyleValue } from './src/authoring/css-numeric-style';
|
|
105
|
+
import {
|
|
106
|
+
type ComponentPropSpec,
|
|
107
|
+
lineColToOffset,
|
|
108
|
+
type OidEntry,
|
|
109
|
+
OidStore,
|
|
110
|
+
oidAttributeForSurface,
|
|
111
|
+
type R3fComponentContract,
|
|
112
|
+
transformSource,
|
|
113
|
+
} from './src/ui-source/oid-transform';
|
|
114
|
+
import { planDeleteStory, planRenameStory, planSaveStory } from './src/ui-source/plan-csf-story';
|
|
115
|
+
import { planComponentExtraction } from './src/ui-source/plan-extract-component';
|
|
116
|
+
import { planComponentFork } from './src/ui-source/plan-fork-component';
|
|
117
|
+
import { planCreateClassRule } from './src/ui-source/plan-named-style';
|
|
118
|
+
import {
|
|
119
|
+
applyStyleWriteRequest,
|
|
120
|
+
planSourceEdit,
|
|
121
|
+
sourceEditFiles,
|
|
122
|
+
} from './src/ui-source/plan-source-edit';
|
|
123
|
+
import {
|
|
124
|
+
type EntryDiagnosticSelector,
|
|
125
|
+
fileDiagnosticJoin,
|
|
126
|
+
} from './src/ui-source/r3f-diagnostic-index';
|
|
127
|
+
import {
|
|
128
|
+
importedR3fContracts,
|
|
129
|
+
r3fAuthoringDiagnostics,
|
|
130
|
+
visibleR3fContracts,
|
|
131
|
+
} from './src/ui-source/r3f-project-contracts';
|
|
132
|
+
import type { SourceEditRequest } from './src/ui-source/source-edit-request';
|
|
133
|
+
import {
|
|
134
|
+
detectUtilityClassSupport,
|
|
135
|
+
isUtilityClassEvidenceFile,
|
|
136
|
+
UTILITY_CLASS_EVIDENCE_FILES,
|
|
137
|
+
type UtilityClassSupport,
|
|
138
|
+
} from './src/ui-source/utility-class-support';
|
|
139
|
+
import {
|
|
140
|
+
addClassNameToken,
|
|
141
|
+
collectLiteralInlineStyles,
|
|
142
|
+
cssCamelToKebab,
|
|
143
|
+
deleteElements,
|
|
144
|
+
editTextContent,
|
|
145
|
+
findTagEnd,
|
|
146
|
+
getEditableText,
|
|
147
|
+
groupSiblingElements,
|
|
148
|
+
removeClassNameToken,
|
|
149
|
+
removeInlineStyle,
|
|
150
|
+
removePropAttribute,
|
|
151
|
+
surgicalCssEdit,
|
|
152
|
+
surgicalCssEditInMedia,
|
|
153
|
+
writePropChange,
|
|
154
|
+
} from './src/ui-source/writer';
|
|
155
|
+
|
|
156
|
+
// `applyStyleWriteRequest` and `oidAttributeForSurface` live in browser-safe
|
|
157
|
+
// modules (`plan-source-edit.ts` / `oid-transform.ts`), because a PAGE reaches
|
|
158
|
+
// the same transform; re-exported here so this file stays the stable import
|
|
159
|
+
// site for both.
|
|
160
|
+
export { applyStyleWriteRequest, oidAttributeForSurface };
|
|
161
|
+
|
|
162
|
+
const NODE_MODULES_RE = /\/node_modules\//;
|
|
163
|
+
const TOOLING_SRC_RE = /\/packages\/(engine\/src|create-vgai-project|vgai-cli|editor-sdk)\//;
|
|
164
|
+
const EDITOR_SRC_RE = /\/packages\/editor\/src\//;
|
|
165
|
+
const EDITABLE_COMPONENTS_RE = /\/ui-editor\/editable-components\/.*\.tsx$/;
|
|
166
|
+
/** Track N, D-N4 item 2 — see the module doc comment's "Vendored-tree exclusion". */
|
|
167
|
+
const VENDOR_RE = /\/vendor\//;
|
|
168
|
+
/**
|
|
169
|
+
* A vendored GAME's own source (`…/vendor/games/<id>/…`) — the ONE tree under
|
|
170
|
+
* a `/vendor/` segment that is a game rather than a dependency.
|
|
171
|
+
*
|
|
172
|
+
* The distinction is what {@link defaultProjectScopeInclude} needs and the
|
|
173
|
+
* blanket `VENDOR_RE` could not express. Characterized before it was carved,
|
|
174
|
+
* because "what else does this regex protect?" is the question a scope change
|
|
175
|
+
* has to answer: under every `/vendor/` root in this repo, the only `.tsx` at
|
|
176
|
+
* all is `vendor/games/racing-game`'s 35 files. The rest is
|
|
177
|
+
* `packages/threejs-runtime/vendor/realism-effects` (a vendored LIBRARY — built `dist`
|
|
178
|
+
* JS and shaders, a dependency we happen to ship in-tree) and the two import
|
|
179
|
+
* analyzers' `vendor/` API dumps (`packages/gd-analyze`,
|
|
180
|
+
* `packages/rbx-analyze` — JSON only). None of those is a game, none is
|
|
181
|
+
* authorable, and all of them stay excluded here.
|
|
182
|
+
*
|
|
183
|
+
* The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
|
|
184
|
+
*/
|
|
185
|
+
const VENDORED_GAME_SRC_RE = /\/vendor\/games\/[^/]+\//;
|
|
186
|
+
|
|
187
|
+
/** True if a parsed `vgai.project.json` body declares at least one
|
|
188
|
+
* React root with an `{ ingest }` adapter — the `{ ingest }` shape alone is
|
|
189
|
+
* the answer, because every ingest-react root is D-N8 no-authoring.
|
|
190
|
+
* Defensive against
|
|
191
|
+
* any shape (never trusts the manifest is even an object) — this is a
|
|
192
|
+
* best-effort identity probe for a WRITE-BACK BAN, not manifest validation
|
|
193
|
+
* (the real Zod schema/loader is `@vgai/project/manifest/load`, not reachable
|
|
194
|
+
* from this vite-config-time, dependency-light file by design). */
|
|
195
|
+
function manifestDeclaresIngestReactWorld(parsed: unknown): boolean {
|
|
196
|
+
if (!parsed || typeof parsed !== 'object') return false;
|
|
197
|
+
const roots = (parsed as { roots?: unknown }).roots;
|
|
198
|
+
if (!Array.isArray(roots)) return false;
|
|
199
|
+
return roots.some((entry) => {
|
|
200
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
201
|
+
const root = entry as { adapter?: unknown };
|
|
202
|
+
const { adapter } = root;
|
|
203
|
+
return (
|
|
204
|
+
!!adapter &&
|
|
205
|
+
typeof adapter === 'object' &&
|
|
206
|
+
(adapter as { surface?: unknown }).surface === 'dom' &&
|
|
207
|
+
'ingest' in adapter
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Cache: absolute directory -> whether ITS OWN ancestor walk (starting AT
|
|
213
|
+
* this directory) hit an ingest-react-declaring manifest. Cached both ways
|
|
214
|
+
* (hit and miss, R-Y3) — see the module doc comment's D-Y4 section. */
|
|
215
|
+
const manifestAncestorExclusionCache = new Map<string, boolean>();
|
|
216
|
+
|
|
217
|
+
function nearestManifestExcludesIngestReact(dir: string): boolean {
|
|
218
|
+
const cached = manifestAncestorExclusionCache.get(dir);
|
|
219
|
+
if (cached !== undefined) return cached;
|
|
220
|
+
|
|
221
|
+
const manifestPath = resolveManifestPath(dir);
|
|
222
|
+
let raw: string | undefined;
|
|
223
|
+
try {
|
|
224
|
+
raw = readFileSync(manifestPath, 'utf8');
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// ENOENT (no manifest at this level) -> keep walking up. Any OTHER fs
|
|
227
|
+
// error (e.g. EACCES) is a real environment problem, not "no manifest
|
|
228
|
+
// here" — never silently swallowed.
|
|
229
|
+
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let result: boolean;
|
|
233
|
+
if (raw === undefined) {
|
|
234
|
+
const parent = dirname(dir);
|
|
235
|
+
result = parent === dir ? false : nearestManifestExcludesIngestReact(parent);
|
|
236
|
+
} else {
|
|
237
|
+
try {
|
|
238
|
+
result = manifestDeclaresIngestReactWorld(JSON.parse(raw));
|
|
239
|
+
} catch {
|
|
240
|
+
// Malformed JSON: honest "no exclusion" (stamping stays on) — never
|
|
241
|
+
// crash the transform hook over a broken manifest file.
|
|
242
|
+
result = false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
manifestAncestorExclusionCache.set(dir, result);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Cap 2 (React visual-edit parity): the scope guard for the `/__ui-source/css` endpoint.
|
|
251
|
+
* A CSS file path (from a stylesheet's `data-vite-dev-id`) is editable iff it is a
|
|
252
|
+
* first-party PROJECT `.css` file — same carve-outs as `defaultProjectScopeInclude`
|
|
253
|
+
* (never node_modules, vendored trees, the vgai tooling/engine source, or the editor's
|
|
254
|
+
* own source). Paths are normalized to forward slashes so a Windows dev-id matches. This
|
|
255
|
+
* is the CSS analogue of the `.tsx` stamping scope: an out-of-scope path must not become
|
|
256
|
+
* writable through a client-supplied `file`.
|
|
257
|
+
*/
|
|
258
|
+
export function isEditableCssFile(id: string): boolean {
|
|
259
|
+
const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
|
|
260
|
+
if (!clean.endsWith('.css')) return false;
|
|
261
|
+
if (NODE_MODULES_RE.test(clean)) return false;
|
|
262
|
+
if (VENDOR_RE.test(clean)) return false;
|
|
263
|
+
if (TOOLING_SRC_RE.test(clean)) return false;
|
|
264
|
+
if (EDITOR_SRC_RE.test(clean)) return false;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** D-1 (Phase D, spec27 §2): sha256 hex digest — the checksum guard the
|
|
269
|
+
* `/__ui-source/restore` endpoint (below) uses to refuse a stale write. Only
|
|
270
|
+
* ever computed SERVER-SIDE: the client (`ReactRootAuthoringAdapter`) never
|
|
271
|
+
* hashes anything itself, it only echoes back a digest this server already
|
|
272
|
+
* handed it in a prior `writeStruct`/`restoreSource` response. */
|
|
273
|
+
function sha256(text: string): string {
|
|
274
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Stable, registry-safe identity for a source file. Project files retain their
|
|
278
|
+
* human-readable relative path. An explicitly allowed file outside the Vite
|
|
279
|
+
* root gets a non-leaking synthetic path instead of an invalid `../…` key. */
|
|
280
|
+
function sourceResourcePath(file: string, projectRoot: string): string {
|
|
281
|
+
const path = relative(projectRoot, file).replace(/\\/g, '/');
|
|
282
|
+
if (path && path !== '..' && !path.startsWith('../') && !isAbsolute(path)) return path;
|
|
283
|
+
const identity = createHash('sha256').update(file).digest('hex').slice(0, 16);
|
|
284
|
+
return `.vgai/external-source/${identity}/${basename(file)}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Utility-class support is a property of the PACKAGE that owns the edited file,
|
|
289
|
+
* not of the editor session — an ingested game's HUD lives in the game's own
|
|
290
|
+
* tree, with its own `package.json` and its own build, and it is THAT build
|
|
291
|
+
* that would have to compile a `bg-[#059669]`. So the probe walks up from the
|
|
292
|
+
* file to the nearest enclosing `package.json` (falling back to the served
|
|
293
|
+
* project root) and reads the evidence `utility-class-support.ts` names.
|
|
294
|
+
*
|
|
295
|
+
* Cached per resolved package directory, like `propResolvers` above: the walk
|
|
296
|
+
* and the reads happen once per package per dev-server lifetime.
|
|
297
|
+
*/
|
|
298
|
+
const utilityClassSupport = new Map<string, UtilityClassSupport>();
|
|
299
|
+
|
|
300
|
+
/** The nearest ancestor of `file` holding a `package.json`, else `fallback`. */
|
|
301
|
+
function owningPackageDir(file: string, fallback: string): string {
|
|
302
|
+
let dir = dirname(isAbsolute(file) ? file : resolve(fallback, file));
|
|
303
|
+
for (let depth = 0; depth < 24; depth++) {
|
|
304
|
+
if (existsSync(join(dir, 'package.json'))) return dir;
|
|
305
|
+
const parent = dirname(dir);
|
|
306
|
+
if (parent === dir) break;
|
|
307
|
+
dir = parent;
|
|
308
|
+
}
|
|
309
|
+
return fallback;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Evidence files under `dir` — the named configs plus a bounded css/html scan. */
|
|
313
|
+
function utilityClassEvidence(dir: string): Map<string, string> {
|
|
314
|
+
const files = new Map<string, string>();
|
|
315
|
+
const read = (absolute: string, relativePath: string): void => {
|
|
316
|
+
try {
|
|
317
|
+
files.set(relativePath, readFileSync(absolute, 'utf8'));
|
|
318
|
+
} catch {
|
|
319
|
+
// Unreadable evidence is simply absent evidence — never a crash, and
|
|
320
|
+
// never an upgrade to "supported".
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
for (const name of UTILITY_CLASS_EVIDENCE_FILES) {
|
|
324
|
+
const absolute = join(dir, name);
|
|
325
|
+
if (existsSync(absolute)) read(absolute, name);
|
|
326
|
+
}
|
|
327
|
+
// Bounded walk for the css/html entry markers (`@tailwind`, a CDN script):
|
|
328
|
+
// shallow, skipping the directories that never hold a project's own entry.
|
|
329
|
+
const skip = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.vgai', '.claude']);
|
|
330
|
+
let budget = 80;
|
|
331
|
+
const walk = (current: string, prefix: string, depth: number): void => {
|
|
332
|
+
if (depth > 3 || budget <= 0) return;
|
|
333
|
+
let entries: import('node:fs').Dirent[];
|
|
334
|
+
try {
|
|
335
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
336
|
+
} catch {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
if (budget <= 0) return;
|
|
341
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
342
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
343
|
+
if (entry.isDirectory()) {
|
|
344
|
+
if (!skip.has(entry.name)) walk(join(current, entry.name), relativePath, depth + 1);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (files.has(relativePath) || !isUtilityClassEvidenceFile(relativePath)) continue;
|
|
348
|
+
budget--;
|
|
349
|
+
read(join(current, entry.name), relativePath);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
walk(dir, '', 0);
|
|
353
|
+
return files;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function utilityClassSupportFor(projectRoot: string, file: string): UtilityClassSupport {
|
|
357
|
+
const dir = owningPackageDir(file, projectRoot);
|
|
358
|
+
const cached = utilityClassSupport.get(dir);
|
|
359
|
+
if (cached) return cached;
|
|
360
|
+
const support = detectUtilityClassSupport(utilityClassEvidence(dir));
|
|
361
|
+
utilityClassSupport.set(dir, support);
|
|
362
|
+
return support;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Declared props are a property of the (file, tag) PAIR, not of each
|
|
366
|
+
* occurrence — six `<Enemy>` tags resolve the component once per index read. */
|
|
367
|
+
function declaredPropsOf(
|
|
368
|
+
propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
|
|
369
|
+
file: string,
|
|
370
|
+
tag: string,
|
|
371
|
+
): ComponentPropSpec[] | undefined {
|
|
372
|
+
const key = `${file}::${tag}`;
|
|
373
|
+
return propsByTag.get(key);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Everything one project file contributes to an index read, resolved from its
|
|
377
|
+
* CURRENT bytes exactly once per read. */
|
|
378
|
+
interface IndexFileAnalysis {
|
|
379
|
+
/** Component contracts visible in this module — `null` for a file this
|
|
380
|
+
* server does not stamp as R3F (or could not read). */
|
|
381
|
+
readonly contracts: ReadonlyMap<string, R3fComponentContract> | null;
|
|
382
|
+
/** H5 — which of this file's authorability diagnostics pertain to a given
|
|
383
|
+
* entry of this file. Inert for a non-R3F/unreadable file. */
|
|
384
|
+
readonly selectDiagnostics: EntryDiagnosticSelector;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const INERT_FILE_ANALYSIS: IndexFileAnalysis = {
|
|
388
|
+
contracts: null,
|
|
389
|
+
selectDiagnostics: () => undefined,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/** The index grouped by file, each bucket keyed by oid — the shape
|
|
393
|
+
* `fileDiagnosticJoin` needs so an entry's `parentOid` resolves. */
|
|
394
|
+
function indexEntriesByFile(store: OidStore): Map<string, Map<string, OidEntry>> {
|
|
395
|
+
const byFile = new Map<string, Map<string, OidEntry>>();
|
|
396
|
+
for (const [oid, entry] of store.index) {
|
|
397
|
+
const bucket = byFile.get(entry.file);
|
|
398
|
+
if (bucket) bucket.set(oid, entry);
|
|
399
|
+
else byFile.set(entry.file, new Map([[oid, entry]]));
|
|
400
|
+
}
|
|
401
|
+
return byFile;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Read one file's current bytes and derive everything an index read wants from
|
|
406
|
+
* it. Unreadable (deleted/renamed since it was transformed) or not stamped as
|
|
407
|
+
* R3F ⇒ the inert analysis: its entries ship exactly as recorded, which is a
|
|
408
|
+
* degradation, never a failure of the whole index read.
|
|
409
|
+
*
|
|
410
|
+
* COST, and why it went DOWN rather than up when H5 landed: this runs at most
|
|
411
|
+
* once per file per index read (the caller memoizes), whereas the previous
|
|
412
|
+
* shape re-read and re-surface-resolved a file for EVERY capitalized-tag entry
|
|
413
|
+
* whose surface turned out not to be `userData-oid`. H5 adds one
|
|
414
|
+
* `r3fAuthoringDiagnostics` pass per R3F file — the same order of work as the
|
|
415
|
+
* `visibleR3fContracts` pass beside it, and it early-returns for any file
|
|
416
|
+
* without an R3F reconciler import.
|
|
417
|
+
*/
|
|
418
|
+
function analyzeIndexFile(
|
|
419
|
+
file: string,
|
|
420
|
+
fileEntries: ReadonlyMap<string, OidEntry>,
|
|
421
|
+
importersOf: ReturnType<typeof importersFromModuleGraph>,
|
|
422
|
+
): IndexFileAnalysis {
|
|
423
|
+
let source: string;
|
|
424
|
+
try {
|
|
425
|
+
source = readFileSync(file, 'utf8');
|
|
426
|
+
} catch {
|
|
427
|
+
return INERT_FILE_ANALYSIS;
|
|
428
|
+
}
|
|
429
|
+
// The SAME decision the transform hook stamped with (task #45) — not a
|
|
430
|
+
// second derivation that could disagree with it.
|
|
431
|
+
if (resolveOidSurface(file, source, importersOf).attribute !== 'userData-oid') {
|
|
432
|
+
return INERT_FILE_ANALYSIS;
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
contracts: visibleR3fContracts(source, file),
|
|
436
|
+
// The surface check above already established this file renders through
|
|
437
|
+
// the R3F reconciler, which is a stronger answer than the source-text
|
|
438
|
+
// sniff the analyzer falls back on — and the one a project that imports
|
|
439
|
+
// its prop shape from a shared module can actually pass.
|
|
440
|
+
selectDiagnostics: fileDiagnosticJoin(
|
|
441
|
+
fileEntries,
|
|
442
|
+
r3fAuthoringDiagnostics(source, file, { knownThreeSurface: true }),
|
|
443
|
+
),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* One recorded entry plus everything the current bytes add to it: its
|
|
449
|
+
* component's authoring contract, its declared props, and (H5) the
|
|
450
|
+
* authorability diagnostics that pertain to it. Returns the entry ITSELF when
|
|
451
|
+
* there is nothing to add, so an unenriched index read allocates nothing.
|
|
452
|
+
*
|
|
453
|
+
* Note that the diagnostic lookup runs for EVERY entry, including a lowercase
|
|
454
|
+
* host tag: R3F002/3/5 are ABOUT a component definition, and a definition's
|
|
455
|
+
* root is usually an ordinary `<group>`/`<mesh>` — that entry is precisely the
|
|
456
|
+
* one an instance row reaches through its boundary object's own `userData.oid`.
|
|
457
|
+
*/
|
|
458
|
+
function enrichIndexEntry(
|
|
459
|
+
entry: OidEntry,
|
|
460
|
+
analysis: IndexFileAnalysis,
|
|
461
|
+
propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
|
|
462
|
+
): OidEntry {
|
|
463
|
+
const diagnostics = analysis.selectDiagnostics(entry);
|
|
464
|
+
const custom = analysis.contracts !== null && /^[A-Z]/.test(entry.tag);
|
|
465
|
+
const contract = custom ? analysis.contracts?.get(entry.tag) : undefined;
|
|
466
|
+
const props = custom ? declaredPropsOf(propsByTag, entry.file, entry.tag) : undefined;
|
|
467
|
+
// `props !== undefined` means the resolver ANSWERED for this tag — an empty
|
|
468
|
+
// answer included. The flag is what lets the inspector's fallback tell a
|
|
469
|
+
// pending cold resolver from a genuine absence (see OidEntry.propsResolved).
|
|
470
|
+
const propsResolved = props !== undefined;
|
|
471
|
+
if (!contract && !props?.length && !diagnostics && !propsResolved) return entry;
|
|
472
|
+
return {
|
|
473
|
+
...entry,
|
|
474
|
+
...(contract ? { r3fAuthoring: contract } : {}),
|
|
475
|
+
...(props?.length ? { props } : {}),
|
|
476
|
+
...(propsResolved ? { propsResolved: true as const } : {}),
|
|
477
|
+
...(diagnostics ? { diagnostics } : {}),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
|
|
482
|
+
const current: Record<string, unknown> = {};
|
|
483
|
+
for (const [oid, entry] of store.index) current[oid] = entry;
|
|
484
|
+
return current;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function buildEnrichedOidIndex(
|
|
488
|
+
store: OidStore,
|
|
489
|
+
devServer?: ViteDevServer,
|
|
490
|
+
propsByTag: ReadonlyMap<string, ComponentPropSpec[]> = new Map(),
|
|
491
|
+
): Record<string, unknown> {
|
|
492
|
+
const importersOf = importersFromModuleGraph(devServer?.moduleGraph);
|
|
493
|
+
const entriesByFile = indexEntriesByFile(store);
|
|
494
|
+
|
|
495
|
+
const analyses = new Map<string, IndexFileAnalysis>();
|
|
496
|
+
const analysisFor = (file: string): IndexFileAnalysis => {
|
|
497
|
+
const cached = analyses.get(file);
|
|
498
|
+
if (cached) return cached;
|
|
499
|
+
const analysis = analyzeIndexFile(file, entriesByFile.get(file) ?? new Map(), importersOf);
|
|
500
|
+
analyses.set(file, analysis);
|
|
501
|
+
return analysis;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const current: Record<string, unknown> = {};
|
|
505
|
+
for (const [oid, entry] of store.index) {
|
|
506
|
+
current[oid] = enrichIndexEntry(entry, analysisFor(entry.file), propsByTag);
|
|
507
|
+
}
|
|
508
|
+
return current;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Props/tag/diagnostic enrich is the measured 2.6 s cold cost (repeat 391 ms)
|
|
513
|
+
* of first design attach: `ComponentPropResolver` builds a `ts.Program`. The Scene
|
|
514
|
+
* can render without it — oids, file/line, and tags are already on the raw
|
|
515
|
+
* stamp. Blocking GET /__ui-source/index on enrich froze the Vite thread
|
|
516
|
+
* (and every module load) for those seconds. Serve enrichment only for an
|
|
517
|
+
* unchanged source index; otherwise serve current stamps while it refreshes.
|
|
518
|
+
*/
|
|
519
|
+
let lastEnrichedIndex: {
|
|
520
|
+
root: string | undefined;
|
|
521
|
+
sourceIndex: ReadonlyMap<string, OidEntry>;
|
|
522
|
+
index: Record<string, unknown>;
|
|
523
|
+
} | null = null;
|
|
524
|
+
let enrichQueued = false;
|
|
525
|
+
let propWorker: Worker | null = null;
|
|
526
|
+
let propRequestId = 0;
|
|
527
|
+
const pendingPropRequests = new Map<
|
|
528
|
+
number,
|
|
529
|
+
{
|
|
530
|
+
resolve: (props: ReadonlyMap<string, ComponentPropSpec[]>) => void;
|
|
531
|
+
reject: (error: Error) => void;
|
|
532
|
+
}
|
|
533
|
+
>();
|
|
534
|
+
|
|
535
|
+
function componentPropWorkerUrl(): URL {
|
|
536
|
+
// Source dev runs this plugin from `packages/editor/`; the published server
|
|
537
|
+
// runs its bundle from `dist-server/`, beside the separately-built worker.
|
|
538
|
+
const source = new URL('./server/component-prop-worker.ts', import.meta.url);
|
|
539
|
+
return existsSync(fileURLToPath(source))
|
|
540
|
+
? source
|
|
541
|
+
: new URL('./component-prop-worker.mjs', import.meta.url);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function rejectPendingPropRequests(error: Error): void {
|
|
545
|
+
for (const pending of pendingPropRequests.values()) pending.reject(error);
|
|
546
|
+
pendingPropRequests.clear();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function declaredPropWorker(): Worker {
|
|
550
|
+
if (propWorker) return propWorker;
|
|
551
|
+
const worker = new Worker(componentPropWorkerUrl(), { name: 'vgai-component-props' });
|
|
552
|
+
worker.on(
|
|
553
|
+
'message',
|
|
554
|
+
(message: { id?: unknown; props?: Record<string, ComponentPropSpec[]>; error?: unknown }) => {
|
|
555
|
+
if (typeof message.id !== 'number') return;
|
|
556
|
+
const pending = pendingPropRequests.get(message.id);
|
|
557
|
+
if (!pending) return;
|
|
558
|
+
pendingPropRequests.delete(message.id);
|
|
559
|
+
if (typeof message.error === 'string') {
|
|
560
|
+
pending.reject(new Error(message.error));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
pending.resolve(new Map(Object.entries(message.props ?? {})));
|
|
564
|
+
},
|
|
565
|
+
);
|
|
566
|
+
worker.on('error', (error: unknown) => {
|
|
567
|
+
if (propWorker === worker) propWorker = null;
|
|
568
|
+
rejectPendingPropRequests(error instanceof Error ? error : new Error(String(error)));
|
|
569
|
+
});
|
|
570
|
+
worker.on('exit', (code) => {
|
|
571
|
+
if (propWorker === worker) propWorker = null;
|
|
572
|
+
if (code !== 0) {
|
|
573
|
+
rejectPendingPropRequests(
|
|
574
|
+
new Error(`Declared-prop worker exited before answering (status ${code}).`),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
propWorker = worker;
|
|
579
|
+
return worker;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function propRequestsFor(store: OidStore): Array<{ key: string; file: string; tag: string }> {
|
|
583
|
+
const requests = new Map<string, { key: string; file: string; tag: string }>();
|
|
584
|
+
for (const entry of store.index.values()) {
|
|
585
|
+
if (!/^[A-Z]/.test(entry.tag)) continue;
|
|
586
|
+
const key = `${entry.file}::${entry.tag}`;
|
|
587
|
+
requests.set(key, { key, file: entry.file, tag: entry.tag });
|
|
588
|
+
}
|
|
589
|
+
return [...requests.values()];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function resolveDeclaredProps(
|
|
593
|
+
projectRoot: string | undefined,
|
|
594
|
+
requests: Array<{ key: string; file: string; tag: string; definition?: true }>,
|
|
595
|
+
): Promise<ReadonlyMap<string, ComponentPropSpec[]>> {
|
|
596
|
+
if (!projectRoot || requests.length === 0) {
|
|
597
|
+
return new Promise((resolveRequest) => setImmediate(() => resolveRequest(new Map())));
|
|
598
|
+
}
|
|
599
|
+
const id = ++propRequestId;
|
|
600
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
601
|
+
pendingPropRequests.set(id, { resolve: resolveRequest, reject: rejectRequest });
|
|
602
|
+
declaredPropWorker().postMessage({ id, projectRoot, requests });
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function scheduleOidIndexEnrich(
|
|
607
|
+
store: OidStore,
|
|
608
|
+
projectRoot?: string,
|
|
609
|
+
devServer?: ViteDevServer,
|
|
610
|
+
): void {
|
|
611
|
+
if (enrichQueued) return;
|
|
612
|
+
enrichQueued = true;
|
|
613
|
+
const sourceIndex = new Map(store.index);
|
|
614
|
+
const requests = propRequestsFor(store);
|
|
615
|
+
void resolveDeclaredProps(projectRoot, requests)
|
|
616
|
+
.then((propsByTag) => {
|
|
617
|
+
lastEnrichedIndex = {
|
|
618
|
+
root: projectRoot,
|
|
619
|
+
sourceIndex,
|
|
620
|
+
index: buildEnrichedOidIndex(store, devServer, propsByTag),
|
|
621
|
+
};
|
|
622
|
+
})
|
|
623
|
+
.catch((error) => {
|
|
624
|
+
// A failed semantic resolver never removes source authoring. Contracts
|
|
625
|
+
// and diagnostics still enrich on the server thread; declared props
|
|
626
|
+
// degrade to the attributes physically present in source.
|
|
627
|
+
process.stderr.write(
|
|
628
|
+
`[ui-oid] Declared-prop analysis unavailable; using source attributes: ${String(error)}\n`,
|
|
629
|
+
);
|
|
630
|
+
lastEnrichedIndex = {
|
|
631
|
+
root: projectRoot,
|
|
632
|
+
sourceIndex,
|
|
633
|
+
index: buildEnrichedOidIndex(store, devServer),
|
|
634
|
+
};
|
|
635
|
+
})
|
|
636
|
+
.finally(() => {
|
|
637
|
+
enrichQueued = false;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function currentOidIndex(
|
|
642
|
+
store: OidStore,
|
|
643
|
+
projectRoot?: string,
|
|
644
|
+
devServer?: ViteDevServer,
|
|
645
|
+
): Record<string, unknown> {
|
|
646
|
+
scheduleOidIndexEnrich(store, projectRoot, devServer);
|
|
647
|
+
const cached = lastEnrichedIndex;
|
|
648
|
+
// Transforms replace entries even when an OID stays stable. Old locations
|
|
649
|
+
// must never be paired with the newly edited source while enrichment runs.
|
|
650
|
+
if (
|
|
651
|
+
cached &&
|
|
652
|
+
cached.root === projectRoot &&
|
|
653
|
+
cached.sourceIndex.size === store.index.size &&
|
|
654
|
+
[...store.index].every(([oid, entry]) => cached.sourceIndex.get(oid) === entry)
|
|
655
|
+
) {
|
|
656
|
+
return cached.index;
|
|
657
|
+
}
|
|
658
|
+
return snapshotRawOidIndex(store);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Test-only: wait for the in-flight background enrich, if any. */
|
|
662
|
+
export function __flushOidIndexEnrichForTest(): Promise<void> {
|
|
663
|
+
return new Promise((resolve) => {
|
|
664
|
+
const finish = (): void => {
|
|
665
|
+
if (!enrichQueued) {
|
|
666
|
+
resolve();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
setImmediate(finish);
|
|
670
|
+
};
|
|
671
|
+
setImmediate(finish);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Test-only reset. */
|
|
676
|
+
export function __resetOidIndexEnrichForTest(): void {
|
|
677
|
+
lastEnrichedIndex = null;
|
|
678
|
+
enrichQueued = false;
|
|
679
|
+
if (propWorker) {
|
|
680
|
+
void propWorker.terminate();
|
|
681
|
+
propWorker = null;
|
|
682
|
+
}
|
|
683
|
+
rejectPendingPropRequests(new Error('Declared-prop worker reset.'));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Test-only: whether the last GET returned without waiting for enrich. */
|
|
687
|
+
export function __oidIndexEnrichStateForTest(): { queued: boolean; hasEnriched: boolean } {
|
|
688
|
+
return { queued: enrichQueued, hasEnriched: lastEnrichedIndex !== null };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* THE write point for every `/__ui-source/*` handler — and the reason a
|
|
693
|
+
* vendored game's source can be stamped without reopening silent drift.
|
|
694
|
+
*
|
|
695
|
+
* A source write lands in one of two recorders, decided by WHERE the file is,
|
|
696
|
+
* never by what the handler was doing:
|
|
697
|
+
*
|
|
698
|
+
* - inside a repo-vendored game → `writeRecordedVendoredFile`, which moves the
|
|
699
|
+
* file AND its `UPSTREAM.lock` entry as ONE journalled operation, so
|
|
700
|
+
* `verify-unaltered.mjs` is green with the edit present (the edit becomes a
|
|
701
|
+
* recorded patch that reverse-applies to upstream). Undo needs no special
|
|
702
|
+
* case: it comes back through here with the previous bytes, the lock
|
|
703
|
+
* reconciles from the bytes alone, and the edit that restores upstream's own
|
|
704
|
+
* content deletes the patch entry again.
|
|
705
|
+
* - anywhere else → the plain `writeFileSync` this file always did, because a
|
|
706
|
+
* user's own project records in the user's own git.
|
|
707
|
+
*
|
|
708
|
+
* Centralised deliberately. Nine handlers in this file write source; routing
|
|
709
|
+
* per-handler would mean nine chances to add a tenth that forgets, and a
|
|
710
|
+
* forgotten one is not a visible bug — it is a vendored tree that quietly
|
|
711
|
+
* stops matching its lock.
|
|
712
|
+
*/
|
|
713
|
+
/**
|
|
714
|
+
* This repo's root — `<root>/packages/editor/vite-plugin-ui-oid.ts` is where
|
|
715
|
+
* this file lives, and `<root>/vendor/games/` is where the locks live. It is a
|
|
716
|
+
* property of the CHECKOUT, not of the opened project, so deriving it from the
|
|
717
|
+
* module's own URL is the right default for every real caller and none of them
|
|
718
|
+
* passes anything.
|
|
719
|
+
*
|
|
720
|
+
* It is a DEFAULT rather than a constant so the estate the recorder judges
|
|
721
|
+
* against is injectable — the same shape `server/creation-site-write.ts` has
|
|
722
|
+
* carried all along, where `engineRoot` is an ordinary parameter of
|
|
723
|
+
* `ingestSourceOwnership`/`resolveIngestSourceFile`. Without it, "does a
|
|
724
|
+
* `/__ui-source/*` write reach the lock recorder?" could only be asked against
|
|
725
|
+
* THIS checkout's real `vendor/games/`, i.e. by editing a shipped game — so the
|
|
726
|
+
* recorder's contract was provable for the ingest routes and unprovable for
|
|
727
|
+
* this family. `test/ui-source-vendored-recorder.test.ts` is the case that
|
|
728
|
+
* needed it.
|
|
729
|
+
*/
|
|
730
|
+
const DEFAULT_ENGINE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* THE MODULE GRAPH THE WRITE POINT ABOVE MUST INVALIDATE — and why the editor
|
|
734
|
+
* cannot leave that to the file watcher.
|
|
735
|
+
*
|
|
736
|
+
* The editor writes the bytes itself. Making its OWN write visible to the next
|
|
737
|
+
* mount is therefore its own job, and routing that through the filesystem
|
|
738
|
+
* watcher makes it a property of the environment instead: Vite serves a module
|
|
739
|
+
* from `ModuleGraph.transformResult` until something invalidates it, and the
|
|
740
|
+
* only thing that ever did was the watcher's change event.
|
|
741
|
+
*
|
|
742
|
+
* Measured 2026-08-16 on `examples/third-person`, with a one-variable A/B: the
|
|
743
|
+
* root config's `server.watch.ignored` carries a `.claude` glob (it is there to
|
|
744
|
+
* stop one agent worktree's churn reloading every OTHER live session's tab), and
|
|
745
|
+
* with it in place
|
|
746
|
+
* a `position` prop written through `/__ui-source/prop` landed on disk, and the
|
|
747
|
+
* dev server then served a BYTE-IDENTICAL pre-edit transform of `src/world.tsx`
|
|
748
|
+
* for the rest of the session — to a cold `GET`, and to a `?t=`-busted one. A
|
|
749
|
+
* page reload therefore re-derived the world from the PRE-EDIT bytes and the
|
|
750
|
+
* authored value read back as the old one, with an honest-looking ack and a
|
|
751
|
+
* real diff on disk behind it. Dropping that one pattern made the very next
|
|
752
|
+
* fetch fresh. The write path may not depend on an ignore list it does not own:
|
|
753
|
+
* an unwatched project is a silently-discarded edit.
|
|
754
|
+
*
|
|
755
|
+
* Graph-only, deliberately: this stamps invalidation exactly the way
|
|
756
|
+
* `handleProjectScriptHotUpdate` does when it swallows Vite's HMR, and sends no
|
|
757
|
+
* client event. Hot-update behaviour on a watched project is unchanged
|
|
758
|
+
* (its watcher event still arrives and still drives the custom events), and
|
|
759
|
+
* stamping twice is idempotent — what is NOT recoverable is the mount that
|
|
760
|
+
* re-derives from bytes the server has already replaced.
|
|
761
|
+
*/
|
|
762
|
+
let writtenSourceInvalidationGraph: HmrInvalidationGraph | undefined;
|
|
763
|
+
let writtenSourceStampBoundary: ((moduleFile: string) => boolean) | undefined;
|
|
764
|
+
|
|
765
|
+
/** Called once by `configureServer` — the plugin has exactly one dev server.
|
|
766
|
+
* `stampBoundary` bounds the stamp's importer propagation at editor-chrome
|
|
767
|
+
* modules: a written PROJECT module must re-evaluate on its next mount, and
|
|
768
|
+
* the editor modules that imported it must not be re-instanced mid-session.
|
|
769
|
+
* This route shipped UNBOUNDED while its creation-site sibling was bounded,
|
|
770
|
+
* and that one copy was the whole failure (measured on bubbo-bubbo,
|
|
771
|
+
* 2026-08-21): every creation-site write re-stamped 33 editor modules
|
|
772
|
+
* through the isolation-document import edge, the next lazy import fetched
|
|
773
|
+
* a second `play-mode.ts?t=…` instance, and every ▶ after a write refused
|
|
774
|
+
* with "Game container not mounted". See `editorChromeStampBoundary`. */
|
|
775
|
+
function bindWrittenSourceInvalidation(
|
|
776
|
+
graph: HmrInvalidationGraph,
|
|
777
|
+
stampBoundary?: (moduleFile: string) => boolean,
|
|
778
|
+
): void {
|
|
779
|
+
writtenSourceInvalidationGraph = graph;
|
|
780
|
+
writtenSourceStampBoundary = stampBoundary;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function invalidateWrittenSource(file: string): void {
|
|
784
|
+
if (!writtenSourceInvalidationGraph) return;
|
|
785
|
+
const outcome = stampHmrInvalidation(
|
|
786
|
+
writtenSourceInvalidationGraph,
|
|
787
|
+
file,
|
|
788
|
+
undefined,
|
|
789
|
+
writtenSourceStampBoundary,
|
|
790
|
+
);
|
|
791
|
+
const warning = staleModuleWarning(file, outcome);
|
|
792
|
+
if (warning) console.warn(warning);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* The path `findVendoredTarget` must judge, symlinks resolved.
|
|
797
|
+
*
|
|
798
|
+
* `realpathSync` is what makes the vendored check survive a symlinked checkout
|
|
799
|
+
* (`vendored-lock-recorder.test.ts` pins that), but it REQUIRES the path to
|
|
800
|
+
* exist — and one write path legitimately names a file that does not exist yet:
|
|
801
|
+
* `/__ui-source/fork-component` writes the fork's brand-new file (it refuses
|
|
802
|
+
* outright when that path already exists). So resolve the containing DIRECTORY
|
|
803
|
+
* and re-join the basename when the file itself is not there; a new file's
|
|
804
|
+
* vendored-ness is decided by the folder it lands in.
|
|
805
|
+
*/
|
|
806
|
+
function realpathForWrite(file: string): string {
|
|
807
|
+
try {
|
|
808
|
+
return realpathSync(file);
|
|
809
|
+
} catch {
|
|
810
|
+
try {
|
|
811
|
+
return join(realpathSync(dirname(file)), basename(file));
|
|
812
|
+
} catch {
|
|
813
|
+
return file;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* THE read point for every `/__ui-source/*` handler — the twin of
|
|
820
|
+
* {@link writeEditableSource} below, and centralised for the same reason it is.
|
|
821
|
+
*
|
|
822
|
+
* A crashed vendored write leaves a roll-forward journal, and the next request
|
|
823
|
+
* that touches the game is what completes it (`settleVendoredWrite`). A read
|
|
824
|
+
* that skips that step plans against bytes the crashed write has already
|
|
825
|
+
* superseded — and MEASURED, that is neither caught downstream nor loud:
|
|
826
|
+
*
|
|
827
|
+
* - Through the prepare/apply pair: `/__ui-source/read` returned the
|
|
828
|
+
* pre-settle bytes with a 200, `/__ui-source/prepare` planned from them, and
|
|
829
|
+
* `/__ui-source/apply` ACCEPTED, because the `ifMatchSha` gate compares the
|
|
830
|
+
* client's stale hash against the same stale bytes and so agrees with
|
|
831
|
+
* itself. A pending journal does not change the file's bytes — that is the
|
|
832
|
+
* whole point of rolling forward — so the 409 is unreachable in exactly the
|
|
833
|
+
* window it exists for.
|
|
834
|
+
* - Through any single-request write handler (`/prop` is the ordinary
|
|
835
|
+
* inspector "revert this prop" gesture): read → plan → `writeEditableSource`,
|
|
836
|
+
* which settles and THEN writes the stale-based edit over it. Strictly
|
|
837
|
+
* worse, because there is no `ifMatchSha` in the loop at all.
|
|
838
|
+
*
|
|
839
|
+
* Either way the journalled write vanishes, the lock comes out internally
|
|
840
|
+
* consistent, and `verify-unaltered.mjs` stays green over a lost update.
|
|
841
|
+
*
|
|
842
|
+
* So the settle is not a line each handler remembers to add — it is welded to
|
|
843
|
+
* the read, the way recorder routing is welded to the write below. Nine
|
|
844
|
+
* handlers read source here; the first fix of this defect added the settle to
|
|
845
|
+
* two of them and read as complete, which is the argument for a single point
|
|
846
|
+
* rather than a convention.
|
|
847
|
+
*/
|
|
848
|
+
function readSettledSource(file: string, engineRoot: string): string {
|
|
849
|
+
settlePendingVendoredWrite(file, engineRoot);
|
|
850
|
+
return readFileSync(file, 'utf8');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Roll a crashed vendored write forward, and — when it moved bytes — stamp the
|
|
855
|
+
* invalidation that a write of our own would have stamped.
|
|
856
|
+
*
|
|
857
|
+
* Settling REPLACES the file's content, so leaving the module graph alone
|
|
858
|
+
* would serve the pre-settle module and leave the oid index on pre-settle
|
|
859
|
+
* offsets: the same silently-discarded-edit class {@link
|
|
860
|
+
* writtenSourceInvalidationGraph} exists to close, arriving through the settle
|
|
861
|
+
* instead of through the write.
|
|
862
|
+
*/
|
|
863
|
+
function settlePendingVendoredWrite(file: string, engineRoot: string): void {
|
|
864
|
+
const vendored = findVendoredTarget(realpathForWrite(file), engineRoot);
|
|
865
|
+
if (vendored && settleVendoredWrite(vendored)) invalidateWrittenSource(file);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function writeEditableSource(
|
|
869
|
+
file: string,
|
|
870
|
+
code: string,
|
|
871
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
872
|
+
): void {
|
|
873
|
+
const target = findVendoredTarget(realpathForWrite(file), engineRoot);
|
|
874
|
+
if (!target) {
|
|
875
|
+
writeFileSync(file, code);
|
|
876
|
+
invalidateWrittenSource(file);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
settleVendoredWrite(target);
|
|
880
|
+
const result = writeRecordedVendoredFile(target, Buffer.from(code, 'utf8'), 'editor source edit');
|
|
881
|
+
if (!result.ok) {
|
|
882
|
+
// REFUSE LOUDLY rather than fall through to an unrecorded write: a lock
|
|
883
|
+
// that already disagrees with its folder is not a base anything may record
|
|
884
|
+
// onto, and writing anyway is precisely the silent drift the lock exists
|
|
885
|
+
// to make impossible.
|
|
886
|
+
throw new Error(
|
|
887
|
+
`vendored source write refused for ${target.id}/${target.rel}: ${result.error}`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
invalidateWrittenSource(file);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Default `include` predicate (see the module doc comment above for the reasoning). */
|
|
894
|
+
export function defaultProjectScopeInclude(id: string): boolean {
|
|
895
|
+
const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
|
|
896
|
+
if (!clean.endsWith('.tsx')) return false;
|
|
897
|
+
if (NODE_MODULES_RE.test(clean)) return false;
|
|
898
|
+
// A vendored GAME's source IS stamped; every other `/vendor/` tree is not.
|
|
899
|
+
// The old blanket exclusion was written under the never-modify-vendored-
|
|
900
|
+
// source rule, which the owner's code-or-data ruling superseded
|
|
901
|
+
// (ARCHITECTURE-CORE §Editor "Every edit edits the game's own CODE or DATA",
|
|
902
|
+
// §Rules "there is no unwritable base"): ownership no longer decides WHETHER
|
|
903
|
+
// an edit may be written, only WHO RECORDS it. For a repo-vendored copy the
|
|
904
|
+
// recorder is its own `UPSTREAM.lock`, written in the same gesture as the
|
|
905
|
+
// file (`server/vendored-lock-recorder.ts`), so the zero-UNRECORDED-diff bar
|
|
906
|
+
// holds WITH the edit present rather than by forbidding the edit. Stamping is
|
|
907
|
+
// what makes the game's own TSX literals addressable by the gizmo and the
|
|
908
|
+
// inspector; refusing it here is what used to make a vendored game
|
|
909
|
+
// permanently unauthorable.
|
|
910
|
+
if (VENDOR_RE.test(clean) && !VENDORED_GAME_SRC_RE.test(clean)) return false;
|
|
911
|
+
if (TOOLING_SRC_RE.test(clean)) return false;
|
|
912
|
+
if (EDITOR_SRC_RE.test(clean) && !EDITABLE_COMPONENTS_RE.test(clean)) return false;
|
|
913
|
+
if (nearestManifestExcludesIngestReact(dirname(clean))) return false;
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function resolveEditableSourceFile(id: string, projectRoot: string): string | null {
|
|
918
|
+
const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
|
|
919
|
+
const candidate = isAbsolute(clean) ? clean : resolve(projectRoot, clean);
|
|
920
|
+
if (defaultProjectScopeInclude(candidate) || isEditableCssFile(candidate)) return candidate;
|
|
921
|
+
|
|
922
|
+
// Whole-file transactions also serve project-owned model source. This is
|
|
923
|
+
// deliberately narrower than a general project-file API: TypeScript below
|
|
924
|
+
// this project's src/ tree, never vendored or an ingest-react identity.
|
|
925
|
+
const projectPath = relative(projectRoot, candidate).replace(/\\/g, '/');
|
|
926
|
+
if (
|
|
927
|
+
projectPath.startsWith('src/') &&
|
|
928
|
+
(projectPath.endsWith('.ts') || projectPath.endsWith('.tsx')) &&
|
|
929
|
+
!VENDOR_RE.test(candidate) &&
|
|
930
|
+
!nearestManifestExcludesIngestReact(dirname(candidate))
|
|
931
|
+
) {
|
|
932
|
+
return candidate;
|
|
933
|
+
}
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function isCanonicalProjectSourcePath(projectRoot: string, file: string): boolean {
|
|
938
|
+
const absolute = isAbsolute(file) ? resolve(file) : resolve(projectRoot, file);
|
|
939
|
+
let ancestor = absolute;
|
|
940
|
+
while (!existsSync(ancestor)) {
|
|
941
|
+
const next = dirname(ancestor);
|
|
942
|
+
if (next === ancestor) return false;
|
|
943
|
+
ancestor = next;
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
const root = realpathSync(projectRoot);
|
|
947
|
+
const candidate = realpathSync(ancestor);
|
|
948
|
+
return candidate === root || candidate.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
949
|
+
} catch {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** The JSON response a source-endpoint handler produces (status defaults to 200). */
|
|
955
|
+
export interface HandlerResult {
|
|
956
|
+
status?: number;
|
|
957
|
+
body: unknown;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function preparedSource(
|
|
961
|
+
file: string,
|
|
962
|
+
prevSource: string,
|
|
963
|
+
newSource: string,
|
|
964
|
+
result: Record<string, unknown>,
|
|
965
|
+
projectRoot: string,
|
|
966
|
+
): HandlerResult {
|
|
967
|
+
const changed = prevSource !== newSource && result['changed'] !== false;
|
|
968
|
+
return {
|
|
969
|
+
body: {
|
|
970
|
+
changed,
|
|
971
|
+
file,
|
|
972
|
+
resourcePath: sourceResourcePath(file, projectRoot),
|
|
973
|
+
...(changed
|
|
974
|
+
? {
|
|
975
|
+
prevSource,
|
|
976
|
+
newSource,
|
|
977
|
+
prevSha: sha256(prevSource),
|
|
978
|
+
newSha: sha256(newSource),
|
|
979
|
+
}
|
|
980
|
+
: {}),
|
|
981
|
+
result: { ...result, changed, file },
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Pure source edit preparation: computes exact bytes and hashes, never writes.
|
|
988
|
+
*
|
|
989
|
+
* The edit itself is planned by the tier-agnostic `planSourceEdit`
|
|
990
|
+
* (`src/ui-source/plan-source-edit.ts`) — this handler only supplies what is
|
|
991
|
+
* genuinely server-side: the `node:fs` reads, the css scope guard, sha256, and
|
|
992
|
+
* the project-relative resource path. The hosted browser backend supplies its
|
|
993
|
+
* own equivalents around the SAME planner, which is what keeps the two tiers
|
|
994
|
+
* from drifting into different edit semantics.
|
|
995
|
+
*/
|
|
996
|
+
export function handlePrepare(
|
|
997
|
+
store: OidStore,
|
|
998
|
+
body: Record<string, unknown>,
|
|
999
|
+
projectRoot = process.cwd(),
|
|
1000
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1001
|
+
): HandlerResult {
|
|
1002
|
+
const request = body as unknown as SourceEditRequest;
|
|
1003
|
+
if (request.kind === 'css' && (!request.file || !isEditableCssFile(request.file))) {
|
|
1004
|
+
return {
|
|
1005
|
+
status: 403,
|
|
1006
|
+
body: { changed: false, result: { error: 'css file out of editable scope' } },
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const resolveOid = (oid: string) => store.index.get(oid);
|
|
1010
|
+
const sources = new Map<string, string>();
|
|
1011
|
+
const files = sourceEditFiles(request, resolveOid);
|
|
1012
|
+
for (const file of files) {
|
|
1013
|
+
sources.set(file, readSettledSource(file, engineRoot));
|
|
1014
|
+
}
|
|
1015
|
+
// The class-vs-inline gate is a question about the EDITED FILE's own project,
|
|
1016
|
+
// so it is answered here (the tier that has `node:fs`) and handed to the pure
|
|
1017
|
+
// planner — never inferred from the element's shape. Only a style request can
|
|
1018
|
+
// route to a class, so nothing else pays for the probe.
|
|
1019
|
+
const support =
|
|
1020
|
+
request.kind === 'style' && files[0]
|
|
1021
|
+
? utilityClassSupportFor(projectRoot, files[0])
|
|
1022
|
+
: undefined;
|
|
1023
|
+
const plan = planSourceEdit(request, sources, resolveOid, support);
|
|
1024
|
+
if (plan.file === null) return { body: { changed: false, result: plan.result } };
|
|
1025
|
+
return preparedSource(plan.file, plan.prevSource, plan.newSource, plan.result, projectRoot);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function handleSourceRead(
|
|
1029
|
+
body: Record<string, unknown>,
|
|
1030
|
+
projectRoot = process.cwd(),
|
|
1031
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1032
|
+
): HandlerResult {
|
|
1033
|
+
const file = body['file'];
|
|
1034
|
+
const resolved = typeof file === 'string' ? resolveEditableSourceFile(file, projectRoot) : null;
|
|
1035
|
+
if (!resolved) {
|
|
1036
|
+
return { status: 403, body: { error: 'file out of editable scope' } };
|
|
1037
|
+
}
|
|
1038
|
+
const source = readSettledSource(resolved, engineRoot);
|
|
1039
|
+
return {
|
|
1040
|
+
body: {
|
|
1041
|
+
source,
|
|
1042
|
+
sha: sha256(source),
|
|
1043
|
+
resourcePath: sourceResourcePath(resolved, projectRoot),
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export function handleSourceApply(
|
|
1049
|
+
body: Record<string, unknown>,
|
|
1050
|
+
projectRoot = process.cwd(),
|
|
1051
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1052
|
+
): HandlerResult {
|
|
1053
|
+
const restored = handleRestore(body, projectRoot, engineRoot);
|
|
1054
|
+
const value = restored.body as { restored: boolean; sha?: string; error?: string };
|
|
1055
|
+
return {
|
|
1056
|
+
...(restored.status ? { status: restored.status } : {}),
|
|
1057
|
+
body: { applied: value.restored, sha: value.sha, error: value.error },
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** POST `/__ui-source/write` — surgical inline-style/className edit (F5 routing).
|
|
1062
|
+
* D-A3 (wave 13): a `value: null` body is the REMOVAL sentinel — routed via
|
|
1063
|
+
* `applyStyleWriteRequest` to `removeInlineStyle` (append-aware undo); `appended`
|
|
1064
|
+
* is forwarded so the client's undo can remove vs. restore. */
|
|
1065
|
+
function handleWrite(
|
|
1066
|
+
store: OidStore,
|
|
1067
|
+
body: Record<string, unknown>,
|
|
1068
|
+
projectRoot = process.cwd(),
|
|
1069
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1070
|
+
): HandlerResult {
|
|
1071
|
+
// A numeric `value` stays a NUMBER all the way to the writer — the JSX then
|
|
1072
|
+
// carries a bare `12` (React px-ifies it) instead of `'12'`, which React
|
|
1073
|
+
// passes through and the CSSOM rejects, keeping the previous value.
|
|
1074
|
+
const { oid, prop, value } = body as {
|
|
1075
|
+
oid: string;
|
|
1076
|
+
prop: string;
|
|
1077
|
+
value: string | number | null;
|
|
1078
|
+
};
|
|
1079
|
+
const entry = store.index.get(oid);
|
|
1080
|
+
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1081
|
+
const src = readSettledSource(entry.file, engineRoot);
|
|
1082
|
+
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1083
|
+
const result = applyStyleWriteRequest(
|
|
1084
|
+
src,
|
|
1085
|
+
off,
|
|
1086
|
+
prop,
|
|
1087
|
+
value,
|
|
1088
|
+
utilityClassSupportFor(projectRoot, entry.file),
|
|
1089
|
+
);
|
|
1090
|
+
if (result.changed) writeEditableSource(entry.file, result.code, engineRoot);
|
|
1091
|
+
return {
|
|
1092
|
+
body: {
|
|
1093
|
+
changed: result.changed,
|
|
1094
|
+
dynamic: result.dynamic,
|
|
1095
|
+
route: result.route,
|
|
1096
|
+
appended: result.appended,
|
|
1097
|
+
...(result.tokenRef ? { tokenRef: result.tokenRef } : {}),
|
|
1098
|
+
...(result.error ? { error: result.error } : {}),
|
|
1099
|
+
file: entry.file,
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* POST `/__ui-source/css` — Cap 2 surgical CSS-FILE edit. The client (adapter) resolves
|
|
1106
|
+
* the target rule from live matched-rule data (`inspect.ts` getMatchedCssRules) and sends
|
|
1107
|
+
* the rule's source file + selector; this edits that file in place. The file path is
|
|
1108
|
+
* client-supplied, so it MUST pass the first-party CSS scope guard before any read/write
|
|
1109
|
+
* — an out-of-scope path is rejected, not touched. A selector not present in source is
|
|
1110
|
+
* generated CSS (`generated: true`); the client re-routes to class/inline.
|
|
1111
|
+
*
|
|
1112
|
+
* Exported (unlike its sibling handlers above, which close over the live
|
|
1113
|
+
* `OidStore`) so it's directly unit-testable without a dev server — it's pure
|
|
1114
|
+
* body-in/HandlerResult-out plus real fs I/O on a client-supplied, scope-
|
|
1115
|
+
* guarded path, no store/session state involved.
|
|
1116
|
+
*/
|
|
1117
|
+
export function handleCss(
|
|
1118
|
+
body: Record<string, unknown>,
|
|
1119
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1120
|
+
): HandlerResult {
|
|
1121
|
+
const { file, selector, prop, value, media } = body as {
|
|
1122
|
+
file: string;
|
|
1123
|
+
selector: string;
|
|
1124
|
+
prop: string;
|
|
1125
|
+
value: string;
|
|
1126
|
+
media?: string;
|
|
1127
|
+
};
|
|
1128
|
+
if (!file || !isEditableCssFile(file)) {
|
|
1129
|
+
return { body: { changed: false, error: 'css file out of editable scope' } };
|
|
1130
|
+
}
|
|
1131
|
+
const cleanFile = file.split('?')[0] ?? file;
|
|
1132
|
+
const src = readSettledSource(cleanFile, engineRoot);
|
|
1133
|
+
// `media` scopes the edit to a breakpoint: the rule inside `@media <media>`
|
|
1134
|
+
// (block and rule both created on demand — a breakpoint's first edit IS
|
|
1135
|
+
// what mints it). The base-path `generated` probe does not apply: the
|
|
1136
|
+
// media write can always land.
|
|
1137
|
+
const edited =
|
|
1138
|
+
typeof media === 'string' && media.trim().length > 0
|
|
1139
|
+
? surgicalCssEditInMedia(src, media, selector, prop, value)
|
|
1140
|
+
: surgicalCssEdit(src, selector, prop, value);
|
|
1141
|
+
if (edited === null) return { body: { changed: false, generated: true, file: cleanFile } };
|
|
1142
|
+
if (edited !== src) writeEditableSource(cleanFile, edited, engineRoot);
|
|
1143
|
+
return { body: { changed: edited !== src, file: cleanFile } };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* POST `/__ui-source/text` — Cap 3 text-content edit. Replaces a leaf element's pure-text
|
|
1148
|
+
* body in place (guarded: refuses a body with `{expression}` or child elements). Returns
|
|
1149
|
+
* `prevText` (the editable text before the edit) for the client's undo inverse.
|
|
1150
|
+
*/
|
|
1151
|
+
function handleText(
|
|
1152
|
+
store: OidStore,
|
|
1153
|
+
body: Record<string, unknown>,
|
|
1154
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1155
|
+
): HandlerResult {
|
|
1156
|
+
const { oid, text } = body as { oid: string; text: string };
|
|
1157
|
+
const entry = store.index.get(oid);
|
|
1158
|
+
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1159
|
+
const src = readSettledSource(entry.file, engineRoot);
|
|
1160
|
+
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1161
|
+
const prevText = getEditableText(src, off);
|
|
1162
|
+
const result = editTextContent(src, off, text);
|
|
1163
|
+
if (result.changed) writeEditableSource(entry.file, result.code, engineRoot);
|
|
1164
|
+
return {
|
|
1165
|
+
body: { changed: result.changed, dynamic: result.dynamic, prevText, file: entry.file },
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* POST `/__ui-source/prop` — Cap 4 component prop / attribute edit at the call site. The
|
|
1171
|
+
* client sends the CALL-SITE oid (resolved from the fiber, `inspect.ts` getCallSiteOid)
|
|
1172
|
+
* whose index entry points at the `<Component …>` tag. Guards a dynamic (non-literal) prop
|
|
1173
|
+
* and the event-handler / structural props.
|
|
1174
|
+
*/
|
|
1175
|
+
function handleProp(
|
|
1176
|
+
store: OidStore,
|
|
1177
|
+
body: Record<string, unknown>,
|
|
1178
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1179
|
+
): HandlerResult {
|
|
1180
|
+
const { oid, prop, value } = body as { oid: string; prop: string; value: string | null };
|
|
1181
|
+
const entry = store.index.get(oid);
|
|
1182
|
+
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1183
|
+
const src = readSettledSource(entry.file, engineRoot);
|
|
1184
|
+
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1185
|
+
// `value: null` is the REMOVAL sentinel, the same shape `/__ui-source/write`
|
|
1186
|
+
// uses for `removeStyle` (D-A3). Removing a prop attribute is how the
|
|
1187
|
+
// inspector reverts a prop to the component's declared default.
|
|
1188
|
+
if (value === null) {
|
|
1189
|
+
const removed = removePropAttribute(src, off, prop);
|
|
1190
|
+
if (removed.changed) writeEditableSource(entry.file, removed.code, engineRoot);
|
|
1191
|
+
return { body: { changed: removed.changed, dynamic: removed.dynamic, file: entry.file } };
|
|
1192
|
+
}
|
|
1193
|
+
const result = writePropChange(src, off, prop, value, {
|
|
1194
|
+
addIfMissing: body['addIfMissing'] === true,
|
|
1195
|
+
// R2: opt-in scalar→tuple shape
|
|
1196
|
+
// upgrade — see writePropChange's own doc comment. Absent/false on every
|
|
1197
|
+
// pre-existing caller, so react-dom prop writes are byte-identical.
|
|
1198
|
+
allowShapeUpgrade: body['allowShapeUpgrade'] === true,
|
|
1199
|
+
});
|
|
1200
|
+
if (result.changed) writeEditableSource(entry.file, result.code, engineRoot);
|
|
1201
|
+
return { body: { changed: result.changed, dynamic: result.dynamic, file: entry.file } };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* POST `/__ui-source/struct` — Cap 5 structural source edits. Ops: `delete`, `duplicate`,
|
|
1206
|
+
* `wrap` (+wrapperTag), `unwrap`, `create` (insert a `<tag/>` child of `oid`),
|
|
1207
|
+
* `create-sibling` (insert the `snippet` immediately AFTER `oid` — how a top-level element
|
|
1208
|
+
* is added to a world whose root JSX is an OID-less fragment), `reorder`
|
|
1209
|
+
* (move `oid` before `targetOid`, or to the end of `parentOid`), and `reparent` (move `oid`
|
|
1210
|
+
* into `parentOid`). An insert may carry `ensureImport` when its snippet names a component.
|
|
1211
|
+
* Multi-oid ops resolve every oid to an offset in the SAME file (a
|
|
1212
|
+
* cross-file move is refused) — OID-resolved, immune to index races.
|
|
1213
|
+
*
|
|
1214
|
+
* D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): when the op actually
|
|
1215
|
+
* changes the file, the response also carries the WHOLE file's `prevSource`/`newSource`
|
|
1216
|
+
* (before/after this write) and their `prevSha`/`newSha` (sha256 digests, computed here —
|
|
1217
|
+
* the client never hashes anything itself). `ReactRootAuthoringAdapter.structOp` uses
|
|
1218
|
+
* these to push a checksum-guarded whole-file-snapshot undo/redo entry — the sound inverse
|
|
1219
|
+
* for a structural op (a per-OID inverse is unsound: OIDs are content-signature keyed,
|
|
1220
|
+
* reorder/delete reassign occurrence indices, and delete has no inverse payload). Exported
|
|
1221
|
+
* (like `handleCss`) so it's directly unit-testable without a running dev server.
|
|
1222
|
+
*
|
|
1223
|
+
* The op switch itself lives in `planSourceEdit` and NOWHERE else. It used to be written
|
|
1224
|
+
* out a second time here, and the two copies drifted the moment `reparent` grew rules of
|
|
1225
|
+
* its own (R1–R4): one caller routed through the planner and got them, this
|
|
1226
|
+
* one did not. Everything that remains below is what is genuinely server-side — the
|
|
1227
|
+
* `node:fs` read/write, sha256, and the response envelope.
|
|
1228
|
+
*/
|
|
1229
|
+
export function handleStruct(
|
|
1230
|
+
store: OidStore,
|
|
1231
|
+
body: Record<string, unknown>,
|
|
1232
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1233
|
+
): HandlerResult {
|
|
1234
|
+
const { oid, op } = body as { oid?: unknown; op?: unknown };
|
|
1235
|
+
if (typeof oid !== 'string' || typeof op !== 'string') {
|
|
1236
|
+
return { body: { changed: false, error: 'struct requests need an oid and an op' } };
|
|
1237
|
+
}
|
|
1238
|
+
const request = { ...body, kind: 'struct', oid, op } as unknown as SourceEditRequest;
|
|
1239
|
+
const resolveOid = (id: string): OidEntry | undefined => store.index.get(id);
|
|
1240
|
+
const sources = new Map<string, string>();
|
|
1241
|
+
for (const file of sourceEditFiles(request, resolveOid)) {
|
|
1242
|
+
sources.set(file, readSettledSource(file, engineRoot));
|
|
1243
|
+
}
|
|
1244
|
+
const plan = planSourceEdit(request, sources, resolveOid);
|
|
1245
|
+
if (plan.file === null) return { body: { changed: false, ...plan.result } };
|
|
1246
|
+
const { changed: _planned, ...detail } = plan.result;
|
|
1247
|
+
if (!plan.changed) return { body: { changed: false, file: plan.file, ...detail } };
|
|
1248
|
+
writeEditableSource(plan.file, plan.newSource, engineRoot);
|
|
1249
|
+
return {
|
|
1250
|
+
body: {
|
|
1251
|
+
changed: true,
|
|
1252
|
+
file: plan.file,
|
|
1253
|
+
...detail,
|
|
1254
|
+
prevSource: plan.prevSource,
|
|
1255
|
+
newSource: plan.newSource,
|
|
1256
|
+
prevSha: sha256(plan.prevSource),
|
|
1257
|
+
newSha: sha256(plan.newSource),
|
|
1258
|
+
},
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* POST `/__ui-source/struct-many` — one source snapshot and one history boundary
|
|
1264
|
+
* for structural operations involving several OIDs. `group` wraps source siblings
|
|
1265
|
+
* beneath an ordinary R3F group. `delete` retains the delete-order-residual fix
|
|
1266
|
+
* (50f90a6d): it resolves EVERY `oid` in `oids` to an offset against ONE shared
|
|
1267
|
+
* `readFileSync` snapshot, then applies them via `deleteElements` (`writer.ts`) —
|
|
1268
|
+
* highest offset first, so
|
|
1269
|
+
* no still-queued target's offset is ever shifted out from under it, REGARDLESS of
|
|
1270
|
+
* what order the caller listed `oids` in. This is what makes the batch immune to the
|
|
1271
|
+
* delete-order-residual: `deleteSelection` (`editor-hotkeys.ts`)'s per-id fallback
|
|
1272
|
+
* loop derives its delete order from the CALLER's own hierarchy walk (DOM order for
|
|
1273
|
+
* `ReactRootAuthoringAdapter` — see `walkOidTree`), which can diverge from true
|
|
1274
|
+
* SOURCE order when a component's live DOM child order is a runtime permutation of
|
|
1275
|
+
* its JSX (e.g. `{[...els].reverse()}`); this endpoint never consults that walk order
|
|
1276
|
+
* at all, only each oid's own registered `{line, col}` and ONE shared file read, so
|
|
1277
|
+
* caller order is irrelevant to correctness. Explicitly NOT a re-scan/re-transform of
|
|
1278
|
+
* the file per oid (see `deleteElements`'s doc comment for why that would be UNSOUND
|
|
1279
|
+
* here — a live re-transform mid-batch reassigns occurrence-index-based oid identity
|
|
1280
|
+
* out from under the very oids this batch is still trying to resolve). One write, one
|
|
1281
|
+
* `prevSource`/`newSource` pair — one undo entry for the whole batch (a bonus over
|
|
1282
|
+
* the N-entry per-id fallback, not the point of this fix, but free from reusing
|
|
1283
|
+
* `pushStructUndo`'s existing whole-file-snapshot inverse unchanged).
|
|
1284
|
+
*/
|
|
1285
|
+
export function handleStructMany(
|
|
1286
|
+
store: OidStore,
|
|
1287
|
+
body: Record<string, unknown>,
|
|
1288
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1289
|
+
): HandlerResult {
|
|
1290
|
+
const { oids, op, wrapperTag } = body as {
|
|
1291
|
+
oids?: unknown;
|
|
1292
|
+
op?: string;
|
|
1293
|
+
wrapperTag?: string;
|
|
1294
|
+
};
|
|
1295
|
+
if (op !== 'delete' && op !== 'group') {
|
|
1296
|
+
return { body: { changed: false, error: `unsupported batch op: ${op}` } };
|
|
1297
|
+
}
|
|
1298
|
+
if (!Array.isArray(oids) || oids.length === 0) {
|
|
1299
|
+
return { body: { changed: false, error: 'oids must be a non-empty array' } };
|
|
1300
|
+
}
|
|
1301
|
+
const uniqueOids = [...new Set(oids as string[])];
|
|
1302
|
+
const entries = uniqueOids.map((oid) => ({ oid, entry: store.index.get(oid) }));
|
|
1303
|
+
const missing = entries.find((e) => !e.entry);
|
|
1304
|
+
if (missing) return { body: { changed: false, error: `unknown oid: ${missing.oid}` } };
|
|
1305
|
+
const file = entries[0]!.entry!.file;
|
|
1306
|
+
const crossFile = entries.find((e) => e.entry!.file !== file);
|
|
1307
|
+
if (crossFile) return { body: { changed: false, error: 'cannot batch-edit across files' } };
|
|
1308
|
+
|
|
1309
|
+
const src = readSettledSource(file, engineRoot); // ONE snapshot every offset below resolves against
|
|
1310
|
+
const offsets = entries.map((e) => lineColToOffset(src, e.entry!.line, e.entry!.col));
|
|
1311
|
+
const result =
|
|
1312
|
+
op === 'group' ? groupSiblingElements(src, offsets, wrapperTag) : deleteElements(src, offsets);
|
|
1313
|
+
if (!result.changed) return { body: { changed: false, file } };
|
|
1314
|
+
const prevSource = src;
|
|
1315
|
+
const newSource = result.code;
|
|
1316
|
+
writeEditableSource(file, newSource, engineRoot);
|
|
1317
|
+
return {
|
|
1318
|
+
body: {
|
|
1319
|
+
changed: true,
|
|
1320
|
+
file,
|
|
1321
|
+
prevSource,
|
|
1322
|
+
newSource,
|
|
1323
|
+
prevSha: sha256(prevSource),
|
|
1324
|
+
newSha: sha256(newSource),
|
|
1325
|
+
},
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* POST `/__ui-source/fork-component` — H7 "Fork Component…" (wave 6).
|
|
1331
|
+
*
|
|
1332
|
+
* The ONE thing this endpoint does that the client cannot: create a file. Every
|
|
1333
|
+
* other `/__ui-source/*` write targets a file the OID index already knows, and
|
|
1334
|
+
* the whole-file `apply`/`restore` pair is checksum-guarded against a file that
|
|
1335
|
+
* exists — there is no create path through the client write seam at all. So the
|
|
1336
|
+
* new module is written HERE with `node:fs`, and the CALLSITE edit is handed
|
|
1337
|
+
* back to the client unwritten, so it can go through project history like every
|
|
1338
|
+
* other source write (the fork is undoable at the callsite; the new file, being
|
|
1339
|
+
* outside history, survives an undo as an inert module nothing imports).
|
|
1340
|
+
*
|
|
1341
|
+
* ORDER AND ATOMICITY. `planComponentFork` computes BOTH edits from both files'
|
|
1342
|
+
* current bytes before anything is written, and refuses — with a sentence — on
|
|
1343
|
+
* every failure it can see (definition not found, tag no longer matches, name
|
|
1344
|
+
* or file collision). Only then is the new file written, and only if nothing is
|
|
1345
|
+
* at that path already: `existsSync` is checked here as well as in the planner,
|
|
1346
|
+
* because the planner reads a directory LISTING the caller supplied and this is
|
|
1347
|
+
* the last moment before the write.
|
|
1348
|
+
*/
|
|
1349
|
+
export function handleForkComponent(
|
|
1350
|
+
store: OidStore,
|
|
1351
|
+
body: Record<string, unknown>,
|
|
1352
|
+
projectRoot = process.cwd(),
|
|
1353
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1354
|
+
): HandlerResult {
|
|
1355
|
+
const { oid, definitionOid, name } = body as {
|
|
1356
|
+
oid?: string;
|
|
1357
|
+
definitionOid?: string;
|
|
1358
|
+
name?: string;
|
|
1359
|
+
};
|
|
1360
|
+
const callsite = typeof oid === 'string' ? store.index.get(oid) : undefined;
|
|
1361
|
+
if (!callsite) return { body: { ok: false, error: 'unknown oid' } };
|
|
1362
|
+
const definition = typeof definitionOid === 'string' ? store.index.get(definitionOid) : undefined;
|
|
1363
|
+
if (!definition) return { body: { ok: false, error: 'unknown definition oid' } };
|
|
1364
|
+
|
|
1365
|
+
const definitionDir = dirname(definition.file);
|
|
1366
|
+
let siblingFiles: string[] = [];
|
|
1367
|
+
try {
|
|
1368
|
+
siblingFiles = readdirSync(definitionDir);
|
|
1369
|
+
} catch {
|
|
1370
|
+
// An unreadable directory is not a reason to refuse: the collision checks
|
|
1371
|
+
// below (and `existsSync`) still hold, they are just less informed.
|
|
1372
|
+
}
|
|
1373
|
+
const planned = planComponentFork({
|
|
1374
|
+
callsiteFile: callsite.file,
|
|
1375
|
+
callsiteSource: readSettledSource(callsite.file, engineRoot),
|
|
1376
|
+
callsiteLine: callsite.line,
|
|
1377
|
+
callsiteCol: callsite.col,
|
|
1378
|
+
definitionFile: definition.file,
|
|
1379
|
+
definitionSource: readSettledSource(definition.file, engineRoot),
|
|
1380
|
+
tag: callsite.tag,
|
|
1381
|
+
nameSeed: typeof name === 'string' ? name : undefined,
|
|
1382
|
+
siblingFiles,
|
|
1383
|
+
});
|
|
1384
|
+
if (!planned.ok) return { body: { ok: false, error: planned.reason } };
|
|
1385
|
+
|
|
1386
|
+
const plan = planned.plan;
|
|
1387
|
+
const newFile = join(definitionDir, plan.newFileName);
|
|
1388
|
+
if (existsSync(newFile)) {
|
|
1389
|
+
return {
|
|
1390
|
+
body: {
|
|
1391
|
+
ok: false,
|
|
1392
|
+
error: `${sourceResourcePath(newFile, projectRoot)} already exists — fork will not overwrite a file.`,
|
|
1393
|
+
},
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
writeEditableSource(newFile, plan.newFileSource, engineRoot);
|
|
1397
|
+
return {
|
|
1398
|
+
body: {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
newName: plan.newName,
|
|
1401
|
+
tag: plan.tag,
|
|
1402
|
+
newFile,
|
|
1403
|
+
newResourcePath: sourceResourcePath(newFile, projectRoot),
|
|
1404
|
+
importSpecifier: plan.importSpecifier,
|
|
1405
|
+
label: plan.historyLabel,
|
|
1406
|
+
callsiteFile: plan.callsiteFile,
|
|
1407
|
+
callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
|
|
1408
|
+
callsitePrevSource: plan.callsitePrevSource,
|
|
1409
|
+
callsiteNewSource: plan.callsiteNewSource,
|
|
1410
|
+
callsitePrevSha: sha256(plan.callsitePrevSource),
|
|
1411
|
+
callsiteNewSha: sha256(plan.callsiteNewSource),
|
|
1412
|
+
},
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* POST `/__ui-source/extract-component` — P1 "Extract Component…".
|
|
1418
|
+
*
|
|
1419
|
+
* The fork route's sibling, and the SECOND file-creating seam on this
|
|
1420
|
+
* middleware (the header on `/__ui-source/fork-component` records why file
|
|
1421
|
+
* creation lives server-side at all). `planComponentExtraction` computes the
|
|
1422
|
+
* two new modules (component + portable CSF story under `src/prefabs/`) and
|
|
1423
|
+
* the callsite edit from current bytes before anything is written, refusing
|
|
1424
|
+
* with a sentence on every failure it can see (subtree gone, lexical capture,
|
|
1425
|
+
* module-local reference, name/file collision). Only then are the new files
|
|
1426
|
+
* written — `existsSync`-guarded here as well as in the planner, because the
|
|
1427
|
+
* planner reads a directory LISTING and this is the last moment before the
|
|
1428
|
+
* write — and the CALLSITE edit is handed back unwritten so the client pushes
|
|
1429
|
+
* it through project history: undo restores the callsite and leaves the two
|
|
1430
|
+
* new files behind as an inert module + story nothing imports.
|
|
1431
|
+
*/
|
|
1432
|
+
export function handleExtractComponent(
|
|
1433
|
+
store: OidStore,
|
|
1434
|
+
body: Record<string, unknown>,
|
|
1435
|
+
projectRoot = process.cwd(),
|
|
1436
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1437
|
+
): HandlerResult {
|
|
1438
|
+
const { oid, name } = body as { oid?: string; name?: string };
|
|
1439
|
+
const entry = typeof oid === 'string' ? store.index.get(oid) : undefined;
|
|
1440
|
+
if (!entry) return { body: { ok: false, error: 'unknown oid' } };
|
|
1441
|
+
|
|
1442
|
+
const prefabDir = join(projectRoot, 'src', 'prefabs');
|
|
1443
|
+
let siblingFiles: string[] = [];
|
|
1444
|
+
try {
|
|
1445
|
+
siblingFiles = readdirSync(prefabDir);
|
|
1446
|
+
} catch {
|
|
1447
|
+
// No prefab folder yet — the write below creates it.
|
|
1448
|
+
}
|
|
1449
|
+
const planned = planComponentExtraction({
|
|
1450
|
+
sourceFile: entry.file,
|
|
1451
|
+
source: readSettledSource(entry.file, engineRoot),
|
|
1452
|
+
line: entry.line,
|
|
1453
|
+
col: entry.col,
|
|
1454
|
+
nameSeed: typeof name === 'string' ? name : undefined,
|
|
1455
|
+
prefabDir,
|
|
1456
|
+
siblingFiles,
|
|
1457
|
+
});
|
|
1458
|
+
if (!planned.ok) return { body: { ok: false, error: planned.reason } };
|
|
1459
|
+
|
|
1460
|
+
const plan = planned.plan;
|
|
1461
|
+
for (const file of [plan.componentFile, plan.storyFile]) {
|
|
1462
|
+
if (existsSync(file)) {
|
|
1463
|
+
return {
|
|
1464
|
+
body: {
|
|
1465
|
+
ok: false,
|
|
1466
|
+
error: `${sourceResourcePath(file, projectRoot)} already exists — extraction will not overwrite a file.`,
|
|
1467
|
+
},
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
mkdirSync(prefabDir, { recursive: true });
|
|
1472
|
+
writeEditableSource(plan.componentFile, plan.componentSource, engineRoot);
|
|
1473
|
+
writeEditableSource(plan.storyFile, plan.storySource, engineRoot);
|
|
1474
|
+
return {
|
|
1475
|
+
body: {
|
|
1476
|
+
ok: true,
|
|
1477
|
+
newName: plan.newName,
|
|
1478
|
+
tag: plan.tag,
|
|
1479
|
+
componentFile: plan.componentFile,
|
|
1480
|
+
componentResourcePath: sourceResourcePath(plan.componentFile, projectRoot),
|
|
1481
|
+
storyFile: plan.storyFile,
|
|
1482
|
+
storyResourcePath: sourceResourcePath(plan.storyFile, projectRoot),
|
|
1483
|
+
importSpecifier: plan.importSpecifier,
|
|
1484
|
+
label: plan.historyLabel,
|
|
1485
|
+
callsiteFile: plan.callsiteFile,
|
|
1486
|
+
callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
|
|
1487
|
+
callsitePrevSource: plan.callsitePrevSource,
|
|
1488
|
+
callsiteNewSource: plan.callsiteNewSource,
|
|
1489
|
+
callsitePrevSha: sha256(plan.callsitePrevSource),
|
|
1490
|
+
callsiteNewSha: sha256(plan.callsiteNewSource),
|
|
1491
|
+
},
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* POST `/__ui-source/csf-story` — the CSF write half (design ledger:
|
|
1497
|
+
* stories composed and framed everywhere, written nowhere): save an arg
|
|
1498
|
+
* set as a new story export, rename one, or delete one. The grammar and
|
|
1499
|
+
* every refusal live in the pure planner (`ui-source/plan-csf-story.ts`);
|
|
1500
|
+
* this handler is scope-guard + read + plan + write.
|
|
1501
|
+
*/
|
|
1502
|
+
export function handleCsfStory(
|
|
1503
|
+
body: Record<string, unknown>,
|
|
1504
|
+
projectRoot = process.cwd(),
|
|
1505
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1506
|
+
): HandlerResult {
|
|
1507
|
+
const { op, file, name, newName, args } = body as {
|
|
1508
|
+
op?: 'save' | 'rename' | 'delete';
|
|
1509
|
+
file?: string;
|
|
1510
|
+
name?: string;
|
|
1511
|
+
newName?: string;
|
|
1512
|
+
args?: Record<string, unknown>;
|
|
1513
|
+
};
|
|
1514
|
+
if (typeof file !== 'string' || typeof name !== 'string' || !op) {
|
|
1515
|
+
return { status: 400, body: { changed: false, error: 'csf-story requires {op, file, name}.' } };
|
|
1516
|
+
}
|
|
1517
|
+
const resolved = resolveEditableSourceFile(file, projectRoot);
|
|
1518
|
+
if (!resolved) {
|
|
1519
|
+
return { status: 403, body: { changed: false, error: 'file out of editable scope' } };
|
|
1520
|
+
}
|
|
1521
|
+
const source = readSettledSource(resolved, engineRoot);
|
|
1522
|
+
const plan =
|
|
1523
|
+
op === 'save'
|
|
1524
|
+
? planSaveStory(source, name, args ?? {})
|
|
1525
|
+
: op === 'rename'
|
|
1526
|
+
? planRenameStory(source, name, typeof newName === 'string' ? newName : '')
|
|
1527
|
+
: planDeleteStory(source, name);
|
|
1528
|
+
if (!plan.ok) return { body: { changed: false, error: plan.reason } };
|
|
1529
|
+
writeEditableSource(resolved, plan.nextSource, engineRoot);
|
|
1530
|
+
return { body: { changed: true, summary: plan.summary, file } };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** A JSX literal style value as CSS TEXT (`'8px'` → `8px`, `8` → `8px` per the
|
|
1534
|
+
* react px-ifying rule in `css-numeric-style.ts`), or null for a literal that
|
|
1535
|
+
* has no CSS reading (a boolean) — such a member stays inline, unmoved. */
|
|
1536
|
+
function cssValueFromJsxLiteral(name: string, raw: string): string | null {
|
|
1537
|
+
const t = raw.trim();
|
|
1538
|
+
if (/^['"`]/.test(t)) return t.slice(1, -1);
|
|
1539
|
+
const num = Number(t);
|
|
1540
|
+
if (Number.isFinite(num)) return cssTextForStyleValue(name, num);
|
|
1541
|
+
return null;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* POST `/__ui-source/named-style` — the design ledger's NAMED STYLES order
|
|
1546
|
+
* (Webflow prior art: a reusable style is a CSS CLASS in the project's own
|
|
1547
|
+
* stylesheet). Three ops on one door:
|
|
1548
|
+
*
|
|
1549
|
+
* - `create` — mint `.className` in the given first-party stylesheet
|
|
1550
|
+
* (`file`, guarded by {@link isEditableCssFile}), MOVING the element's
|
|
1551
|
+
* literal inline declarations into it (dynamic members stay inline — they
|
|
1552
|
+
* are not the writer's to move), and adding the class token to the
|
|
1553
|
+
* element. Both files are planned before either is written, so a refusal
|
|
1554
|
+
* (duplicate class, dynamic className) leaves nothing half-done.
|
|
1555
|
+
* - `apply` / `remove` — add/remove the bare class token on the element.
|
|
1556
|
+
*
|
|
1557
|
+
* Every refusal names its gate; a `className={expr}` is never rewritten.
|
|
1558
|
+
*/
|
|
1559
|
+
export function handleNamedStyle(
|
|
1560
|
+
store: OidStore,
|
|
1561
|
+
body: Record<string, unknown>,
|
|
1562
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1563
|
+
): HandlerResult {
|
|
1564
|
+
const { op, oid, className, file } = body as {
|
|
1565
|
+
op?: 'create' | 'apply' | 'remove';
|
|
1566
|
+
oid?: string;
|
|
1567
|
+
className?: string;
|
|
1568
|
+
file?: string;
|
|
1569
|
+
};
|
|
1570
|
+
if (
|
|
1571
|
+
(op !== 'create' && op !== 'apply' && op !== 'remove') ||
|
|
1572
|
+
typeof oid !== 'string' ||
|
|
1573
|
+
typeof className !== 'string'
|
|
1574
|
+
) {
|
|
1575
|
+
return {
|
|
1576
|
+
status: 400,
|
|
1577
|
+
body: { changed: false, error: 'named-style requires {op, oid, className}.' },
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
const entry = store.index.get(oid);
|
|
1581
|
+
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1582
|
+
const src = readSettledSource(entry.file, engineRoot);
|
|
1583
|
+
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1584
|
+
|
|
1585
|
+
if (op === 'apply' || op === 'remove') {
|
|
1586
|
+
const r = (op === 'apply' ? addClassNameToken : removeClassNameToken)(src, off, className);
|
|
1587
|
+
if (r.dynamic) {
|
|
1588
|
+
return {
|
|
1589
|
+
body: {
|
|
1590
|
+
changed: false,
|
|
1591
|
+
error: "the element's className is a dynamic expression — refusing to rewrite it.",
|
|
1592
|
+
},
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
if (r.changed) writeEditableSource(entry.file, r.code, engineRoot);
|
|
1596
|
+
return { body: { changed: r.changed, file: entry.file } };
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// No stylesheet named ⇒ MINT one beside the element's own module (and make
|
|
1600
|
+
// the module import it — an unimported stylesheet never paints, and a bare
|
|
1601
|
+
// `import './styles.css'` is exactly the ecosystem-native form Vite and the
|
|
1602
|
+
// game's own build both honor). No example or fresh scaffold ships project
|
|
1603
|
+
// CSS today, so without this the whole feature would open on a refusal.
|
|
1604
|
+
const cleanCss =
|
|
1605
|
+
typeof file === 'string'
|
|
1606
|
+
? (file.split('?')[0] ?? file)
|
|
1607
|
+
: join(dirname(entry.file), 'styles.css');
|
|
1608
|
+
if (!isEditableCssFile(cleanCss)) {
|
|
1609
|
+
return { body: { changed: false, error: 'css file out of editable scope' } };
|
|
1610
|
+
}
|
|
1611
|
+
const cssSrc = existsSync(cleanCss) ? readSettledSource(cleanCss, engineRoot) : '';
|
|
1612
|
+
const decls: { prop: string; value: string }[] = [];
|
|
1613
|
+
const moved: string[] = [];
|
|
1614
|
+
for (const member of collectLiteralInlineStyles(src, off)) {
|
|
1615
|
+
const value = cssValueFromJsxLiteral(member.name, member.raw);
|
|
1616
|
+
if (value === null) continue;
|
|
1617
|
+
decls.push({ prop: cssCamelToKebab(member.name), value });
|
|
1618
|
+
moved.push(member.name);
|
|
1619
|
+
}
|
|
1620
|
+
const plan = planCreateClassRule(cssSrc, className, decls);
|
|
1621
|
+
if (!plan.ok) return { body: { changed: false, error: plan.reason } };
|
|
1622
|
+
let code = src;
|
|
1623
|
+
for (const name of moved) code = removeInlineStyle(code, off, name).code;
|
|
1624
|
+
const applied = addClassNameToken(code, off, className);
|
|
1625
|
+
if (applied.dynamic) {
|
|
1626
|
+
return {
|
|
1627
|
+
body: {
|
|
1628
|
+
changed: false,
|
|
1629
|
+
error: "the element's className is a dynamic expression — refusing to rewrite it.",
|
|
1630
|
+
},
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
let elementCode = applied.code;
|
|
1634
|
+
// Bulk removal leaves each moved member's line as bare indentation — collapse
|
|
1635
|
+
// the blank lines inside the opening tag so the authored source stays clean.
|
|
1636
|
+
const tagEnd = findTagEnd(elementCode, off);
|
|
1637
|
+
if (tagEnd > 0) {
|
|
1638
|
+
const seg = elementCode.slice(off, tagEnd);
|
|
1639
|
+
const collapsed = seg.replace(/\n(?:[ \t]*\n)+/g, '\n');
|
|
1640
|
+
elementCode = elementCode.slice(0, off) + collapsed + elementCode.slice(off + seg.length);
|
|
1641
|
+
}
|
|
1642
|
+
const specifier = `./${basename(cleanCss)}`;
|
|
1643
|
+
if (
|
|
1644
|
+
dirname(cleanCss) === dirname(entry.file) &&
|
|
1645
|
+
!new RegExp(`import\\s+['"]\\.?/?${basename(cleanCss).replace('.', '\\.')}['"]`).test(
|
|
1646
|
+
elementCode,
|
|
1647
|
+
)
|
|
1648
|
+
) {
|
|
1649
|
+
elementCode = `import '${specifier}';\n${elementCode}`;
|
|
1650
|
+
}
|
|
1651
|
+
writeEditableSource(cleanCss, plan.nextSource, engineRoot);
|
|
1652
|
+
if (elementCode !== src) writeEditableSource(entry.file, elementCode, engineRoot);
|
|
1653
|
+
return {
|
|
1654
|
+
body: { changed: true, file: cleanCss, elementFile: entry.file, moved, summary: plan.summary },
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* POST `/__ui-source/create-file` — create a NEW project source file. The
|
|
1660
|
+
* pasteboard "materialize" action's door (extraction and fork keep their own
|
|
1661
|
+
* purpose-built routes); creation is the ONLY thing it does — edits go
|
|
1662
|
+
* through the surgical writers, and an existing file is a refusal, never an
|
|
1663
|
+
* overwrite. Same editable-scope guard as every other source route.
|
|
1664
|
+
*/
|
|
1665
|
+
export function handleCreateSourceFile(
|
|
1666
|
+
body: Record<string, unknown>,
|
|
1667
|
+
projectRoot = process.cwd(),
|
|
1668
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1669
|
+
): HandlerResult {
|
|
1670
|
+
const { file, source } = body as { file?: string; source?: string };
|
|
1671
|
+
if (typeof file !== 'string' || typeof source !== 'string') {
|
|
1672
|
+
return {
|
|
1673
|
+
status: 400,
|
|
1674
|
+
body: { created: false, error: 'create-file requires {file, source}.' },
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
const resolved = resolveEditableSourceFile(file, projectRoot);
|
|
1678
|
+
if (!resolved) {
|
|
1679
|
+
return { status: 403, body: { created: false, error: 'file out of editable scope' } };
|
|
1680
|
+
}
|
|
1681
|
+
if (existsSync(resolved)) {
|
|
1682
|
+
return {
|
|
1683
|
+
body: {
|
|
1684
|
+
created: false,
|
|
1685
|
+
error: `${sourceResourcePath(resolved, projectRoot)} already exists — create-file never overwrites.`,
|
|
1686
|
+
},
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
1690
|
+
writeEditableSource(resolved, source, engineRoot);
|
|
1691
|
+
return { body: { created: true, file } };
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* POST `/__ui-source/restore` — D-1 (Phase D, spec27 §2): the checksum-guarded whole-file
|
|
1696
|
+
* restore that makes Cap-5 structural edits (delete/duplicate/wrap/unwrap/reorder/reparent/
|
|
1697
|
+
* create) undoable. `ReactRootAuthoringAdapter.structOp` captures `{prevSource, newSource,
|
|
1698
|
+
* prevSha, newSha}` from a struct write's response (`handleStruct`, above) and, on undo/
|
|
1699
|
+
* redo, posts the INVERSE `{file, source, ifMatchSha}` here: undo sends `source: prevSource,
|
|
1700
|
+
* ifMatchSha: newSha` (the hash of what SHOULD be on disk right now); redo sends the mirror
|
|
1701
|
+
* image. Refuses — WITHOUT writing — when the file's CURRENT sha256 doesn't match
|
|
1702
|
+
* `ifMatchSha` (an out-of-band change since: a hand-edit, a since-diverged reload, …);
|
|
1703
|
+
* `restored: false` is the caller's cue to surface a loud refusal instead of silently
|
|
1704
|
+
* corrupting the file. Scope-guarded the same way `handleCss`'s client-supplied `file` is
|
|
1705
|
+
* (`defaultProjectScopeInclude` — restore targets the SAME `.tsx` project files struct ops
|
|
1706
|
+
* do). Exported (like `handleCss`) so it's directly unit-testable without a dev server.
|
|
1707
|
+
*/
|
|
1708
|
+
export function handleRestore(
|
|
1709
|
+
body: Record<string, unknown>,
|
|
1710
|
+
projectRoot = process.cwd(),
|
|
1711
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1712
|
+
): HandlerResult {
|
|
1713
|
+
const { file, source, ifMatchSha } = body as {
|
|
1714
|
+
file: string;
|
|
1715
|
+
source: string;
|
|
1716
|
+
ifMatchSha: string;
|
|
1717
|
+
};
|
|
1718
|
+
const resolved = file ? resolveEditableSourceFile(file, projectRoot) : null;
|
|
1719
|
+
if (!resolved) {
|
|
1720
|
+
return { status: 403, body: { restored: false, error: 'file out of editable scope' } };
|
|
1721
|
+
}
|
|
1722
|
+
let current: string;
|
|
1723
|
+
try {
|
|
1724
|
+
current = readSettledSource(resolved, engineRoot);
|
|
1725
|
+
} catch (e) {
|
|
1726
|
+
return { status: 404, body: { restored: false, error: String(e) } };
|
|
1727
|
+
}
|
|
1728
|
+
const currentSha = sha256(current);
|
|
1729
|
+
if (currentSha !== ifMatchSha) {
|
|
1730
|
+
return {
|
|
1731
|
+
status: 409,
|
|
1732
|
+
body: {
|
|
1733
|
+
restored: false,
|
|
1734
|
+
error: 'stale: the file changed since this undo/redo entry was recorded',
|
|
1735
|
+
sha: currentSha,
|
|
1736
|
+
},
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
writeEditableSource(resolved, source, engineRoot);
|
|
1740
|
+
return { body: { restored: true, sha: sha256(source) } };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* This plugin's Vite name — the ONE spelling, so a host can ask its own
|
|
1745
|
+
* resolved plugin list whether it serves `/__ui-source/*` instead of declaring
|
|
1746
|
+
* the same fact a second time. See {@link servesUiSourceRoutes}.
|
|
1747
|
+
*/
|
|
1748
|
+
export const UI_OID_PLUGIN_NAME = 'vgai-ui-oid';
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Does this Vite instance serve the `/__ui-source/*` read/write endpoints?
|
|
1752
|
+
*
|
|
1753
|
+
* Read off the RESOLVED plugin list, never declared: an editor host that boots
|
|
1754
|
+
* a Vite instance registers `uiOidPlugin()` or it does not, and that
|
|
1755
|
+
* registration is the only fact there is. A host with no Vite at all has no
|
|
1756
|
+
* plugins and correctly answers `false`.
|
|
1757
|
+
*
|
|
1758
|
+
* This is what `/__editor/project`'s `sourceWrite` reports and what the editor
|
|
1759
|
+
* client's `tier-source-write-backend.ts` reads to decide whether authoring
|
|
1760
|
+
* edits are recorded to the game's own source or are honestly live-only — the
|
|
1761
|
+
* axis that used to be (wrongly) `import.meta.env.DEV`.
|
|
1762
|
+
*/
|
|
1763
|
+
export function servesUiSourceRoutes(plugins: readonly { name: string }[]): boolean {
|
|
1764
|
+
return plugins.some((plugin) => plugin.name === UI_OID_PLUGIN_NAME);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
export function uiOidPlugin(
|
|
1768
|
+
include: RegExp | ((id: string) => boolean) = defaultProjectScopeInclude,
|
|
1769
|
+
projectRoot?: string | (() => string | undefined),
|
|
1770
|
+
/** The vendored ESTATE this plugin's writes are recorded against — see
|
|
1771
|
+
* {@link DEFAULT_ENGINE_ROOT}. A parameter, not a config option: no shipped
|
|
1772
|
+
* caller passes it and none should, it exists so a test can stand up a
|
|
1773
|
+
* synthetic `vendor/games/` instead of editing a real one. */
|
|
1774
|
+
engineRoot: string = DEFAULT_ENGINE_ROOT,
|
|
1775
|
+
): Plugin {
|
|
1776
|
+
const test = typeof include === 'function' ? include : (id: string) => include.test(id);
|
|
1777
|
+
const store = new OidStore();
|
|
1778
|
+
// Captured by `configureServer` below, read by `transform` — the dev
|
|
1779
|
+
// server's own `ModuleGraph` is what lets `declaredSurfaceForProjectFileViaGraph`
|
|
1780
|
+
// reach a root entry's surface through a NON-entry file's importer chain
|
|
1781
|
+
// (a child component with no direct fiber/world3d-react import of its
|
|
1782
|
+
// own). `undefined` in a build/SSR context without `configureServer`
|
|
1783
|
+
// (e.g. a one-off transform in a test) degrades to the direct-entry-only
|
|
1784
|
+
// answer, same as before this existed.
|
|
1785
|
+
let devServer: ViteDevServer | undefined;
|
|
1786
|
+
return {
|
|
1787
|
+
name: UI_OID_PLUGIN_NAME,
|
|
1788
|
+
enforce: 'pre',
|
|
1789
|
+
transform(code, id) {
|
|
1790
|
+
// A VIRTUAL MODULE IS NOT A FILE, and everything below this line treats
|
|
1791
|
+
// `clean` as one — `resolveOidSurface` walks up from it looking for the
|
|
1792
|
+
// owning `vgai.project.json`, and `transformSource` records it as the
|
|
1793
|
+
// stamped file's path. Vite's convention is that a `\0`-prefixed id was
|
|
1794
|
+
// synthesized by a plugin and has no path behind it, so a path plugin
|
|
1795
|
+
// skips it.
|
|
1796
|
+
//
|
|
1797
|
+
// The `test(clean)` below happens to reject today's virtual ids for the
|
|
1798
|
+
// wrong reason (they carry no extension); a synthesized id may legally
|
|
1799
|
+
// end in `.tsx`, and then the extension test passes and a NUL byte
|
|
1800
|
+
// reaches `readFileSync`. That exact throw was measured one layer down
|
|
1801
|
+
// on 2026-09-21 — see `importerFiles` in `server/project-root-surface.ts`
|
|
1802
|
+
// for the failure and what it looked like from the editor.
|
|
1803
|
+
if (id.startsWith('\0')) return null;
|
|
1804
|
+
const clean = id.split('?')[0] ?? id;
|
|
1805
|
+
if (!test(clean)) return null;
|
|
1806
|
+
// W1 — R3F-dialect files get ` userData-oid` (fiber pierces it into
|
|
1807
|
+
// `object.userData.oid`); react-dom files keep ` data-oid` unchanged.
|
|
1808
|
+
// WHICH REGION owns this exact file decides: a declaration the project
|
|
1809
|
+
// made (an `include` glob or a `mounts` entry in `vgai.adapter.ts`), the
|
|
1810
|
+
// manifest root whose `entry` it is, or reach from one through the live
|
|
1811
|
+
// import graph. Nothing is inferred from the file's own bytes; a file
|
|
1812
|
+
// nothing places gets the documented `data-oid` default, said out loud.
|
|
1813
|
+
// The project root is threaded because an INGEST root's sources live
|
|
1814
|
+
// outside its manifest's directory (see `manifestOwning`), so the walk
|
|
1815
|
+
// alone cannot find the project that declares them.
|
|
1816
|
+
const decision = resolveOidSurface(
|
|
1817
|
+
clean,
|
|
1818
|
+
code,
|
|
1819
|
+
importersFromModuleGraph(devServer?.moduleGraph),
|
|
1820
|
+
typeof projectRoot === 'function' ? projectRoot() : projectRoot,
|
|
1821
|
+
);
|
|
1822
|
+
const { code: out, entries } = transformSource(code, clean, store, {
|
|
1823
|
+
attribute: decision.attribute,
|
|
1824
|
+
canvasComponents: decision.surface === 'canvas',
|
|
1825
|
+
importedR3fContracts: importedR3fContracts(code, clean),
|
|
1826
|
+
});
|
|
1827
|
+
// Task #45: a wrong stamp must not be silent. Reported only when this
|
|
1828
|
+
// file actually GOT stamps — a module with no JSX has no selection to
|
|
1829
|
+
// break, so its (irrelevant) attribute is not worth a terminal line.
|
|
1830
|
+
if (entries.length > 0) reportOidSurfaceDiagnostics(decision);
|
|
1831
|
+
return { code: out, map: null };
|
|
1832
|
+
},
|
|
1833
|
+
configureServer(server) {
|
|
1834
|
+
devServer = server;
|
|
1835
|
+
const activeProjectRoot = (): string =>
|
|
1836
|
+
(typeof projectRoot === 'function' ? projectRoot() : projectRoot) ?? server.config.root;
|
|
1837
|
+
// The editor's own writes invalidate the modules they replace — see
|
|
1838
|
+
// `writtenSourceInvalidationGraph` for the measured stale-remount this
|
|
1839
|
+
// closes, and why the watcher is not allowed to be the only path. The
|
|
1840
|
+
// stamp is BOUNDED at editor chrome (see `bindWrittenSourceInvalidation`).
|
|
1841
|
+
bindWrittenSourceInvalidation(
|
|
1842
|
+
server.moduleGraph,
|
|
1843
|
+
editorChromeStampBoundary(engineRoot, activeProjectRoot),
|
|
1844
|
+
);
|
|
1845
|
+
// The Express host may apply express.json() app-wide, consuming the body and
|
|
1846
|
+
// leaving the parsed object on req.body. Prefer that; else read the raw stream.
|
|
1847
|
+
const readJson = async (
|
|
1848
|
+
req: import('node:http').IncomingMessage,
|
|
1849
|
+
): Promise<Record<string, unknown>> => {
|
|
1850
|
+
const pre = (req as unknown as { body?: unknown }).body;
|
|
1851
|
+
if (pre && typeof pre === 'object') return pre as Record<string, unknown>;
|
|
1852
|
+
const chunks: Buffer[] = [];
|
|
1853
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
1854
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
1855
|
+
return raw ? JSON.parse(raw) : {};
|
|
1856
|
+
};
|
|
1857
|
+
const json = (res: import('node:http').ServerResponse, obj: unknown, code = 200): void => {
|
|
1858
|
+
res.statusCode = code;
|
|
1859
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1860
|
+
res.end(JSON.stringify(obj));
|
|
1861
|
+
};
|
|
1862
|
+
const mutationFiles = (body: Record<string, unknown>, result?: HandlerResult): string[] => {
|
|
1863
|
+
const files = new Set<string>();
|
|
1864
|
+
const suppliedFile = body['file'];
|
|
1865
|
+
if (typeof suppliedFile === 'string') files.add(suppliedFile.split('?')[0] ?? suppliedFile);
|
|
1866
|
+
const oid = body['oid'];
|
|
1867
|
+
if (typeof oid === 'string') {
|
|
1868
|
+
const entry = store.index.get(oid);
|
|
1869
|
+
if (entry) files.add(entry.file);
|
|
1870
|
+
}
|
|
1871
|
+
const oids = body['oids'];
|
|
1872
|
+
if (Array.isArray(oids)) {
|
|
1873
|
+
for (const candidate of oids) {
|
|
1874
|
+
if (typeof candidate !== 'string') continue;
|
|
1875
|
+
const entry = store.index.get(candidate);
|
|
1876
|
+
if (entry) files.add(entry.file);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
const response = result?.body as Record<string, unknown> | undefined;
|
|
1880
|
+
for (const key of ['file', 'newFile', 'callsiteFile']) {
|
|
1881
|
+
const file = response?.[key];
|
|
1882
|
+
if (typeof file === 'string') files.add(file);
|
|
1883
|
+
}
|
|
1884
|
+
return [...files];
|
|
1885
|
+
};
|
|
1886
|
+
const resourcePath = (file: string) =>
|
|
1887
|
+
sourceResourcePath(
|
|
1888
|
+
isAbsolute(file) ? file : resolve(activeProjectRoot(), file),
|
|
1889
|
+
activeProjectRoot(),
|
|
1890
|
+
);
|
|
1891
|
+
/**
|
|
1892
|
+
* Record this write with the collaboration session and REPORT THE
|
|
1893
|
+
* REVISION IT PRODUCED.
|
|
1894
|
+
*
|
|
1895
|
+
* The revision is what the next mutation's optimistic-concurrency guard
|
|
1896
|
+
* (`assertSourceMutation`, below) compares against, and the client only
|
|
1897
|
+
* learned it from the collaboration SSE stream — one event loop later.
|
|
1898
|
+
* A gesture that writes the same file TWICE therefore sent a revision
|
|
1899
|
+
* that its own first write had already superseded, and the second write
|
|
1900
|
+
* was rejected: measured on a canvas transform drag, whose `position`
|
|
1901
|
+
* channel is two props (`x` and `y`) by construction, so EVERY drag lost
|
|
1902
|
+
* its whole gesture to the compensation path. An R3F pivot-rotate
|
|
1903
|
+
* (position + rotation) has the same shape.
|
|
1904
|
+
*
|
|
1905
|
+
* Returning it here closes the window without a second channel: the
|
|
1906
|
+
* writer already awaits this response, so adopting the number it carries
|
|
1907
|
+
* is exactly as fresh as the write itself (`source-write-backend.ts`'s
|
|
1908
|
+
* `postJson`).
|
|
1909
|
+
*/
|
|
1910
|
+
const recordEditorMutation = (
|
|
1911
|
+
participantId: string,
|
|
1912
|
+
files: readonly string[],
|
|
1913
|
+
): number | undefined => {
|
|
1914
|
+
const resources = files.map((file) => {
|
|
1915
|
+
const absolute = isAbsolute(file) ? file : resolve(activeProjectRoot(), file);
|
|
1916
|
+
return {
|
|
1917
|
+
path: resourcePath(absolute),
|
|
1918
|
+
sha: existsSync(absolute) ? sha256(readFileSync(absolute, 'utf8')).slice(0, 16) : null,
|
|
1919
|
+
};
|
|
1920
|
+
});
|
|
1921
|
+
if (resources.length === 0) return undefined;
|
|
1922
|
+
const session = collaborationSession(activeProjectRoot());
|
|
1923
|
+
// `null` means the bytes were already recorded (a filesystem observer
|
|
1924
|
+
// won the race) — the session's CURRENT revision is still the honest
|
|
1925
|
+
// answer to "what must the next write expect".
|
|
1926
|
+
return (
|
|
1927
|
+
session.recordSourceMutation({ authorId: participantId, source: 'editor', resources })
|
|
1928
|
+
?.revision ?? session.snapshot().revision
|
|
1929
|
+
);
|
|
1930
|
+
};
|
|
1931
|
+
// Single middleware switching on the URL — avoids connect prefix-matching subtleties.
|
|
1932
|
+
// Each POST endpoint delegates to a small module-level handler (below) so this
|
|
1933
|
+
// dispatcher stays flat; the try/catch + JSON plumbing lives here once.
|
|
1934
|
+
const post = async (
|
|
1935
|
+
req: import('node:http').IncomingMessage,
|
|
1936
|
+
res: import('node:http').ServerResponse,
|
|
1937
|
+
url: string,
|
|
1938
|
+
handler: (body: Record<string, unknown>) => HandlerResult,
|
|
1939
|
+
): Promise<void> => {
|
|
1940
|
+
try {
|
|
1941
|
+
const body = await readJson(req);
|
|
1942
|
+
const claimedParticipantId = body['participantId'];
|
|
1943
|
+
const trustedParticipantId = req.headers['x-vgai-share-participant-id'];
|
|
1944
|
+
const trustedRole = req.headers['x-vgai-share-role'];
|
|
1945
|
+
if (
|
|
1946
|
+
typeof trustedParticipantId === 'string' &&
|
|
1947
|
+
typeof claimedParticipantId === 'string' &&
|
|
1948
|
+
trustedParticipantId !== claimedParticipantId
|
|
1949
|
+
) {
|
|
1950
|
+
throw new Error(
|
|
1951
|
+
'The authenticated share participant does not match the source mutation author.',
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
const participantId =
|
|
1955
|
+
typeof trustedParticipantId === 'string' ? trustedParticipantId : claimedParticipantId;
|
|
1956
|
+
const expectedRevision = body['expectedRevision'];
|
|
1957
|
+
const readOnlySourceRequest =
|
|
1958
|
+
url === '/__ui-source/prepare' || url === '/__ui-source/read';
|
|
1959
|
+
const beforeFiles = mutationFiles(body);
|
|
1960
|
+
if (
|
|
1961
|
+
typeof trustedParticipantId === 'string' &&
|
|
1962
|
+
beforeFiles.some((file) => !isCanonicalProjectSourcePath(activeProjectRoot(), file))
|
|
1963
|
+
) {
|
|
1964
|
+
throw new Error('Shared source path leaves the project root.');
|
|
1965
|
+
}
|
|
1966
|
+
if (!readOnlySourceRequest && beforeFiles.length > 0) {
|
|
1967
|
+
if (typeof participantId !== 'string' || !Number.isInteger(expectedRevision)) {
|
|
1968
|
+
throw new Error('Source mutations require participantId and expectedRevision.');
|
|
1969
|
+
}
|
|
1970
|
+
if (
|
|
1971
|
+
typeof trustedRole === 'string' &&
|
|
1972
|
+
trustedRole !== 'editor' &&
|
|
1973
|
+
trustedRole !== 'terminal' &&
|
|
1974
|
+
trustedRole !== 'maintainer'
|
|
1975
|
+
) {
|
|
1976
|
+
throw new Error(`The ${trustedRole} share role cannot edit source.`);
|
|
1977
|
+
}
|
|
1978
|
+
collaborationSession(activeProjectRoot()).assertSourceMutation(
|
|
1979
|
+
participantId,
|
|
1980
|
+
expectedRevision as number,
|
|
1981
|
+
beforeFiles.map(resourcePath),
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
const result = handler(body);
|
|
1985
|
+
const response = result.body as Record<string, unknown>;
|
|
1986
|
+
const changed =
|
|
1987
|
+
response['changed'] === true ||
|
|
1988
|
+
response['applied'] === true ||
|
|
1989
|
+
response['restored'] === true ||
|
|
1990
|
+
((url === '/__ui-source/fork-component' || url === '/__ui-source/extract-component') &&
|
|
1991
|
+
response['ok'] === true) ||
|
|
1992
|
+
(url === '/__ui-source/create-file' && response['created'] === true) ||
|
|
1993
|
+
(url === '/__ui-source/csf-story' && response['changed'] === true);
|
|
1994
|
+
const revision =
|
|
1995
|
+
!readOnlySourceRequest && changed && typeof participantId === 'string'
|
|
1996
|
+
? recordEditorMutation(participantId, mutationFiles(body, result))
|
|
1997
|
+
: undefined;
|
|
1998
|
+
json(
|
|
1999
|
+
res,
|
|
2000
|
+
revision === undefined ? result.body : { ...response, revision },
|
|
2001
|
+
result.status ?? 200,
|
|
2002
|
+
);
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
json(
|
|
2005
|
+
res,
|
|
2006
|
+
{ changed: false, error: e instanceof Error ? e.message : String(e) },
|
|
2007
|
+
e instanceof CollaborationConflictError ? 409 : 400,
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
// POST route table — keeps the dispatcher flat (one lookup, no per-endpoint branch).
|
|
2012
|
+
const postRoutes: Record<string, (body: Record<string, unknown>) => HandlerResult> = {
|
|
2013
|
+
'/__ui-source/prepare': (b) => handlePrepare(store, b, activeProjectRoot(), engineRoot),
|
|
2014
|
+
'/__ui-source/read': (b) => handleSourceRead(b, activeProjectRoot(), engineRoot),
|
|
2015
|
+
'/__ui-source/apply': (b) => handleSourceApply(b, activeProjectRoot(), engineRoot),
|
|
2016
|
+
'/__ui-source/write': (b) => handleWrite(store, b, activeProjectRoot(), engineRoot),
|
|
2017
|
+
'/__ui-source/css': (b) => handleCss(b, engineRoot),
|
|
2018
|
+
'/__ui-source/text': (b) => handleText(store, b, engineRoot),
|
|
2019
|
+
'/__ui-source/prop': (b) => handleProp(store, b, engineRoot),
|
|
2020
|
+
'/__ui-source/struct': (b) => handleStruct(store, b, engineRoot),
|
|
2021
|
+
'/__ui-source/struct-many': (b) => handleStructMany(store, b, engineRoot),
|
|
2022
|
+
'/__ui-source/create-file': (b) => handleCreateSourceFile(b, activeProjectRoot()),
|
|
2023
|
+
'/__ui-source/csf-story': (b) => handleCsfStory(b, activeProjectRoot()),
|
|
2024
|
+
'/__ui-source/named-style': (b) => handleNamedStyle(store, b, engineRoot),
|
|
2025
|
+
'/__ui-source/fork-component': (b) =>
|
|
2026
|
+
handleForkComponent(store, b, activeProjectRoot(), engineRoot),
|
|
2027
|
+
'/__ui-source/extract-component': (b) =>
|
|
2028
|
+
handleExtractComponent(store, b, activeProjectRoot(), engineRoot),
|
|
2029
|
+
};
|
|
2030
|
+
server.middlewares.use(async (req, res, next) => {
|
|
2031
|
+
const url = (req.url ?? '').split('?')[0] ?? '';
|
|
2032
|
+
if (url === '/__ui-source/index') {
|
|
2033
|
+
// Re-resolve contracts from current project bytes on every index
|
|
2034
|
+
// read — but OFF the request: a cold enrich is a 2.6 s ts.Program
|
|
2035
|
+
// and must not freeze first paint. The handler returns the last
|
|
2036
|
+
// enriched map (or the raw stamp) immediately; see currentOidIndex.
|
|
2037
|
+
//
|
|
2038
|
+
// H5 (wave 4): the R3F
|
|
2039
|
+
// authorability DIAGNOSTICS ride this same response, per entry.
|
|
2040
|
+
// Deliberately not a channel of their own — attaching them to the
|
|
2041
|
+
// payload the R3F adapter's `refreshSourceState()` already fetches is
|
|
2042
|
+
// what makes their freshness structurally identical to the index's,
|
|
2043
|
+
// with no second fetch, no polling loop and no cache to invalidate.
|
|
2044
|
+
|
|
2045
|
+
const indexStarted = Date.now();
|
|
2046
|
+
const index = currentOidIndex(store, activeProjectRoot(), server);
|
|
2047
|
+
res.setHeader('Server-Timing', `index;dur=${Date.now() - indexStarted}`);
|
|
2048
|
+
res.setHeader(
|
|
2049
|
+
'X-Vgai-Oid-Index-Enriched',
|
|
2050
|
+
index === lastEnrichedIndex?.index ? '1' : '0',
|
|
2051
|
+
);
|
|
2052
|
+
if (typeof req.headers['x-vgai-share-participant-id'] !== 'string') {
|
|
2053
|
+
return json(res, index);
|
|
2054
|
+
}
|
|
2055
|
+
return json(
|
|
2056
|
+
res,
|
|
2057
|
+
Object.fromEntries(
|
|
2058
|
+
Object.entries(index).filter(([, value]) => {
|
|
2059
|
+
const file = (value as { file?: unknown } | null)?.file;
|
|
2060
|
+
return (
|
|
2061
|
+
typeof file === 'string' &&
|
|
2062
|
+
isCanonicalProjectSourcePath(activeProjectRoot(), file)
|
|
2063
|
+
);
|
|
2064
|
+
}),
|
|
2065
|
+
),
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
if (url === '/__ui-source/component-props' && req.method === 'GET') {
|
|
2069
|
+
// THE PER-DEFINITION DOOR: the props a prefab DECLARES, asked before
|
|
2070
|
+
// any callsite exists. The index above only knows tags already used
|
|
2071
|
+
// in a file; a drop needs the declaration itself so a REQUIRED enum
|
|
2072
|
+
// prop can be written with its first option instead of nothing
|
|
2073
|
+
// (`<WeaponPickup>` rendered NOTHING until `kind` was set — runhuman
|
|
2074
|
+
// pass 123). Same worker, same checker, one request.
|
|
2075
|
+
const params = new URL(req.url ?? '', 'http://localhost').searchParams;
|
|
2076
|
+
const file = params.get('file') ?? '';
|
|
2077
|
+
const name = params.get('name') ?? '';
|
|
2078
|
+
const root = activeProjectRoot();
|
|
2079
|
+
if (!root || !file || !name) return json(res, { props: [] });
|
|
2080
|
+
const absolute = isAbsolute(file) ? resolve(file) : resolve(root, file);
|
|
2081
|
+
if (
|
|
2082
|
+
!isCanonicalProjectSourcePath(root, absolute) ||
|
|
2083
|
+
resolveEditableSourceFile(absolute, root) === null
|
|
2084
|
+
) {
|
|
2085
|
+
return json(res, { props: [] });
|
|
2086
|
+
}
|
|
2087
|
+
const key = `${absolute}::${name}::definition`;
|
|
2088
|
+
try {
|
|
2089
|
+
const props = await resolveDeclaredProps(root, [
|
|
2090
|
+
{ key, file: absolute, tag: name, definition: true },
|
|
2091
|
+
]);
|
|
2092
|
+
return json(res, { props: props.get(key) ?? [] });
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
return json(res, {
|
|
2095
|
+
props: [],
|
|
2096
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
const handler = req.method === 'POST' ? postRoutes[url] : undefined;
|
|
2101
|
+
if (handler) return post(req, res, url, handler);
|
|
2102
|
+
next();
|
|
2103
|
+
});
|
|
2104
|
+
},
|
|
2105
|
+
};
|
|
2106
|
+
}
|