Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export namespace ConfigKey {
export const DebugUseNodeFetchFetcher = defineSetting('advanced.debug.useNodeFetchFetcher', ConfigType.Simple, true);
export const DebugUseNodeFetcher = defineSetting('advanced.debug.useNodeFetcher', ConfigType.Simple, false);
export const DebugUseElectronFetcher = defineSetting('advanced.debug.useElectronFetcher', ConfigType.Simple, true);
export const DebugNodeFetchCache = defineSetting<'off' | 'memory' | 'persistent'>('advanced.debug.nodeFetchCache', ConfigType.Simple, 'memory');
export const AuthProvider = defineSetting<AuthProviderId>('advanced.authProvider', ConfigType.Simple, AuthProviderId.GitHub);
export const AuthPermissions = defineSetting<AuthPermissionMode>('advanced.authPermissions', ConfigType.Simple, AuthPermissionMode.Default);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,16 @@ export interface FetchTelemetryEvent {
latencyMs: number;
statusCode: number | undefined;
success: boolean;
/**
* Cache outcome when the caller opted in via {@link FetchOptions.cache}.
* `undefined` when the caller did not opt in or the selected fetcher does
* not implement caching.
*/
cacheStatus?: CacheStatus;
}

export type CacheStatus = 'hit' | 'stale-hit' | 'revalidated' | 'miss' | 'bypass';

/** A basic version of http://developer.mozilla.org/en-US/docs/Web/API/Response */
export class Response {
ok = this.status >= 200 && this.status < 300;
Expand All @@ -92,6 +100,7 @@ export class Response {
private readonly _reportEvent: ReportFetchEvent,
private readonly _internalId: string,
private readonly _hostname: string,
readonly cacheStatus?: CacheStatus,
) {
const transformer = {
transform: (chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>) => {
Expand Down Expand Up @@ -169,6 +178,13 @@ export interface FetchOptions {
expectJSON?: boolean;
useFetcher?: FetcherId;
suppressIntegrationId?: boolean;
/**
* Opportunistic cache hint. When set to `true`, fetchers that implement
* caching (today only the Node fetch fetcher) will route the request
* through an RFC 9111 cache. Fetchers without cache support ignore this
* flag, and fallback to other fetchers continues to work normally.
*/
cache?: boolean;
}

export interface PaginationOptions<T> extends FetchOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
import { generateUuid } from '../../../util/vs/base/common/uuid';
import { IEnvService } from '../../env/common/envService';
import { collectSingleLineErrorMessage } from '../../log/common/logService';
import { FetcherId, FetchOptions, IAbortController, isAbortError, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService';
import { CacheStatus, FetcherId, FetchOptions, IAbortController, isAbortError, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService';
import { IFetcher, userAgentLibraryHeader } from '../common/networking';
import { VSCODE_CACHE_STATUS_HEADER } from './taggedCacheInterceptor';

export type FetchImpl = (
input: string | globalThis.Request,
init?: RequestInit,
useCache?: boolean,
) => Promise<globalThis.Response>;

export abstract class BaseFetchFetcher implements IFetcher {

constructor(
private readonly _fetchImpl: typeof fetch | typeof import('electron').net.fetch,
private readonly _fetchImpl: FetchImpl,
private readonly _envService: IEnvService,
private readonly _fetcherId: FetcherId,
private readonly _reportEvent: ReportFetchEvent,
Expand Down Expand Up @@ -52,7 +59,7 @@ export abstract class BaseFetchFetcher implements IFetcher {
const internalId = generateUuid();
const hostname = safeGetHostname(url);
try {
const response = await this._fetch(url, method, headers, body, signal, internalId, hostname);
const response = await this._fetch(url, method, headers, body, signal, internalId, hostname, options);
this._reportEvent({ internalId, timestamp: Date.now(), outcome: 'success', phase: 'requestResponse', fetcher: this._fetcherId, hostname, statusCode: response.status });
return response;
} catch (e) {
Expand Down Expand Up @@ -89,8 +96,8 @@ export abstract class BaseFetchFetcher implements IFetcher {
return items;
}

private async _fetch(url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', headers: { [name: string]: string }, body: string | undefined, signal: AbortSignal, internalId: string, hostname: string): Promise<Response> {
const resp = await this._fetchImpl(url, { method, headers, body, signal });
private async _fetch(url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', headers: { [name: string]: string }, body: string | undefined, signal: AbortSignal, internalId: string, hostname: string, options: FetchOptions): Promise<Response> {
const resp = await this._fetchImpl(url, { method, headers, body, signal }, !!options.cache);
return new Response(
resp.status,
resp.statusText,
Expand All @@ -100,9 +107,24 @@ export abstract class BaseFetchFetcher implements IFetcher {
this._reportEvent,
internalId,
hostname,
this._readCacheStatus(resp.headers, options),
);
}

private _readCacheStatus(headers: { get(name: string): string | null }, options: FetchOptions): CacheStatus | undefined {
if (!options.cache) {
return undefined;
}
const stamped = headers.get(VSCODE_CACHE_STATUS_HEADER);
if (stamped === 'hit' || stamped === 'stale-hit' || stamped === 'revalidated' || stamped === 'miss') {
return stamped;
}
// Caller opted in but the response carried no marker — either the
// fetcher does not implement caching or the cache interceptor skipped
// this request.
return 'bypass';
}

async disconnectAll(): Promise<void> {
// Nothing to do
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,22 @@ import * as undici from 'undici';
import { Lazy } from '../../../util/vs/base/common/lazy';
import { IEnvService } from '../../env/common/envService';
import { HeadersImpl, IHeaders, ReportFetchEvent, WebSocketConnection, WebSocketConnectOptions } from '../common/fetcherService';
import { BaseFetchFetcher } from './baseFetchFetcher';
import { BaseFetchFetcher, FetchImpl } from './baseFetchFetcher';
import { taggedCacheInterceptor } from './taggedCacheInterceptor';

type CacheInterceptorOptions = NonNullable<Parameters<typeof undici.interceptors.cache>[0]>;
type CacheStore = NonNullable<CacheInterceptorOptions['store']>;

type FetchPatchFactory = (options?: {
interceptors?: readonly undici.Dispatcher.DispatcherComposeInterceptor[];
}) => typeof globalThis.fetch;

export type NodeFetchCacheMode = 'off' | 'memory' | 'persistent';

export interface NodeFetchCacheOptions {
readonly mode: NodeFetchCacheMode;
readonly storeLocation?: string;
}

export class NodeFetchFetcher extends BaseFetchFetcher {

Expand All @@ -18,8 +33,14 @@ export class NodeFetchFetcher extends BaseFetchFetcher {
envService: IEnvService,
reportEvent: ReportFetchEvent = () => { },
userAgentLibraryUpdate?: (original: string) => string,
cacheOptions: NodeFetchCacheOptions = { mode: 'memory' },
) {
super(getFetch(), envService, NodeFetchFetcher.ID, reportEvent, userAgentLibraryUpdate);
// Caching requires the host-provided fetch-patch factory so cached requests
// still go through the proxy/CA-injection patch. On older hosts that lack
// the factory, caching is silently disabled.
const factory = (globalThis as any).__vscodeCreateFetchPatch as FetchPatchFactory | undefined;
const interceptor = cacheOptions.mode !== 'off' && factory ? createCacheInterceptor(cacheOptions) : undefined;
super(getFetch(interceptor, factory), envService, NodeFetchFetcher.ID, reportEvent, userAgentLibraryUpdate);
}

getUserAgentLibrary(): string {
Expand All @@ -35,10 +56,44 @@ export class NodeFetchFetcher extends BaseFetchFetcher {
}
}

function getFetch(): typeof globalThis.fetch {
const fetch = (globalThis as any).__vscodePatchedFetch || globalThis.fetch;
return function (input: string | URL | globalThis.Request, init?: RequestInit) {
return fetch(input, { dispatcher: agent.value, ...init });
function createCacheInterceptor(options: NodeFetchCacheOptions): undici.Dispatcher.DispatcherComposeInterceptor | undefined {
const store = createCacheStore(options);
if (!store) {
return undefined;
}
return taggedCacheInterceptor({ store, type: 'private' });
}

function createCacheStore(options: NodeFetchCacheOptions): CacheStore | undefined {
if (options.mode === 'persistent') {
const SqliteCacheStore = (undici as unknown as { cacheStores?: { SqliteCacheStore?: new (init?: object) => CacheStore } }).cacheStores?.SqliteCacheStore;
if (SqliteCacheStore && options.storeLocation) {
try {
return new SqliteCacheStore({
location: options.storeLocation,
maxCount: 5000,
maxEntrySize: 5 * 1024 * 1024,
});
} catch {
Comment thread
deepak1556 marked this conversation as resolved.
}
}
}
const MemoryCacheStore = (undici as unknown as { cacheStores?: { MemoryCacheStore?: new (init?: object) => CacheStore } }).cacheStores?.MemoryCacheStore;
if (!MemoryCacheStore) {
return undefined;
}
return new MemoryCacheStore({ maxCount: 1000, maxEntrySize: 5 * 1024 * 1024 });
}

function getFetch(cacheInterceptor: undici.Dispatcher.DispatcherComposeInterceptor | undefined, createFetchPatch: FetchPatchFactory | undefined): FetchImpl {
const defaultFetch = (globalThis as any).__vscodePatchedFetch || globalThis.fetch;
const cachedFetch = cacheInterceptor && createFetchPatch ? createFetchPatch({ interceptors: [cacheInterceptor] }) : undefined;
return function (input, init, useCache) {
if (useCache && cachedFetch) {
return cachedFetch(input, init);
}
const dispatcher = (init as { dispatcher?: undici.Dispatcher } | undefined)?.dispatcher ?? agent.value;
return defaultFetch(input, { ...init, dispatcher });
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as undici from 'undici';

/**
* Internal response header stamped by {@link taggedCacheInterceptor} so the
* fetcher can attribute a response to the cache for telemetry purposes.
*/
export const VSCODE_CACHE_STATUS_HEADER = 'x-vscode-cache-status';

export type CacheStatus = 'hit' | 'stale-hit' | 'revalidated' | 'miss';

/**
* Wraps undici's built-in `interceptors.cache(...)` and stamps the served
* response with {@link VSCODE_CACHE_STATUS_HEADER} so downstream code can
* tell whether the response came from the cache without relying on header
* heuristics.
*
* Detection is based on observing whether the cache interceptor invoked the
* downstream dispatcher for a given request and whether it added conditional
* revalidation headers — both of which are deterministic for a given code
* path in `undici/lib/interceptor/cache.js`.
*/
export function taggedCacheInterceptor(
cacheOpts: Parameters<typeof undici.interceptors.cache>[0],
): undici.Dispatcher.DispatcherComposeInterceptor {
const cacheInterceptor = undici.interceptors.cache(cacheOpts);

return (dispatch) => (opts, handler) => {
const state = { networkCalled: false, conditional: false };

const countingDispatch: typeof dispatch = (dOpts, dHandler) => {
state.networkCalled = true;
const h = dOpts.headers as Record<string, string> | undefined;
state.conditional = !!(h && (h['if-modified-since'] || h['if-none-match']));
return dispatch(dOpts, dHandler);
Comment thread
deepak1556 marked this conversation as resolved.
};

const taggingHandler = new Proxy(handler, {
get(target, prop, receiver) {
if (prop === 'onResponseStart') {
return (
controller: Parameters<NonNullable<undici.Dispatcher.DispatchHandler['onResponseStart']>>[0],
statusCode: number,
headers: unknown,
statusMessage?: string,
) => {
const status = classify(state, headers);
stampStatus(headers, status);
const orig = Reflect.get(target, prop, receiver) as undici.Dispatcher.DispatchHandler['onResponseStart'];
return orig?.call(target, controller, statusCode, headers as Parameters<NonNullable<undici.Dispatcher.DispatchHandler['onResponseStart']>>[2], statusMessage);
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
}) as undici.Dispatcher.DispatchHandler;

return cacheInterceptor(countingDispatch)(opts, taggingHandler);
};
}

function classify(
state: { networkCalled: boolean; conditional: boolean },
headers: unknown,
): CacheStatus {
if (!state.networkCalled) {
return isStaleWarning(headers) ? 'stale-hit' : 'hit';
}
if (state.conditional) {
return 'revalidated';
}
return 'miss';
}

function isStaleWarning(headers: unknown): boolean {
const value = readHeader(headers, 'warning');
return typeof value === 'string' && value.startsWith('110');
}

function readHeader(headers: unknown, name: string): string | undefined {
if (!headers || typeof headers !== 'object') {
return undefined;
}
const value = (headers as Record<string, unknown>)[name];
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
return undefined;
}

function stampStatus(headers: unknown, status: CacheStatus): void {
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
(headers as Record<string, string>)[VSCODE_CACHE_STATUS_HEADER] = status;
}
}
Loading
Loading