diff --git a/docker-compose.yml b/docker-compose.yml index 48af458..8e26af2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -300,6 +300,7 @@ services: restart: unless-stopped volumes: - scribus-designs:/data/designs + - rspace-files:/data/files environment: - BRIDGE_SECRET=${SCRIBUS_BRIDGE_SECRET} - BRIDGE_PORT=8765 diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts index b62927b..08766e3 100644 --- a/lib/folk-blender.ts +++ b/lib/folk-blender.ts @@ -49,10 +49,18 @@ const styles = css` background: rgba(255, 255, 255, 0.2); } + .wrapper { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + } + .content { display: flex; flex-direction: column; - height: calc(100% - 36px); + flex: 1; + min-height: 0; overflow: hidden; } @@ -136,6 +144,7 @@ const styles = css` .preview-area { flex: 1; + min-height: 0; overflow: hidden; display: flex; flex-direction: column; @@ -148,11 +157,13 @@ const styles = css` justify-content: center; padding: 12px; overflow: hidden; + min-height: 0; } .render-preview img { max-width: 100%; max-height: 100%; + object-fit: contain; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } @@ -284,6 +295,7 @@ export class FolkBlender extends FolkShape { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); + wrapper.className = "wrapper"; wrapper.innerHTML = html`
diff --git a/lib/folk-design-agent.ts b/lib/folk-design-agent.ts index 1be18bd..b99ac7a 100644 --- a/lib/folk-design-agent.ts +++ b/lib/folk-design-agent.ts @@ -59,10 +59,18 @@ const styles = css` background: rgba(255,255,255,0.2); } + .wrapper { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + } + .content { display: flex; flex-direction: column; - height: calc(100% - 36px); + flex: 1; + min-height: 0; overflow: hidden; } @@ -123,6 +131,7 @@ const styles = css` overflow-y: auto; padding: 12px; font-size: 12px; + min-height: 0; } .step { @@ -234,6 +243,7 @@ export class FolkDesignAgent extends FolkShape { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); + wrapper.className = "wrapper"; wrapper.innerHTML = html`
diff --git a/lib/folk-freecad.ts b/lib/folk-freecad.ts index 95e85a7..b2ad8d5 100644 --- a/lib/folk-freecad.ts +++ b/lib/folk-freecad.ts @@ -49,10 +49,18 @@ const styles = css` background: rgba(255, 255, 255, 0.2); } + .wrapper { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + } + .content { display: flex; flex-direction: column; - height: calc(100% - 36px); + flex: 1; + min-height: 0; overflow: hidden; } @@ -111,11 +119,13 @@ const styles = css` justify-content: center; padding: 12px; overflow: hidden; + min-height: 0; } .preview-area img { max-width: 100%; max-height: 100%; + object-fit: contain; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } @@ -234,6 +244,7 @@ export class FolkFreeCAD extends FolkShape { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); + wrapper.className = "wrapper"; wrapper.innerHTML = html`
diff --git a/lib/folk-kicad.ts b/lib/folk-kicad.ts index a1542d3..12baaab 100644 --- a/lib/folk-kicad.ts +++ b/lib/folk-kicad.ts @@ -49,10 +49,18 @@ const styles = css` background: rgba(255, 255, 255, 0.2); } + .wrapper { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + } + .content { display: flex; flex-direction: column; - height: calc(100% - 36px); + flex: 1; + min-height: 0; overflow: hidden; } @@ -151,10 +159,15 @@ const styles = css` flex: 1; overflow: auto; padding: 12px; + min-height: 0; + display: flex; + flex-direction: column; } .preview-area img { max-width: 100%; + max-height: 100%; + object-fit: contain; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } @@ -294,6 +307,7 @@ export class FolkKiCAD extends FolkShape { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); + wrapper.className = "wrapper"; wrapper.innerHTML = html`
diff --git a/modules/rdesign/design-agent-route.ts b/modules/rdesign/design-agent-route.ts index ffc7c73..642f28f 100644 --- a/modules/rdesign/design-agent-route.ts +++ b/modules/rdesign/design-agent-route.ts @@ -118,16 +118,21 @@ async function generateAndPlaceImage(args: Record): Promise { const data = await res.json() as any; if (!data.url) return { error: "Image generation failed", details: data }; - // Download the image to a local path inside the Scribus container + // Download the image and save to shared volume (rspace-files, mounted in both containers) const imageUrl = data.url; const downloadRes = await fetch(imageUrl, { signal: AbortSignal.timeout(30_000) }); if (!downloadRes.ok) return { error: "Failed to download generated image" }; + const { writeFile, mkdir } = await import("node:fs/promises"); const imageName = `gen_${Date.now()}.png`; - const imagePath = `/data/designs/_generated/${imageName}`; + const imageDir = "/data/files/generated"; + const imagePath = `${imageDir}/${imageName}`; - // Write image to bridge container via a bridge command - // For now, place the frame with the URL reference + await mkdir(imageDir, { recursive: true }); + const imageBytes = Buffer.from(await downloadRes.arrayBuffer()); + await writeFile(imagePath, imageBytes); + + // Place the image frame in Scribus — path is accessible via shared rspace-files volume const placeResult = await bridgeCommand("add_image_frame", { x: args.x, y: args.y, diff --git a/modules/rwallet/lib/data-transform.ts b/modules/rwallet/lib/data-transform.ts index 712f375..a39cd73 100644 --- a/modules/rwallet/lib/data-transform.ts +++ b/modules/rwallet/lib/data-transform.ts @@ -106,14 +106,24 @@ export function txExplorerLink(txHash: string, chainId: string): string { return `${base}/tx/${txHash}`; } +/** Convert raw wei string to human-readable number, preserving precision for large values */ +function weiToHuman(raw: string, decimals: number): number { + try { + const wei = BigInt(raw || "0"); + const divisor = 10n ** BigInt(decimals); + return Number(wei / divisor) + Number(wei % divisor) / Number(divisor); + } catch { + return parseFloat(raw || "0") / Math.pow(10, decimals); + } +} + export function getTransferValue(transfer: any): number { if (transfer.type === "ERC20_TRANSFER" || transfer.transferType === "ERC20_TRANSFER") { const decimals = transfer.tokenInfo?.decimals || transfer.token?.decimals || 18; - const raw = transfer.value || "0"; - return parseFloat(raw) / Math.pow(10, decimals); + return weiToHuman(transfer.value || "0", decimals); } if (transfer.type === "ETHER_TRANSFER" || transfer.transferType === "ETHER_TRANSFER") { - return parseFloat(transfer.value || "0") / 1e18; + return weiToHuman(transfer.value || "0", 18); } return 0; } @@ -134,14 +144,15 @@ const STABLECOINS = new Set([ "GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD", ]); -// Approximate USD prices for major non-stablecoin tokens (updated periodically) +// Approximate USD prices for major non-stablecoin tokens (updated 2026-03-25 from CoinGecko) const NATIVE_APPROX_USD: Record = { - ETH: 2500, WETH: 2500, stETH: 2500, cbETH: 2500, rETH: 2800, wstETH: 2900, - MATIC: 0.40, POL: 0.40, WMATIC: 0.40, + ETH: 2165, WETH: 2165, stETH: 2165, cbETH: 2165, rETH: 2500, wstETH: 2665, + MATIC: 0.20, POL: 0.20, WMATIC: 0.20, BNB: 600, WBNB: 600, - AVAX: 35, WAVAX: 35, + AVAX: 20, WAVAX: 20, xDAI: 1, WXDAI: 1, - CELO: 0.50, GNO: 250, + CELO: 0.30, GNO: 129, + SAFE: 0.10, COW: 0.21, ENS: 6.11, LDO: 0.30, BAL: 0.15, }; export function estimateUSD(value: number, symbol: string): number | null { @@ -217,7 +228,7 @@ export function transformToTimelineData( // Fallback: parse from dataDecoded or direct value if (txTransfers.length === 0) { if (tx.value && tx.value !== "0") { - const val = parseFloat(tx.value) / 1e18; + const val = weiToHuman(tx.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; txTransfers.push({ to: tx.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); } @@ -226,7 +237,7 @@ export function transformToTimelineData( const params = tx.dataDecoded.parameters || []; const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; - const val = parseFloat(rawVal) / 1e18; + const val = weiToHuman(rawVal, 18); txTransfers.push({ to, value: val, symbol: "Token", usd: null }); } @@ -235,14 +246,14 @@ export function transformToTimelineData( if (txsParam?.valueDecoded) { for (const inner of txsParam.valueDecoded) { if (inner.value && inner.value !== "0") { - const val = parseFloat(inner.value) / 1e18; + const val = weiToHuman(inner.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; txTransfers.push({ to: inner.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); } if (inner.dataDecoded?.method === "transfer") { const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; - const val2 = parseFloat(raw2) / 1e18; + const val2 = weiToHuman(raw2, 18); txTransfers.push({ to: to2, value: val2, symbol: "Token", usd: null }); } } @@ -334,7 +345,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain const txLabel = tx.transfers?.[0]?._toLabel; if (tx.value && tx.value !== "0" && tx.to) { - const val = parseFloat(tx.value) / 1e18; + const val = weiToHuman(tx.value, 18); const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH"; const key = `${tx.to.toLowerCase()}:${sym}`; const existing = outflowAgg.get(key) || { to: tx.to, value: 0, symbol: sym, label: txLabel }; @@ -347,7 +358,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; if (to) { - const val = parseFloat(rawVal) / 1e18; + const val = weiToHuman(rawVal, 18); const key = `${to.toLowerCase()}:Token`; const existing = outflowAgg.get(key) || { to, value: 0, symbol: "Token" }; existing.value += val; @@ -360,7 +371,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain if (txsParam?.valueDecoded) { for (const inner of txsParam.valueDecoded) { if (inner.value && inner.value !== "0" && inner.to) { - const val = parseFloat(inner.value) / 1e18; + const val = weiToHuman(inner.value, 18); const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH"; const key = `${inner.to.toLowerCase()}:${sym}`; const existing = outflowAgg.get(key) || { to: inner.to, value: 0, symbol: sym }; @@ -371,7 +382,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; if (to2) { - const val2 = parseFloat(raw2) / 1e18; + const val2 = weiToHuman(raw2, 18); const key = `${to2.toLowerCase()}:Token`; const existing = outflowAgg.get(key) || { to: to2, value: 0, symbol: "Token" }; existing.value += val2; @@ -513,7 +524,7 @@ export function transformToMultichainData( const txLabel = tx.transfers?.[0]?._toLabel; if (tx.value && tx.value !== "0" && tx.to) { - const val = parseFloat(tx.value) / 1e18; + const val = weiToHuman(tx.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; outTransfers.push({ to: tx.to, value: val, symbol: sym, label: txLabel }); } @@ -522,7 +533,7 @@ export function transformToMultichainData( const params = tx.dataDecoded.parameters || []; const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; - if (to) outTransfers.push({ to, value: parseFloat(rawVal) / 1e18, symbol: "Token" }); + if (to) outTransfers.push({ to, value: weiToHuman(rawVal, 18), symbol: "Token" }); } if (tx.dataDecoded?.method === "multiSend") { @@ -530,14 +541,14 @@ export function transformToMultichainData( if (txsParam?.valueDecoded) { for (const inner of txsParam.valueDecoded) { if (inner.value && inner.value !== "0" && inner.to) { - const val = parseFloat(inner.value) / 1e18; + const val = weiToHuman(inner.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; outTransfers.push({ to: inner.to, value: val, symbol: sym }); } if (inner.dataDecoded?.method === "transfer") { const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; - if (to2) outTransfers.push({ to: to2, value: parseFloat(raw2) / 1e18, symbol: "Token" }); + if (to2) outTransfers.push({ to: to2, value: weiToHuman(raw2, 18), symbol: "Token" }); } } } diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index 0f888e5..9c12703 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -213,7 +213,11 @@ export async function enrichWithPrices( if (price === 0) return b; const decimals = b.token?.decimals ?? 18; - const balHuman = Number(balWei) / Math.pow(10, decimals); + // Split BigInt to preserve precision for values > 2^53 + const divisor = 10n ** BigInt(decimals); + const intPart = balWei / divisor; + const fracPart = balWei % divisor; + const balHuman = Number(intPart) + Number(fracPart) / Number(divisor); const fiatValue = balHuman * price; return { diff --git a/modules/rwallet/lib/wallet-viz.ts b/modules/rwallet/lib/wallet-viz.ts index 8983e15..86ba8f9 100644 --- a/modules/rwallet/lib/wallet-viz.ts +++ b/modules/rwallet/lib/wallet-viz.ts @@ -231,7 +231,9 @@ export function renderTimeline( function drawContent(scale: any) { contentGroup.selectAll("*").remove(); - const smoothCurve = d3.curveBasis; + // curveStepAfter: balance is constant between txs, steps at each tx. + // This ensures the river edges align exactly with the waterfall shapes. + const smoothCurve = d3.curveStepAfter; // River glow contentGroup.append("path").datum(balanceData).attr("fill", "rgba(0,212,255,0.08)") diff --git a/server/cad-orchestrator.ts b/server/cad-orchestrator.ts index f83a925..9909746 100644 --- a/server/cad-orchestrator.ts +++ b/server/cad-orchestrator.ts @@ -176,34 +176,44 @@ export function assembleKicadResult(orch: OrchestrationResult): KicadResult { let drcResults: { violations: string[] } | null = null; for (const entry of orch.toolCallLog) { - try { - const parsed = JSON.parse(entry.result); - switch (entry.tool) { - case "export_svg": - // Could be schematic or board SVG — check args or content + const text = entry.result; + + // Try JSON parse first, then fall back to regex path extraction + let parsed: any = null; + try { parsed = JSON.parse(text); } catch {} + + switch (entry.tool) { + case "export_svg": { + const path = parsed?.svg_path || parsed?.path || parsed?.url + || extractPathFromText(text, [".svg"]); + if (path) { if (entry.args.type === "board" || entry.args.board) { - boardSvg = parsed.svg_path || parsed.path || parsed.url || null; + boardSvg = path; } else { - schematicSvg = parsed.svg_path || parsed.path || parsed.url || null; + schematicSvg = path; } - break; - case "run_drc": + } + break; + } + case "run_drc": + if (parsed) { drcResults = { violations: parsed.violations || parsed.errors || [], }; - break; - case "export_gerber": - gerberUrl = parsed.gerber_path || parsed.path || parsed.url || null; - break; - case "export_bom": - bomUrl = parsed.bom_path || parsed.path || parsed.url || null; - break; - case "export_pdf": - pdfUrl = parsed.pdf_path || parsed.path || parsed.url || null; - break; - } - } catch { - // Non-JSON results are fine (intermediate steps) + } + break; + case "export_gerber": + gerberUrl = parsed?.gerber_path || parsed?.path || parsed?.url + || extractPathFromText(text, [".zip", ".gbr"]); + break; + case "export_bom": + bomUrl = parsed?.bom_path || parsed?.path || parsed?.url + || extractPathFromText(text, [".csv", ".json", ".xml"]); + break; + case "export_pdf": + pdfUrl = parsed?.pdf_path || parsed?.path || parsed?.url + || extractPathFromText(text, [".pdf"]); + break; } } @@ -232,37 +242,35 @@ export function assembleFreecadResult(orch: OrchestrationResult): FreecadResult let stepUrl: string | null = null; let stlUrl: string | null = null; + // Scan ALL tool results for file paths (not just execute_python_script) for (const entry of orch.toolCallLog) { - try { - const parsed = JSON.parse(entry.result); - // FreeCAD exports via execute_python_script — look for file paths in results - if (entry.tool === "execute_python_script" || entry.tool === "execute_script") { - const text = entry.result.toLowerCase(); - if (text.includes(".step") || text.includes(".stp")) { - stepUrl = parsed.path || parsed.file_path || extractPathFromText(entry.result, [".step", ".stp"]); - } - if (text.includes(".stl")) { - stlUrl = parsed.path || parsed.file_path || extractPathFromText(entry.result, [".stl"]); - } - } - // save_document may also produce a path - if (entry.tool === "save_document") { - const path = parsed.path || parsed.file_path || null; - if (path && (path.endsWith(".FCStd") || path.endsWith(".fcstd"))) { - // Not directly servable, but note it - } - } - } catch { - // Try extracting paths from raw text - stepUrl = stepUrl || extractPathFromText(entry.result, [".step", ".stp"]); - stlUrl = stlUrl || extractPathFromText(entry.result, [".stl"]); + const text = entry.result; + + // Extract preview image path + if (!previewUrl) { + const pngPath = extractPathFromText(text, [".png", ".jpg", ".jpeg"]); + if (pngPath) previewUrl = pngPath; + } + + // Extract STEP path + if (!stepUrl) { + stepUrl = extractPathFromText(text, [".step", ".stp"]); + } + + // Extract STL path + if (!stlUrl) { + stlUrl = extractPathFromText(text, [".stl"]); } } + // Convert internal paths to servable URLs + // /data/files/generated/... is served at /data/files/generated/... + const toUrl = (p: string | null) => p; // paths are already servable + return { - previewUrl, - stepUrl, - stlUrl, + previewUrl: toUrl(previewUrl), + stepUrl: toUrl(stepUrl), + stlUrl: toUrl(stlUrl), summary: orch.finalMessage, toolCallLog: orch.toolCallLog, }; @@ -314,6 +322,17 @@ Follow this workflow: 5. save_document to save the FreeCAD file 6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-/model.step") 7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-/model.stl") +8. execute_python_script to render a preview PNG (do this last, ignore errors if GUI unavailable): + try: + import FreeCADGui + FreeCADGui.showMainWindow() + view = FreeCADGui.ActiveDocument.ActiveView + view.viewIsometric() + view.fitAll() + view.saveImage("/data/files/generated/freecad-/preview.png", 1024, 1024, "White") + print("/data/files/generated/freecad-/preview.png") + except Exception as e: + print(f"Preview rendering not available: {e}") Important: - Use /data/files/generated/freecad-${Date.now()}/ as the working directory @@ -321,4 +340,7 @@ Important: - For complex shapes, build up from primitives with boolean operations - Wall thickness should be at least 1mm for 3D printing - Always export both STEP (for CAD) and STL (for 3D printing) +- Try to render a preview PNG as the final step — this is displayed to the user +- When exporting files, always print() the FULL absolute path so it can be extracted from output +- Example: after Part.export(...), add print("/data/files/generated/freecad-xxx/model.step") - If a tool call fails, try an alternative approach`; diff --git a/server/community-store.ts b/server/community-store.ts index 0d07729..1bc0f63 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -618,6 +618,10 @@ export function receiveSyncMessage( const peerState = getPeerSyncState(slug, peerId); + // Snapshot server-authoritative fields before sync merge + const prevVisibility = doc.meta?.visibility; + const prevOwnerDID = doc.meta?.ownerDID; + // Apply incoming sync message const result = Automerge.receiveSyncMessage( doc, @@ -625,9 +629,23 @@ export function receiveSyncMessage( message ); - const newDoc = result[0]; + let newDoc = result[0]; const newSyncState = result[1]; + // Pin server-authoritative fields — clients must not overwrite these via sync. + // Visibility and ownership can only be changed through the authenticated API. + if (newDoc !== doc) { + const visChanged = newDoc.meta?.visibility !== prevVisibility; + const ownerChanged = newDoc.meta?.ownerDID !== prevOwnerDID; + if (visChanged || ownerChanged) { + console.warn(`[Store] Sync tried to change authoritative fields in ${slug} — reverting (vis: ${prevVisibility}→${newDoc.meta?.visibility}, owner: ${prevOwnerDID}→${newDoc.meta?.ownerDID})`); + newDoc = Automerge.change(newDoc, 'Pin server-authoritative fields', (d) => { + if (visChanged && prevVisibility) d.meta.visibility = prevVisibility; + if (ownerChanged && prevOwnerDID) d.meta.ownerDID = prevOwnerDID; + }); + } + } + communities.set(slug, newDoc); peerState.syncState = newSyncState; diff --git a/server/index.ts b/server/index.ts index f09ae69..b0451e7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -198,23 +198,37 @@ app.get("/collect.js", async (c) => { // ── Serve generated files from /data/files/generated/ and /api/files/generated/ ── // The /api/ route avoids Cloudflare/Traefik redirecting /data/ paths +const GENERATED_MIME: Record = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", + glb: "model/gltf-binary", gltf: "model/gltf+json", + step: "application/step", stp: "application/step", + stl: "application/sla", fcstd: "application/octet-stream", + svg: "image/svg+xml", pdf: "application/pdf", +}; + function serveGeneratedFile(c: any) { + // Support both flat files and subdirectory paths (e.g. freecad-xxx/model.step) const filename = c.req.param("filename"); - if (!filename || filename.includes("..") || filename.includes("/")) { + const subdir = c.req.param("subdir"); + const relPath = subdir ? `${subdir}/${filename}` : filename; + if (!relPath || relPath.includes("..")) { return c.json({ error: "Invalid filename" }, 400); } const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - const filePath = resolve(dir, filename); + const filePath = resolve(dir, relPath); + // Ensure resolved path stays within generated dir + if (!filePath.startsWith(dir)) return c.json({ error: "Invalid path" }, 400); const file = Bun.file(filePath); return file.exists().then((exists: boolean) => { if (!exists) return c.notFound(); - const ext = filename.split(".").pop() || ""; - const mimeMap: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", glb: "model/gltf-binary", gltf: "model/gltf+json" }; - return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } }); + const ext = filePath.split(".").pop()?.toLowerCase() || ""; + return new Response(file, { headers: { "Content-Type": GENERATED_MIME[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } }); }); } app.get("/data/files/generated/:filename", serveGeneratedFile); +app.get("/data/files/generated/:subdir/:filename", serveGeneratedFile); app.get("/api/files/generated/:filename", serveGeneratedFile); +app.get("/api/files/generated/:subdir/:filename", serveGeneratedFile); // ── Link preview / unfurl API ── const linkPreviewCache = new Map(); @@ -2922,8 +2936,8 @@ async function serveStatic(path: string, url?: URL): Promise { const headers: Record = { "Content-Type": getContentType(path) }; if (url?.searchParams.has("v")) { headers["Cache-Control"] = "public, max-age=31536000, immutable"; - } else if (path.endsWith(".html")) { - // HTML must revalidate so browsers pick up new hashed JS/CSS references + } else if (path.endsWith(".html") || path === "sw.js" || path === "manifest.json") { + // HTML, service worker, and manifest must revalidate every time headers["Cache-Control"] = "no-cache"; } else if (path.startsWith("assets/")) { // Vite content-hashed assets are safe to cache long-term diff --git a/server/shell.ts b/server/shell.ts index c3e5a02..c2457dc 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -278,7 +278,7 @@ export function renderShell(opts: ShellOptions): string { import '/shell.js'; // ── Service worker registration ── if ("serviceWorker" in navigator && location.hostname !== "localhost") { - navigator.serviceWorker.register("/sw.js").catch(() => {}); + navigator.serviceWorker.register("/sw.js?v=4").catch(() => {}); } // ── Install prompt capture ── window.addEventListener("beforeinstallprompt", (e) => { @@ -2113,7 +2113,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {