client-modules: a batch that already executed is never requested again, even when its first importer was a row it did register — every executed batch URL is remembered and a still-missing row goes straight to its one-resource URL. The in-process module system test in test-support is updated to the aggregated arrival message. describeError replaces three inline non-Error ternaries and is covered. subprocess-local: the spill path is published only after the exclusive open succeeds, so a failed open (EEXIST on a planted entry included) can no longer unlink a path this process did not create; a reporter that throws is contained and written to stderr; logSpillFailure builds the owner reporter for both the runtime and the SSH helper so the logging path is exercised once instead of duplicated. Tests cover the runtime logger, the bare-caller stderr default, EEXIST, and a throwing reporter. desktop: a fatal failure during shutdown still writes its report; the report phase follows the backend state so a failure during a restart is 'startup' again; writeReport is a required recovery operation and the dialog tests await the write; the shell pid is labelled; the withheld response-header list states why set-cookie is dropped. app-boot, READMEs, Agent Note: "never resumes" is restated as control never returning to the failed operation while the loop runs until release settles; inspect output is bounded; incident inferences (who removed the spill directory, which batch failure occurred) are written as inferences.
94 lines
4.6 KiB
TypeScript
94 lines
4.6 KiB
TypeScript
/** Local Web document and authenticated HTTP forwarding for the application window. */
|
|
import { readFile } from 'node:fs/promises'
|
|
import { extname, resolve, sep } from 'node:path'
|
|
|
|
const MIME: Readonly<Record<string, string>> = {
|
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json',
|
|
'.woff2': 'font/woff2', '.png': 'image/png', '.ico': 'image/x-icon',
|
|
}
|
|
const BOOT = '<script>globalThis.__DSH_BOOT_READY__ = Promise.withResolvers()</script>'
|
|
|
|
/**
|
|
* Read an application-owned static asset; the index waits for asynchronous Host injections.
|
|
* @param request - Local application request.
|
|
* @param root - Packaged Web dist directory.
|
|
* @returns Static response, or a missing/invalid path response.
|
|
*/
|
|
export async function serveWebDocument(request: Request, root: string): Promise<Response> {
|
|
if (!['GET', 'HEAD'].includes(request.method)) return new Response(null, { status: 405 })
|
|
const url = new URL(request.url)
|
|
let pathname: string
|
|
try { pathname = decodeURIComponent(url.pathname) } catch { return new Response(null, { status: 400 }) }
|
|
const target = resolve(root, '.' + (pathname === '/' ? '/index.html' : pathname))
|
|
const directory = resolve(root)
|
|
if (!target.startsWith(directory + sep)) return new Response(null, { status: 403 })
|
|
let body: Buffer
|
|
try { body = await readFile(target) } catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Response(null, { status: 404 })
|
|
throw error
|
|
}
|
|
const content = pathname === '/' || pathname === '/index.html'
|
|
? body.toString().replace('<head>', '<head>' + BOOT) : new Uint8Array(body)
|
|
return new Response(request.method === 'HEAD' ? null : content, {
|
|
headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Exchange the Host launch URL for an authority-bound browser cookie.
|
|
* @param url - Authenticated URL reported by the owned Host process.
|
|
* @returns Cookie header for requests forwarded to that Host.
|
|
*/
|
|
export async function authenticateWebHost(url: string): Promise<string> {
|
|
const response = await fetch(url, { redirect: 'manual' })
|
|
const cookie = response.headers.get('set-cookie')
|
|
await response.body?.cancel()
|
|
if (response.status !== 303 || cookie === null) throw new Error('Desktop Host authentication failed')
|
|
const end = cookie.indexOf(';')
|
|
return end < 0 ? cookie : cookie.slice(0, end)
|
|
}
|
|
|
|
/**
|
|
* Response headers not relayed to the renderer. `set-cookie` would hand the
|
|
* Host's authentication cookie to the page's cookie jar, which the shell owns
|
|
* instead; the rest describe the Node `fetch` connection (its encoding, length,
|
|
* and hop-by-hop transport), which Chromium never sees.
|
|
*/
|
|
const WITHHELD_RESPONSE_HEADERS = [
|
|
'set-cookie',
|
|
'content-encoding', 'content-length',
|
|
'transfer-encoding', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization',
|
|
]
|
|
|
|
/** Host routes whose responses carry immutable cache headers keyed by a per-process revision. */
|
|
const PLUGIN_BUNDLE_PATH = /^\/plugins\//u
|
|
|
|
/**
|
|
* Forward local application requests to its authenticated Host, preserving streaming and cancellation.
|
|
* Plugin bundle responses lose their `cache-control` for `no-store`: the Host marks them immutable
|
|
* under a revision that changes every launch, so Chromium's disk cache would only accumulate bundles
|
|
* no later launch can reuse.
|
|
* @param request - Request from the application origin.
|
|
* @param host - Owned Host URL.
|
|
* @param cookie - Host-issued authentication cookie.
|
|
* @returns Host response without connection-level headers.
|
|
*/
|
|
export async function forwardWebRequest(request: Request, host: string, cookie: string): Promise<Response> {
|
|
const source = new URL(request.url)
|
|
const origin = request.headers.get('origin')
|
|
if (origin !== null && origin !== 'dsh-app://app') return new Response(null, { status: 403 })
|
|
const target = new URL(host)
|
|
target.pathname = source.pathname
|
|
target.search = source.search
|
|
const headers = new Headers(request.headers)
|
|
for (const name of ['host', 'origin', 'cookie', 'sec-fetch-site']) headers.delete(name)
|
|
headers.set('cookie', cookie)
|
|
const init = { method: request.method, headers, body: request.body, signal: request.signal, duplex: 'half', redirect: 'manual' as const }
|
|
const response = await fetch(target, init)
|
|
const outgoing = new Headers(response.headers)
|
|
for (const name of WITHHELD_RESPONSE_HEADERS) outgoing.delete(name)
|
|
if (PLUGIN_BUNDLE_PATH.test(source.pathname)) outgoing.set('cache-control', 'no-store')
|
|
return new Response(response.body, { status: response.status, headers: outgoing })
|
|
}
|