diff --git a/esbuild/build.esb.js b/esbuild/build.esb.js index 13171ff..593cda6 100644 --- a/esbuild/build.esb.js +++ b/esbuild/build.esb.js @@ -24,7 +24,7 @@ const externalPackages = [ // TODO(pablo): this builds for me, but the bundle isn't running yet. esbuild .build({ - entryPoints: ['./src/server.js'], + entryPoints: ['./src/server/index.js'], outfile: './build/server-bundle.js', //outdir: 'build', bundle: true, diff --git a/package.json b/package.json index f3497b5..2cb41b8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@babel/core": "^7.18.10", "@babel/preset-env": "^7.22.10", "@babel/preset-typescript": "^7.23.2", + "@jest-mock/express": "^2.0.2", "babel-jest": "^28.1.3", "esbuild": "^0.18.16", "jest": "^29.6.2", diff --git a/src/server/healthcheck.js b/src/server/healthcheck.js new file mode 100644 index 0000000..e06c7a2 --- /dev/null +++ b/src/server/healthcheck.js @@ -0,0 +1,5 @@ +const healthcheckHandler = (req, res) => { + res.status(200).send() +} + +export default healthcheckHandler diff --git a/src/server/healthcheck.test.js b/src/server/healthcheck.test.js new file mode 100644 index 0000000..3949cb0 --- /dev/null +++ b/src/server/healthcheck.test.js @@ -0,0 +1,13 @@ +import healthcheckHandler from './healthcheck.js'; +import { getMockRes } from '@jest-mock/express'; + +describe('/healthcheck', () => { + it('should always return a 200 ok', () => { + const req = {} + const { res } = getMockRes() + + healthcheckHandler(req, res) + + expect(res.status).toHaveBeenLastCalledWith(200) + }) +}) diff --git a/src/server.js b/src/server/index.js similarity index 50% rename from src/server.js rename to src/server/index.js index 83db302..3dd51dd 100644 --- a/src/server.js +++ b/src/server/index.js @@ -1,16 +1,9 @@ import express from 'express' import * as Sentry from '@sentry/node' -import { - fitModelToFrame, - initThree, - render, - captureScreenshot, - parseCamera, -} from "./lib.js" -import {parseUrl} from './urls.js' -import {load} from './Loader.js' -import debug, {INFO} from './debug.js' -import {createTaggedLogger} from './logging' +import debug, {INFO} from '../debug.js' +import {createTaggedLogger} from '../logging.js' +import renderHandler from './render.js' +import healthcheckHandler from './healthcheck.js' const renderLogger = createTaggedLogger('/render') @@ -81,10 +74,8 @@ function loggingHandler(req, res, next) { app.use(loggingHandler) -app.post('/render', handler) -app.get('/healthcheck', (req, res) => { - res.status(200).send() -}) +app.get('/healthcheck', healthcheckHandler) +app.post('/render', renderHandler) // Install Sentry error handler after all routes but before any other error handlers app.use(Sentry.Handlers.errorHandler()) @@ -92,58 +83,3 @@ app.use(Sentry.Handlers.errorHandler()) app.listen(port, () => { debug(INFO).log(`Listening on 0.0.0.0:${port}`) }) - - -async function handler(req, res) { - const [glCtx, renderer, scene, camera] = initThree() - const modelUrl = new URL(req.body.url) - const parsedUrl = parseUrl(modelUrl) - renderLogger.log('debug', 'server#post, parsedUrl:', parsedUrl) - if (parsedUrl.target === undefined) { - renderLogger.warn(msg) - res.status(404).send(`Cannot parse URL: ${modelUrl}`).end() - return - } - const [px, py, pz, tx, ty, tz] = parsedUrl.params.c ? parseCamera(parsedUrl.params.c) : [0,0,0,0,0,0] - const targetUrl = parsedUrl.target.url - let model - try { - model = await load(targetUrl) - } catch (e) { - const msg = `Internal server error ${e}` - renderLogger.error(msg) - res.status(500).send(msg) - return - } - if (model === undefined) { - const msg = `Could not load model for unknown reason` - renderLogger.error(msg) - res.status(500).send(msg) - return - } - - // renderLogger.log('server#post, model:', model) - scene.add(model) - - if (parsedUrl.params.c) { - const [px, py, pz, tx, ty, tz] = parseCamera(parsedUrl.params.c) || [0,0,0,0,0,0] - renderLogger.log('debug', `headless#camera setting: camera.pos(${px}, ${py}, ${pz}) target.pos(${tx}, ${ty}, ${tz})`) - if (isFinite(px) && isFinite(py) && isFinite(pz)) { - camera.position.set(px, py, pz) - } - if (isFinite(tx) && isFinite(ty) && isFinite(tz)) { - renderLogger.log('debug', `server#post, camera.pos(${px}, ${py}, ${pz}) target.pos(${tx}, ${ty}, ${tz})`) - camera.lookAt(tx, ty, tz) - } else { - renderLogger.log('debug', `server#post, camera.pos(${px}, ${py}, ${pz}) target.pos(0, 0, 0)`) - camera.lookAt(0, 0, 0) - } - } else { - fitModelToFrame(renderer.domElement, scene, model, camera) - } - - const useSsaa = false - render(renderer, scene, camera, useSsaa) - res.setHeader('content-type', 'image/png') - captureScreenshot(glCtx).pipe(res) -} diff --git a/src/server/render.js b/src/server/render.js new file mode 100644 index 0000000..b1b3ab1 --- /dev/null +++ b/src/server/render.js @@ -0,0 +1,59 @@ +import { captureScreenshot, fitModelToFrame, initThree, parseCamera, render } from '../lib.js'; +import { parseUrl } from '../urls.js'; +import { load } from '../Loader.js'; + +const renderHandler = async (req, res) => { + const [glCtx, renderer, scene, camera] = initThree() + const modelUrl = new URL(req.body.url) + const parsedUrl = parseUrl(modelUrl) + renderLogger.log('debug', 'server#post, parsedUrl:', parsedUrl) + if (parsedUrl.target === undefined) { + renderLogger.warn(msg) + res.status(404).send(`Cannot parse URL: ${modelUrl}`).end() + return + } + const [px, py, pz, tx, ty, tz] = parsedUrl.params.c ? parseCamera(parsedUrl.params.c) : [0,0,0,0,0,0] + const targetUrl = parsedUrl.target.url + let model + try { + model = await load(targetUrl) + } catch (e) { + const msg = `Internal server error ${e}` + renderLogger.error(msg) + res.status(500).send(msg) + return + } + if (model === undefined) { + const msg = `Could not load model for unknown reason` + renderLogger.error(msg) + res.status(500).send(msg) + return + } + + // renderLogger.log('server#post, model:', model) + scene.add(model) + + if (parsedUrl.params.c) { + const [px, py, pz, tx, ty, tz] = parseCamera(parsedUrl.params.c) || [0,0,0,0,0,0] + renderLogger.log('debug', `headless#camera setting: camera.pos(${px}, ${py}, ${pz}) target.pos(${tx}, ${ty}, ${tz})`) + if (isFinite(px) && isFinite(py) && isFinite(pz)) { + camera.position.set(px, py, pz) + } + if (isFinite(tx) && isFinite(ty) && isFinite(tz)) { + renderLogger.log('debug', `server#post, camera.pos(${px}, ${py}, ${pz}) target.pos(${tx}, ${ty}, ${tz})`) + camera.lookAt(tx, ty, tz) + } else { + renderLogger.log('debug', `server#post, camera.pos(${px}, ${py}, ${pz}) target.pos(0, 0, 0)`) + camera.lookAt(0, 0, 0) + } + } else { + fitModelToFrame(renderer.domElement, scene, model, camera) + } + + const useSsaa = false + render(renderer, scene, camera, useSsaa) + res.setHeader('content-type', 'image/png') + captureScreenshot(glCtx).pipe(res) +} + +export default renderHandler diff --git a/yarn.lock b/yarn.lock index 6dcbde6..ed50695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1133,6 +1133,13 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== +"@jest-mock/express@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@jest-mock/express/-/express-2.0.2.tgz#e4f61c30b45e517c14b35ea5d67d89a7e52908f7" + integrity sha512-B1mjh5Tgm/HDd3BLC9s2jZNqRIxiJJD5rMWm48gEeK0K2hfUE66QZ+AxHxHlb/uaqL9H+PFJzCSjJPl46oNzDg== + dependencies: + "@types/express" "^4.17.17" + "@jest/console@^29.6.2": version "29.6.2" resolved "https://registry.npmjs.org/@jest/console/-/console-29.6.2.tgz" @@ -1544,6 +1551,21 @@ dependencies: "@babel/types" "^7.20.7" +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz" @@ -1556,6 +1578,26 @@ dependencies: "@types/ms" "*" +"@types/express-serve-static-core@^4.17.33": + version "4.17.41" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6" + integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.17": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz" @@ -1563,6 +1605,11 @@ dependencies: "@types/node" "*" +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz" @@ -1596,6 +1643,16 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" +"@types/mime@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" + integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + "@types/ms@*": version "0.7.32" resolved "https://registry.npmjs.org/@types/ms/-/ms-0.7.32.tgz" @@ -1606,6 +1663,33 @@ resolved "https://registry.npmjs.org/@types/node/-/node-20.4.8.tgz" integrity sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg== +"@types/qs@*": + version "6.9.10" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.10.tgz#0af26845b5067e1c9a622658a51f60a3934d51e8" + integrity sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" + integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== + dependencies: + "@types/http-errors" "*" + "@types/mime" "*" + "@types/node" "*" + "@types/set-cookie-parser@^2.4.0": version "2.4.4" resolved "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.4.tgz"