| 1 | import https from 'node:https'; |
| 2 | import http from 'node:http'; |
| 3 | import fs from 'node:fs'; |
| 4 | import { color, urlHostnameToIPv6, getHasIP } from './util.js'; |
| 5 | |
| 6 | // Express routers |
| 7 | import { router as userDataRouter } from './users.js'; |
| 8 | import { router as usersPrivateRouter } from './endpoints/users-private.js'; |
| 9 | import { router as usersAdminRouter } from './endpoints/users-admin.js'; |
| 10 | import { router as movingUIRouter } from './endpoints/moving-ui.js'; |
| 11 | import { router as imagesRouter } from './endpoints/images.js'; |
| 12 | import { router as quickRepliesRouter } from './endpoints/quick-replies.js'; |
| 13 | import { router as avatarsRouter } from './endpoints/avatars.js'; |
| 14 | import { router as themesRouter } from './endpoints/themes.js'; |
| 15 | import { router as openAiRouter } from './endpoints/openai.js'; |
| 16 | import { router as googleRouter } from './endpoints/google.js'; |
| 17 | import { router as anthropicRouter } from './endpoints/anthropic.js'; |
| 18 | import { router as tokenizersRouter } from './endpoints/tokenizers.js'; |
| 19 | import { router as presetsRouter } from './endpoints/presets.js'; |
| 20 | import { router as secretsRouter } from './endpoints/secrets.js'; |
| 21 | import { router as thumbnailRouter } from './endpoints/thumbnails.js'; |
| 22 | import { router as novelAiRouter } from './endpoints/novelai.js'; |
| 23 | import { router as extensionsRouter } from './endpoints/extensions.js'; |
| 24 | import { router as assetsRouter } from './endpoints/assets.js'; |
| 25 | import { router as filesRouter } from './endpoints/files.js'; |
| 26 | import { router as charactersRouter } from './endpoints/characters.js'; |
| 27 | import { router as chatsRouter } from './endpoints/chats.js'; |
| 28 | import { router as groupsRouter } from './endpoints/groups.js'; |
| 29 | import { router as worldInfoRouter } from './endpoints/worldinfo.js'; |
| 30 | import { router as statsRouter } from './endpoints/stats.js'; |
| 31 | import { router as contentManagerRouter } from './endpoints/content-manager.js'; |
| 32 | import { router as settingsRouter } from './endpoints/settings.js'; |
| 33 | import { router as backgroundsRouter } from './endpoints/backgrounds.js'; |
| 34 | import { router as spritesRouter } from './endpoints/sprites.js'; |
| 35 | import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js'; |
| 36 | import { router as hordeRouter } from './endpoints/horde.js'; |
| 37 | import { router as vectorsRouter } from './endpoints/vectors.js'; |
| 38 | import { router as translateRouter } from './endpoints/translate.js'; |
| 39 | import { router as classifyRouter } from './endpoints/classify.js'; |
| 40 | import { router as captionRouter } from './endpoints/caption.js'; |
| 41 | import { router as searchRouter } from './endpoints/search.js'; |
| 42 | import { router as openRouterRouter } from './endpoints/openrouter.js'; |
| 43 | import { router as nanogptRouter } from './endpoints/nanogpt.js'; |
| 44 | import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js'; |
| 45 | import { router as koboldRouter } from './endpoints/backends/kobold.js'; |
| 46 | import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js'; |
| 47 | import { router as speechRouter } from './endpoints/speech.js'; |
| 48 | import { router as azureRouter } from './endpoints/azure.js'; |
| 49 | import { router as minimaxRouter } from './endpoints/minimax.js'; |
| 50 | import { router as dataMaidRouter } from './endpoints/data-maid.js'; |
| 51 | import { router as backupsRouter } from './endpoints/backups.js'; |
| 52 | import { router as imageMetadataRouter } from './endpoints/image-metadata.js'; |
| 53 | import { router as volcengineRouter } from './endpoints/volcengine.js'; |
| 54 | |
| 55 | /** |
| 56 | * @typedef {object} ServerStartupResult |
| 57 | * @property {boolean} v6Failed If the server failed to start on IPv6 |
| 58 | * @property {boolean} v4Failed If the server failed to start on IPv4 |
| 59 | * @property {unknown} [v6Error] The IPv6 server startup error |
| 60 | * @property {unknown} [v4Error] The IPv4 server startup error |
| 61 | * @property {boolean} useIPv6 If use IPv6 |
| 62 | * @property {boolean} useIPv4 If use IPv4 |
| 63 | */ |
| 64 | |
| 65 | /** |
| 66 | * Redirect deprecated API endpoints to their replacements. |
| 67 | * @param {import('express').Express} app The Express app to use |
| 68 | */ |
| 69 | export function redirectDeprecatedEndpoints(app) { |
| 70 | /** |
| 71 | * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow |
| 72 | * redirects, this is transparent to client-side code. |
| 73 | * @param {string} src The URL to redirect from. |
| 74 | * @param {string} destination The URL to redirect to. |
| 75 | */ |
| 76 | function redirect(src, destination) { |
| 77 | app.use(src, (req, res) => { |
| 78 | console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`); |
| 79 | // HTTP 301 causes the request to become a GET. 308 preserves the request method. |
| 80 | res.redirect(308, destination); |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | redirect('/createcharacter', '/api/characters/create'); |
| 85 | redirect('/renamecharacter', '/api/characters/rename'); |
| 86 | redirect('/editcharacter', '/api/characters/edit'); |
| 87 | redirect('/editcharacterattribute', '/api/characters/edit-attribute'); |
| 88 | redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes'); |
| 89 | redirect('/deletecharacter', '/api/characters/delete'); |
| 90 | redirect('/getcharacters', '/api/characters/all'); |
| 91 | redirect('/getonecharacter', '/api/characters/get'); |
| 92 | redirect('/getallchatsofcharacter', '/api/characters/chats'); |
| 93 | redirect('/importcharacter', '/api/characters/import'); |
| 94 | redirect('/dupecharacter', '/api/characters/duplicate'); |
| 95 | redirect('/exportcharacter', '/api/characters/export'); |
| 96 | redirect('/savechat', '/api/chats/save'); |
| 97 | redirect('/getchat', '/api/chats/get'); |
| 98 | redirect('/renamechat', '/api/chats/rename'); |
| 99 | redirect('/delchat', '/api/chats/delete'); |
| 100 | redirect('/exportchat', '/api/chats/export'); |
| 101 | redirect('/importgroupchat', '/api/chats/group/import'); |
| 102 | redirect('/importchat', '/api/chats/import'); |
| 103 | redirect('/getgroupchat', '/api/chats/group/get'); |
| 104 | redirect('/deletegroupchat', '/api/chats/group/delete'); |
| 105 | redirect('/savegroupchat', '/api/chats/group/save'); |
| 106 | redirect('/getgroups', '/api/groups/all'); |
| 107 | redirect('/creategroup', '/api/groups/create'); |
| 108 | redirect('/editgroup', '/api/groups/edit'); |
| 109 | redirect('/deletegroup', '/api/groups/delete'); |
| 110 | redirect('/getworldinfo', '/api/worldinfo/get'); |
| 111 | redirect('/deleteworldinfo', '/api/worldinfo/delete'); |
| 112 | redirect('/importworldinfo', '/api/worldinfo/import'); |
| 113 | redirect('/editworldinfo', '/api/worldinfo/edit'); |
| 114 | redirect('/getstats', '/api/stats/get'); |
| 115 | redirect('/recreatestats', '/api/stats/recreate'); |
| 116 | redirect('/updatestats', '/api/stats/update'); |
| 117 | redirect('/getbackgrounds', '/api/backgrounds/all'); |
| 118 | redirect('/delbackground', '/api/backgrounds/delete'); |
| 119 | redirect('/renamebackground', '/api/backgrounds/rename'); |
| 120 | redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one |
| 121 | redirect('/savetheme', '/api/themes/save'); |
| 122 | redirect('/getuseravatars', '/api/avatars/get'); |
| 123 | redirect('/deleteuseravatar', '/api/avatars/delete'); |
| 124 | redirect('/uploaduseravatar', '/api/avatars/upload'); |
| 125 | redirect('/deletequickreply', '/api/quick-replies/delete'); |
| 126 | redirect('/savequickreply', '/api/quick-replies/save'); |
| 127 | redirect('/uploadimage', '/api/images/upload'); |
| 128 | redirect('/listimgfiles/:folder', '/api/images/list/:folder'); |
| 129 | redirect('/api/content/import', '/api/content/importURL'); |
| 130 | redirect('/savemovingui', '/api/moving-ui/save'); |
| 131 | redirect('/api/serpapi/search', '/api/search/serpapi'); |
| 132 | redirect('/api/serpapi/visit', '/api/search/visit'); |
| 133 | redirect('/api/serpapi/transcript', '/api/search/transcript'); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Setup the routers for the endpoints. |
| 138 | * @param {import('express').Express} app The Express app to use |
| 139 | */ |
| 140 | export function setupPrivateEndpoints(app) { |
| 141 | app.use('/', userDataRouter); |
| 142 | app.use('/api/users', usersPrivateRouter); |
| 143 | app.use('/api/users', usersAdminRouter); |
| 144 | app.use('/api/moving-ui', movingUIRouter); |
| 145 | app.use('/api/images', imagesRouter); |
| 146 | app.use('/api/quick-replies', quickRepliesRouter); |
| 147 | app.use('/api/avatars', avatarsRouter); |
| 148 | app.use('/api/themes', themesRouter); |
| 149 | app.use('/api/openai', openAiRouter); |
| 150 | app.use('/api/google', googleRouter); |
| 151 | app.use('/api/anthropic', anthropicRouter); |
| 152 | app.use('/api/tokenizers', tokenizersRouter); |
| 153 | app.use('/api/presets', presetsRouter); |
| 154 | app.use('/api/secrets', secretsRouter); |
| 155 | app.use('/thumbnail', thumbnailRouter); |
| 156 | app.use('/api/novelai', novelAiRouter); |
| 157 | app.use('/api/extensions', extensionsRouter); |
| 158 | app.use('/api/assets', assetsRouter); |
| 159 | app.use('/api/files', filesRouter); |
| 160 | app.use('/api/characters', charactersRouter); |
| 161 | app.use('/api/chats', chatsRouter); |
| 162 | app.use('/api/groups', groupsRouter); |
| 163 | app.use('/api/worldinfo', worldInfoRouter); |
| 164 | app.use('/api/stats', statsRouter); |
| 165 | app.use('/api/backgrounds', backgroundsRouter); |
| 166 | app.use('/api/sprites', spritesRouter); |
| 167 | app.use('/api/content', contentManagerRouter); |
| 168 | app.use('/api/settings', settingsRouter); |
| 169 | app.use('/api/sd', stableDiffusionRouter); |
| 170 | app.use('/api/horde', hordeRouter); |
| 171 | app.use('/api/vector', vectorsRouter); |
| 172 | app.use('/api/translate', translateRouter); |
| 173 | app.use('/api/extra/classify', classifyRouter); |
| 174 | app.use('/api/extra/caption', captionRouter); |
| 175 | app.use('/api/search', searchRouter); |
| 176 | app.use('/api/backends/text-completions', textCompletionsRouter); |
| 177 | app.use('/api/openrouter', openRouterRouter); |
| 178 | app.use('/api/nanogpt', nanogptRouter); |
| 179 | app.use('/api/backends/kobold', koboldRouter); |
| 180 | app.use('/api/backends/chat-completions', chatCompletionsRouter); |
| 181 | app.use('/api/speech', speechRouter); |
| 182 | app.use('/api/azure', azureRouter); |
| 183 | app.use('/api/volcengine', volcengineRouter); |
| 184 | app.use('/api/minimax', minimaxRouter); |
| 185 | app.use('/api/data-maid', dataMaidRouter); |
| 186 | app.use('/api/backups', backupsRouter); |
| 187 | app.use('/api/image-metadata', imageMetadataRouter); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Utilities for starting the express server. |
| 192 | */ |
| 193 | export class ServerStartup { |
| 194 | /** |
| 195 | * Creates a new ServerStartup instance. |
| 196 | * @param {import('express').Express} app The Express app to use |
| 197 | * @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments |
| 198 | */ |
| 199 | constructor(app, cliArgs) { |
| 200 | this.app = app; |
| 201 | this.cliArgs = cliArgs; |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * Prints a fatal error message and exits the process. |
| 206 | * @param {string} message |
| 207 | */ |
| 208 | #fatal(message) { |
| 209 | console.error(color.red(message)); |
| 210 | process.exit(1); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Checks if the error was caused by an occupied port. |
| 215 | * @param {unknown} error |
| 216 | * @returns {error is NodeJS.ErrnoException} |
| 217 | */ |
| 218 | #isAddressInUseError(error) { |
| 219 | return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EADDRINUSE'; |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Gets a readable listen address for an IP version. |
| 224 | * @param {URL} url The URL to listen on |
| 225 | * @param {number} ipVersion The IP version to use |
| 226 | * @returns {string} |
| 227 | */ |
| 228 | #getListenAddress(url, ipVersion) { |
| 229 | const host = ipVersion === 6 ? urlHostnameToIPv6(url.hostname) : url.hostname; |
| 230 | return `${host}:${Number(url.port || (this.cliArgs.ssl ? 443 : 80))}`; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Builds a user-facing error for an occupied port. |
| 235 | * @param {URL} url The URL that failed to bind |
| 236 | * @param {number} ipVersion The IP version that failed |
| 237 | * @returns {string} |
| 238 | */ |
| 239 | #getAddressInUseMessage(url, ipVersion) { |
| 240 | const listenAddress = this.#getListenAddress(url, ipVersion); |
| 241 | return `Address ${listenAddress} is already in use. Another SillyTavern instance may already be running. Stop the other process or change "port" in config.yaml.`; |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Checks if SSL options are valid. If not, it will print an error message and exit the process. |
| 246 | * @returns {void} |
| 247 | */ |
| 248 | #verifySslOptions() { |
| 249 | if (!this.cliArgs.ssl) return; |
| 250 | |
| 251 | if (!this.cliArgs.certPath) { |
| 252 | this.#fatal('Error: SSL certificate path is required when using HTTPS. Check your config'); |
| 253 | } |
| 254 | |
| 255 | if (!this.cliArgs.keyPath) { |
| 256 | this.#fatal('Error: SSL key path is required when using HTTPS. Check your config'); |
| 257 | } |
| 258 | |
| 259 | if (!fs.existsSync(this.cliArgs.certPath)) { |
| 260 | this.#fatal('Error: SSL certificate path does not exist'); |
| 261 | } |
| 262 | |
| 263 | if (!fs.existsSync(this.cliArgs.keyPath)) { |
| 264 | this.#fatal('Error: SSL key path does not exist'); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Creates an HTTPS server. |
| 270 | * @param {URL} url The URL to listen on |
| 271 | * @param {number} ipVersion the ip version to use |
| 272 | * @returns {Promise<void>} A promise that resolves when the server is listening |
| 273 | */ |
| 274 | #createHttpsServer(url, ipVersion) { |
| 275 | this.#verifySslOptions(); |
| 276 | return new Promise((resolve, reject) => { |
| 277 | /** @type {import('https').ServerOptions} */ |
| 278 | const sslOptions = { |
| 279 | cert: fs.readFileSync(this.cliArgs.certPath), |
| 280 | key: fs.readFileSync(this.cliArgs.keyPath), |
| 281 | passphrase: String(this.cliArgs.keyPassphrase ?? ''), |
| 282 | }; |
| 283 | const server = https.createServer(sslOptions, this.app); |
| 284 | server.on('error', reject); |
| 285 | server.on('listening', resolve); |
| 286 | |
| 287 | let host = url.hostname; |
| 288 | if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); |
| 289 | server.listen({ |
| 290 | host: host, |
| 291 | port: Number(url.port || 443), |
| 292 | // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used |
| 293 | ipv6Only: true, |
| 294 | }); |
| 295 | }); |
| 296 | } |
| 297 | |
| 298 | /** |
| 299 | * Creates an HTTP server. |
| 300 | * @param {URL} url The URL to listen on |
| 301 | * @param {number} ipVersion the ip version to use |
| 302 | * @returns {Promise<void>} A promise that resolves when the server is listening |
| 303 | */ |
| 304 | #createHttpServer(url, ipVersion) { |
| 305 | return new Promise((resolve, reject) => { |
| 306 | const server = http.createServer(this.app); |
| 307 | server.on('error', reject); |
| 308 | server.on('listening', resolve); |
| 309 | |
| 310 | let host = url.hostname; |
| 311 | if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); |
| 312 | server.listen({ |
| 313 | host: host, |
| 314 | port: Number(url.port || 80), |
| 315 | // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used |
| 316 | ipv6Only: true, |
| 317 | }); |
| 318 | }); |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Starts the server using http or https depending on config |
| 323 | * @param {boolean} useIPv6 If use IPv6 |
| 324 | * @param {boolean} useIPv4 If use IPv4 |
| 325 | * @returns {Promise<[boolean, boolean, unknown, unknown]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively, and the corresponding errors |
| 326 | */ |
| 327 | async #startHTTPorHTTPS(useIPv6, useIPv4) { |
| 328 | let v6Failed = false; |
| 329 | let v4Failed = false; |
| 330 | let v6Error; |
| 331 | let v4Error; |
| 332 | |
| 333 | const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this); |
| 334 | |
| 335 | if (useIPv6) { |
| 336 | try { |
| 337 | await createFunc(this.cliArgs.getIPv6ListenUrl(), 6); |
| 338 | } catch (error) { |
| 339 | console.error('Warning: failed to start server on IPv6'); |
| 340 | if (this.#isAddressInUseError(error)) { |
| 341 | console.error(this.#getAddressInUseMessage(this.cliArgs.getIPv6ListenUrl(), 6)); |
| 342 | } else { |
| 343 | console.error(error); |
| 344 | } |
| 345 | |
| 346 | v6Failed = true; |
| 347 | v6Error = error; |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | if (useIPv4) { |
| 352 | try { |
| 353 | await createFunc(this.cliArgs.getIPv4ListenUrl(), 4); |
| 354 | } catch (error) { |
| 355 | console.error('Warning: failed to start server on IPv4'); |
| 356 | if (this.#isAddressInUseError(error)) { |
| 357 | console.error(this.#getAddressInUseMessage(this.cliArgs.getIPv4ListenUrl(), 4)); |
| 358 | } else { |
| 359 | console.error(error); |
| 360 | } |
| 361 | |
| 362 | v4Failed = true; |
| 363 | v4Error = error; |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | return [v6Failed, v4Failed, v6Error, v4Error]; |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * Handles the case where the server failed to start on one or both protocols. |
| 372 | * @param {ServerStartupResult} result The results of the server startup |
| 373 | * @returns {void} |
| 374 | */ |
| 375 | #handleServerListenFail({ v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }) { |
| 376 | if (v6Failed && !useIPv4) { |
| 377 | if (this.#isAddressInUseError(v6Error)) { |
| 378 | this.#fatal('Error: Startup aborted because IPv6 is the only enabled protocol and its listen port is already in use.'); |
| 379 | } |
| 380 | this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled'); |
| 381 | } |
| 382 | |
| 383 | if (v4Failed && !useIPv6) { |
| 384 | if (this.#isAddressInUseError(v4Error)) { |
| 385 | this.#fatal('Error: Startup aborted because IPv4 is the only enabled protocol and its listen port is already in use.'); |
| 386 | } |
| 387 | this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled'); |
| 388 | } |
| 389 | |
| 390 | if (v6Failed && v4Failed) { |
| 391 | if (this.#isAddressInUseError(v6Error) && this.#isAddressInUseError(v4Error)) { |
| 392 | this.#fatal('Error: Failed to start server because the configured IPv6 and IPv4 listen ports are already in use.'); |
| 393 | } |
| 394 | this.#fatal('Error: Failed to start server on both IPv6 and IPv4'); |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | /** |
| 399 | * Performs the server startup. |
| 400 | * @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup |
| 401 | */ |
| 402 | async start() { |
| 403 | let useIPv6 = (this.cliArgs.enableIPv6 === true); |
| 404 | let useIPv4 = (this.cliArgs.enableIPv4 === true); |
| 405 | |
| 406 | if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') { |
| 407 | const ipQuery = await getHasIP(); |
| 408 | let hasIPv6 = false, hasIPv4 = false; |
| 409 | |
| 410 | hasIPv6 = this.cliArgs.listen ? ipQuery.hasIPv6Any : ipQuery.hasIPv6Local; |
| 411 | if (this.cliArgs.enableIPv6 === 'auto') { |
| 412 | useIPv6 = hasIPv6; |
| 413 | } |
| 414 | if (hasIPv6) { |
| 415 | if (useIPv6) { |
| 416 | console.log(color.green('IPv6 support detected')); |
| 417 | } else { |
| 418 | console.log('IPv6 support detected (but disabled)'); |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | hasIPv4 = this.cliArgs.listen ? ipQuery.hasIPv4Any : ipQuery.hasIPv4Local; |
| 423 | if (this.cliArgs.enableIPv4 === 'auto') { |
| 424 | useIPv4 = hasIPv4; |
| 425 | } |
| 426 | if (hasIPv4) { |
| 427 | if (useIPv4) { |
| 428 | console.log(color.green('IPv4 support detected')); |
| 429 | } else { |
| 430 | console.log('IPv4 support detected (but disabled)'); |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') { |
| 435 | if (!hasIPv6 && !hasIPv4) { |
| 436 | console.error('Both IPv6 and IPv4 are not detected'); |
| 437 | process.exit(1); |
| 438 | } |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | if (!useIPv6 && !useIPv4) { |
| 443 | console.error('Both IPv6 and IPv4 are disabled or not detected'); |
| 444 | process.exit(1); |
| 445 | } |
| 446 | |
| 447 | const [v6Failed, v4Failed, v6Error, v4Error] = await this.#startHTTPorHTTPS(useIPv6, useIPv4); |
| 448 | const result = { v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }; |
| 449 | this.#handleServerListenFail(result); |
| 450 | return result; |
| 451 | } |
| 452 | } |