/** * Pure URL vocabulary of the /sidebar/html route (HTML previewer). * * Why path-encoded parameters instead of a query string: the previewed * page resolves its relative assets (./style.css, img/x.png) against the * document URL, and the WHATWG URL algorithm DROPS the query of a * path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css` * would lose the session scope and the route would reject the asset. * Encoding everything into the URL path keeps relative resolution inside * the same route with every request self-contained: * * /sidebar/html// * /sidebar/html/S/Users/me/proj/index.html * + ./style.css → /sidebar/html/S/Users/me/proj/style.css * Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html * Windows UNC (windowsPathStyle): \\server\share\proj\a.html * → /sidebar/html/S//server/share/proj/a.html ('//' right after the * sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so * relative assets still resolve inside the same route) * * The `windowsPathStyle` flag is the platform guard: a leading `//` is a * legitimate POSIX absolute path, so the UNC marker is only emitted for * sessions whose cwd shape identifies them as Windows (see the client's * isWindowsStylePath) — otherwise a POSIX `//server/share/...` would decode * back to a backslash UNC form the host then rejects. * * This module is intentionally dependency-free (no node imports, no wire * helpers) so the client bundle can import `encodeHtmlUrl` without tripping * the build-time purity gate; the host converts decode failures into * SidebarError responses at the route boundary. */ /** One decoded route reference. */ export interface HtmlRouteRef { sessionId: string /** Absolute file path (leading slash; Windows drives keep their colon). */ path: string } /** Decode outcome: the reference, or a client-error description. */ export type HtmlDecodeResult = | { ok: true; ref: HtmlRouteRef } | { ok: false; status: 400 | 404; message: string } /** The route prefix both encoders/decoders agree on. */ export const HTML_ROUTE_PREFIX = '/sidebar/html/' /** * Build the route URL for one absolute file path (client + tests). * @param windowsPathStyle - whether the path belongs to a Windows-style * session (drive or UNC). Only then is a `\\`/`//`-prefixed UNC path marked * with the '//' marker; POSIX sessions keep `//` as an ordinary absolute * path (see the module comment for the platform guard rationale). */ export function encodeHtmlUrl(sessionId: string, path: string, windowsPathStyle = false): string { const unc = windowsPathStyle && /^[\\/]{2}[^\\/]/.test(path) const segments = path.split(/[\\/]+/).filter(segment => segment !== '') return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}` } /** * Decode a route pathname into the session + absolute file path. Rejects * a wrong prefix (404), an empty path, malformed percent encoding, and a * missing sessionId or file path (400). The caller still must bound the * decoded path with requireAbsolute + isWithin(cwd) — a decoded `..` * segment resolves outside the cwd and is refused there. */ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult { if (!pathname.startsWith(HTML_ROUTE_PREFIX)) { return { ok: false, status: 404, message: 'not an html route' } } const rest = pathname.slice(HTML_ROUTE_PREFIX.length) if (rest === '') { return { ok: false, status: 400, message: 'invalid html route path' } } let segments: string[] try { segments = rest.split('/').map(segment => decodeURIComponent(segment)) } catch { return { ok: false, status: 400, message: 'malformed URL encoding' } } const [sessionId, ...pathSegments] = segments if (sessionId === undefined || sessionId === '') { return { ok: false, status: 400, message: 'sessionId and file path are required' } } // An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits // '//server/share/...' for Windows UNC paths); the encoder filters // empty segments everywhere else, so an empty segment can only be the // marker or a malformed URL — both handled here. const unc = pathSegments[0] === '' const tail = unc ? pathSegments.slice(1) : pathSegments if (tail.length === 0 || tail.some(segment => segment === '')) { return { ok: false, status: 400, message: 'sessionId and file path are required' } } let path: string if (unc) { // Rebuild the UNC form \\server\share\... so requireAbsolute() + // isWithin(cwd) see the same path the explorer works with. path = `\\\\${tail.join('\\')}` } else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) { // A Windows drive segment ('D:') is the FIRST path segment of an encoded // drive path. Rejoining it with a leading slash would yield '/D:/work/...' // which node's path.resolve() mangles into 'D:\D:\work\...' on Windows — // the html route's isWithin(cwd) fence would then reject every drive path. // Keep the drive form slash-free so requireAbsolute() resolves it verbatim. path = tail.join('/') } else { path = `/${tail.join('/')}` } return { ok: true, ref: { sessionId, path } } }