LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - http_session_bundle.ts Coverage Total Hit
Test: DashQL 915828b Lines: 89.9 % 188 169
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type { HttpClient, HttpFetchResult } from '../../platform/http/http_client.js';
       2              : import type { DataFileData, ScriptData } from '../../storage/storage_backend.js';
       3              : import {
       4              :     STORAGE_SESSION_FILE,
       5              :     STORAGE_ATTACHMENTS_FOLDER,
       6              :     STORAGE_SCRIPTS_FOLDER,
       7              : } from '../../storage/storage_backend.js';
       8              : import type { SessionBundle } from '../../storage/session_bundle.js';
       9              : import { assembleSessionBundle, readSessionBundleHead } from '../../storage/session_bundle.js';
      10              : import { type MigrationFolder, OverlayFolder } from '../../storage/migration/migration_folder.js';
      11              : import { LEGACY_SESSION_FILE } from '../../platform/events/legacy_links.js';
      12              : 
      13            1 : const MAX_MANIFEST_BYTES = 1024 * 1024;
      14            1 : const MAX_SCRIPT_BYTES = 8 * 1024 * 1024;
      15            1 : const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
      16              : 
      17              : export interface HttpSessionLoadResult {
      18              :     /// The loaded session
      19              :     bundle: SessionBundle;
      20              :     /// The number of scripts that the session file lists
      21              :     listedScriptCount: number;
      22              :     /// The number of scripts that were loaded
      23              :     loadedScriptCount: number;
      24              :     /// Were files missing, unusable or over the size budget?
      25              :     incomplete: boolean;
      26              : }
      27              : 
      28              : export type HttpSessionLoadProgress =
      29              :     | { phase: 'preparing' }
      30              :     | { phase: 'manifest' }
      31              :     | { phase: 'listing'; sessionName: string; sessionId: string }
      32              :     | {
      33              :         phase: 'files';
      34              :         sessionName: string;
      35              :         sessionId: string;
      36              :         completedFileCount: number;
      37              :         totalFileCount: number;
      38              :         completedScriptCount: number;
      39              :         totalScriptCount: number;
      40              :     };
      41              : 
      42            1 : export async function readSessionBundleFromHttp(
      43            1 :     manifestUrl: URL,
      44            1 :     httpClient: HttpClient,
      45            1 :     signal?: AbortSignal,
      46            1 :     onProgress?: (progress: HttpSessionLoadProgress) => void,
      47            1 : ): Promise<HttpSessionLoadResult> {
      48            1 :     const manifestName = validateManifestUrl(manifestUrl);
      49            1 :     const baseUrl = new URL('./', manifestUrl);
      50            1 :     let totalBytes = 0;
      51              : 
      52            1 :     onProgress?.({ phase: 'manifest' });
      53            1 :     const manifestText = await fetchRequiredText(httpClient, manifestUrl, manifestName, MAX_MANIFEST_BYTES, signal);
      54            1 :     totalBytes += textBytes(manifestText);
      55              :     // Sessions published by older versions are migrated in memory
      56            1 :     const folder = new OverlayFolder(new HttpFolder(baseUrl, httpClient, MAX_SCRIPT_BYTES, signal, manifestName, manifestText));
      57            1 :     const head = await readSessionBundleHead(folder, 'Invalid remote session');
      58            1 :     const session = head.session;
      59            1 :     const sessionName = session.name?.trim() || session.metadata.originalFileName || 'Unnamed session';
      60            1 :     totalBytes += head.catalogs.flatMap(catalog => [catalog.relations, catalog.functions])
      61            1 :         .reduce((bytes, text) => bytes + (text == null ? 0 : textBytes(text)), 0);
      62              : 
      63            1 :     onProgress?.({ phase: 'listing', sessionName, sessionId: session.sessionId });
      64            1 :     const listedScripts = session.scripts;
      65            1 :     const listedAttachments = session.attachments;
      66            1 :     const listedScriptCount = listedScripts.length;
      67              :     // The manifest and the catalog files of the connections
      68            1 :     const headFileCount = 1 + 2 * session.connections.length;
      69            1 :     const totalFileCount = headFileCount + listedScriptCount + listedAttachments.length;
      70            1 :     let completedFileCount = headFileCount;
      71            1 :     let completedScriptCount = 0;
      72            1 :     const reportFileProgress = () => onProgress?.({
      73            1 :         phase: 'files',
      74            1 :         sessionName,
      75            1 :         sessionId: session.sessionId,
      76            1 :         completedFileCount,
      77            1 :         totalFileCount,
      78            1 :         completedScriptCount,
      79            1 :         totalScriptCount: listedScriptCount,
      80            1 :     });
      81            1 :     const trackFile = async <T>(load: Promise<T>, script: boolean = false): Promise<T> => {
      82            1 :         try {
      83            1 :             return await load;
      84            1 :         } finally {
      85            1 :             completedFileCount += 1;
      86            1 :             if (script) completedScriptCount += 1;
      87            1 :             reportFileProgress();
      88            1 :         }
      89            1 :     };
      90            1 :     reportFileProgress();
      91              : 
      92            1 :     const scripts: ScriptData[] = (await Promise.all(listedScripts.map(async script => {
      93            1 :             const sql = await trackFile(fetchBestEffortText(
      94            1 :                 httpClient,
      95            1 :                 sessionFileUrl(baseUrl, script.name),
      96            1 :                 MAX_SCRIPT_BYTES,
      97            1 :                 signal,
      98            1 :             ), true);
      99            1 :             return sql == null ? null : { name: script.name, sql };
     100            1 :         }))).filter((script): script is ScriptData => script != null);
     101              : 
     102            1 :     totalBytes += scripts.reduce((bytes, script) => bytes + textBytes(script.sql), 0);
     103            1 :     const data = (await Promise.all(listedAttachments.map(async file => {
     104            1 :         const bytes = await trackFile(fetchBestEffortBytes(httpClient,
     105            1 :             new URL(`${STORAGE_ATTACHMENTS_FOLDER}/${encodeURIComponent(file.name)}`, baseUrl), MAX_TOTAL_BYTES, signal));
     106            1 :         return bytes == null ? null : { name: file.name, bytes };
     107            1 :     }))).filter((file): file is DataFileData => file != null);
     108            1 :     totalBytes += data.reduce((size, file) => size + file.bytes.byteLength, 0);
     109              :     // Optional files are loaded only while the session remains within the total budget. The
     110              :     // manifest is the sole required file, so an oversized optional tree degrades to metadata only.
     111            1 :     const includeOptionalFiles = totalBytes <= MAX_TOTAL_BYTES;
     112              : 
     113              :     // Scripts that failed to load are dropped from the list
     114            1 :     const bundle = assembleSessionBundle({
     115            1 :         session: {
     116            1 :             ...session,
     117            1 :             metadata: {
     118            1 :                 ...session.metadata,
     119            1 :                 originType: 'HTTP',
     120            1 :                 originalHttpUrl: manifestUrl.toString(),
     121            1 :             },
     122            1 :         },
     123            1 :         catalogs: includeOptionalFiles ? head.catalogs : [],
     124            1 :     }, includeOptionalFiles ? scripts : [], includeOptionalFiles ? data : []);
     125            1 :     const loadedScriptCount = bundle.scripts.length;
     126            1 :     return {
     127            1 :         bundle,
     128            1 :         listedScriptCount,
     129            1 :         loadedScriptCount,
     130            1 :         incomplete: !head.filesValid || !includeOptionalFiles || loadedScriptCount !== listedScriptCount
     131            1 :             || data.length !== listedAttachments.length,
     132            1 :     };
     133            1 : }
     134              : 
     135              : /// The top-level files of a hosted session, fetched when a reader asks for them.
     136              : ///
     137              : /// Each file is fetched at most once, a missing or unreadable file reads as null. The folder is
     138              : /// read-only, the session migration changes it through an `OverlayFolder`.
     139            1 : class HttpFolder implements MigrationFolder {
     140              :     /// The URL of the session folder
     141            1 :     private readonly baseUrl: URL;
     142              :     /// The client that fetches the files
     143            1 :     private readonly httpClient: HttpClient;
     144              :     /// The maximum size of a file
     145            1 :     private readonly maxBytes: number;
     146              :     /// The signal that aborts the fetches
     147            1 :     private readonly signal: AbortSignal | undefined;
     148              :     /// The fetched files by name, null for missing ones
     149            1 :     private readonly files: Map<string, Promise<string | null>>;
     150              : 
     151            1 :     constructor(baseUrl: URL, httpClient: HttpClient, maxBytes: number, signal: AbortSignal | undefined, manifestName: string, manifestText: string) {
     152            1 :         this.baseUrl = baseUrl;
     153            1 :         this.httpClient = httpClient;
     154            1 :         this.maxBytes = maxBytes;
     155            1 :         this.signal = signal;
     156            1 :         this.files = new Map([[manifestName, Promise.resolve(manifestText)]]);
     157            1 :     }
     158              : 
     159            1 :     async exists(name: string): Promise<boolean> {
     160            1 :         return (await this.readText(name)) != null;
     161            1 :     }
     162              : 
     163            1 :     readText(name: string): Promise<string | null> {
     164            1 :         let file = this.files.get(name);
     165            1 :         if (file === undefined) {
     166            1 :             file = fetchBestEffortText(this.httpClient, new URL(name, this.baseUrl), this.maxBytes, this.signal);
     167            1 :             this.files.set(name, file);
     168            1 :         }
     169            1 :         return file;
     170            1 :     }
     171              : 
     172            0 :     async writeText(name: string): Promise<void> {
     173            0 :         throw new Error(`Cannot write ${name} of a remote session`);
     174            0 :     }
     175              : 
     176            0 :     async rename(from: string): Promise<void> {
     177            0 :         throw new Error(`Cannot rename ${from} of a remote session`);
     178            0 :     }
     179              : 
     180            0 :     async remove(name: string): Promise<void> {
     181            0 :         throw new Error(`Cannot remove ${name} of a remote session`);
     182            0 :     }
     183            1 : }
     184              : 
     185              : /// Validate the URL of a hosted session, returns the name of the session metadata
     186            1 : function validateManifestUrl(url: URL): string {
     187            1 :     const isPublicHttps = url.protocol === 'https:';
     188              :     const isDevelopmentLoopback = process.env.DASHQL_BUILD_MODE === 'development'
     189              :         && url.protocol === 'http:'
     190              :         && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]');
     191            1 :     if ((!isPublicHttps && !isDevelopmentLoopback) || url.username || url.password || url.hash) {
     192            0 :         throw new Error('Session URL must be public HTTPS or development loopback HTTP without credentials or a fragment');
     193              :     }
     194              :     // Links of older versions point to the metadata under its older name
     195            1 :     const name = url.pathname.split('/').pop();
     196            1 :     if (name !== STORAGE_SESSION_FILE && name !== LEGACY_SESSION_FILE) {
     197            1 :         throw new Error(`Remote session URL must end in ${STORAGE_SESSION_FILE}`);
     198              :     }
     199            1 :     return name;
     200            1 : }
     201              : 
     202              : 
     203            1 : async function fetchBestEffortBytes(
     204            1 :     httpClient: HttpClient, url: URL, maxBytes: number, signal?: AbortSignal,
     205            1 : ): Promise<Uint8Array | null> {
     206            1 :     try {
     207            1 :         const response = await httpClient.fetch(url, { method: 'GET', credentials: 'omit', signal });
     208            1 :         if (response.status < 200 || response.status >= 300) return null;
     209            1 :         const declaredSize = Number(response.headers.get('content-length'));
     210            1 :         if (Number.isFinite(declaredSize) && declaredSize > maxBytes) return null;
     211            1 :         const bytes = new Uint8Array(await response.arrayBuffer());
     212            1 :         return bytes.byteLength > maxBytes ? null : bytes;
     213            1 :     } catch (error) {
     214            0 :         if (signal?.aborted || (error as Error)?.name === 'AbortError') throw error;
     215            0 :         return null;
     216            0 :     }
     217            1 : }
     218              : 
     219            1 : function sessionFileUrl(baseUrl: URL, scriptName: string): URL {
     220            1 :     return new URL(
     221            1 :         `${STORAGE_SCRIPTS_FOLDER}/${encodeURIComponent(scriptName)}`,
     222            1 :         baseUrl,
     223            1 :     );
     224            1 : }
     225              : 
     226            1 : async function fetchRequiredText(
     227            1 :     httpClient: HttpClient,
     228            1 :     url: URL,
     229            1 :     label: string,
     230            1 :     maxBytes: number,
     231            1 :     signal?: AbortSignal,
     232            1 : ): Promise<string> {
     233            1 :     const response = await httpClient.fetch(url, { method: 'GET', credentials: 'omit', signal });
     234            1 :     if (response.status < 200 || response.status >= 300) {
     235            0 :         throw new Error(`Failed to fetch remote session file ${label}: HTTP ${response.status}`);
     236              :     }
     237            1 :     return await readLimitedText(response, label, maxBytes);
     238            1 : }
     239              : 
     240            1 : async function fetchBestEffortText(
     241            1 :     httpClient: HttpClient,
     242            1 :     url: URL,
     243            1 :     maxBytes: number,
     244            1 :     signal?: AbortSignal,
     245            1 : ): Promise<string | null> {
     246            1 :     try {
     247            1 :         const response = await httpClient.fetch(url, { method: 'GET', credentials: 'omit', signal });
     248            1 :         if (response.status < 200 || response.status >= 300) return null;
     249            1 :         return await readLimitedText(response, url.pathname, maxBytes);
     250            1 :     } catch (error) {
     251            0 :         if (signal?.aborted || (error as Error)?.name === 'AbortError') throw error;
     252            0 :         return null;
     253            0 :     }
     254            1 : }
     255              : 
     256            1 : async function readLimitedText(response: HttpFetchResult, label: string, maxBytes: number): Promise<string> {
     257            1 :     const declaredSize = Number(response.headers.get('content-length'));
     258            1 :     if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
     259            0 :         throw new Error(`Remote session file ${label} exceeds the maximum size`);
     260              :     }
     261            1 :     const text = await response.text();
     262            1 :     if (textBytes(text) > maxBytes) {
     263            0 :         throw new Error(`Remote session file ${label} exceeds the maximum size`);
     264              :     }
     265            1 :     return text;
     266            1 : }
     267              : 
     268              : 
     269              : 
     270              : 
     271            1 : function textBytes(value: string): number {
     272            1 :     return new TextEncoder().encode(value).byteLength;
     273            1 : }
        

Generated by: LCOV version 2.0-1