diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts index f7a6f0280..4d4b8d66a 100644 --- a/packages/happy-dom/src/browser/types/IBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -1,5 +1,6 @@ import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; +import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js'; /** * Browser settings. @@ -40,6 +41,8 @@ export default interface IBrowserSettings { * @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy */ disableSameOriginPolicy: boolean; + + interceptor?: IFetchInterceptor; }; /** diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts index bd58fdb8b..5592028d6 100644 --- a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -1,5 +1,6 @@ import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; +import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js'; export default interface IOptionalBrowserSettings { /** Disables JavaScript evaluation. */ @@ -34,6 +35,8 @@ export default interface IOptionalBrowserSettings { * @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy */ disableSameOriginPolicy?: boolean; + + interceptor?: IFetchInterceptor; }; /** diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 3aa304e13..2b79b4a01 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -26,6 +26,7 @@ import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.j import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js'; import { Buffer } from 'buffer'; import FetchBodyUtility from './utilities/FetchBodyUtility.js'; +import IFetchInterceptor from './types/IFetchInterceptor.js'; const LAST_CHUNK = Buffer.from('0\r\n\r\n'); @@ -39,7 +40,7 @@ const LAST_CHUNK = Buffer.from('0\r\n\r\n'); */ export default class Fetch { private reject: (reason: Error) => void | null = null; - private resolve: (value: Response | Promise) => void | null = null; + private resolve: (value: Response | Promise) => Promise = null; private listeners = { onSignalAbort: this.onSignalAbort.bind(this) }; @@ -50,6 +51,7 @@ export default class Fetch { private nodeResponse: IncomingMessage | null = null; private response: Response | null = null; private responseHeaders: Headers | null = null; + private interceptor?: IFetchInterceptor; private request: Request; private redirectCount = 0; private disableCache: boolean; @@ -99,6 +101,7 @@ export default class Fetch { options.disableSameOriginPolicy ?? this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? false; + this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor; } /** @@ -108,6 +111,15 @@ export default class Fetch { */ public async send(): Promise { FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request); + const beforeRequestResponse = this.interceptor?.beforeAsyncRequest + ? await this.interceptor.beforeAsyncRequest({ + request: this.request, + window: this.#window + }) + : undefined; + if (beforeRequestResponse instanceof Response) { + return beforeRequestResponse; + } FetchRequestValidationUtility.validateSchema(this.request); if (this.request.signal.aborted) { @@ -122,7 +134,14 @@ export default class Fetch { this.response = new this.#window.Response(result.buffer, { headers: { 'Content-Type': result.type } }); - return this.response; + const interceptedResponse = this.interceptor?.afterAsyncResponse + ? await this.interceptor.afterAsyncResponse({ + window: this.#window, + response: this.response, + request: this.request + }) + : undefined; + return interceptedResponse instanceof Response ? interceptedResponse : this.response; } // Security check for "https" to "http" requests. @@ -365,9 +384,9 @@ export default class Fetch { throw new this.#window.Error('Fetch already sent.'); } - this.resolve = (response: Response | Promise): void => { + this.resolve = async (response: Response | Promise): Promise => { // We can end up here when closing down the browser frame and there is an ongoing request. - // Therefore we need to check if browserFrame.page.context is still available. + // Therefore, we need to check if browserFrame.page.context is still available. if ( !this.disableCache && response instanceof Response && @@ -382,8 +401,16 @@ export default class Fetch { waitingForBody: !response[PropertySymbol.buffer] && !!response.body }); } + + const interceptedResponse = this.interceptor?.afterAsyncResponse + ? await this.interceptor.afterAsyncResponse({ + window: this.#window, + response: await response, + request: this.request + }) + : undefined; this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); - resolve(response); + resolve(interceptedResponse instanceof Response ? interceptedResponse : response); }; this.reject = (error: Error): void => { this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts index e04c0c156..863b0b923 100644 --- a/packages/happy-dom/src/fetch/SyncFetch.ts +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -20,6 +20,7 @@ import Zlib from 'zlib'; import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtility.js'; import FetchCORSUtility from './utilities/FetchCORSUtility.js'; import Fetch from './Fetch.js'; +import IFetchInterceptor from './types/IFetchInterceptor.js'; interface ISyncHTTPResponse { error: string; @@ -39,6 +40,7 @@ export default class SyncFetch { private redirectCount = 0; private disableCache: boolean; private disableSameOriginPolicy: boolean; + private interceptor?: IFetchInterceptor; #browserFrame: IBrowserFrame; #window: BrowserWindow; #unfilteredHeaders: Headers | null = null; @@ -84,6 +86,7 @@ export default class SyncFetch { options.disableSameOriginPolicy ?? this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? false; + this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor; } /** @@ -93,6 +96,15 @@ export default class SyncFetch { */ public send(): ISyncResponse { FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request); + const beforeRequestResponse = this.interceptor?.beforeSyncRequest + ? this.interceptor.beforeSyncRequest({ + request: this.request, + window: this.#window + }) + : undefined; + if (typeof beforeRequestResponse === 'object') { + return beforeRequestResponse; + } FetchRequestValidationUtility.validateSchema(this.request); if (this.request.signal.aborted) { @@ -104,7 +116,7 @@ export default class SyncFetch { if (this.request[PropertySymbol.url].protocol === 'data:') { const result = DataURIParser.parse(this.request.url); - return { + const response = { status: 200, statusText: 'OK', ok: true, @@ -113,6 +125,14 @@ export default class SyncFetch { headers: new Headers({ 'Content-Type': result.type }), body: result.buffer }; + const interceptedResponse = this.interceptor?.afterSyncResponse + ? this.interceptor.afterSyncResponse({ + window: this.#window, + response, + request: this.request + }) + : undefined; + return typeof interceptedResponse === 'object' ? interceptedResponse : response; } // Security check for "https" to "http" requests. @@ -416,7 +436,14 @@ export default class SyncFetch { }); } - return redirectedResponse; + const interceptedResponse = this.interceptor?.afterSyncResponse + ? this.interceptor.afterSyncResponse({ + window: this.#window, + response: redirectedResponse, + request: this.request + }) + : undefined; + return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse; } /** diff --git a/packages/happy-dom/src/fetch/types/IFetchInterceptor.ts b/packages/happy-dom/src/fetch/types/IFetchInterceptor.ts new file mode 100644 index 000000000..3739b74b6 --- /dev/null +++ b/packages/happy-dom/src/fetch/types/IFetchInterceptor.ts @@ -0,0 +1,60 @@ +import Request from '../Request.js'; +import BrowserWindow from '../../window/BrowserWindow.js'; +import Response from '../Response.js'; +import ISyncResponse from './ISyncResponse.js'; + +export default interface IFetchInterceptor { + /** + * Hook dispatched before making an async request. + * It can be used for modifying the request, providing a response without making a request or for logging. + * + * @param context Contains the request and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + beforeAsyncRequest?: (context: { + request: Request; + window: BrowserWindow; + }) => Promise; + + /** + * Hook dispatched before making an sync request. + * It can be used for modifying the request, providing a response without making a request or for logging. + * + * @param context Contains the request and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + beforeSyncRequest?: (context: { + request: Request; + window: BrowserWindow; + }) => ISyncResponse | void; + + /** + * Hook dispatched after receiving an async response. + * It can be used for modifying or replacing the response and logging. + * + * @param context Contains the request, response and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + afterAsyncResponse?: (context: { + request: Request; + response: Response; + window: BrowserWindow; + }) => Promise; + + /** + * Hook dispatched after receiving a sync response. + * It can be used for modifying or replacing the response and logging + * + * @param context Contains the request, response and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + afterSyncResponse?: (context: { + request: Request; + response: ISyncResponse; + window: BrowserWindow; + }) => ISyncResponse | void; +} diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index a0b3fda2c..da546cf76 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -57,6 +57,8 @@ import AbortSignal from './fetch/AbortSignal.js'; import Headers from './fetch/Headers.js'; import Request from './fetch/Request.js'; import Response from './fetch/Response.js'; +import IFetchInterceptor from './fetch/types/IFetchInterceptor.js'; +import ISyncResponse from './fetch/types/ISyncResponse.js'; import Blob from './file/Blob.js'; import File from './file/File.js'; import FileReader from './file/FileReader.js'; @@ -206,6 +208,8 @@ import type ITouchEventInit from './event/events/ITouchEventInit.js'; import type IWheelEventInit from './event/events/IWheelEventInit.js'; export type { + IFetchInterceptor, + ISyncResponse, IAnimationEventInit, IBrowser, IBrowserContext, diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index fbd3088a4..29dbaaecb 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -18,6 +18,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'; import FetchHTTPSCertificate from '../../src/fetch/certificate/FetchHTTPSCertificate.js'; import * as PropertySymbol from '../../src/PropertySymbol.js'; import Event from '../../src/event/Event.js'; +import Fetch from '../../lib/fetch/Fetch'; const LAST_CHUNK = Buffer.from('0\r\n\r\n'); @@ -1343,7 +1344,7 @@ describe('Fetch', () => { }); }); - it("Does'nt allow requests to HTTP from HTTPS (mixed content).", async () => { + it("Doesn't allow requests to HTTP from HTTPS (mixed content).", async () => { const originURL = 'https://localhost:8080/'; const window = new Window({ url: originURL }); const url = 'http://localhost:8080/some/path'; @@ -1363,6 +1364,191 @@ describe('Fetch', () => { ); }); + it('Should use intercepted response and not send the request when beforeAsyncRequest returns a Response', async () => { + const originURL = 'https://localhost:8080/'; + const window = new Window({ + url: originURL, + settings: { + fetch: { + interceptor: { + async beforeAsyncRequest({ window }) { + return new window.Response('intercepted text'); + } + } + } + } + }); + const url = 'https://localhost:8080/some/path'; + + mockModule('https', { + request: () => { + fail('No request should be made when beforeAsyncRequest returns a Response'); + } + }); + + const response = await window.fetch(url); + + expect(await response.text()).toBe('intercepted text'); + }); + + it('Should make a normal request when before does not return a Response', async () => { + const originURL = 'https://localhost:8080/'; + const responseText = 'some text'; + const window = new Window({ + url: originURL, + settings: { + fetch: { + interceptor: { + async beforeAsyncRequest() { + return undefined; + } + } + } + } + }); + const url = 'https://localhost:8080/some/path'; + + mockModule('https', { + request: () => { + return { + end: () => {}, + on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => { + if (event === 'response') { + async function* generate(): AsyncGenerator { + yield responseText; + } + + const response = Stream.Readable.from(generate()); + + response.statusCode = 200; + response.statusMessage = 'OK'; + response.headers = {}; + response.rawHeaders = [ + 'content-type', + 'text/html', + 'content-length', + String(responseText.length) + ]; + + callback(response); + } + }, + setTimeout: () => {} + }; + } + }); + + const response = await window.fetch(url); + + expect(await response.text()).toBe('some text'); + }); + + it('Should use intercepted response when given', async () => { + const originURL = 'https://localhost:8080/'; + const responseText = 'some text'; + const window = new Window({ + url: originURL, + settings: { + fetch: { + interceptor: { + async afterAsyncResponse({ window }) { + return new window.Response('intercepted text'); + } + } + } + } + }); + const url = 'https://localhost:8080/some/path'; + + mockModule('https', { + request: () => { + return { + end: () => {}, + on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => { + if (event === 'response') { + async function* generate(): AsyncGenerator { + yield responseText; + } + + const response = Stream.Readable.from(generate()); + + response.statusCode = 200; + response.statusMessage = 'OK'; + response.headers = {}; + response.rawHeaders = [ + 'content-type', + 'text/html', + 'content-length', + String(responseText.length) + ]; + + callback(response); + } + }, + setTimeout: () => {} + }; + } + }); + + const response = await window.fetch(url); + + expect(await response.text()).toBe('intercepted text'); + }); + + it('Should use original response when no response is given', async () => { + const originURL = 'https://localhost:8080/'; + const responseText = 'some text'; + const window = new Window({ + url: originURL, + settings: { + fetch: { + interceptor: { + async afterAsyncResponse({ response }) { + response.headers.set('x-test', 'yes'); + return undefined; + } + } + } + } + }); + const url = 'https://localhost:8080/some/path'; + + mockModule('https', { + request: () => { + return { + end: () => {}, + on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => { + if (event === 'response') { + async function* generate(): AsyncGenerator { + yield responseText; + } + + const response = Stream.Readable.from(generate()); + + response.statusCode = 200; + response.statusMessage = 'OK'; + response.headers = {}; + response.rawHeaders = [ + 'content-type', + 'text/html', + 'content-length', + String(responseText.length) + ]; + + callback(response); + } + }, + setTimeout: () => {} + }; + } + }); + + const response = await window.fetch(url); + + expect(await response.text()).toBe(responseText); + expect(response.headers.get('x-test')).toBe('yes'); + }); + it('Forwards "cookie", "authorization" or "www-authenticate" if request credentials are set to "same-origin" and the request goes to the same origin as the document.', async () => { const originURL = 'https://localhost:8080'; const window = new Window({ url: originURL }); diff --git a/packages/happy-dom/test/fetch/SyncFetch.test.ts b/packages/happy-dom/test/fetch/SyncFetch.test.ts index 6b781a565..39f6e9b83 100644 --- a/packages/happy-dom/test/fetch/SyncFetch.test.ts +++ b/packages/happy-dom/test/fetch/SyncFetch.test.ts @@ -13,6 +13,7 @@ import SyncFetch from '../../src/fetch/SyncFetch.js'; import IBrowserFrame from '../../src/browser/types/IBrowserFrame.js'; import Browser from '../../src/browser/Browser.js'; import '../types.d.js'; +import { fail } from 'node:assert'; const PLATFORM = 'X11; ' + @@ -191,6 +192,336 @@ describe('SyncFetch', () => { }); }); + it('Should use intercepted response and not send the request when beforeSyncRequest returns a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + beforeSyncRequest() { + return { + status: 200, + statusText: 'OK', + ok: true, + url, + redirected: false, + headers: new Headers(), + body: Buffer.from('intercepted text') + }; + } + } + } + } + }); + const page = browser.newPage(); + + const browserFrame = page.mainFrame; + const window = page.mainFrame.window; + browserFrame.url = 'https://localhost:8080/'; + + mockModule('child_process', { + execFileSync: () => { + fail('No request should be made when beforeSyncRequest returns a response'); + } + }); + + const response = new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'GET' + } + }).send(); + + expect(response.body.toString()).toBe('intercepted text'); + }); + + it('Should perform the http request normally when the beforeSyncRequest does not return a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + beforeSyncRequest() { + return undefined; + } + } + } + } + }); + const page = browser.newPage(); + + const browserFrame = page.mainFrame; + const window = page.mainFrame.window; + browserFrame.url = 'https://localhost:8080/'; + + const responseText = 'some text'; + + mockModule('child_process', { + execFileSync: ( + command: string, + args: string[], + options: { encoding: string; maxBuffer: number } + ) => { + expect(command).toEqual(process.argv[0]); + expect(args[0]).toBe('-e'); + expect(args[1]).toBe( + SyncFetchScriptBuilder.getScript({ + url: new URL(url), + method: 'GET', + headers: { + Accept: '*/*', + Connection: 'close', + Referer: 'https://localhost:8080/', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br' + }, + body: null + }) + ); + expect(options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + return JSON.stringify({ + error: null, + incomingMessage: { + statusCode: 200, + statusMessage: 'OK', + rawHeaders: [ + 'content-type', + 'text/html', + 'content-length', + String(responseText.length) + ], + data: Buffer.from(responseText).toString('base64') + } + }); + } + }); + + const response = new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'GET' + } + }).send(); + + expect(response.url).toBe(url); + expect(response.ok).toBe(true); + expect(response.redirected).toBe(false); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.body.toString()).toBe(responseText); + expect(response.headers instanceof Headers).toBe(true); + + const headers = {}; + for (const [key, value] of response.headers) { + headers[key] = value; + } + + expect(headers).toEqual({ + 'content-type': 'text/html', + 'content-length': String(responseText.length) + }); + }); + + it('Should return the intercepted response when afterSyncRequest returns a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + afterSyncResponse() { + return { + status: 200, + statusText: 'OK', + ok: true, + url, + redirected: false, + headers: new Headers({ 'Content-Type': 'text/plain' }), + body: Buffer.from('intercepted text') + }; + } + } + } + } + }); + const page = browser.newPage(); + + const browserFrame = page.mainFrame; + const window = page.mainFrame.window; + browserFrame.url = 'https://localhost:8080/'; + + const responseText = 'some text'; + + mockModule('child_process', { + execFileSync: ( + command: string, + args: string[], + options: { encoding: string; maxBuffer: number } + ) => { + expect(command).toEqual(process.argv[0]); + expect(args[0]).toBe('-e'); + expect(args[1]).toBe( + SyncFetchScriptBuilder.getScript({ + url: new URL(url), + method: 'GET', + headers: { + Accept: '*/*', + Connection: 'close', + Referer: 'https://localhost:8080/', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br' + }, + body: null + }) + ); + expect(options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + return JSON.stringify({ + error: null, + incomingMessage: { + statusCode: 200, + statusMessage: 'OK', + rawHeaders: [ + 'content-type', + 'text/plain', + 'content-length', + String(responseText.length) + ], + data: Buffer.from(responseText).toString('base64') + } + }); + } + }); + + const response = new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'GET' + } + }).send(); + + expect(response.url).toBe(url); + expect(response.ok).toBe(true); + expect(response.redirected).toBe(false); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.body.toString()).toBe('intercepted text'); + expect(response.headers instanceof Headers).toBe(true); + + const headers = {}; + for (const [key, value] of response.headers) { + headers[key] = value; + } + + expect(headers).toEqual({ + 'Content-Type': 'text/plain' + }); + }); + + it('Should return the original response when afterSyncRequest does not return a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + afterSyncResponse({ response }) { + return { + ...response, + headers: new Headers({ 'Content-Type': 'text/plain' }) + }; + } + } + } + } + }); + const page = browser.newPage(); + + const browserFrame = page.mainFrame; + const window = page.mainFrame.window; + browserFrame.url = 'https://localhost:8080/'; + + const responseText = 'some text'; + + mockModule('child_process', { + execFileSync: ( + command: string, + args: string[], + options: { encoding: string; maxBuffer: number } + ) => { + expect(command).toEqual(process.argv[0]); + expect(args[0]).toBe('-e'); + expect(args[1]).toBe( + SyncFetchScriptBuilder.getScript({ + url: new URL(url), + method: 'GET', + headers: { + Accept: '*/*', + Connection: 'close', + Referer: 'https://localhost:8080/', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br' + }, + body: null + }) + ); + expect(options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + return JSON.stringify({ + error: null, + incomingMessage: { + statusCode: 200, + statusMessage: 'OK', + rawHeaders: [ + 'content-type', + 'text/html', + 'content-length', + String(responseText.length) + ], + data: Buffer.from(responseText).toString('base64') + } + }); + } + }); + + const response = new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'GET' + } + }).send(); + + expect(response.url).toBe(url); + expect(response.ok).toBe(true); + expect(response.redirected).toBe(false); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.body.toString()).toBe(responseText); + expect(response.headers instanceof Headers).toBe(true); + + const headers = {}; + for (const [key, value] of response.headers) { + headers[key] = value; + } + + expect(headers).toEqual({ + 'Content-Type': 'text/plain' + }); + }); + it('Performs a request with a relative URL and adds the "Referer" header set to the window location.', () => { const baseUrl = 'https://localhost:8080/base/';