From d24941b78ac939bf82cb3ca43ab1ef5dfe1dd26f Mon Sep 17 00:00:00 2001 From: Iddo Gino Date: Tue, 4 Aug 2026 20:09:19 -0700 Subject: [PATCH] fix: honor configured base for dev authoring APIs Client /__* fetches now go through a devApiUrl() helper built on import.meta.env.BASE_URL, and server routes are mounted beneath the resolved base (keeping the root mounts for direct probes), so folder mutations, inline editing, notes, comments, assets, and server actions work when OpenSlide is hosted at a subpath. Fixes #392 Claude-Session: https://claude.ai/code/session_01Gu3Kz4JQBFc6kKkAfEoXCq --- .changeset/brave-paths-follow.md | 5 + .../src/app/components/image-placeholder.tsx | 3 +- .../app/components/sidebar/sidebar-footer.tsx | 5 +- .../app/components/style-panel/use-design.ts | 7 +- packages/core/src/app/lib/assets.ts | 19 +- packages/core/src/app/lib/dev-api.test.ts | 76 ++++++++ packages/core/src/app/lib/dev-api.ts | 12 ++ packages/core/src/app/lib/folders.ts | 19 +- .../src/app/lib/inspector/use-comments.ts | 14 +- .../core/src/app/lib/inspector/use-editor.ts | 5 +- .../core/src/app/lib/inspector/use-notes.ts | 3 +- .../core/src/app/lib/use-restart-server.ts | 5 +- packages/core/src/app/routes/slide.tsx | 14 +- packages/core/src/vite/design-plugin.ts | 3 +- packages/core/src/vite/notes-plugin.ts | 3 +- packages/core/src/vite/routes/assets.ts | 8 +- .../core/src/vite/routes/base-routing.test.ts | 181 ++++++++++++++++++ packages/core/src/vite/routes/comments.ts | 3 +- packages/core/src/vite/routes/edit.ts | 3 +- packages/core/src/vite/routes/folders.ts | 3 +- packages/core/src/vite/routes/mount.test.ts | 66 +++++++ packages/core/src/vite/routes/mount.ts | 25 +++ packages/core/src/vite/routes/restart.ts | 5 +- packages/core/src/vite/routes/slides.ts | 3 +- packages/core/src/vite/routes/svgl.ts | 3 +- packages/core/src/vite/routes/update.ts | 5 +- 26 files changed, 445 insertions(+), 53 deletions(-) create mode 100644 .changeset/brave-paths-follow.md create mode 100644 packages/core/src/app/lib/dev-api.test.ts create mode 100644 packages/core/src/app/lib/dev-api.ts create mode 100644 packages/core/src/vite/routes/base-routing.test.ts create mode 100644 packages/core/src/vite/routes/mount.test.ts create mode 100644 packages/core/src/vite/routes/mount.ts diff --git a/.changeset/brave-paths-follow.md b/.changeset/brave-paths-follow.md new file mode 100644 index 000000000..b22ff5ad7 --- /dev/null +++ b/.changeset/brave-paths-follow.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': patch +--- + +Keep dev authoring APIs (folders, editing, notes, comments, assets, server actions) under the configured `base` so they work behind subpath hosting. diff --git a/packages/core/src/app/components/image-placeholder.tsx b/packages/core/src/app/components/image-placeholder.tsx index f27743fba..386ed062f 100644 --- a/packages/core/src/app/components/image-placeholder.tsx +++ b/packages/core/src/app/components/image-placeholder.tsx @@ -1,6 +1,7 @@ import { type CSSProperties, type HTMLAttributes, useRef, useState } from 'react'; import { toast } from 'sonner'; import { uploadWithAutoRename } from '@/lib/assets'; +import { devApiUrl } from '@/lib/dev-api'; import { useLocale } from '@/lib/use-locale'; export type ImagePlaceholderProps = { @@ -206,7 +207,7 @@ function pickImageFile(files: FileList): File | null { async function handleDrop(slideId: string, file: File, line: number, column: number) { const { ok, entry } = await uploadWithAutoRename(slideId, file); if (!ok || !entry) throw new Error('upload failed'); - const res = await fetch('/__edit', { + const res = await fetch(devApiUrl('/__edit'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ diff --git a/packages/core/src/app/components/sidebar/sidebar-footer.tsx b/packages/core/src/app/components/sidebar/sidebar-footer.tsx index 7035dc021..1badf36da 100644 --- a/packages/core/src/app/components/sidebar/sidebar-footer.tsx +++ b/packages/core/src/app/components/sidebar/sidebar-footer.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { devApiUrl } from '@/lib/dev-api'; import { format, useLocale } from '@/lib/use-locale'; import { useRestartServer } from '@/lib/use-restart-server'; @@ -23,7 +24,7 @@ export function SidebarFooter() { useEffect(() => { if (!import.meta.env.DEV) return; let cancelled = false; - fetch('/__update-check') + fetch(devApiUrl('/__update-check')) .then((res) => (res.ok ? (res.json() as Promise) : null)) .then((data) => { if (!cancelled && data?.outdated) setUpdate(data); @@ -43,7 +44,7 @@ export function SidebarFooter() { setUpdateStatus('running'); setOpen(true); try { - const res = await fetch('/__update-package', { method: 'POST' }); + const res = await fetch(devApiUrl('/__update-package'), { method: 'POST' }); if (!res.ok) throw new Error('update failed'); setUpdateStatus('done'); toast.success(t.home.updatePackageDone); diff --git a/packages/core/src/app/components/style-panel/use-design.ts b/packages/core/src/app/components/style-panel/use-design.ts index 8839285a0..ab7eefd45 100644 --- a/packages/core/src/app/components/style-panel/use-design.ts +++ b/packages/core/src/app/components/style-panel/use-design.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { DesignSystem } from '../../lib/design'; +import { devApiUrl } from '../../lib/dev-api'; type FetchedState = { design: DesignSystem | null; @@ -28,7 +29,7 @@ export function useDesign(slideId: string): UseDesignReturn { const id = slideIdRef.current; if (!id) return; try { - const res = await fetch(`/__design?slideId=${encodeURIComponent(id)}`); + const res = await fetch(devApiUrl(`/__design?slideId=${encodeURIComponent(id)}`)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { design: DesignSystem; @@ -55,7 +56,7 @@ export function useDesign(slideId: string): UseDesignReturn { const id = slideIdRef.current; if (!id) return { ok: false, error: 'no slide id' }; try { - const res = await fetch(`/__design?slideId=${encodeURIComponent(id)}`, { + const res = await fetch(devApiUrl(`/__design?slideId=${encodeURIComponent(id)}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ patch }), @@ -87,7 +88,7 @@ export function useDesign(slideId: string): UseDesignReturn { const id = slideIdRef.current; if (!id) return { ok: false, error: 'no slide id' }; try { - const res = await fetch(`/__design/reset?slideId=${encodeURIComponent(id)}`, { + const res = await fetch(devApiUrl(`/__design/reset?slideId=${encodeURIComponent(id)}`), { method: 'POST', }); const body = (await res.json()) as { ok?: boolean; error?: string; design?: DesignSystem }; diff --git a/packages/core/src/app/lib/assets.ts b/packages/core/src/app/lib/assets.ts index 17c9dacda..a0cc6a15c 100644 --- a/packages/core/src/app/lib/assets.ts +++ b/packages/core/src/app/lib/assets.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { devApiUrl } from './dev-api'; export type AssetEntry = { name: string; @@ -13,7 +14,7 @@ export type AssetEntry = { export type UploadOptions = { overwrite?: boolean }; export async function listAssets(slideId: string): Promise { - const res = await fetch(`/__assets/${slideId}`); + const res = await fetch(devApiUrl(`/__assets/${slideId}`)); if (!res.ok) throw new Error(`GET /__assets/${slideId} ${res.status}`); const data = (await res.json()) as { assets?: AssetEntry[] }; return data.assets ?? []; @@ -25,7 +26,7 @@ export async function uploadAsset( opts: UploadOptions = {}, ): Promise { const qs = opts.overwrite ? '?overwrite=1' : ''; - return fetch(`/__assets/${slideId}/${encodeURIComponent(file.name)}${qs}`, { + return fetch(devApiUrl(`/__assets/${slideId}/${encodeURIComponent(file.name)}${qs}`), { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream', @@ -36,7 +37,7 @@ export async function uploadAsset( } async function renameAsset(slideId: string, from: string, to: string): Promise { - return fetch(`/__assets/${slideId}/${encodeURIComponent(from)}`, { + return fetch(devApiUrl(`/__assets/${slideId}/${encodeURIComponent(from)}`), { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: to }), @@ -44,13 +45,13 @@ async function renameAsset(slideId: string, from: string, to: string): Promise { - return fetch(`/__assets/${slideId}/${encodeURIComponent(name)}`, { method: 'DELETE' }); + return fetch(devApiUrl(`/__assets/${slideId}/${encodeURIComponent(name)}`), { method: 'DELETE' }); } export type AssetUsage = { slideId: string; count: number }; export async function listAssetUsages(slideId: string, name: string): Promise { - const res = await fetch(`/__assets/${slideId}/${encodeURIComponent(name)}/usages`); + const res = await fetch(devApiUrl(`/__assets/${slideId}/${encodeURIComponent(name)}/usages`)); if (!res.ok) return []; const data = (await res.json().catch(() => null)) as { usages?: AssetUsage[] } | null; return data?.usages ?? []; @@ -60,7 +61,7 @@ export async function revertAssetUsage( slideId: string, assetPath: string, ): Promise<{ ok: boolean; status: number }> { - const res = await fetch('/__edit/revert-asset', { + const res = await fetch(devApiUrl('/__edit/revert-asset'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slideId, assetPath }), @@ -93,7 +94,7 @@ export async function uploadWithAutoRename( createdAt: body?.createdAt ?? now, mtime: body?.mtime ?? now, mime: body?.mime ?? uploaded.type ?? 'application/octet-stream', - url: body?.url ?? `/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`, + url: body?.url ?? devApiUrl(`/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`), unused: body?.unused ?? false, }; return { ok: true, status: res.status, entry }; @@ -137,7 +138,7 @@ export async function searchSvgl(query: string, signal?: AbortSignal): Promise { diff --git a/packages/core/src/app/lib/dev-api.test.ts b/packages/core/src/app/lib/dev-api.test.ts new file mode 100644 index 000000000..2ea947660 --- /dev/null +++ b/packages/core/src/app/lib/dev-api.test.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { devApiUrl, joinBase } from './dev-api.ts'; + +describe('joinBase', () => { + it('leaves paths untouched for the root base', () => { + expect(joinBase('/', '/__folders')).toBe('/__folders'); + expect(joinBase('/', '/__slides/intro')).toBe('/__slides/intro'); + }); + + it('prefixes paths with a nested base', () => { + expect(joinBase('/my-slides/', '/__folders')).toBe('/my-slides/__folders'); + expect(joinBase('/my-slides/', '/__slides/intro/reorder')).toBe( + '/my-slides/__slides/intro/reorder', + ); + }); + + it('handles a nested base without a trailing slash', () => { + expect(joinBase('/my-slides', '/__folders')).toBe('/my-slides/__folders'); + }); + + it('handles a deeply nested base', () => { + expect(joinBase('/team/decks/', '/__edit')).toBe('/team/decks/__edit'); + }); + + it('falls back to root for empty or relative bases', () => { + expect(joinBase('', '/__folders')).toBe('/__folders'); + expect(joinBase('./', '/__folders')).toBe('/__folders'); + }); + + it('preserves query strings', () => { + expect(joinBase('/my-slides/', '/__design?slideId=a')).toBe('/my-slides/__design?slideId=a'); + }); +}); + +describe('devApiUrl', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns root-absolute URLs when BASE_URL is /', () => { + vi.stubEnv('BASE_URL', '/'); + expect(devApiUrl('/__folders')).toBe('/__folders'); + }); + + it('keeps URLs beneath a configured base', () => { + vi.stubEnv('BASE_URL', '/my-slides/'); + expect(devApiUrl('/__folders')).toBe('/my-slides/__folders'); + expect(devApiUrl('/__edit')).toBe('/my-slides/__edit'); + expect(devApiUrl('/__notes')).toBe('/my-slides/__notes'); + }); +}); + +describe('app sources', () => { + it('never fetch root-absolute /__ dev API URLs directly', async () => { + const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const offenders: string[] = []; + const walk = async (dir: string): Promise => { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.test.ts')) { + const source = await fs.readFile(full, 'utf8'); + if (/fetch\(\s*['"`]\/__/.test(source)) { + offenders.push(path.relative(appRoot, full)); + } + } + } + }; + await walk(appRoot); + expect(offenders, 'route these fetches through devApiUrl() so they honor BASE_URL').toEqual([]); + }); +}); diff --git a/packages/core/src/app/lib/dev-api.ts b/packages/core/src/app/lib/dev-api.ts new file mode 100644 index 000000000..bcc19e0c0 --- /dev/null +++ b/packages/core/src/app/lib/dev-api.ts @@ -0,0 +1,12 @@ +export function joinBase(base: string, path: string): string { + if (!base.startsWith('/')) return path; + const trimmed = base.replace(/\/+$/, ''); + return trimmed + path; +} + +// Dev-server API URLs must stay beneath the configured `base` so authoring +// still works when the app is hosted at a subpath (e.g. behind a reverse +// proxy). Route every `/__*` fetch through here instead of hardcoding paths. +export function devApiUrl(path: string): string { + return joinBase(import.meta.env.BASE_URL ?? '/', path); +} diff --git a/packages/core/src/app/lib/folders.ts b/packages/core/src/app/lib/folders.ts index dc636220f..56b301b09 100644 --- a/packages/core/src/app/lib/folders.ts +++ b/packages/core/src/app/lib/folders.ts @@ -1,5 +1,6 @@ import buildManifest from 'virtual:open-slide/folders'; import { useCallback, useEffect, useState } from 'react'; +import { devApiUrl } from './dev-api'; import type { Folder, FolderIcon, FoldersManifest } from './sdk'; const EMPTY: FoldersManifest = { folders: [], assignments: {} }; @@ -10,7 +11,7 @@ async function getManifest(): Promise { // is no server, so fall back to the bundled snapshot from the virtual // module (populated at build time from slides/.folders.json). if (import.meta.env.DEV) { - const res = await fetch('/__folders'); + const res = await fetch(devApiUrl('/__folders')); if (!res.ok) throw new Error(`GET /__folders ${res.status}`); const raw = (await res.json()) as Partial; return { @@ -25,7 +26,7 @@ async function getManifest(): Promise { } async function patchSlideName(slideId: string, name: string): Promise { - const res = await fetch(`/__slides/${slideId}`, { + const res = await fetch(devApiUrl(`/__slides/${slideId}`), { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), @@ -39,7 +40,7 @@ async function duplicateSlideReq(slideId: string, newId?: string): Promise { - const res = await fetch(`/__slides/${slideId}`, { method: 'DELETE' }); + const res = await fetch(devApiUrl(`/__slides/${slideId}`), { method: 'DELETE' }); if (!res.ok) throw new Error(`DELETE /__slides/${slideId} ${res.status}`); } async function postFolder(name: string, icon: FolderIcon): Promise { - const res = await fetch('/__folders', { + const res = await fetch(devApiUrl('/__folders'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, icon }), @@ -65,7 +66,7 @@ async function patchFolder( id: string, patch: { name?: string; icon?: FolderIcon }, ): Promise { - const res = await fetch(`/__folders/${id}`, { + const res = await fetch(devApiUrl(`/__folders/${id}`), { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch), @@ -75,12 +76,12 @@ async function patchFolder( } async function deleteFolder(id: string): Promise { - const res = await fetch(`/__folders/${id}`, { method: 'DELETE' }); + const res = await fetch(devApiUrl(`/__folders/${id}`), { method: 'DELETE' }); if (!res.ok) throw new Error(`DELETE /__folders/${id} ${res.status}`); } async function putAssign(slideId: string, folderId: string | null): Promise { - const res = await fetch('/__folders/assign', { + const res = await fetch(devApiUrl('/__folders/assign'), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slideId, folderId }), @@ -89,7 +90,7 @@ async function putAssign(slideId: string, folderId: string | null): Promise { - const res = await fetch('/__folders/reorder', { + const res = await fetch(devApiUrl('/__folders/reorder'), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ids }), diff --git a/packages/core/src/app/lib/inspector/use-comments.ts b/packages/core/src/app/lib/inspector/use-comments.ts index 1b523e5fa..f7241bb60 100644 --- a/packages/core/src/app/lib/inspector/use-comments.ts +++ b/packages/core/src/app/lib/inspector/use-comments.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { devApiUrl } from '../dev-api'; export type SlideComment = { id: string; @@ -17,7 +18,7 @@ export function useComments(slideId: string) { const refetch = useCallback(async () => { if (!slideId) return; try { - const res = await fetch(`/__comments?slideId=${encodeURIComponent(slideId)}`); + const res = await fetch(devApiUrl(`/__comments?slideId=${encodeURIComponent(slideId)}`)); if (!res.ok) { setError(`GET /__comments → ${res.status}`); return; @@ -32,7 +33,7 @@ export function useComments(slideId: string) { const add = useCallback( async (line: number, column: number, text: string) => { - const res = await fetch('/__comments/add', { + const res = await fetch(devApiUrl('/__comments/add'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slideId, line, column, text }), @@ -48,9 +49,12 @@ export function useComments(slideId: string) { const remove = useCallback( async (id: string) => { - const res = await fetch(`/__comments/${id}?slideId=${encodeURIComponent(slideId)}`, { - method: 'DELETE', - }); + const res = await fetch( + devApiUrl(`/__comments/${id}?slideId=${encodeURIComponent(slideId)}`), + { + method: 'DELETE', + }, + ); if (!res.ok) throw new Error(`DELETE /__comments/${id} → ${res.status}`); await refetch(); }, diff --git a/packages/core/src/app/lib/inspector/use-editor.ts b/packages/core/src/app/lib/inspector/use-editor.ts index 950a67615..be225e3d0 100644 --- a/packages/core/src/app/lib/inspector/use-editor.ts +++ b/packages/core/src/app/lib/inspector/use-editor.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { devApiUrl } from '../dev-api'; export type EditOp = | { kind: 'set-style'; key: string; value: string | null; prevText?: string } @@ -30,7 +31,7 @@ export class NoOpEditError extends Error { export function useEditor(slideId: string) { const applyEdit = useCallback( async (line: number, column: number, ops: EditOp[]) => { - const res = await fetch('/__edit', { + const res = await fetch(devApiUrl('/__edit'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slideId, line, column, ops }), @@ -52,7 +53,7 @@ export function useEditor(slideId: string) { const applyEdits = useCallback( async (edits: Edit[]): Promise => { if (edits.length === 0) return []; - const res = await fetch('/__edit/batch', { + const res = await fetch(devApiUrl('/__edit/batch'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slideId, edits }), diff --git a/packages/core/src/app/lib/inspector/use-notes.ts b/packages/core/src/app/lib/inspector/use-notes.ts index cd3f8cc0d..ae5896b13 100644 --- a/packages/core/src/app/lib/inspector/use-notes.ts +++ b/packages/core/src/app/lib/inspector/use-notes.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { devApiUrl } from '../dev-api'; export type NoteSaveStatus = | { kind: 'idle' } @@ -59,7 +60,7 @@ export function useNotes(slideId: string, index: number, initial: string | undef inflightRef.current = ctl; setStatus({ kind: 'saving' }); try { - const res = await fetch('/__notes', { + const res = await fetch(devApiUrl('/__notes'), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slideId: target.slideId, index: target.index, text }), diff --git a/packages/core/src/app/lib/use-restart-server.ts b/packages/core/src/app/lib/use-restart-server.ts index 016b31013..635f8a97f 100644 --- a/packages/core/src/app/lib/use-restart-server.ts +++ b/packages/core/src/app/lib/use-restart-server.ts @@ -1,11 +1,12 @@ import { useCallback, useEffect, useSyncExternalStore } from 'react'; import { toast } from 'sonner'; import { useLocale } from '@/lib/use-locale'; +import { devApiUrl } from './dev-api'; type ServerStatus = { executionId: string; canRestart: boolean }; async function fetchServerStatus(): Promise { - const res = await fetch('/__server-status'); + const res = await fetch(devApiUrl('/__server-status')); if (!res.ok) return null; return (await res.json()) as ServerStatus; } @@ -36,7 +37,7 @@ function getSnapshot(): typeof state { async function performRestart(): Promise { const before = await fetchServerStatus(); if (!before) return false; - const res = await fetch('/__restart-server', { method: 'POST' }); + const res = await fetch(devApiUrl('/__restart-server'), { method: 'POST' }); if (!res.ok) return false; // A different executionId means the replacement process is serving. for (let attempt = 0; attempt < 30; attempt++) { diff --git a/packages/core/src/app/routes/slide.tsx b/packages/core/src/app/routes/slide.tsx index 7b729f5be..66cd2a9c3 100644 --- a/packages/core/src/app/routes/slide.tsx +++ b/packages/core/src/app/routes/slide.tsx @@ -60,6 +60,7 @@ import { SlideCanvas } from '../components/slide-canvas'; import { isDeckWarmed, markDeckWarmed, SlidePreloadLayer } from '../components/slide-preload-layer'; import { SlideTransitionLayer } from '../components/slide-transition-layer'; import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail'; +import { devApiUrl } from '../lib/dev-api'; import { exportSlideAsHtml } from '../lib/export-html'; import { exportSlideAsPdf, isSafari } from '../lib/export-pdf'; import { exportSlideAsImagePptx } from '../lib/export-pptx'; @@ -158,7 +159,7 @@ export function Slide() { if (nextIndex !== index) goTo(nextIndex); try { - const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/reorder`, { + const res = await fetch(devApiUrl(`/__slides/${encodeURIComponent(slideId)}/reorder`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ order }), @@ -187,9 +188,12 @@ export function Slide() { if (index > i) goTo(index + 1); try { - const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/pages/${i}/duplicate`, { - method: 'POST', - }); + const res = await fetch( + devApiUrl(`/__slides/${encodeURIComponent(slideId)}/pages/${i}/duplicate`), + { + method: 'POST', + }, + ); if (!res.ok) { const detail = await res.json().catch(() => ({ error: res.statusText })); throw new Error(detail.error ?? `HTTP ${res.status}`); @@ -217,7 +221,7 @@ export function Slide() { } try { - const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/pages/${i}`, { + const res = await fetch(devApiUrl(`/__slides/${encodeURIComponent(slideId)}/pages/${i}`), { method: 'DELETE', }); if (!res.ok) { diff --git a/packages/core/src/vite/design-plugin.ts b/packages/core/src/vite/design-plugin.ts index 1d0aff811..e90330d92 100644 --- a/packages/core/src/vite/design-plugin.ts +++ b/packages/core/src/vite/design-plugin.ts @@ -5,6 +5,7 @@ import { type DesignSystem, defaultDesign } from '../app/lib/design.ts'; import type { AstNode } from '../editing/babel-walk.ts'; import { validateMutationRequest } from '../http/request-guard.ts'; import { json, readBody, resolveSlidePath } from './routes/context.ts'; +import { mountDevRoute } from './routes/mount.ts'; function parseSource(source: string): AstNode | null { try { @@ -340,7 +341,7 @@ export function designPlugin(opts: DesignPluginOptions): Plugin { name: 'open-slide:design', apply: 'serve', configureServer(server: ViteDevServer) { - server.middlewares.use('/__design', async (req, res, next) => { + mountDevRoute(server, '/__design', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; const slideId = url.searchParams.get('slideId') ?? ''; diff --git a/packages/core/src/vite/notes-plugin.ts b/packages/core/src/vite/notes-plugin.ts index 7276a388f..3c3ac9575 100644 --- a/packages/core/src/vite/notes-plugin.ts +++ b/packages/core/src/vite/notes-plugin.ts @@ -5,6 +5,7 @@ import type { Plugin, ViteDevServer } from 'vite'; import { validateMutationRequest } from '../http/request-guard.ts'; import { hasRecentWrite, recordWrite } from './recent-writes.ts'; import { json, readBody, resolveSlidePath } from './routes/context.ts'; +import { mountDevRoute } from './routes/mount.ts'; type NotesBody = { slideId?: string; @@ -170,7 +171,7 @@ export function notesPlugin(opts: NotesPluginOptions): Plugin { return undefined; }, configureServer(server: ViteDevServer) { - server.middlewares.use('/__notes', async (req, res, next) => { + mountDevRoute(server, '/__notes', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; if (method !== 'PUT' || url.pathname !== '/') return next(); diff --git a/packages/core/src/vite/routes/assets.ts b/packages/core/src/vite/routes/assets.ts index 86df04171..acfe7f334 100644 --- a/packages/core/src/vite/routes/assets.ts +++ b/packages/core/src/vite/routes/assets.ts @@ -14,6 +14,7 @@ import { } from '../../files/assets.ts'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { type ApiContext, json, readBody } from './context.ts'; +import { mountDevRoute, withBase } from './mount.ts'; // GET /__assets/:scope list assets in slide or @global // GET /__assets/:scope/:file serve raw asset bytes @@ -23,7 +24,8 @@ import { type ApiContext, json, readBody } from './context.ts'; // GET /__assets/:scope/:file/usages count references export function registerAssetRoutes(server: ViteDevServer, ctx: ApiContext): void { - server.middlewares.use('/__assets', async (req, res, next) => { + const base = server.config.base; + mountDevRoute(server, '/__assets', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; @@ -109,7 +111,7 @@ export function registerAssetRoutes(server: ViteDevServer, ctx: ApiContext): voi createdAt: assetCreatedAt(stat.birthtimeMs, stat.mtimeMs), mtime: stat.mtimeMs, mime: mimeForFilename(name), - url: `/__assets/${slideId}/${encodeURIComponent(name)}`, + url: withBase(base, `/__assets/${slideId}/${encodeURIComponent(name)}`), unused: true, }); } @@ -230,7 +232,7 @@ export function registerAssetRoutes(server: ViteDevServer, ctx: ApiContext): voi createdAt: assetCreatedAt(stat.birthtimeMs, stat.mtimeMs), mtime: stat.mtimeMs, mime: mimeForFilename(filename), - url: `/__assets/${slideId}/${encodeURIComponent(filename)}`, + url: withBase(base, `/__assets/${slideId}/${encodeURIComponent(filename)}`), }); } diff --git a/packages/core/src/vite/routes/base-routing.test.ts b/packages/core/src/vite/routes/base-routing.test.ts new file mode 100644 index 000000000..f44320c91 --- /dev/null +++ b/packages/core/src/vite/routes/base-routing.test.ts @@ -0,0 +1,181 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import type { Connect, ViteDevServer } from 'vite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { designPlugin } from '../design-plugin.ts'; +import { notesPlugin } from '../notes-plugin.ts'; +import { registerAssetRoutes } from './assets.ts'; +import { registerCommentRoutes } from './comments.ts'; +import { type ApiContext, makeContext } from './context.ts'; +import { registerEditRoutes } from './edit.ts'; +import { registerFolderRoutes } from './folders.ts'; +import { registerRestartRoutes } from './restart.ts'; +import { registerSlideRoutes } from './slides.ts'; +import { registerSvglRoutes } from './svgl.ts'; +import { registerUpdateRoutes } from './update.ts'; + +type Mount = { route: string; handler: Connect.NextHandleFunction }; + +// Minimal stand-in for Vite's connect stack: prefix-matches mounted routes +// (stripping the matched prefix like connect does) and falls through to an +// SPA-style HTML response — the behavior the issue's repro observes when a +// request beneath the base misses every dev API route. +function serveMounts(mounts: Mount[]): http.Server { + return http.createServer((req, res) => { + const url = req.url ?? '/'; + const queue = [...mounts]; + const next = (): void => { + const mount = queue.shift(); + if (!mount) { + res.statusCode = 200; + res.setHeader('content-type', 'text/html'); + res.end(''); + return; + } + const { route, handler } = mount; + const boundary = url.length > route.length ? url[route.length] : ''; + if (!url.startsWith(route) || (boundary !== '' && boundary !== '/' && boundary !== '?')) { + next(); + return; + } + req.url = url.slice(route.length); + if (req.url[0] !== '/') req.url = `/${req.url}`; + handler(req, res, next); + }; + next(); + }); +} + +function fakeServer(base: string): { server: ViteDevServer; mounts: Mount[] } { + const mounts: Mount[] = []; + const server = { + config: { base }, + middlewares: { + use: (route: string, handler: Connect.NextHandleFunction) => { + mounts.push({ route, handler }); + }, + }, + } as unknown as ViteDevServer; + return { server, mounts }; +} + +function testContext(): ApiContext { + const ctx = makeContext({ userCwd: os.tmpdir(), coreVersion: '0.0.0' }); + ctx.manifestPath = path.join(os.tmpdir(), 'open-slide-missing', '.folders.json'); + return ctx; +} + +function registerWithBase(base: string): Mount[] { + const { server, mounts } = fakeServer(base); + registerFolderRoutes(server, testContext()); + return mounts; +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +describe('dev API routing under a configured base', () => { + const servers: http.Server[] = []; + + afterEach(async () => { + await Promise.all( + servers.splice(0).map((s) => new Promise((resolve) => s.close(() => resolve()))), + ); + }); + + async function start(base: string): Promise { + const server = serveMounts(registerWithBase(base)); + servers.push(server); + return await listen(server); + } + + it('serves /__folders at root when base is /', async () => { + const origin = await start('/'); + const res = await fetch(`${origin}/__folders`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + expect(await res.json()).toEqual({ folders: [], assignments: {} }); + }); + + it('serves /__folders beneath a nested base instead of falling through to HTML', async () => { + const origin = await start('/my-slides/'); + const res = await fetch(`${origin}/my-slides/__folders`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + expect(await res.json()).toEqual({ folders: [], assignments: {} }); + }); + + it('keeps serving root-mounted /__folders with a nested base for direct probes', async () => { + const origin = await start('/my-slides/'); + const res = await fetch(`${origin}/__folders`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + }); + + it('still falls through to HTML for non-API paths beneath the base', async () => { + const origin = await start('/my-slides/'); + const res = await fetch(`${origin}/my-slides/s/intro`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + }); +}); + +describe('dev API route inventory', () => { + const ROUTES = [ + '/__edit', + '/__comments', + '/__slides', + '/__assets', + '/__svgl', + '/__folders', + '/__update-check', + '/__update-package', + '/__server-status', + '/__restart-server', + '/__design', + '/__notes', + ]; + + function registerAll(base: string): string[] { + const { server, mounts } = fakeServer(base); + const ctx = testContext(); + registerEditRoutes(server, ctx); + registerCommentRoutes(server, ctx); + registerSlideRoutes(server, ctx); + registerAssetRoutes(server, ctx); + registerSvglRoutes(server); + registerFolderRoutes(server, ctx); + registerUpdateRoutes(server, ctx); + registerRestartRoutes(server); + for (const plugin of [ + designPlugin({ userCwd: os.tmpdir() }), + notesPlugin({ userCwd: os.tmpdir() }), + ]) { + const hook = plugin.configureServer; + const fn = typeof hook === 'function' ? hook : hook?.handler; + fn?.(server); + } + return mounts.map((m) => m.route); + } + + it('mounts every dev API route beneath a nested base', () => { + const routes = registerAll('/my-slides/'); + for (const route of ROUTES) { + expect(routes, `expected ${route} beneath the base`).toContain(`/my-slides${route}`); + expect(routes, `expected ${route} at root`).toContain(route); + } + }); + + it('mounts every dev API route once at root for the root base', () => { + const routes = registerAll('/'); + for (const route of ROUTES) { + expect(routes).toContain(route); + } + expect(new Set(routes).size).toBe(routes.length); + }); +}); diff --git a/packages/core/src/vite/routes/comments.ts b/packages/core/src/vite/routes/comments.ts index 1790b3012..6f94a9e4b 100644 --- a/packages/core/src/vite/routes/comments.ts +++ b/packages/core/src/vite/routes/comments.ts @@ -10,6 +10,7 @@ import { } from '../../editing/comments.ts'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { type ApiContext, json, readBody, resolveSlideEntryPath } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // GET /__comments list markers for ?slideId=… // POST /__comments/add add marker { slideId, line, column?, text, hint? } @@ -24,7 +25,7 @@ type AddCommentBody = { }; export function registerCommentRoutes(server: ViteDevServer, ctx: ApiContext): void { - server.middlewares.use('/__comments', async (req, res, next) => { + mountDevRoute(server, '/__comments', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; diff --git a/packages/core/src/vite/routes/edit.ts b/packages/core/src/vite/routes/edit.ts index d12eeac47..f12d53e60 100644 --- a/packages/core/src/vite/routes/edit.ts +++ b/packages/core/src/vite/routes/edit.ts @@ -4,6 +4,7 @@ import { applyEdit, type EditOp } from '../../editing/edit-ops.ts'; import { applyRevertAsset } from '../../editing/revert-asset.ts'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { type ApiContext, json, readBody, resolveSlideEntryPath } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // POST /__edit applyEdit({ slideId, line, column, ops }) // POST /__edit/revert-asset applyRevertAsset({ slideId, assetPath }) @@ -22,7 +23,7 @@ type EditBatchBody = { }; export function registerEditRoutes(server: ViteDevServer, ctx: ApiContext): void { - server.middlewares.use('/__edit', async (req, res, next) => { + mountDevRoute(server, '/__edit', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; if (method !== 'POST') return next(); diff --git a/packages/core/src/vite/routes/folders.ts b/packages/core/src/vite/routes/folders.ts index e54a8ac27..95fe1010a 100644 --- a/packages/core/src/vite/routes/folders.ts +++ b/packages/core/src/vite/routes/folders.ts @@ -12,6 +12,7 @@ import { } from '../../files/folders.ts'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { type ApiContext, json, readBody } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // GET /__folders list manifest // POST /__folders create folder { name, icon } @@ -26,7 +27,7 @@ type AssignFolderBody = { slideId?: unknown; folderId?: unknown }; type ReorderFoldersBody = { ids?: unknown }; export function registerFolderRoutes(server: ViteDevServer, ctx: ApiContext): void { - server.middlewares.use('/__folders', async (req, res, next) => { + mountDevRoute(server, '/__folders', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; diff --git a/packages/core/src/vite/routes/mount.test.ts b/packages/core/src/vite/routes/mount.test.ts new file mode 100644 index 000000000..59e2a72dd --- /dev/null +++ b/packages/core/src/vite/routes/mount.test.ts @@ -0,0 +1,66 @@ +import type { Connect, ViteDevServer } from 'vite'; +import { describe, expect, it, vi } from 'vitest'; +import { devRoutePaths, mountDevRoute, withBase } from './mount.ts'; + +function fakeServer(base: string): { server: ViteDevServer; use: ReturnType } { + const use = vi.fn(); + const server = { config: { base }, middlewares: { use } } as unknown as ViteDevServer; + return { server, use }; +} + +describe('devRoutePaths', () => { + it('mounts only at root for the root base', () => { + expect(devRoutePaths('/', '/__folders')).toEqual(['/__folders']); + }); + + it('mounts beneath the base and at root for a nested base', () => { + expect(devRoutePaths('/my-slides/', '/__folders')).toEqual([ + '/my-slides/__folders', + '/__folders', + ]); + }); + + it('handles a nested base without a trailing slash', () => { + expect(devRoutePaths('/my-slides', '/__edit')).toEqual(['/my-slides/__edit', '/__edit']); + }); + + it('handles a deeply nested base', () => { + expect(devRoutePaths('/team/decks/', '/__notes')).toEqual(['/team/decks/__notes', '/__notes']); + }); + + it('falls back to root for undefined, empty, or relative bases', () => { + expect(devRoutePaths(undefined, '/__folders')).toEqual(['/__folders']); + expect(devRoutePaths('', '/__folders')).toEqual(['/__folders']); + expect(devRoutePaths('./', '/__folders')).toEqual(['/__folders']); + }); +}); + +describe('withBase', () => { + it('returns the path unchanged for the root base', () => { + expect(withBase('/', '/__assets/intro/a.png')).toBe('/__assets/intro/a.png'); + }); + + it('prefixes the path for a nested base', () => { + expect(withBase('/my-slides/', '/__assets/intro/a.png')).toBe( + '/my-slides/__assets/intro/a.png', + ); + }); +}); + +describe('mountDevRoute', () => { + const handler: Connect.NextHandleFunction = (_req, _res, next) => next(); + + it('registers a single mount for the root base', () => { + const { server, use } = fakeServer('/'); + mountDevRoute(server, '/__folders', handler); + expect(use.mock.calls.map((c) => c[0])).toEqual(['/__folders']); + expect(use.mock.calls.every((c) => c[1] === handler)).toBe(true); + }); + + it('registers base-prefixed and root mounts for a nested base', () => { + const { server, use } = fakeServer('/my-slides/'); + mountDevRoute(server, '/__folders', handler); + expect(use.mock.calls.map((c) => c[0])).toEqual(['/my-slides/__folders', '/__folders']); + expect(use.mock.calls.every((c) => c[1] === handler)).toBe(true); + }); +}); diff --git a/packages/core/src/vite/routes/mount.ts b/packages/core/src/vite/routes/mount.ts new file mode 100644 index 000000000..3050764f8 --- /dev/null +++ b/packages/core/src/vite/routes/mount.ts @@ -0,0 +1,25 @@ +import type { Connect, ViteDevServer } from 'vite'; + +export function withBase(base: string | undefined, path: string): string { + const b = base ?? '/'; + if (!b.startsWith('/')) return path; + return b.replace(/\/+$/, '') + path; +} + +// With a nested `base` the client requests dev API routes beneath it, so +// mount there first; keep the root mount so direct probes and pre-base +// clients keep working. +export function devRoutePaths(base: string | undefined, route: string): string[] { + const prefixed = withBase(base, route); + return prefixed === route ? [route] : [prefixed, route]; +} + +export function mountDevRoute( + server: ViteDevServer, + route: string, + handler: Connect.NextHandleFunction, +): void { + for (const path of devRoutePaths(server.config.base, route)) { + server.middlewares.use(path, handler); + } +} diff --git a/packages/core/src/vite/routes/restart.ts b/packages/core/src/vite/routes/restart.ts index bde96ed65..d226bc31b 100644 --- a/packages/core/src/vite/routes/restart.ts +++ b/packages/core/src/vite/routes/restart.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { ViteDevServer } from 'vite'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { json } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // GET /__server-status → { executionId, canRestart } // executionId identifies the current dev-server process; a changed value @@ -20,13 +21,13 @@ function isSupervised(): boolean { } export function registerRestartRoutes(server: ViteDevServer): void { - server.middlewares.use('/__server-status', (req, res, next) => { + mountDevRoute(server, '/__server-status', (req, res, next) => { if ((req.method ?? 'GET') !== 'GET') return next(); res.setHeader('cache-control', 'no-store'); json(res, 200, { executionId, canRestart: isSupervised() }); }); - server.middlewares.use('/__restart-server', (req, res, next) => { + mountDevRoute(server, '/__restart-server', (req, res, next) => { if ((req.method ?? 'GET') !== 'POST') return next(); const guard = validateMutationRequest(req); diff --git a/packages/core/src/vite/routes/slides.ts b/packages/core/src/vite/routes/slides.ts index 0e9ef2c33..742c82ba1 100644 --- a/packages/core/src/vite/routes/slides.ts +++ b/packages/core/src/vite/routes/slides.ts @@ -17,6 +17,7 @@ import { import { readManifest, writeManifest } from '../../files/folders.ts'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { type ApiContext, json, readBody } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // PUT /__slides/:id/reorder reorder pages { order: number[] } // DELETE /__slides/:id/pages/:i remove page @@ -29,7 +30,7 @@ type DuplicateSlideBody = { newId?: unknown }; type SlidePatchBody = { name?: unknown }; export function registerSlideRoutes(server: ViteDevServer, ctx: ApiContext): void { - server.middlewares.use('/__slides', async (req, res, next) => { + mountDevRoute(server, '/__slides', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; diff --git a/packages/core/src/vite/routes/svgl.ts b/packages/core/src/vite/routes/svgl.ts index 522088289..443ff8f97 100644 --- a/packages/core/src/vite/routes/svgl.ts +++ b/packages/core/src/vite/routes/svgl.ts @@ -1,11 +1,12 @@ import type { ViteDevServer } from 'vite'; import { json } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // GET /__svgl/search?q=&limit= proxy https://api.svgl.app/?search=… // GET /__svgl/svg?u=… proxy raw svg from svgl.app (https only) export function registerSvglRoutes(server: ViteDevServer): void { - server.middlewares.use('/__svgl', async (req, res, next) => { + mountDevRoute(server, '/__svgl', async (req, res, next) => { const reqUrl = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; if (method !== 'GET') return next(); diff --git a/packages/core/src/vite/routes/update.ts b/packages/core/src/vite/routes/update.ts index feac97a68..a6505355b 100644 --- a/packages/core/src/vite/routes/update.ts +++ b/packages/core/src/vite/routes/update.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import type { ViteDevServer } from 'vite'; import { validateMutationRequest } from '../../http/request-guard.ts'; import { type ApiContext, json } from './context.ts'; +import { mountDevRoute } from './mount.ts'; // GET /__update-check → { current, latest, outdated } // Compares the running @open-slide/core version against the npm `latest` @@ -162,7 +163,7 @@ async function updatePackage(ctx: ApiContext): Promise { } export function registerUpdateRoutes(server: ViteDevServer, ctx: ApiContext): void { - server.middlewares.use('/__update-check', async (req, res, next) => { + mountDevRoute(server, '/__update-check', async (req, res, next) => { if ((req.method ?? 'GET') !== 'GET') return next(); const latest = await fetchLatest(Date.now()); const result: CheckResult = { @@ -174,7 +175,7 @@ export function registerUpdateRoutes(server: ViteDevServer, ctx: ApiContext): vo json(res, 200, result); }); - server.middlewares.use('/__update-package', async (req, res, next) => { + mountDevRoute(server, '/__update-package', async (req, res, next) => { if ((req.method ?? 'GET') !== 'POST') return next(); const guard = validateMutationRequest(req);