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) {