diff --git a/AGENTS.md b/AGENTS.md index 5ad62c3c2ade..8c92bc2cebb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ Rspack is a high-performance JavaScript bundler written in Rust that offers stro - Update snapshots: `npm run test -- -u` - Filter tests: `npm run test -- -t configCases/asset` +Depends on what you have modified, you need to rebuild by `pnpm run build:js` or `pnpm run build:binding:dev` or `pnpm run build:cli:dev` first, then run testing commands to verify the modification. + ## Debugging - **VS Code**: `.vscode/launch.json` with `Debug Rspack` and `Attach` options diff --git a/packages/rspack-test-tools/src/plugin/lazy-compilation-test-plugin.ts b/packages/rspack-test-tools/src/plugin/lazy-compilation-test-plugin.ts index be255bd0a34a..b70a09eadedf 100644 --- a/packages/rspack-test-tools/src/plugin/lazy-compilation-test-plugin.ts +++ b/packages/rspack-test-tools/src/plugin/lazy-compilation-test-plugin.ts @@ -38,6 +38,9 @@ export class LazyCompilationTestPlugin { resolve(null); }); server.on('request', (req, res) => { + // Set CORS headers for jsdom's XMLHttpRequest + res.setHeader('Access-Control-Allow-Origin', '*'); + middleware(req, res, () => {}); }); server.on('connection', (socket) => { diff --git a/packages/rspack-test-tools/src/runner/web/index.ts b/packages/rspack-test-tools/src/runner/web/index.ts index eff92d3d4426..aa1f1ecbb37c 100644 --- a/packages/rspack-test-tools/src/runner/web/index.ts +++ b/packages/rspack-test-tools/src/runner/web/index.ts @@ -175,7 +175,12 @@ export class WebRunner extends NodeRunner { protected createBaseModuleScope() { const moduleScope = super.createBaseModuleScope(); moduleScope.EventSource = EventSource; - moduleScope.fetch = async (url: string) => { + moduleScope.fetch = async (url: string, options: any) => { + // For Lazy Compilation Proxy the POST request to the real dev server. + if (options?.method === 'POST') { + return fetch(url, options as any); + } + try { const filePath = this.urlToPath(url); this.log(`fetch: ${url} -> ${filePath}`); diff --git a/packages/rspack/hot/lazy-compilation-node.js b/packages/rspack/hot/lazy-compilation-node.js index 9ecf07ab958d..2bd57fec8d90 100644 --- a/packages/rspack/hot/lazy-compilation-node.js +++ b/packages/rspack/hot/lazy-compilation-node.js @@ -1,4 +1,79 @@ var urlBase = decodeURIComponent(__resourceQuery.slice(1)); +var compiling = new Set(); +var errorHandlers = new Set(); + +/** @type {import("http").ClientRequest | undefined} */ +var pendingRequest; +/** @type {boolean} */ +var hasPendingUpdate = false; + +function sendRequest() { + if (compiling.size === 0) { + hasPendingUpdate = false; + return; + } + + var modules = Array.from(compiling); + var data = modules.join('\n'); + + var httpModule = urlBase.startsWith('https') + ? require('https') + : require('http'); + + var request = httpModule.request( + urlBase, + { + method: 'POST', + agent: false, + headers: { + 'Content-Type': 'text/plain', + }, + }, + function (res) { + pendingRequest = undefined; + if (res.statusCode < 200 || res.statusCode >= 300) { + var error = new Error( + 'Problem communicating active modules to the server: HTTP ' + + res.statusCode, + ); + errorHandlers.forEach(function (onError) { + onError(error); + }); + } + // Consume response data to free up memory + res.resume(); + if (hasPendingUpdate) { + hasPendingUpdate = false; + sendRequest(); + } + }, + ); + + pendingRequest = request; + + request.on('error', function (err) { + pendingRequest = undefined; + var error = new Error( + 'Problem communicating active modules to the server: ' + err.message, + ); + errorHandlers.forEach(function (onError) { + onError(error); + }); + }); + + request.write(data); + request.end(); +} + +function sendActiveRequest() { + hasPendingUpdate = true; + + // If no request is pending, start one + if (!pendingRequest) { + hasPendingUpdate = false; + sendRequest(); + } +} /** * @param {{ data: string, onError: (err: Error) => void, active: boolean, module: module }} options options @@ -9,42 +84,23 @@ exports.activate = function (options) { var onError = options.onError; var active = options.active; var module = options.module; - /** @type {import("http").IncomingMessage} */ - var response; - var request = ( - urlBase.startsWith('https') ? require('https') : require('http') - ).request( - urlBase + encodeURIComponent(data), - { - agent: false, - headers: { accept: 'text/event-stream' }, - }, - function (res) { - response = res; - response.on('error', errorHandler); - if (!active && !module.hot) { - console.log( - 'Hot Module Replacement is not enabled. Waiting for process restart...', - ); - } - }, - ); - /** - * @param {Error} err error - */ - function errorHandler(err) { - err.message = - 'Problem communicating active modules to the server' + - (err.message ? ': ' + err.message : '') + - '\nRequest: ' + - urlBase + - data; - onError(err); + errorHandlers.add(onError); + + if (!compiling.has(data)) { + compiling.add(data); + sendActiveRequest(); } - request.on('error', errorHandler); - request.end(); + + if (!active && !module.hot) { + console.log( + 'Hot Module Replacement is not enabled. Waiting for process restart...', + ); + } + return function () { - response.destroy(); + errorHandlers.delete(onError); + compiling.delete(data); + sendActiveRequest(); }; }; diff --git a/packages/rspack/hot/lazy-compilation-web.js b/packages/rspack/hot/lazy-compilation-web.js index 28027edaa777..e49ff2bcb18f 100644 --- a/packages/rspack/hot/lazy-compilation-web.js +++ b/packages/rspack/hot/lazy-compilation-web.js @@ -1,47 +1,73 @@ -if (typeof EventSource !== 'function') { +if (typeof XMLHttpRequest === 'undefined') { throw new Error( - "Environment doesn't support lazy compilation (requires EventSource)", + "Environment doesn't support lazy compilation (requires XMLHttpRequest)", ); } var urlBase = decodeURIComponent(__resourceQuery.slice(1)); -/** @type {EventSource | undefined} */ -var activeEventSource; var compiling = new Set(); var errorHandlers = new Set(); -var updateEventSource = function updateEventSource() { - if (activeEventSource) activeEventSource.close(); - if (compiling.size) { - activeEventSource = new EventSource( - urlBase + - Array.from(compiling, function (module) { - return encodeURIComponent(module); - }).join('@'), - ); - /** - * @this {EventSource} - * @param {Event & { message?: string, filename?: string, lineno?: number, colno?: number, error?: Error }} event event - */ - activeEventSource.onerror = function (event) { - errorHandlers.forEach(function (onError) { - onError( - new Error( - 'Problem communicating active modules to the server' + - (event.message ? `: ${event.message} ` : '') + - (event.filename ? `: ${event.filename} ` : '') + - (event.lineno ? `: ${event.lineno} ` : '') + - (event.colno ? `: ${event.colno} ` : '') + - (event.error ? `: ${event.error}` : ''), - ), - ); - }); - }; - } else { - activeEventSource = undefined; +/** @type {XMLHttpRequest | undefined} */ +var pendingXhr; +/** @type {boolean} */ +var hasPendingUpdate = false; + +var sendRequest = function sendRequest() { + if (compiling.size === 0) { + hasPendingUpdate = false; + return; } + + var modules = Array.from(compiling); + var data = modules.join('\n'); + + var xhr = new XMLHttpRequest(); + pendingXhr = xhr; + xhr.open('POST', urlBase, true); + // text/plain Content-Type is simple request header + xhr.setRequestHeader('Content-Type', 'text/plain'); + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + pendingXhr = undefined; + if (xhr.status < 200 || xhr.status >= 300) { + var error = new Error( + 'Problem communicating active modules to the server: HTTP ' + + xhr.status, + ); + errorHandlers.forEach(function (onError) { + onError(error); + }); + } + if (hasPendingUpdate) { + hasPendingUpdate = false; + sendRequest(); + } + } + }; + + xhr.onerror = function () { + pendingXhr = undefined; + var error = new Error('Problem communicating active modules to the server'); + errorHandlers.forEach(function (onError) { + onError(error); + }); + }; + + xhr.send(data); }; +function sendActiveRequest() { + hasPendingUpdate = true; + + // If no request is pending, start one + if (!pendingXhr) { + hasPendingUpdate = false; + sendRequest(); + } +} + /** * @param {{ data: string, onError: (err: Error) => void, active: boolean, module: module }} options options * @returns {() => void} function to destroy response @@ -55,7 +81,7 @@ exports.activate = function (options) { if (!compiling.has(data)) { compiling.add(data); - updateEventSource(); + sendActiveRequest(); } if (!active && !module.hot) { @@ -67,6 +93,6 @@ exports.activate = function (options) { return function () { errorHandlers.delete(onError); compiling.delete(data); - updateEventSource(); + sendActiveRequest(); }; }; diff --git a/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts index 7a5a243a59da..d1778752b41d 100644 --- a/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts +++ b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts @@ -88,7 +88,6 @@ export const lazyCompilationMiddleware = ( } const options = { - // TODO: remove this when experiments.lazyCompilation is removed ...c.options.experiments.lazyCompilation, ...c.options.lazyCompilation, }; @@ -173,7 +172,69 @@ function applyPlugin( plugin.apply(compiler); } -// used for reuse code, do not export this +function readModuleIdsFromBody( + req: IncomingMessage & { body?: unknown }, +): Promise { + // If body is already parsed by another middleware, use it directly + if (req.body !== undefined) { + if (Array.isArray(req.body)) { + return Promise.resolve(req.body); + } + if (typeof req.body === 'string') { + return Promise.resolve(req.body.split('\n').filter(Boolean)); + } + throw new Error('Invalid body type'); + } + + return new Promise((resolve, reject) => { + if ((req as any).aborted || req.destroyed) { + reject(new Error('Request was aborted before body could be read')); + return; + } + + const cleanup = () => { + req.removeListener('data', onData); + req.removeListener('end', onEnd); + req.removeListener('error', onError); + req.removeListener('close', onClose); + req.removeListener('aborted', onAborted); + }; + + const chunks: Buffer[] = []; + const onData = (chunk: Buffer) => { + chunks.push(chunk); + }; + + const onEnd = () => { + cleanup(); + // Concatenate all chunks and decode as UTF-8 to handle multibyte characters correctly + const body = Buffer.concat(chunks).toString('utf8'); + resolve(body.split('\n').filter(Boolean)); + }; + + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + const onClose = () => { + cleanup(); + reject(new Error('Request was closed before body could be read')); + }; + + const onAborted = () => { + cleanup(); + reject(new Error('Request was aborted before body could be read')); + }; + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + req.on('close', onClose); + req.on('aborted', onAborted); + }); +} + const lazyCompilationMiddlewareInternal = ( compiler: Compiler | MultiCompiler, activeModules: Set, @@ -181,21 +242,24 @@ const lazyCompilationMiddlewareInternal = ( ): MiddlewareHandler => { const logger = compiler.getInfrastructureLogger('LazyCompilation'); - return (req: IncomingMessage, res: ServerResponse, next?: () => void) => { - if (!req.url?.startsWith(lazyCompilationPrefix)) { - // only handle requests that are come from lazyCompilation + return async ( + req: IncomingMessage, + res: ServerResponse, + next?: () => void, + ) => { + if (!req.url?.startsWith(lazyCompilationPrefix) || req.method !== 'POST') { return next?.(); } - const modules = req.url - .slice(lazyCompilationPrefix.length) - .split('@') - .map(decodeURIComponent); - req.socket.setNoDelay(true); - - res.setHeader('content-type', 'text/event-stream'); - res.writeHead(200); - res.write('\n'); + let modules: string[] = []; + try { + modules = await readModuleIdsFromBody(req); + } catch (err) { + logger.error('Failed to parse request body: ' + err); + res.writeHead(400); + res.end('Bad Request'); + return; + } const moduleActivated = []; for (const key of modules) { @@ -210,5 +274,9 @@ const lazyCompilationMiddlewareInternal = ( if (moduleActivated.length && compiler.watching) { compiler.watching.invalidate(); } + + res.writeHead(200); + res.write('\n'); + res.end(); }; }; diff --git a/tests/e2e/cases/lazy-compilation/active-lots-modules/index.test.ts b/tests/e2e/cases/lazy-compilation/active-lots-modules/index.test.ts new file mode 100644 index 000000000000..b4df048e2626 --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/active-lots-modules/index.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@/fixtures'; + +test('should activate and compile lots of modules with long names', async ({ + page, +}) => { + const body = await page.locator('body'); + await expect(body).toContainText('All Modules Loaded'); +}); diff --git a/tests/e2e/cases/lazy-compilation/active-lots-modules/rspack.config.js b/tests/e2e/cases/lazy-compilation/active-lots-modules/rspack.config.js new file mode 100644 index 000000000000..a9ade0287662 --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/active-lots-modules/rspack.config.js @@ -0,0 +1,43 @@ +const { rspack } = require('@rspack/core'); + +/* +Construct a project with lots of virtual files with very long file names +And `virtual_index.js` dynamically imports all the long file name files. +src/ +├── virtual_index.js +├── virtual_with_a_very_long_file_name_number_....._0.js +... +└── virtual_with_a_very_long_file_name_number_....._19.js +*/ +let lotsLongFileNameVirtualFiles = {}; +let longStr = new Array(1024).fill('a').join(''); +for (let i = 0; i < 20; i++) { + lotsLongFileNameVirtualFiles[ + `src/virtual_with_a_very_long_file_name_number_${longStr}_${i}.js` + ] = `"dynamic_imported"`; +} +let allFiles = Object.keys(lotsLongFileNameVirtualFiles); +lotsLongFileNameVirtualFiles['src/virtual_index.js'] = ` + Promise.all([ + ${allFiles.map((file) => `import('./${file.slice(3)}')\n`).join(',')} + ]).then(()=> document.body.innerHTML = 'All Modules Loaded'); +`; + +/** @type { import('@rspack/core').RspackOptions } */ +module.exports = { + context: __dirname, + entry: './src/virtual_index.js', + mode: 'development', + lazyCompilation: true, + devServer: { + hot: true, + port: 5678, + }, + plugins: [ + new rspack.HtmlRspackPlugin(), + new rspack.experiments.VirtualModulesPlugin(lotsLongFileNameVirtualFiles), + ], + experiments: { + useInputFileSystem: [/virtual/], + }, +}; diff --git a/tests/e2e/cases/lazy-compilation/cross-origin/README.md b/tests/e2e/cases/lazy-compilation/cross-origin/README.md new file mode 100644 index 000000000000..817ebaa50fb2 --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/cross-origin/README.md @@ -0,0 +1,46 @@ +# Cross-Origin Lazy Compilation Test + +This test verifies that lazy compilation works correctly +when the lazy compilation server runs on a different origin (port) than the frontend dev server. + +## Architecture + +``` ++----------------------+ +----------------------+ +-----------------------------+ +| | | | | | +| Browser | | Dev Server | | Lazy Compilation Server | +| (Frontend) | | (Port: 8500) | | (Port: 8600) | +| | | | | | ++----------+-----------+ +----------+-----------+ +--------------+--------------+ + | | | + | 1. Load page | | + | GET http://localhost:8500 | + +-------------------------> | + | | | + | 2. Click button -> dynamic import() | + | | | + | 3. Cross-origin POST request | + | POST http://127.0.0.1:8600/lazy-... | + | Content-Type: text/plain | + | Body: "module-id-1\nmodule-id-2" | + +----------------------------------------------------------> + | | | + | 4. Response (SSE or empty for POST) | + <----------------------------------------------------------+ + | | | + | 5. Webpack invalidate & rebuild | + | | | + | 6. Load compiled chunk | + v | | ++----------------------+ | | +| Component | | | +| Rendered | | | ++----------------------+ | | +``` + +## Key Points + +1. **Two Separate Servers**: Frontend runs on port 8500, lazy compilation on port 8600 +2. **Cross-Origin Request**: Browser sends POST request to a different origin +3. **Simple Request**: Uses `Content-Type: text/plain` to avoid CORS preflight +4. **XMLHttpRequest**: Uses XHR instead of fetch for better browser compatibility diff --git a/tests/e2e/cases/lazy-compilation/cross-origin/index.test.ts b/tests/e2e/cases/lazy-compilation/cross-origin/index.test.ts new file mode 100644 index 000000000000..279d43a2ed8b --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/cross-origin/index.test.ts @@ -0,0 +1,170 @@ +import http from 'node:http'; +import path from 'node:path'; +import { test as base, expect } from '@playwright/test'; +import fs from 'fs-extra'; +import { type Compiler, type Configuration, rspack } from '@rspack/core'; +import { RspackDevServer } from '@rspack/dev-server'; + +const tempDir = path.resolve(__dirname, '../../temp'); + +// Create a separate lazy compilation server on a different port (cross-origin) +function createLazyCompilationServer( + compiler: Compiler, + port: number, +): Promise { + return new Promise((resolve, reject) => { + const middleware = rspack.lazyCompilationMiddleware(compiler); + const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + middleware(req, res, () => { + res.writeHead(404); + res.end('Not Found'); + }); + }); + + server.listen(port, () => { + resolve(server); + }); + + server.on('error', reject); + }); +} + +const test = base.extend<{ + crossOriginSetup: { + frontendPort: number; + lazyCompilationPort: number; + }; +}>({ + crossOriginSetup: [ + async ({ page }, use, testInfo) => { + const workerId = String(testInfo.workerIndex); + const testProjectDir = path.dirname(testInfo.file); + const tempProjectDir = path.join(tempDir, `cross-origin-${workerId}`); + + // Copy test project to temp directory + if (await fs.exists(tempProjectDir)) { + await fs.remove(tempProjectDir); + } + await fs.copy(testProjectDir, tempProjectDir); + + // Clear require cache for temp directory + for (const modulePath of Object.keys(require.cache)) { + if (modulePath.startsWith(tempProjectDir)) { + delete require.cache[modulePath]; + } + } + + // Use different ports for frontend and lazy compilation server + const basePort = 8500; + const frontendPort = basePort + testInfo.workerIndex; + const lazyCompilationPort = frontendPort + 100; + + // Load and modify config + const configPath = path.resolve(tempProjectDir, 'rspack.config.js'); + const config: Configuration = require(configPath); + delete require.cache[configPath]; + + config.context = tempProjectDir; + config.output = { + ...config.output, + path: path.resolve(tempProjectDir, 'dist'), + }; + config.devServer = { + ...config.devServer, + port: frontendPort, + }; + // Set cross-origin serverUrl + config.lazyCompilation = { + ...(typeof config.lazyCompilation === 'object' + ? config.lazyCompilation + : {}), + entries: false, + imports: true, + serverUrl: `http://127.0.0.1:${lazyCompilationPort}`, + }; + + // Create compiler + const compiler = rspack(config); + + // Start lazy compilation server on a different port (cross-origin) + const lazyServer = await createLazyCompilationServer( + compiler, + lazyCompilationPort, + ); + + // Start dev server (frontend) - without lazy compilation middleware + // since we're running it on a separate server + const devServer = new RspackDevServer( + { + ...config.devServer, + port: frontendPort, + }, + compiler, + ); + await devServer.start(); + + // Wait for initial build + await new Promise((resolve) => { + compiler.hooks.done.tap('test', () => resolve()); + }); + + // Navigate to frontend + await page.goto(`http://localhost:${frontendPort}`); + + await use({ frontendPort, lazyCompilationPort }); + + // Cleanup + await new Promise((res, rej) => { + compiler.close((err) => (err ? rej(err) : res())); + }); + await devServer.stop(); + await new Promise((resolve) => { + lazyServer.close(() => resolve()); + }); + await fs.remove(tempProjectDir); + }, + { auto: true }, + ], +}); + +test('should work with cross-origin lazy compilation using simple POST request', async ({ + page, + crossOriginSetup, +}) => { + const { lazyCompilationPort } = crossOriginSetup; + + // Set up request interception to verify the request format + const requests: { method: string; contentType: string; body: string }[] = []; + + page.on('request', (request) => { + const url = request.url(); + if (url.includes(`${lazyCompilationPort}`)) { + requests.push({ + method: request.method(), + contentType: request.headers()['content-type'] || '', + body: request.postData() || '', + }); + } + }); + + await page.waitForSelector('button:has-text("Click me")'); + + // Click the button to trigger dynamic import (cross-origin lazy compilation request) + await page.getByText('Click me').click(); + + // Wait for the component to appear - this confirms the cross-origin request worked + await page.waitForSelector('div:has-text("CrossOriginComponent")', { + timeout: 10000, + }); + + const componentCount = await page.getByText('CrossOriginComponent').count(); + expect(componentCount).toBe(1); + + // Verify the request was a simple POST request with text/plain content type + const lazyRequest = requests.find((r) => r.method === 'POST'); + expect(lazyRequest).toBeDefined(); + expect(lazyRequest!.contentType).toBe('text/plain'); + // The body should be newline-separated module IDs, not JSON + expect(lazyRequest!.body).not.toMatch(/^\[/); // Not a JSON array +}); diff --git a/tests/e2e/cases/lazy-compilation/cross-origin/rspack.config.js b/tests/e2e/cases/lazy-compilation/cross-origin/rspack.config.js new file mode 100644 index 000000000000..ccdecd9fc0e7 --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/cross-origin/rspack.config.js @@ -0,0 +1,24 @@ +const { rspack } = require('@rspack/core'); + +/** @type { import('@rspack/core').RspackOptions } */ +module.exports = { + context: __dirname, + entry: { + main: './src/index.js', + }, + stats: 'none', + mode: 'development', + plugins: [new rspack.HtmlRspackPlugin()], + lazyCompilation: { + entries: false, + imports: true, + // serverUrl will be set dynamically in the test to point to a different port + }, + devtool: false, + devServer: { + hot: true, + devMiddleware: { + writeToDisk: true, + }, + }, +}; diff --git a/tests/e2e/cases/lazy-compilation/cross-origin/src/component.js b/tests/e2e/cases/lazy-compilation/cross-origin/src/component.js new file mode 100644 index 000000000000..660827de9de6 --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/cross-origin/src/component.js @@ -0,0 +1,3 @@ +const component = document.createElement('div'); +component.textContent = 'CrossOriginComponent'; +document.body.appendChild(component); diff --git a/tests/e2e/cases/lazy-compilation/cross-origin/src/index.js b/tests/e2e/cases/lazy-compilation/cross-origin/src/index.js new file mode 100644 index 000000000000..571d00ff49e7 --- /dev/null +++ b/tests/e2e/cases/lazy-compilation/cross-origin/src/index.js @@ -0,0 +1,9 @@ +const button = document.createElement('button'); +button.textContent = 'Click me'; + +button.addEventListener('click', () => { + import('./component.js').then(() => { + console.log('Component loaded via cross-origin lazy compilation'); + }); +}); +document.body.appendChild(button); diff --git a/tests/e2e/cases/lazy-compilation/custom-prefix/index.test.ts b/tests/e2e/cases/lazy-compilation/custom-prefix/index.test.ts index a7e1ca5e376f..4354ff62c592 100644 --- a/tests/e2e/cases/lazy-compilation/custom-prefix/index.test.ts +++ b/tests/e2e/cases/lazy-compilation/custom-prefix/index.test.ts @@ -3,9 +3,7 @@ import { expect, test } from '@/fixtures'; test('should use custom prefix for lazy compilation', async ({ page }) => { // Wait for a request with custom prefix const responsePromise = page.waitForResponse( - (response) => - response.url().includes('/custom-lazy-endpoint-') && - response.request().method() === 'GET', + (response) => response.url().includes('/custom-lazy-endpoint-'), { timeout: 5000 }, );