From 29fecbf548fe4ac2837a52e6dab421922d13f5be Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:03:36 +0200 Subject: [PATCH] fix(studio): export from editor source without ?script= POST current editor code to /__kernelcad/export so STL/STEP/DXF/3MF/GLB work in browser Studio without a curated script deep-link, and reflect live edits. Vite middleware accepts POST { source }; GET ?script= remains. --- src/studio/__tests__/tabs/ExportTab.test.tsx | 64 ++++++++++++++++-- src/studio/api/apiBase.ts | 4 +- src/studio/tabs/ExportTab.tsx | 37 +++++++---- vite.config.ts | 68 +++++++++++++++----- 4 files changed, 139 insertions(+), 34 deletions(-) diff --git a/src/studio/__tests__/tabs/ExportTab.test.tsx b/src/studio/__tests__/tabs/ExportTab.test.tsx index d6c936bbf..d3ab1ffb6 100644 --- a/src/studio/__tests__/tabs/ExportTab.test.tsx +++ b/src/studio/__tests__/tabs/ExportTab.test.tsx @@ -14,10 +14,16 @@ let recompute: StudioRecomputeResult = { recomputeMs: 0, }; +const mockCode = { code: 'return box(10, 10, 10);' }; + vi.mock('../../hooks/useRecomputeResult', () => ({ useRecomputeResult: () => recompute, })); +vi.mock('../../context/CodeContext', () => ({ + useCode: () => mockCode, +})); + // S1: ExportTab now routes through the apiBase helper, which calls // supabase.auth.getSession(). Stub the Supabase client so the test stays // behavior-equivalent to today (unsigned-in → relative URL). @@ -36,6 +42,7 @@ beforeEach(() => { diagnostics: [], recomputeMs: 0, }; + mockCode.code = 'return box(10, 10, 10);'; Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, search: '?script=examples/foo.kcad.ts' }, @@ -165,23 +172,50 @@ describe('ExportTab', () => { HTMLAnchorElement.prototype.click = originalClick; }); - it('shows an error when the script param is missing', async () => { + it('POSTs current editor source when the script param is missing', async () => { Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, search: '' }, }); recompute = { ...recompute, geometries: [{ faces: [] }] }; + mockCode.code = 'return cylinder(5, 20);'; + + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'model/stl' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: async () => blob, + headers: new Headers({ 'content-disposition': 'attachment; filename="studio-export.stl"' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const clickSpy = vi.fn(); + const originalClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = clickSpy; const { ExportTab } = await import('../../tabs/ExportTab'); render(); fireEvent.click(screen.getByTestId('export-stl')); + await waitFor(() => { - expect(screen.getByTestId('export-tab-error')).toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); + expect(fetchMock).toHaveBeenCalledWith( + '/__kernelcad/export?format=stl', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ source: 'return cylinder(5, 20);' }), + }), + ); + expect(clickSpy).toHaveBeenCalled(); + expect(screen.queryByTestId('export-tab-error')).toBeNull(); + + HTMLAnchorElement.prototype.click = originalClick; }); - it('fetches the export endpoint with the right query and triggers a download', async () => { + it('POSTs editor source even when a ?script= param is present (exports live edits)', async () => { recompute = { ...recompute, geometries: [{ faces: [] }] }; + mockCode.code = 'return box(1, 2, 3); // edited'; const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'model/stl' }); const fetchMock = vi.fn().mockResolvedValue({ @@ -204,11 +238,31 @@ describe('ExportTab', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); expect(fetchMock).toHaveBeenCalledWith( - '/__kernelcad/export?script=examples%2Ffoo.kcad.ts&format=stl', - expect.objectContaining({ headers: {} }), + '/__kernelcad/export?format=stl', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ source: 'return box(1, 2, 3); // edited' }), + }), ); expect(clickSpy).toHaveBeenCalled(); HTMLAnchorElement.prototype.click = originalClick; }); + + it('shows an error when the editor has no source', async () => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, search: '' }, + }); + recompute = { ...recompute, geometries: [{ faces: [] }] }; + mockCode.code = ' '; + + const { ExportTab } = await import('../../tabs/ExportTab'); + render(); + fireEvent.click(screen.getByTestId('export-stl')); + await waitFor(() => { + expect(screen.getByTestId('export-tab-error')).toBeDefined(); + }); + expect(screen.getByTestId('export-tab-error').textContent).toMatch(/script source/i); + }); }); diff --git a/src/studio/api/apiBase.ts b/src/studio/api/apiBase.ts index 8873020c2..8ee1fa298 100644 --- a/src/studio/api/apiBase.ts +++ b/src/studio/api/apiBase.ts @@ -15,8 +15,8 @@ // - Hosted: `base = VITE_API_BASE_URL` (= https://api.kernelcad.com, the Hetzner // backend, direct origin — NOT CF-proxied). The `/__kernelcad` prefix is KEPT, // because that is exactly where the backend mounts every route (mesh, session, -// params, transforms, events, animation-bake, source). Signed-in calls add the -// Supabase JWT as a bearer header. +// params, transforms, events, animation-bake, source, export). Signed-in calls +// add the Supabase JWT as a bearer header. // // History / why no `/api/v1`: an earlier draft sent signed-in calls to // `app.kernelcad.com/api/v1/*` and stripped the `/__kernelcad` prefix, relying diff --git a/src/studio/tabs/ExportTab.tsx b/src/studio/tabs/ExportTab.tsx index 7bc365e23..c5c4b4b39 100644 --- a/src/studio/tabs/ExportTab.tsx +++ b/src/studio/tabs/ExportTab.tsx @@ -3,7 +3,9 @@ import { useCallback, useState } from 'react'; import { Download, Loader2 } from 'lucide-react'; import { useRecomputeResult } from '../hooks/useRecomputeResult'; +import { useCode } from '../context/CodeContext'; import { apiCall, rewritePath } from '../api/apiBase'; +import { shouldUseHostedMesh } from '../scriptSource'; import type { JSX } from 'react'; // Studio Export tab. Slice 1.4 + Slice A export-trio. @@ -14,6 +16,10 @@ import type { JSX } from 'react'; // targets: stl, step, dxf, 3mf, glb. The middleware threads `format` // verbatim through to runAndExport. // +// Export always POSTs the current editor source so it works without a +// `?script=` deep-link and reflects live edits (not just the on-disk +// example). The server accepts the same body shape as POST /__kernelcad/mesh. +// // Visibility is adaptive: ExportTab is only rendered by Inspector when // the recompute result has at least one geometry. See // src/studio/logic/adaptiveTabs.ts. DXF additionally requires at least @@ -38,13 +44,9 @@ const FORMATS: ReadonlyArray = [ { id: 'glb', label: 'GLB', help: 'Web / AR viewer; PBR materials' }, ]; -function getCurrentScriptParam(): string | null { - if (typeof window === 'undefined') return null; - return new URLSearchParams(window.location.search).get('script'); -} - export function ExportTab(): JSX.Element { const { geometries } = useRecomputeResult(); + const { code } = useCode(); const [pending, setPending] = useState(null); const [error, setError] = useState(null); @@ -60,19 +62,30 @@ export function ExportTab(): JSX.Element { const handleExport = useCallback(async (format: ExportFormat) => { setError(null); - const script = getCurrentScriptParam(); - if (!script) { - setError('Export requires the studio to be loaded from a script URL (?script=…).'); + const source = code.trim(); + if (!source) { + setError('Export requires script source in the editor.'); return; } setPending(format); try { const { base, headers } = await apiCall(); + // Hosted static app has no same-origin /__kernelcad/* middleware. + // meshSourceHosted uses VITE_API_BASE_URL even when unsigned-in; + // mirror that so Export works without a Supabase session. + const effectiveBase = base + || (shouldUseHostedMesh() + ? (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '' + : ''); const url = rewritePath( - `/__kernelcad/export?script=${encodeURIComponent(script)}&format=${format}`, - base, + `/__kernelcad/export?format=${format}`, + effectiveBase, ); - const response = await fetch(url, { headers }); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ source }), + }); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(typeof payload?.error === 'string' ? payload.error : response.statusText); @@ -96,7 +109,7 @@ export function ExportTab(): JSX.Element { } finally { setPending(null); } - }, []); + }, [code]); if (geometries.length === 0) { return ( diff --git a/vite.config.ts b/vite.config.ts index c056ac6d8..fb7eb52c2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -390,14 +390,7 @@ function kernelCadMeshEndpoint(): Plugin { server.middlewares.use('/__kernelcad/export', async (req, res) => { try { const url = new URL(req.url ?? '', 'http://localhost'); - const scriptPath = resolveExampleScript(url.searchParams.get('script')); const formatParam = url.searchParams.get('format'); - if (!scriptPath) { - res.statusCode = 400; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ error: 'script must be a repo examples/*.kcad.ts file' })); - return; - } // Slice A export-trio: widened from {stl, step} to the five-format // set runAndExport now dispatches. The reserved urdf/srdf/sdf-gazebo // slots intentionally stay out of the Studio UI — they ship in a @@ -415,18 +408,63 @@ function kernelCadMeshEndpoint(): Plugin { return; } - const [{ readFile }, { runAndExport }, { dirname, basename }] = await Promise.all([ - import('node:fs/promises'), - import('./src/agent/script-runtime/export'), - import('node:path'), - ]); - const code = await readFile(scriptPath, 'utf-8'); - const fileName = basename(scriptPath); + // POST { source } exports ARBITRARY edited editor code (Studio + // without ?script=, or live edits). GET ?script= keeps the + // curated-example path for deep-links / tooling. + const isPost = (req.method ?? 'GET').toUpperCase() === 'POST'; + let code: string; + let fileName: string; + let scriptDir: string; + + if (isPost) { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + let parsedBody: { source?: unknown }; + try { + parsedBody = JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}'); + } catch { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'POST body must be JSON { source: string }' })); + return; + } + if (typeof parsedBody.source !== 'string' || parsedBody.source.trim().length === 0) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'POST body must include a non-empty "source" string' })); + return; + } + code = parsedBody.source; + fileName = 'studio-export.kcad.ts'; + // Relative asset paths resolve the same way as mesh POST. + scriptDir = resolve(repoRoot, 'examples'); + } else { + const scriptPath = resolveExampleScript(url.searchParams.get('script')); + if (!scriptPath) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + error: 'missing script query parameter (or POST { source })', + })); + return; + } + const [{ readFile }, { dirname, basename }] = await Promise.all([ + import('node:fs/promises'), + import('node:path'), + ]); + code = await readFile(scriptPath, 'utf-8'); + fileName = basename(scriptPath); + scriptDir = dirname(scriptPath); + } + + ensureOcctShims(); + + const { runAndExport } = await import('./src/agent/script-runtime/export'); const result = await runAndExport({ code, fileName, format: formatParam as StudioFormat, - scriptDir: dirname(scriptPath), + scriptDir, }); if (result.bytes.length === 0) {