/**
* 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
* UNC (\\server\share\... or //server/share/...):
* → /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 decoder rebuilds the marker as a forward-slash `//server/share/...`
* path. That form is intentionally platform-neutral: `node:path` resolves it
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
* host's existing requireAbsolute + isWithin fence needs no platform signal
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
* either platform).
*
* 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). */
export function encodeHtmlUrl(sessionId: string, path: string): string {
const unc = /^[\\/]{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 the workspace real-path guard — 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 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 platform-neutral forward-slash form `//server/share/...`;
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
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 workspace 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 } }
}