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