gergo/previews (#3765)

* feat(preview-generator): add new preview generator webapp

* wip(preview-service): reworking the preview service backend

* feat(previews): logging

* feat(preview-service): streamline payloads

* fix(preview-service): do not log the full payload

* feat(preview-service): build new preview service

* feat(preview-service): add separate response queue

* feat(previews): integrate preview queues with the server

* feat(previews): use module alias

* chore(previews): remove old preview service code

* feat(previews): log stuff on job statuses

* fix(previews): add missing deps and scripts

* fix(previews): package deps fix

* fix(server): moar typing fixes

* Metrics related to jobs: total count, request failures, response errors & durations

* duration should include unit.
- histogram metric should be summary
- error responses include duration in seconds
- attempt to remove metric before adding it (prevent errors with duplicate metrics)

* fix(server, frontend): some ts fixes

* fixes

* fix(frontend): remove unneeded ts-expect-error

* chore(preview-service): eslint

* TS fix

* feat(previews): more smoal fixes

* fix(preview-service): alias loading

* feat(helm): updates for new preview service queue setup

* feat(preview-service): launch new browser for each job

* feat(preview-service): add timeout, fix liveliness

* fix(helm): add access to new secret in service accounts

* tidy metrics into a separate file

* Remove broken preview service acceptance test

* fix broken import

* Add metrics to test

* feat(preview-service): handle preview service shutdown properly

* fix(previews): merge bork

---------

Co-authored-by: Iain Sproat <68657+iainsproat@users.noreply.github.com>
Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
This commit is contained in:
Gergő Jedlicska
2025-03-06 14:26:56 +01:00
committed by GitHub
parent fb6dc448ca
commit 61609de97e
106 changed files with 3530 additions and 6109 deletions
@@ -1,125 +0,0 @@
name: Preview service acceptance test
on:
workflow_dispatch:
pull_request: # Pushing a new commit to the HEAD ref of a pull request will trigger the “synchronize” event
paths:
- .yarnrc.yml .
- .yarn
- package.json
- '.github/workflows/preview-service-acceptance.yml'
- 'packages/frontend-2/type-augmentations/stubs/**/*'
- 'packages/preview-service/**/*'
- 'packages/viewer/**/*'
- 'packages/objectloader/**/*'
- 'packages/shared/**/*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/speckle-preview-service
OUTPUT_FILE_PATH: 'preview-service-output/${{ github.sha }}.png'
jobs:
build-preview-service:
name: Build Preview Service
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
packages: write # publishing container to GitHub registry
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5.5.1
with:
tags: type=sha,format=long
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and load preview-service Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./packages/preview-service/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs:
tags: ${{ steps.meta.outputs.tags }}
preview-service-acceptance:
name: Preview Service Acceptance test
runs-on: ubuntu-latest
needs: build-preview-service
permissions:
contents: write # to update the screenshot saved in the branch. This is a HACK as GitHub API does not yet support uploading attachments to a comment.
pull-requests: write # to write a comment on the PR
packages: read # to download the preview-service image
services:
postgres:
# Docker Hub image
image: postgres:16.4-bookworm@sha256:e62fbf9d3e2b49816a32c400ed2dba83e3b361e6833e624024309c35d334b412
env:
POSTGRES_DB: preview_service_test
POSTGRES_PASSWORD: preview_service_test
POSTGRES_USER: preview_service_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
preview-service:
image: ${{ needs.build-preview-service.outputs.tags }}
env:
# note that the host is the postgres service name
PG_CONNECTION_STRING: postgres://preview_service_test:preview_service_test@postgres:5432/preview_service_test
steps:
- uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- name: Install dependencies
working-directory: packages/preview-service
run: yarn install
- name: Run the acceptance test
working-directory: packages/preview-service
run: yarn test:acceptance
env:
NODE_ENV: test
TEST_DB: preview_service_test
# note that the host is localhost, but the port is the port mapped to the postgres service
PG_CONNECTION_STRING: postgres://preview_service_test:preview_service_test@localhost:5432/preview_service_test
OUTPUT_FILE_PATH: ${{ env.OUTPUT_FILE_PATH }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_REGION: ${{ vars.S3_REGION }}
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '📸 Preview service has generated <a href="${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/${{ env.OUTPUT_FILE_PATH}}">an image.</a>'
})
+5
View File
@@ -0,0 +1,5 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{}
-4
View File
@@ -83,10 +83,6 @@
"ws": "^8.17.1"
},
"devDependencies": {
"@babel/core": "^7.19.6",
"@babel/preset-env": "^7.19.4",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@datadog/datadog-ci": "^2.37.0",
"@eslint/config-inspector": "^0.4.10",
"@graphql-codegen/cli": "^5.0.5",
+7
View File
@@ -0,0 +1,7 @@
# preview-frontend
This app is the frontend of the preview service.
It is built into static assets and bundled into the final preview service.
To test the app locally, run `yarn dev` and call the functions available on the global `window` object.
@@ -0,0 +1,61 @@
import { baseConfigs, globals, getESMDirname } from '../../eslint.config.mjs'
import tseslint from 'typescript-eslint'
/**
* @type {Array<import('eslint').Linter.FlatConfig>}
*/
const configs = [
...baseConfigs,
{
files: ['**/*.js'],
languageOptions: {
sourceType: 'module'
}
},
{
files: ['*.{js,cjs,mjs,ts}'],
languageOptions: {
globals: {
...globals.node
}
}
},
{
files: ['**/*.src'],
languageOptions: {
globals: {
...globals.browser
}
}
},
...tseslint.configs.recommendedTypeChecked.map((c) => ({
...c,
files: [...(c.files || []), '**/*.ts', '**/*.d.ts']
})),
{
files: ['**/*.ts', '**/*.d.ts'],
languageOptions: {
parserOptions: {
tsconfigRootDir: getESMDirname(import.meta.url),
project: './tsconfig.json'
}
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-base-to-string': 'off', // too restrictive
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
'@typescript-eslint/no-unsafe-enum-comparison': 'off', // too restrictive
'@typescript-eslint/require-await': 'off', // too restrictive
'@typescript-eslint/unbound-method': 'off', // too restrictive
'@typescript-eslint/no-misused-promises': 'off'
}
},
{
rules: {
'no-console': 'off'
}
}
]
export default configs
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Speckle Viewer</title>
<style>
body {
font-family: 'Space Mono', monospace !important;
margin: 0px;
}
button {
font-family: 'Space Mono', monospace !important;
border-color: #0a66ff;
}
</style>
</head>
<body>
<div
id="renderer"
style="width: 700px; height: 400px; left: 0px; top: 0px; position: absolute"
></div>
<script type="module" defer="defer" src="/src/main.ts"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@speckle/preview-frontend",
"description": "Webapp to Generate PNG previews of Speckle objects",
"private": true,
"homepage": "https://speckle.systems",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/specklesystems/speckle-server.git",
"directory": "packages/preview-frontend"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "yarn lint:tsc && yarn lint:eslint",
"lint:tsc": "tsc --noEmit",
"lint:eslint": "eslint ."
},
"dependencies": {
"@speckle/shared": "workspace:^",
"@speckle/viewer": "workspace:^"
},
"devDependencies": {
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"typescript": "^5.7.2",
"typescript-eslint": "^7.12.0",
"vite": "^6.0.0"
}
}
+85
View File
@@ -0,0 +1,85 @@
import {
Load,
LoadArgs,
PreviewGenerator,
PreviewResult,
TakeScreenshot
} from '@speckle/shared/dist/esm/previews/interface.js'
import {
Viewer,
DefaultViewerParams,
SpeckleLoader,
UrlHelper,
UpdateFlags
} from '@speckle/viewer'
import { CameraController } from '@speckle/viewer'
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Window extends PreviewGenerator {}
}
let viewer: Viewer | undefined = undefined
const init = async (): Promise<Viewer> => {
/** Get the HTML container */
const container = document.getElementById('renderer') as HTMLElement
/** Configure the viewer params */
const params = DefaultViewerParams
params.showStats = false
params.verbose = false
/** Create Viewer instance */
const viewer = new Viewer(container, params)
/** Initialise the viewer */
await viewer.init()
/** Add the stock camera controller extension */
viewer.createExtension(CameraController)
return viewer
}
const load: Load = async ({ url, token }: LoadArgs) => {
if (!viewer) viewer = await init()
/** Create a loader for the speckle stream */
const resourceUrls = await UrlHelper.getResourceUrls(url, token)
for (const resourceUrl of resourceUrls) {
const loader = new SpeckleLoader(viewer.getWorldTree(), resourceUrl, token)
/** Load the speckle data */
await viewer.loadObject(loader, true)
}
}
window.load = load
// TODO: replace with sleep from speckle/shared
const waitForAnimation = async (ms = 70) =>
await new Promise((resolve) => {
setTimeout(resolve, ms)
})
const takeScreenshot: TakeScreenshot = async () => {
if (!viewer) viewer = await init()
const ret: PreviewResult = {
durationSeconds: 0,
screenshots: {}
}
const t0 = Date.now()
viewer.resize()
const cameraController = viewer.getExtension(CameraController)
cameraController.setCameraView([], false, 0.95)
await waitForAnimation(100)
for (let i = 0; i < 24; i++) {
cameraController.setCameraView({ azimuth: Math.PI / 12, polar: 0 }, false)
viewer.requestRender(UpdateFlags.RENDER_RESET)
await waitForAnimation(10)
ret.screenshots[i + ''] = await viewer.screenshot()
}
ret.durationSeconds = (Date.now() - t0) / 1000
return ret
}
window.takeScreenshot = takeScreenshot
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+1 -2
View File
@@ -1,8 +1,7 @@
PREVIEWS_HEADED='true'
CHROMIUM_EXECUTABLE_PATH='/usr/bin/google-chrome-stable'
USER_DATA_DIR='/tmp/puppeteer'
PG_CONNECTION_STRING='postgres://speckle:speckle@127.0.0.1/speckle'
POSTGRES_MAX_CONNECTIONS_PREVIEW_SERVICE='2'
REDIS_URL='redis://localhost'
PROMETHEUS_METRICS_PORT='9094'
PORT='3001'
LOG_LEVEL='info'
+1 -1
View File
@@ -1 +1 @@
public/
public
+18
View File
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch via YARN",
"request": "launch",
"console": "integratedTerminal",
"runtimeArgs": ["dev"],
"runtimeExecutable": "yarn",
"skipFiles": ["<node_internals>/**"],
"type": "node",
"envFile": "${workspaceFolder}/.env"
}
]
}
+7 -4
View File
@@ -29,6 +29,7 @@ COPY package.json yarn.lock ./
# Only copy in the relevant package.json files for the dependencies
COPY packages/frontend-2/type-augmentations/stubs ./packages/frontend-2/type-augmentations/stubs/
COPY packages/preview-frontend/package.json ./packages/preview-frontend/
COPY packages/preview-service/package.json ./packages/preview-service/
COPY packages/viewer/package.json ./packages/viewer/
COPY packages/objectloader/package.json ./packages/objectloader/
@@ -40,6 +41,7 @@ RUN yarn workspaces focus -A && yarn
COPY packages/shared ./packages/shared/
COPY packages/objectloader ./packages/objectloader/
COPY packages/viewer ./packages/viewer/
COPY packages/preview-frontend ./packages/preview-frontend/
COPY packages/preview-service ./packages/preview-service/
# This way the foreach only builds the frontend and its deps
@@ -86,15 +88,13 @@ COPY .yarn ./.yarn
COPY package.json yarn.lock ./
# Only copy in the relevant package.json files for the dependencies
COPY packages/frontend-2/type-augmentations/stubs ./packages/frontend-2/type-augmentations/stubs/
COPY packages/preview-service/package.json ./packages/preview-service/
WORKDIR /speckle-server/packages
COPY --link --from=build-stage /speckle-server/packages/shared ./shared
COPY --link --from=build-stage /speckle-server/packages/objectloader ./objectloader
COPY --link --from=build-stage /speckle-server/packages/viewer ./viewer
COPY --link --from=build-stage /speckle-server/packages/preview-service ./preview-service
COPY --link --from=build-stage /speckle-server/packages/preview-frontend/dist ./preview-service/public
WORKDIR /speckle-server/packages/preview-service
@@ -131,7 +131,10 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV CHROMIUM_EXECUTABLE_PATH="/usr/bin/google-chrome"
ENV USER_DATA_DIR='/tmp/puppeteer'
# Run everything after as non-privileged user.
USER pptruser
ENTRYPOINT [ "tini", "--", "node", "--loader=./dist/src/aliasLoader.js", "bin/www.js" ]
CMD [ "tini", "--", "node", "--loader=./dist/bootstrap.js", "dist/main.js" ]
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import '../dist/src/bin.js'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

+22 -47
View File
@@ -1,54 +1,27 @@
import { baseConfigs, globals, getESMDirname } from '../../eslint.config.mjs'
import tseslint from 'typescript-eslint'
import {
baseConfigs,
getESMDirname,
globals,
prettierConfig
} from '../../eslint.config.mjs'
/**
* @type {Array<import('eslint').Linter.FlatConfig>}
*/
const configs = [
...baseConfigs,
{
ignores: ['dist', 'public', 'docs']
},
{
files: ['webpack.config.renderPage.cjs'],
languageOptions: {
globals: {
...globals.node
}
}
},
{
files: ['**/*.js'],
ignores: ['renderPage', '**/*.mjs', 'src/scripts/puppeteerDriver.js'],
languageOptions: {
sourceType: 'module',
sourceType: 'module'
}
},
{
files: ['*.{js,cjs,mjs,ts}'],
languageOptions: {
globals: {
...globals.node
}
}
},
{
files: ['bin/www'],
languageOptions: {
sourceType: 'module',
globals: {
...globals.node
}
}
},
{
files: ['renderPage/**/*.js'],
languageOptions: {
sourceType: 'module',
globals: {
...globals.browser
}
}
},
{
files: ['src/scripts/puppeteerDriver.js'],
files: ['**/*.src'],
languageOptions: {
globals: {
...globals.browser
@@ -68,19 +41,21 @@ const configs = [
}
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-return': 'error'
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-base-to-string': 'off', // too restrictive
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
'@typescript-eslint/no-unsafe-enum-comparison': 'off', // too restrictive
'@typescript-eslint/require-await': 'off', // too restrictive
'@typescript-eslint/unbound-method': 'off', // too restrictive
'@typescript-eslint/no-misused-promises': 'off'
}
},
{
files: ['**/*.spec.{js,ts}'],
languageOptions: {
globals: {
...globals.node
}
rules: {
'no-console': 'off'
}
},
prettierConfig
}
]
export default configs
+18 -53
View File
@@ -15,78 +15,43 @@
"node": "^18.19.0"
},
"scripts": {
"build:tsc:watch": "tsc -p ./tsconfig.build.json --watch",
"build:webpack:watch": "webpack --env build --config webpack.config.renderPage.cjs --watch",
"run:watch": "NODE_ENV=development LOG_PRETTY=true LOG_LEVEL=debug nodemon --exec \"yarn start\" --trace-deprecation --watch ./bin/www.js --watch ./dist",
"dev": "concurrently \"npm:build:tsc:watch\" \"npm:build:webpack:watch\" \"npm:run:watch\"",
"dev:headed": "PREVIEWS_HEADED=true yarn dev",
"build:tsc": "rimraf ./dist/src && tsc -p ./tsconfig.build.json",
"build:webpack": "webpack --env build --config webpack.config.renderPage.cjs",
"build": "yarn build:tsc && yarn build:webpack",
"build:frontend": "yarn workspace @speckle/preview-frontend build",
"link:frontend": "yarn build:frontend && rimraf ./public && ln -s ../preview-frontend/dist ./public",
"dev": "tsx --env-file=.env --watch src/main.ts",
"publishTask": "tsx --env-file=.env scripts/publishTask.ts",
"test": "echo 'no tests configured'",
"lint": "yarn lint:tsc && yarn lint:eslint",
"lint:ci": "yarn lint:tsc",
"lint:tsc": "tsc --noEmit",
"lint:eslint": "eslint .",
"start": "node --loader=./dist/src/aliasLoader.js ./bin/www.js",
"test": "NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true vitest run --sequence.shuffle --exclude 'tests/acceptance/**/*.spec.ts'",
"test:acceptance": "NODE_ENV=test LOG_LEVEL=debug LOG_PRETTY=true vitest run 'tests/acceptance/acceptance.spec.ts' --sequence.shuffle --hookTimeout 60000 --testNamePattern 'Acceptance'"
"build": "tsc -p ./tsconfig.build.json"
},
"dependencies": {
"@speckle/objectloader": "workspace:^",
"@speckle/shared": "workspace:^",
"@speckle/viewer": "workspace:^",
"axios": "^1.7.7",
"cookie-parser": "~1.4.4",
"crypto": "^1.0.1",
"dotenv": "^16.4.5",
"esm-module-alias": "^2.2.0",
"bull": "^4.16.4",
"dotenv": "^16.4.7",
"esm-module-alias": "^2.2.1",
"express": "^4.19.2",
"file-type": "^16.5.4",
"http-errors": "~1.6.3",
"join-images": "^1.1.3",
"knex": "^2.5.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"node-fetch": "^2.6.1",
"pg": "^8.7.3",
"pg-query-stream": "^4.2.3",
"pino": "^8.7.0",
"pino-http": "^8.2.1",
"pino-http": "^8.6.1",
"pino-pretty": "^9.1.1",
"prom-client": "^14.0.1",
"puppeteer": "^22.11.1",
"sharp": "^0.32.6",
"tarn": "^3.0.2",
"yargs": "^17.3.0",
"zlib": "^1.0.5",
"puppeteer": "^23.9.0",
"znv": "^0.4.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.645.0",
"@babel/core": "^7.17.5",
"@swc/cli": "^0.5.1",
"@swc/core": "^1.9.3",
"@types/express": "^4.17.13",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18.19.38",
"@vitest/coverage-istanbul": "^1.6.0",
"babel-loader": "^8.2.2",
"clean-webpack-plugin": "^4.0.0-alpha.0",
"concurrently": "^8.2.2",
"crypto-random-string": "^5.0.0",
"csv-parse": "^5.5.6",
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vitest": "^0.5.4",
"html-webpack-plugin": "^5.3.1",
"iconv-lite": "^0.6.3",
"nodemon": "^2.0.20",
"prettier": "^2.5.1",
"rimraf": "^5.0.7",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^4.6.4",
"typescript-eslint": "^7.12.0",
"vitest": "^1.6.0",
"webpack": "^5.76.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^4.6.0"
"typescript-eslint": "^7.12.0"
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
This component generates object previews for Speckle Objects.
It reads preview tasks from the DB and uses Puppeteer and an internal Viewer to generate previews, which are currently stored in the DB.
It reads preview tasks from a Redis backed Bull queue and uses Puppeteer and an internal Viewer to generate previews, which are sent back to a response queue.
This is an overview of this service:
@@ -38,7 +38,7 @@ Finally, you can run the preview service with:
yarn dev
```
This will use the default dev DB connection of `postgres://speckle:speckle@127.0.0.1/speckle`. You can pass the environment variable `PG_CONNECTION_STRING` to change this to a different DB.
This will use the default dev DB connection. You can pass the environment variable `REDIS_URL` to change this to a different DB.
### In a docker image
@@ -1,3 +0,0 @@
# Render Page
This is the page that is rendered by Puppeteer within Chromium. It is packaged by Webpack to run in the browser context.
@@ -1,19 +0,0 @@
import { DefaultViewerParams, LegacyViewer } from '@speckle/viewer'
console.log('Initialising Viewer')
const v = new LegacyViewer(document.getElementById('renderer'), DefaultViewerParams)
window.v = v
// v.on( ViewerEvent.LoadProgress, args => logger.debug( args ) )
window.LoadData = async function LoadData(url) {
// token is not used in this context, since the preview service talks directly to the DB
await v.loadObject(url, undefined)
}
window.onload = () => {
const testUrl = window.location.hash.substring(1)
if (testUrl) {
window.LoadData(testUrl)
}
}
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Speckle Viewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" integrity="sha512-EZLkOqwILORob+p0BXZc+Vm3RgJBOe1Iq/0fiI7r/wJgzOFZMlsqTa29UEl6v6U6gsV4uIpsNZoV32YZqrCRCQ==" crossorigin="anonymous" />
-->
<link href="{%=o.htmlWebpackPlugin.files.favicon%}" rel="shortcut icon" />
<style type="text/css">
body {
font-family: 'Space Mono', monospace !important;
margin: 0px;
}
button {
font-family: 'Space Mono', monospace !important;
border-color: #0a66ff;
}
#renderer {
height: 400px;
width: 700px;
}
</style>
</head>
<body>
<div id="renderer"></div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,15 @@
import Bull from 'bull'
import { REDIS_URL } from '../src/config.js'
const jobQueue = new Bull('preview-service-jobs', REDIS_URL)
await jobQueue.add({
url: 'https://latest.speckle.systems/projects/8b94a55ee5/models/7f98c5b62e',
token: '',
jobId: '1',
responseQueue: 'preview-service-results'
})
console.log('published')
process.exit()
@@ -1,8 +0,0 @@
import generateAliasesResolver from 'esm-module-alias'
import { packageRoot, srcRoot } from './root.js'
import path from 'node:path'
export const resolve = generateAliasesResolver({
'@': srcRoot,
'#': path.resolve(packageRoot, './tests')
})
-15
View File
@@ -1,15 +0,0 @@
import '@/bootstrap.js' // This has side-effects and has to be imported first
import { startServer } from '@/server/server.js'
import { startPreviewService } from '@/server/background.js'
const start = async () => {
await startServer()
await startPreviewService()
}
start()
.then()
.catch((err) => {
throw err
})
+26
View File
@@ -1,2 +1,28 @@
import generateAliasesResolver from 'esm-module-alias'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'url'
import dotenv from 'dotenv'
dotenv.config()
/**
* Singleton module for src root and package root directory resolution
*/
const __filename = fileURLToPath(import.meta.url)
const srcRoot = path.dirname(__filename)
// Recursively walk back from __dirname till we find our package.json
let packageRoot = srcRoot
while (packageRoot !== '/') {
if (fs.readdirSync(packageRoot).includes('package.json')) {
break
}
packageRoot = path.resolve(packageRoot, '..')
}
export { srcRoot, packageRoot }
export const resolve = generateAliasesResolver({
'@': srcRoot,
'#': path.resolve(packageRoot, './tests')
})
@@ -1,6 +0,0 @@
import fs from 'fs'
export type UpdateHealthcheckData = () => void
export const updateHealthcheckDataFactory =
(deps: { healthCheckFilePath: string }) => () =>
fs.writeFile(deps.healthCheckFilePath, Date.now().toLocaleString(), () => {})
@@ -1,85 +0,0 @@
import { knexLogger as logger } from '@/observability/logging.js'
import {
getConnectionAcquireTimeoutMillis,
getConnectionCreateTimeoutMillis,
getPostgresConnectionString,
getPostgresMaxConnections,
isDevOrTestEnv,
isTest
} from '@/utils/env.js'
import Environment from '@speckle/shared/dist/commonjs/environment/index.js'
import {
loadMultiRegionsConfig,
configureKnexClient
} from '@speckle/shared/dist/commonjs/environment/multiRegionConfig.js'
import { Knex } from 'knex'
const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags()
type ConfiguredKnexClient = ReturnType<typeof configureKnexClient>
export type DbClients = Record<'main', ConfiguredKnexClient> &
Record<string, ConfiguredKnexClient>
let dbClients: DbClients
export const getDbClients = async () => {
if (dbClients) return dbClients
const maxConnections = getPostgresMaxConnections()
const connectionAcquireTimeoutMillis = getConnectionAcquireTimeoutMillis()
const connectionCreateTimeoutMillis = getConnectionCreateTimeoutMillis()
const configArgs = {
migrationDirs: [],
isTestEnv: isTest(),
isDevOrTestEnv: isDevOrTestEnv(),
logger,
maxConnections,
applicationName: 'speckle_preview_service',
connectionAcquireTimeoutMillis,
connectionCreateTimeoutMillis
}
if (!FF_WORKSPACES_MULTI_REGION_ENABLED) {
const mainClient = configureKnexClient(
{
postgres: {
connectionUri: getPostgresConnectionString()
}
},
configArgs
)
dbClients = { main: mainClient }
} else {
const configPath = process.env.MULTI_REGION_CONFIG_PATH || 'multiregion.json'
const config = await loadMultiRegionsConfig({ path: configPath })
const clients = [['main', configureKnexClient(config.main, configArgs)]]
Object.entries(config.regions).map(([key, config]) => {
clients.push([key, configureKnexClient(config, configArgs)])
})
dbClients = Object.fromEntries(clients) as DbClients
}
return dbClients
}
export const getProjectDbClient = async ({
projectId
}: {
projectId: string
}): Promise<Knex> => {
const dbClients = await getDbClients()
const mainDb = dbClients.main.public
if (!FF_WORKSPACES_MULTI_REGION_ENABLED) return mainDb
const projectRegion = await mainDb<{ id: string; regionKey: string | null }>(
'streams'
)
.select('id', 'regionKey')
.where({ id: projectId })
.first()
if (!projectRegion?.regionKey) return mainDb
const regionDb = dbClients[projectRegion.regionKey]
if (!regionDb)
throw new Error(`Project region client not found for ${projectRegion.regionKey}`)
return regionDb.public
}
@@ -1,28 +0,0 @@
import type { Angle, ObjectIdentifier } from '@/domain/domain.js'
import { isCastableToBrand } from '@/utils/brand.js'
import axios from 'axios'
import { z } from 'zod'
const previewResponseSchema = z.record(
z.string().refine((value): value is Angle => isCastableToBrand<Angle>(value)),
z.string()
)
export type GeneratePreview = (
task: ObjectIdentifier
) => Promise<Record<Angle, string | undefined>>
export const generatePreviewFactory =
({
serviceOrigin,
timeout
}: {
serviceOrigin: string
timeout: number
}): GeneratePreview =>
async (task: ObjectIdentifier) => {
const previewUrl = `${serviceOrigin}/preview/${task.streamId}/${task.objectId}`
const res = await axios.get(previewUrl, { timeout })
const previewResponse = previewResponseSchema.parse(res.data)
return previewResponse
}
@@ -1,91 +0,0 @@
import { extendLoggerComponent } from '@/observability/logging.js'
import { isDevelopment } from '@/utils/env.js'
import type { Logger } from 'pino'
import puppeteer, { type EvaluateFunc, type PuppeteerLaunchOptions } from 'puppeteer'
export type LoadPageAndEvaluateScript = (...args: unknown[]) => Promise<unknown>
export type PuppeteerClient = {
loadPageAndEvaluateScript: LoadPageAndEvaluateScript
dispose: () => Promise<void>
}
export const puppeteerClientFactory = async (deps: {
logger: Logger
url: string
script: EvaluateFunc<[unknown[]]>
launchParams?: PuppeteerLaunchOptions
timeoutMilliseconds: number
}): Promise<PuppeteerClient> => {
const logger = extendLoggerComponent(
deps.logger.child({ renderPageUrl: deps.url }),
'puppeteer'
)
const { url, script, launchParams } = deps
const browser = await puppeteer.launch({ ...launchParams, dumpio: isDevelopment() })
return {
loadPageAndEvaluateScript: async (...args: unknown[]) => {
if (!browser) {
const errorMessage = 'Browser must be initialized using init() before use.'
logger.error(errorMessage)
throw new Error(errorMessage)
}
logger.info('Loading page from {renderPageUrl}')
const page = await browser.newPage()
page.setDefaultTimeout(deps.timeoutMilliseconds)
await page.goto(url)
logger.info('Page loaded from {renderPageUrl}')
// Handle page crash (oom?)
page
.on('error', (err) => {
logger.error(err, 'Page crashed')
throw err
})
.on('console', (message) => {
let messageText = message.text()
if (messageText.startsWith('data:image'))
messageText = messageText.substring(0, 200).concat('...')
logger.debug(
{
puppeteerMessageType: message.type().substring(0, 3).toUpperCase()
},
`{puppeteerMessageType} ${messageText}`
)
})
.on('pageerror', (error) => {
logger.error(error, 'Puppeteer page encountered an error.')
})
.on('response', (response) =>
logger.info(
{
response: {
headers: response.headers(),
status: response.status(),
statusText: response.statusText()
}
},
'Response received from puppeteer page'
)
)
.on('requestfailed', (request) =>
logger.error(
{ err: request.failure()?.errorText, puppeteerPageUrl: request.url() },
'Request sent to puppeteer page failed'
)
)
const evaluationResult: unknown = await page.evaluate(script, args)
logger.info('Page evaluated with Puppeteer script.')
return evaluationResult
},
dispose: async () => {
if (!browser) return
await browser.close()
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from 'zod'
import { parseEnv } from 'znv'
export const {
REDIS_URL,
PORT,
PREVIEW_TIMEOUT,
PREVIEWS_HEADED,
CHROMIUM_EXECUTABLE_PATH,
USER_DATA_DIR,
LOG_LEVEL,
LOG_PRETTY
} = parseEnv(process.env, {
REDIS_URL: z.string().url(),
PORT: z.number(),
PREVIEW_TIMEOUT: z.number().default(3600000),
PREVIEWS_HEADED: z.boolean().default(false),
CHROMIUM_EXECUTABLE_PATH: z.string(),
USER_DATA_DIR: z.string(),
LOG_LEVEL: z.string().default('info'),
LOG_PRETTY: z.boolean().default(false)
})
@@ -1,7 +0,0 @@
export enum WorkStatus {
SUCCESS = 'SUCCESS',
NOWORKFOUND = 'NOWORKFOUND',
FAILED = 'FAILED'
}
export type WorkToBeDone = () => Promise<WorkStatus>
@@ -1 +0,0 @@
export const REQUEST_ID_HEADER = 'x-request-id'
@@ -1,14 +0,0 @@
import { Brand } from '@/utils/brand.js'
export type ObjectIdentifier = {
streamId: string
objectId: string
}
export type Preview = {
previewId: string
imgBuffer: Buffer
}
export type Angle = Brand<string, 'Angle'>
export type PreviewId = Brand<string, 'PreviewId'>
@@ -0,0 +1,103 @@
import { Page, Browser } from 'puppeteer'
import { PreviewGenerator } from '@speckle/shared/dist/esm/previews/interface.js'
import {
JobPayload,
PreviewResultPayload,
PreviewSuccessPayload
} from '@speckle/shared/dist/esm/previews/job.js'
import { Logger } from 'pino'
import { timeoutAt } from '@speckle/shared'
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Window extends PreviewGenerator {}
}
type SharedArgs = {
job: JobPayload
port: number
timeout: number
logger: Logger
}
type JobArgs = SharedArgs & {
browser: Browser
}
type PageArgs = SharedArgs & {
page: Page
}
export const jobProcessor = async ({
logger,
browser,
job,
port,
timeout
}: JobArgs): Promise<PreviewResultPayload> => {
const jobId = job.jobId
const jobLogger = logger.child({ jobId, serverUrl: job.url })
const start = new Date()
jobLogger.info('Picked up job {jobId} for {serverUrl}')
let page: Page | undefined = undefined
try {
page = await browser.newPage()
const result = await pageFunction({ page, job, logger: jobLogger, port, timeout })
const elapsed = (new Date().getTime() - start.getTime()) / 1000
jobLogger.info(
{ status: result.status, elapsed },
'Processes job {jobId} with result {status}. It took {elapsed} seconds.'
)
return result
} catch (err) {
const elapsed = (new Date().getTime() - start.getTime()) / 1000
jobLogger.error(
{ err, elapsed },
'Failed to process {jobId} job. It took {elapsed} seconds'
)
const reason =
err instanceof Error
? err.stack ?? err.toString()
: err instanceof Object
? err.toString()
: 'unknown error'
return {
jobId: job.jobId,
status: 'error',
result: {
durationSeconds: elapsed
},
reason
}
} finally {
await page?.close()
}
}
const pageFunction = async ({
page,
job,
port,
timeout,
logger
}: PageArgs): Promise<PreviewSuccessPayload> => {
page.on('error', (err) => {
logger.error({ err }, 'Page crashed')
throw err
})
await page.goto(`http://127.0.0.1:${port}/index.html`)
page.setDefaultTimeout(timeout)
// page.setDefaultTimeout(deps.timeoutMilliseconds)
const previewResult = await Promise.race([
page.evaluate(async (job: JobPayload) => {
await window.load(job)
return await window.takeScreenshot()
}, job),
timeoutAt(timeout)
])
return { jobId: job.jobId, status: 'success', result: previewResult }
}
+10
View File
@@ -0,0 +1,10 @@
import {
extendLoggerComponent,
getLogger
} from '@speckle/shared/dist/commonjs/observability/index.js'
import { LOG_LEVEL, LOG_PRETTY } from '@/config.js'
export const logger = extendLoggerComponent(
getLogger(LOG_LEVEL, LOG_PRETTY),
'preview-service'
)
+140
View File
@@ -0,0 +1,140 @@
import express from 'express'
import puppeteer, { Browser } from 'puppeteer'
import {
REDIS_URL,
PORT,
CHROMIUM_EXECUTABLE_PATH,
PREVIEWS_HEADED,
USER_DATA_DIR,
PREVIEW_TIMEOUT
} from '@/config.js'
import Bull from 'bull'
import { logger } from '@/logging.js'
import { jobProcessor } from '@/jobProcessor.js'
import { Redis, RedisOptions } from 'ioredis'
import { jobPayload } from '@speckle/shared/dist/esm/previews/job.js'
import { wait } from '@speckle/shared'
const app = express()
const port = PORT
// serve the preview-frontend
app.use(express.static('public'))
let client: Redis
let subscriber: Redis
const opts = {
// redisOpts here will contain at least a property of connectionName which will identify the queue based on its name
createClient(type: string, redisOpts: RedisOptions) {
switch (type) {
case 'client':
if (!client) {
client = new Redis(REDIS_URL, redisOpts)
}
return client
case 'subscriber':
if (!subscriber) {
subscriber = new Redis(REDIS_URL, {
...redisOpts,
maxRetriesPerRequest: null,
enableReadyCheck: false
})
}
return subscriber
case 'bclient':
return new Redis(REDIS_URL, {
...redisOpts,
maxRetriesPerRequest: null,
enableReadyCheck: false
})
default:
throw new Error('Unexpected connection type: ' + type)
}
}
}
const jobQueue = new Bull('preview-service-jobs', opts)
// store this callback, so on shutdown we can error the job
let jobDoneCallback: Bull.DoneCallback | undefined = undefined
const server = app.listen(port, async () => {
logger.info({ port }, '📡 Started Preview Service server, listening on {port}')
const launchBrowser = async (): Promise<Browser> => {
logger.debug('Starting browser')
return await puppeteer.launch({
headless: !PREVIEWS_HEADED,
executablePath: CHROMIUM_EXECUTABLE_PATH,
userDataDir: USER_DATA_DIR,
// we trust the web content that is running, so can disable the sandbox
// disabling the sandbox allows us to run the docker image without linux kernel privileges
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
})
}
logger.debug('Starting message queues')
// nothing after this line is getting called, this blocks
await jobQueue.process(async (payload, done) => {
try {
console.log('starting')
jobDoneCallback = done
await wait(30000)
const browser = await launchBrowser()
const parseResult = jobPayload.safeParse(payload.data)
if (!parseResult.success) {
logger.error({ parseError: parseResult.error }, 'Invalid job payload')
return done(parseResult.error)
}
const job = parseResult.data
const result = await jobProcessor({
logger,
browser,
job: parseResult.data,
port: PORT,
timeout: PREVIEW_TIMEOUT
})
const resultsQueue = new Bull(job.responseQueue, opts)
// with removeOnComplete, the job response potentially containing a large images,
// is cleared from the response queue
await resultsQueue.add(result, { removeOnComplete: true })
await browser.close()
done()
} catch (err) {
logger.error({ err }, 'Job processing failed')
if (err instanceof Error) {
done(err)
} else {
throw err
}
}
jobDoneCallback = undefined
})
})
const shutdown = async () => {
// stop accepting new jobs
await jobQueue.pause(
true, // just pausing this local worker of the queue
true // do not wait for active jobs to finish
)
// if there is a job currently running, cancell it with an error
if (jobDoneCallback) {
console.log('canceling job')
jobDoneCallback(new Error('Job cancelled due to perview-service shutdown'))
console.log('should be canceled')
}
logger.info('Received signal to shut down')
server.close(() => {
logger.debug('Exiting the express server')
process.exit()
})
}
process.on('SIGINT', async () => await shutdown())
process.on('SIGQUIT', async () => await shutdown())
process.on('SIGABRT', async () => await shutdown())
@@ -1,46 +0,0 @@
import { REQUEST_ID_HEADER } from '@/domain/const.js'
import { logger } from '@/observability/logging.js'
import { randomUUID } from 'crypto'
import type { Request } from 'express'
import type { IncomingHttpHeaders, IncomingMessage } from 'http'
import { get } from 'lodash-es'
import { pinoHttp } from 'pino-http'
function determineRequestId(headers: IncomingHttpHeaders, uuidGenerator = randomUUID) {
const idHeader = headers[REQUEST_ID_HEADER]
if (!idHeader) return uuidGenerator()
if (Array.isArray(idHeader)) return idHeader[0] ?? uuidGenerator()
return idHeader
}
const generateReqId = (req: IncomingMessage) => determineRequestId(req.headers)
export const getRequestPath = (req: IncomingMessage | Request) => {
const path = ((get(req, 'originalUrl') || get(req, 'url') || '') as string).split(
'?'
)[0]
return path?.length ? path : null
}
export const loggingExpressMiddleware = pinoHttp({
genReqId: generateReqId,
logger,
autoLogging: true,
// this is here, to force logging 500 responses as errors in the final log
// and we don't really care about 3xx stuff
// all the user related 4xx responses are treated as info
customLogLevel: (req, res, error) => {
const path = getRequestPath(req)
const shouldBeDebug = ['/metrics'].includes(path || '') ?? false
if (res.statusCode >= 400 && res.statusCode < 500) {
return 'info'
} else if (res.statusCode >= 500 || error) {
return 'error'
} else if (res.statusCode >= 300 && res.statusCode < 400) {
return 'silent'
}
return shouldBeDebug ? 'debug' : 'info'
}
})
@@ -1,14 +0,0 @@
import { getLogLevel, isLogPretty } from '@/utils/env.js'
import {
extendLoggerComponent as elc,
getLogger
} from '@speckle/shared/dist/commonjs/observability/index.js'
export const extendLoggerComponent = elc
export const logger = extendLoggerComponent(
getLogger(getLogLevel(), isLogPretty()),
'preview-service'
)
export const serverLogger = extendLoggerComponent(logger, 'server')
export const testLogger = getLogger(getLogLevel(), isLogPretty())
export const knexLogger = extendLoggerComponent(logger, 'knex')
@@ -1,28 +0,0 @@
import { getDbClients } from '@/clients/knex.js'
import { loggingExpressMiddleware } from '@/observability/expressLogging.js'
import { metricsRouterFactory } from '@/observability/metricsRoute.js'
import { initPrometheusMetrics } from '@/observability/prometheusMetrics.js'
import { errorHandler } from '@/utils/errorHandler.js'
import express from 'express'
import createError from 'http-errors'
export const appFactory = async () => {
const db = (await getDbClients()).main.public
initPrometheusMetrics({ db })
const app = express()
app.use(loggingExpressMiddleware)
app.use(express.json({ limit: '100mb' }))
app.use(express.urlencoded({ limit: '100mb', extended: false }))
app.use('/metrics', metricsRouterFactory())
// catch 404 and forward to error handler
app.use(function (req, _res, next) {
next(createError(404, `Not Found: ${req.url}`))
})
app.set('json spaces', 2) // pretty print json
app.use(errorHandler)
return app
}
@@ -1,15 +0,0 @@
import express, { RequestHandler } from 'express'
import prometheusClient from 'prom-client'
export const metricsRouterFactory = () => {
const metricsRouter = express.Router()
metricsRouter.get(
'/', //root path of the sub-path to which this router is attached (should be `/metrics`)
(async (_req, res) => {
res.setHeader('Content-Type', prometheusClient.register.contentType)
res.end(await prometheusClient.register.metrics())
}) as RequestHandler //FIXME: this works around a type error with async, which is resolved in express 5
)
return metricsRouter
}
@@ -1,170 +0,0 @@
import { logger } from '@/observability/logging.js'
import { getPostgresMaxConnections } from '@/utils/env.js'
import type { Knex } from 'knex'
import { isObject } from 'lodash-es'
import type { Counter, Histogram, Summary } from 'prom-client'
import prometheusClient from 'prom-client'
import { Pool } from 'tarn'
// let metricFree: Gauge<string> | null = null
// let metricUsed: Gauge<string> = null
// let metricPendingAquires: Gauge<string> | null = null
let metricQueryDuration: Summary<string> | null = null
let metricQueryErrors: Counter<string> | null = null
export let metricDuration: Histogram<string> | null = null
export let metricOperationErrors: Counter<string> | null = null
let prometheusInitialized = false
function isPrometheusInitialized() {
return prometheusInitialized
}
function initKnexPrometheusMetrics(params: { db: Knex }) {
const queryStartTime: Record<string, number> = {}
const { db } = params
if (!('pool' in db.client)) {
throw new Error(
'DB client does not have a pool. Skipping knex metrics initialization.'
)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const dbConnectionPool = db.client.pool as Pool<unknown>
//metricFree =
new prometheusClient.Gauge({
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(dbConnectionPool.numFree())
}
})
//metricUsed =
new prometheusClient.Gauge({
name: 'speckle_server_knex_used',
help: 'Number of used DB connections',
collect() {
this.set(dbConnectionPool.numUsed())
}
})
//metricPendingAquires =
new prometheusClient.Gauge({
name: 'speckle_server_knex_pending',
help: 'Number of pending DB connection aquires',
collect() {
this.set(dbConnectionPool.numPendingAcquires())
}
})
//metricPendingCreates =
new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_creates',
help: 'Number of pending DB connection creates',
collect() {
this.set(dbConnectionPool.numPendingCreates())
}
})
//metricPendingValidations =
new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_validations',
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
collect() {
this.set(dbConnectionPool.numPendingValidations())
}
})
//metricRemainingCapacity =
new prometheusClient.Gauge({
name: 'speckle_server_knex_remaining_capacity',
help: 'Remaining capacity of the DB connection pool',
collect() {
const postgresMaxConnections = getPostgresMaxConnections()
const demand =
dbConnectionPool.numUsed() +
dbConnectionPool.numPendingCreates() +
dbConnectionPool.numPendingValidations() +
dbConnectionPool.numPendingAcquires()
this.set(Math.max(postgresMaxConnections - demand, 0))
}
})
metricQueryDuration = new prometheusClient.Summary({
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
metricQueryErrors = new prometheusClient.Counter({
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
db.on('query', (data) => {
if (isObject(data) && '__knexQueryUid' in data) {
const queryId = String(data.__knexQueryUid)
queryStartTime[queryId] = Date.now()
}
})
db.on('query-response', (_data, obj) => {
if (isObject(obj) && '__knexQueryUid' in obj) {
const queryId = String(obj.__knexQueryUid)
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (metricQueryDuration && !isNaN(durationSec))
metricQueryDuration.observe(durationSec)
}
})
db.on('query-error', (_err, querySpec) => {
if (isObject(querySpec) && '__knexQueryUid' in querySpec) {
const queryId = String(querySpec.__knexQueryUid)
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (metricQueryDuration && !isNaN(durationSec))
metricQueryDuration.observe(durationSec)
if (metricQueryErrors) metricQueryErrors.inc()
}
})
}
export function initPrometheusMetrics(params: { db: Knex }) {
logger.info('Initializing Prometheus metrics...')
if (isPrometheusInitialized()) {
logger.info('Prometheus metrics already initialized')
return
}
prometheusInitialized = true
prometheusClient.register.clear()
prometheusClient.register.setDefaultLabels({
project: 'speckle-server',
app: 'preview-service'
})
try {
metricDuration = new prometheusClient.Histogram({
name: 'speckle_server_operation_duration',
help: 'Summary of the operation durations in seconds',
buckets: [0.5, 1, 5, 10, 30, 60, 300, 600, 1200, 1800],
labelNames: ['op']
})
metricOperationErrors = new prometheusClient.Counter({
name: 'speckle_server_operation_errors',
help: 'Number of operations with errors',
labelNames: ['op']
})
initKnexPrometheusMetrics(params)
prometheusClient.collectDefaultMetrics()
} catch (e) {
logger.error(e, 'Failed to initialize Prometheus metrics.')
prometheusInitialized = false
}
}
@@ -1,68 +0,0 @@
import type { Angle, ObjectIdentifier, PreviewId } from '@/domain/domain.js'
import type { Knex } from 'knex'
export type ObjectPreviewRow = ObjectIdentifier & {
preview: Record<Angle, PreviewId>
previewStatus: number
lastUpdate: number
}
export const ObjectPreview = (deps: { db: Knex }) =>
deps.db<ObjectPreviewRow>('object_preview')
export type GetNextUnstartedObjectPreview = () => Promise<ObjectIdentifier>
export const getNextUnstartedObjectPreviewFactory =
(deps: { db: Knex }): GetNextUnstartedObjectPreview =>
async () => {
const { db } = deps
const {
rows: [maybeRow]
} = await db.raw<{ rows: ObjectIdentifier[] }>(`
UPDATE object_preview
SET
"previewStatus" = 1,
"lastUpdate" = NOW()
FROM (
SELECT "streamId", "objectId" FROM object_preview
WHERE "previewStatus" = 0 OR ("previewStatus" = 1 AND "lastUpdate" < NOW() - INTERVAL '1 WEEK')
ORDER BY "priority" ASC, "lastUpdate" ASC
LIMIT 1
) as task
WHERE object_preview."streamId" = task."streamId" AND object_preview."objectId" = task."objectId"
RETURNING object_preview."streamId", object_preview."objectId"
`)
return maybeRow
}
export type UpdatePreviewMetadataParams = ObjectIdentifier & {
metadata: Record<string, string>
}
export type UpdatePreviewMetadata = (
params: UpdatePreviewMetadataParams
) => Promise<void>
export const updatePreviewMetadataFactory =
(deps: { db: Knex }): UpdatePreviewMetadata =>
async (params) => {
const { db } = deps
// Update preview metadata
await db.raw<void>(
`
UPDATE object_preview
SET
"previewStatus" = 2,
"lastUpdate" = NOW(),
"preview" = ?
WHERE "streamId" = ? AND "objectId" = ?
`,
[params.metadata, params.streamId, params.objectId]
)
}
export type NotifyUpdate = (params: ObjectIdentifier) => Promise<void>
export const notifyUpdateFactory =
(deps: { db: Knex }): NotifyUpdate =>
async (params) => {
const { db } = deps
await db.raw<void>(
`NOTIFY preview_generation_update, 'finished:${params.streamId}:${params.objectId}'`
)
}
@@ -1,92 +0,0 @@
import type { ObjectIdentifier } from '@/domain/domain.js'
import type { Knex } from 'knex'
import type { PassThrough } from 'stream'
export const Objects = (deps: { db: Knex }) => deps.db<DbObject>('objects')
type DbObject = {
id: string
streamId: string
data: object
totalChildrenCount: number
}
type ReturnedObject = {
id: string
data: { totalChildrenCount: number } & Record<string, unknown>
}
export type GetObject = (params: ObjectIdentifier) => Promise<ReturnedObject | null>
export const getObjectFactory =
(deps: { db: Knex }): GetObject =>
async ({ streamId, objectId }) => {
const { db } = deps
const res = await Objects({ db })
.where({ streamId, id: objectId })
.select('*')
.first()
if (!res) return null
const returned: ReturnedObject = {
id: res.id,
data: { totalChildrenCount: res.totalChildrenCount, ...res.data }
}
return returned
}
export type GetObjectChildrenStream = (params: ObjectIdentifier) => Promise<PassThrough>
export const getObjectChildrenStreamFactory =
(deps: { db: Knex }): GetObjectChildrenStream =>
async ({ streamId, objectId }) => {
const { db } = deps
const q = db.with(
'object_children_closure',
db.raw(
`SELECT objects.id as parent, d.key as child, d.value as mindepth, ? as "streamId"
FROM objects
JOIN jsonb_each_text(objects.data->'__closure') d ON true
where objects.id = ?`,
[streamId, objectId]
)
)
await q.select('id')
await q.select(db.raw('data::text as "dataText"'))
await q.from('object_children_closure')
await q
.rightJoin('objects', function () {
this.on('objects.streamId', '=', 'object_children_closure.streamId').andOn(
'objects.id',
'=',
'object_children_closure.child'
)
})
.where(
db.raw('object_children_closure."streamId" = ? AND parent = ?', [
streamId,
objectId
])
)
.orderBy('objects.id')
return q.stream({ highWaterMark: 500 })
}
type BatchObjectIdentifier = {
streamId: string
objectIds: string[]
}
export type GetObjectsStream = (params: BatchObjectIdentifier) => PassThrough
export const getObjectsStreamFactory =
(deps: { db: Knex }): GetObjectsStream =>
({ streamId, objectIds }) => {
const { db } = deps
const res = Objects({ db })
.whereIn('id', objectIds)
.andWhere('streamId', streamId)
.orderBy('id')
.select(
db.raw(
'"id", "speckleType", "totalChildrenCount", "totalChildrenCountByDepth", "createdAt", data::text as "dataText"'
)
)
return res.stream({ highWaterMark: 500 })
}
@@ -1,16 +0,0 @@
import type { Preview } from '@/domain/domain.js'
import type { Knex } from 'knex'
export type PreviewRow = { id: string; data: Buffer }
export const Previews = (deps: { db: Knex }) => deps.db<PreviewRow>('previews')
export type InsertPreview = (params: Preview) => Promise<void>
export const insertPreviewFactory =
(deps: { db: Knex }): InsertPreview =>
async (params) => {
const { db } = deps
await db.raw(
'INSERT INTO "previews" (id, data) VALUES (?, ?) ON CONFLICT DO NOTHING',
[params.previewId, params.imgBuffer]
)
}
-21
View File
@@ -1,21 +0,0 @@
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'url'
/**
* Singleton module for src root and package root directory resolution
*/
const __filename = fileURLToPath(import.meta.url)
const srcRoot = path.dirname(__filename)
// Recursively walk back from __dirname till we find our package.json
let packageRoot = srcRoot
while (packageRoot !== '/') {
if (fs.readdirSync(packageRoot).includes('package.json')) {
break
}
packageRoot = path.resolve(packageRoot, '..')
}
export { srcRoot, packageRoot }
@@ -1,47 +0,0 @@
export const puppeteerDriver = async (objectUrl) => {
const waitForAnimation = async (ms = 70) =>
await new Promise((resolve) => {
setTimeout(resolve, ms)
})
const ret = {
duration: 0,
mem: 0,
scr: {}
}
const t0 = Date.now()
await window.v.init()
try {
await window.v.loadObjectAsync(objectUrl)
} catch {
// Main call failed. Wait some time for other objects to load inside the viewer and generate the preview anyway
await waitForAnimation(1000)
}
window.v.resize()
window.v.zoom(undefined, 0.95, false)
await waitForAnimation(100)
for (let i = 0; i < 24; i++) {
window.v.setView({ azimuth: Math.PI / 12, polar: 0 }, false)
window.v.getRenderer().resetPipeline(true)
/** Not sure what the frame time when running pupeteer is, but it's not 16ms.
* That's why we're allowing more time between frames than probably needed
* In a future update, we'll have the viewer signal when convergence is complete
* regradless of how many frames/time that takes
*/
/** 22.11.2022 Alex: Commenting this out for now */
// await waitForAnimation(2500)
await waitForAnimation()
ret.scr[i + ''] = await window.v.screenshot()
}
ret.duration = (Date.now() - t0) / 1000
ret.mem = {
total: performance.memory.totalJSHeapSize,
used: performance.memory.usedJSHeapSize
}
ret.userAgent = navigator.userAgent
return ret
}
@@ -1,35 +0,0 @@
import { loggingExpressMiddleware } from '@/observability/expressLogging.js'
import { srcRoot } from '@/root.js'
import apiRouterFactory from '@/server/routes/api.js'
import indexRouterFactory from '@/server/routes/index.js'
import objectsRouterFactory from '@/server/routes/objects.js'
import previewRouterFactory from '@/server/routes/preview.js'
import { errorHandler } from '@/utils/errorHandler.js'
import express from 'express'
import createError from 'http-errors'
import path from 'path'
export const appFactory = () => {
const app = express()
app.use(loggingExpressMiddleware)
app.use(express.json({ limit: '100mb' }))
app.use(express.urlencoded({ limit: '100mb', extended: false }))
//webpack will build the renderPage and save it to the packages/preview-service/dist/public directory
app.use(express.static(path.join(srcRoot, '../public')))
app.use('/', indexRouterFactory())
app.use('/preview', previewRouterFactory())
app.use('/objects', objectsRouterFactory())
app.use('/api', apiRouterFactory())
// catch 404 and forward to error handler
app.use(function (req, res, next) {
next(createError(404, `Not Found: ${req.url}`))
})
app.set('json spaces', 2) // pretty print json
app.use(errorHandler)
return app
}
@@ -1,98 +0,0 @@
/**
* @fileoverview Background service for preview service. This service is responsible for generating 360 previews for objects.
*/
//FIXME this doesn't quite fit in the /server directory, but it's not a service either. It's a background worker.
import { updateHealthcheckDataFactory } from '@/clients/execHealthcheck.js'
import { generatePreviewFactory } from '@/clients/previewService.js'
import { WorkStatus } from '@/domain/backgroundWorker.js'
import { extendLoggerComponent, logger } from '@/observability/logging.js'
import { initPrometheusMetrics } from '@/observability/prometheusMetrics.js'
import {
getNextUnstartedObjectPreviewFactory,
notifyUpdateFactory,
updatePreviewMetadataFactory
} from '@/repositories/objectPreview.js'
import { insertPreviewFactory } from '@/repositories/previews.js'
import { generateAndStore360PreviewFactory } from '@/services/360preview.js'
import { pollForAndCreatePreviewFactory } from '@/services/pollForPreview.js'
import { throwUncoveredError, wait } from '@speckle/shared'
import {
getHealthCheckFilePath,
serviceOrigin,
getPreviewTimeout
} from '@/utils/env.js'
import { DbClients, getDbClients } from '@/clients/knex.js'
let shouldExit = false
export async function startPreviewService() {
const backgroundLogger = extendLoggerComponent(logger, 'backgroundWorker')
backgroundLogger.info('📸 Starting Preview Service background worker')
process.on('SIGTERM', () => {
shouldExit = true
backgroundLogger.info('Shutting down...')
})
process.on('SIGINT', () => {
shouldExit = true
backgroundLogger.info('Shutting down...')
})
const dbClients = await getDbClients()
// TODO, this should also be initialized for all DBs
initPrometheusMetrics({ db: dbClients.main.public })
const clientGenerator = infiniteDbClientsIterator(dbClients)
while (!shouldExit) {
const db = clientGenerator.next().value
if (!db) throw new Error('The infinite client generator failed to return a client')
const status = await pollForAndCreatePreviewFactory({
updateHealthcheckData: updateHealthcheckDataFactory({
healthCheckFilePath: getHealthCheckFilePath()
}),
getNextUnstartedObjectPreview: getNextUnstartedObjectPreviewFactory({ db }),
generateAndStore360Preview: generateAndStore360PreviewFactory({
generatePreview: generatePreviewFactory({
serviceOrigin: serviceOrigin(),
timeout: getPreviewTimeout()
}),
insertPreview: insertPreviewFactory({ db })
}),
updatePreviewMetadata: updatePreviewMetadataFactory({ db }),
notifyUpdate: notifyUpdateFactory({ db }),
logger: backgroundLogger
})()
switch (status) {
case WorkStatus.SUCCESS:
await wait(10)
break
case WorkStatus.NOWORKFOUND:
await wait(1000)
break
case WorkStatus.FAILED:
await wait(5000)
break
default:
throwUncoveredError(status)
}
}
process.exit(0)
}
function* infiniteDbClientsIterator(dbClients: DbClients) {
let index = 0
const dbClientEntries = Object.values(dbClients)
const clientCount = dbClientEntries.length
while (true) {
// reset index
if (index === clientCount) index = 0
const dbConnection = dbClientEntries[index]
index++
yield dbConnection.public
}
}
@@ -1,81 +0,0 @@
import { getProjectDbClient } from '@/clients/knex.js'
import { getObjectsStreamFactory } from '@/repositories/objects.js'
import { isSimpleTextRequested, simpleTextOrJsonContentType } from '@/utils/headers.js'
import { SpeckleObjectsStream } from '@/utils/speckleObjectsStream.js'
import express from 'express'
import { PassThrough, pipeline } from 'stream'
import zlib from 'zlib'
import { z } from 'zod'
const apiRouterFactory = () => {
const apiRouter = express.Router()
const getObjectsRequestBodySchema = z.object({
objects: z.preprocess((objects) => JSON.parse(String(objects)), z.array(z.string()))
})
// This method was copy-pasted from the server method, without authentication/authorization (this web service is an internal one)
apiRouter.post(
'/getobjects/:streamId',
(async (req, res) => {
const boundLogger = req.log.child({
streamId: req.params.streamId
})
const getObjectsRequestBody = await getObjectsRequestBodySchema.parseAsync(
req.body
)
res.writeHead(200, {
'Content-Encoding': 'gzip',
'Content-Type': simpleTextOrJsonContentType(req)
})
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const dbStream = getObjectsStreamFactory({ db: projectDb })({
streamId: req.params.streamId,
objectIds: getObjectsRequestBody.objects
})
// https://knexjs.org/faq/recipes.html#manually-closing-streams
// https://github.com/knex/knex/issues/2324
const responseCloseHandler = () => {
dbStream.end()
dbStream.destroy()
}
dbStream.on('close', () => {
res.removeListener('close', responseCloseHandler)
})
res.on('close', responseCloseHandler)
const speckleObjStream = new SpeckleObjectsStream(isSimpleTextRequested(req))
const gzipStream = zlib.createGzip()
pipeline(
dbStream,
speckleObjStream,
gzipStream,
new PassThrough({ highWaterMark: 16384 * 31 }),
res,
(err) => {
if (err) {
boundLogger.error(err, `Error streaming objects.`)
} else {
boundLogger.info(
{
numberOfStreamedObjects: getObjectsRequestBody.objects.length,
sizeOfStreamedObjectsMB: gzipStream.bytesWritten / 1000000
},
'Streamed {numberOfStreamedObjects} objects (size: {sizeOfStreamedObjectsMB} MB)'
)
}
}
)
}) as express.RequestHandler //FIXME: this works around a type error with async, which is resolved in express 5
)
return apiRouter
}
export default apiRouterFactory
@@ -1,13 +0,0 @@
import express from 'express'
const indexRouterFactory = () => {
const indexRouter = express.Router()
indexRouter.get('/', (_req, res) => {
res.send('Speckle Object Preview Service')
})
return indexRouter
}
export default indexRouterFactory
@@ -1,116 +0,0 @@
import { getProjectDbClient } from '@/clients/knex.js'
import {
getObjectChildrenStreamFactory,
getObjectFactory
} from '@/repositories/objects.js'
import { isSimpleTextRequested, simpleTextOrJsonContentType } from '@/utils/headers.js'
import { SpeckleObjectsStream } from '@/utils/speckleObjectsStream.js'
import express, { RequestHandler } from 'express'
import { PassThrough, pipeline } from 'stream'
import zlib from 'zlib'
const objectsRouterFactory = () => {
const objectsRouter = express.Router()
// This method was copy-pasted from the server method, without authentication/authorization (this web service is an internal one)
objectsRouter.get(
'/:streamId/:objectId',
async function (req, res) {
const boundLogger = req.log.child({
streamId: req.params.streamId,
objectId: req.params.objectId
})
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
// Populate first object (the "commit")
const obj = await getObjectFactory({ db: projectDb })({
streamId: req.params.streamId,
objectId: req.params.objectId
})
if (!obj) {
return res.status(404).send('Failed to find object.')
}
res.writeHead(200, {
'Content-Encoding': 'gzip',
'Content-Type': simpleTextOrJsonContentType(req)
})
const dbStream = await getObjectChildrenStreamFactory({ db: projectDb })({
streamId: req.params.streamId,
objectId: req.params.objectId
})
// https://knexjs.org/faq/recipes.html#manually-closing-streams
// https://github.com/knex/knex/issues/2324
const responseCloseHandler = () => {
dbStream.end()
dbStream.destroy()
}
dbStream.on('close', () => {
res.removeListener('close', responseCloseHandler)
})
res.on('close', responseCloseHandler)
const speckleObjStream = new SpeckleObjectsStream(isSimpleTextRequested(req))
const gzipStream = zlib.createGzip()
speckleObjStream.write(obj)
pipeline(
dbStream,
speckleObjStream,
gzipStream,
new PassThrough({ highWaterMark: 16384 * 31 }),
res,
(err) => {
if (err) {
switch (err.code) {
case 'ERR_STREAM_PREMATURE_CLOSE':
boundLogger.info({ err }, 'Client closed connection early')
break
default:
boundLogger.error({ err }, 'Error downloading object from stream')
break
}
} else {
boundLogger.info(
{ megaBytesWritten: gzipStream.bytesWritten / 1000000 },
'Downloaded object from stream (size: {megaBytesWritten} MB)'
)
}
}
)
} as RequestHandler //FIXME: this works around a type error with async, which is resolved in express 5
)
objectsRouter.get(
'/:streamId/:objectId/single',
(async (req, res) => {
const boundLogger = req.log.child({
streamId: req.params.streamId,
objectId: req.params.objectId
})
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const obj = await getObjectFactory({ db: projectDb })({
streamId: req.params.streamId,
objectId: req.params.objectId
})
if (!obj) {
return res.status(404).send('Failed to find object.')
}
boundLogger.info('Downloaded single object.')
res.send(obj.data)
}) as RequestHandler //FIXME: this works around a type error with async, which is resolved in express 5
)
return objectsRouter
}
export default objectsRouterFactory
@@ -1,71 +0,0 @@
import { puppeteerClientFactory } from '@/clients/puppeteer.js'
import { puppeteerDriver } from '@/scripts/puppeteerDriver.js'
import { getScreenshotFactory } from '@/services/screenshot.js'
import {
getChromiumExecutablePath,
getPreviewTimeout,
getPuppeteerUserDataDir,
serviceOrigin,
shouldBeHeadless
} from '@/utils/env.js'
import express, { RequestHandler } from 'express'
const previewRouterFactory = () => {
const previewRouter = express.Router()
previewRouter.get(
'/:streamId/:objectId',
async function (req, res) {
const { streamId, objectId } = req.params || {}
const safeParamRgx = /^[\w]+$/i
if (!safeParamRgx.test(streamId) || !safeParamRgx.test(objectId)) {
return res.status(400).json({ error: 'Invalid streamId or objectId!' })
}
const boundLogger = req.log.child({ streamId, objectId })
boundLogger.info('Requesting screenshot.')
//FIXME should we be creating a puppeteer client for every request, or per app instance?
const puppeteerClient = await puppeteerClientFactory({
logger: boundLogger,
url: `${serviceOrigin()}/render/`,
script: puppeteerDriver,
launchParams: {
headless: shouldBeHeadless(),
userDataDir: getPuppeteerUserDataDir(),
executablePath: getChromiumExecutablePath(),
protocolTimeout: getPreviewTimeout(),
// we trust the web content that is running, so can disable the sandbox
// disabling the sandbox allows us to run the docker image without linux kernel privileges
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
},
timeoutMilliseconds: getPreviewTimeout()
})
let screenshot: { [key: string]: string } | null = null
try {
screenshot = await getScreenshotFactory({
loadPageAndEvaluateScript: puppeteerClient.loadPageAndEvaluateScript,
logger: boundLogger,
serviceOrigin: serviceOrigin()
})({
objectId,
streamId
})
} finally {
await puppeteerClient.dispose()
}
if (!screenshot) {
return res.status(500).end()
}
res.setHeader('content-type', 'image/png')
res.send(screenshot)
} as RequestHandler //FIXME: this works around a type error with async, which is resolved in express 5
)
return previewRouter
}
export default previewRouterFactory
@@ -1,109 +0,0 @@
import { serverLogger } from '@/observability/logging.js'
import { appFactory as metricsAppFactory } from '@/observability/metricsApp.js'
import { appFactory } from '@/server/app.js'
import { getAppPort, getHost, getMetricsHost, getMetricsPort } from '@/utils/env.js'
import http from 'http'
import { isNaN, isString, toNumber } from 'lodash-es'
export const startServer = async (params?: { serveOnRandomPort?: boolean }) => {
/**
* Get port from environment and store in Express.
*/
const inputPort = params?.serveOnRandomPort ? 0 : normalizePort(getAppPort())
const app = appFactory()
app.set('port', inputPort)
// we place the metrics on a separate port as we wish to expose it to external monitoring tools, but do not wish to expose other routes (for now)
const inputMetricsPort = params?.serveOnRandomPort
? 0
: normalizePort(getMetricsPort())
const metricsApp = await metricsAppFactory()
metricsApp.set('port', inputMetricsPort)
/**
* Create HTTP server.
*/
const server = http.createServer(app)
const metricsServer = http.createServer(metricsApp)
/**
* Listen on provided port, on all network interfaces.
*/
const host = getHost()
server.on('error', onErrorFactory(inputPort))
server.on('listening', () => {
serverLogger.info('📡 Started Preview Service server')
onListening(server)
})
server.listen(inputPort, host)
const metricsHost = getMetricsHost()
metricsServer.on('error', onErrorFactory(inputPort))
metricsServer.on('listening', () => {
serverLogger.info('📊 Started Preview Service metrics server')
onListening(metricsServer)
})
metricsServer.listen(inputMetricsPort, metricsHost)
return { app, server, metricsServer }
}
export const stopServer = (params: { server: http.Server }) => {
const { server } = params
server.close()
}
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val: string | number) {
const port = toNumber(val)
if (!isNaN(port) && port >= 0) return port
throw new Error('Invalid port; port must be a positive integer.')
}
/**
* Event listener for HTTP server "error" event.
*/
const onErrorFactory = (port: string | number | false) => (error: Error) => {
if ('syscall' in error && error.syscall !== 'listen') {
throw error
}
const bind = isString(port) ? 'Pipe ' + port : 'Port ' + port
if (!('code' in error)) throw error
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
serverLogger.error(error, bind + ' requires elevated privileges')
process.exit(1)
case 'EADDRINUSE':
serverLogger.error(error, bind + ' is already in use')
process.exit(1)
default:
throw error
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening(referenceServer: http.Server) {
const addr = referenceServer.address()
if (!addr) throw new Error('Server address is not defined')
switch (typeof addr) {
case 'string':
serverLogger.info(`Listening on pipe ${addr}`)
return addr
default:
serverLogger.info(`Listening on port ${addr.port}`)
return addr.port
}
}
@@ -1,61 +0,0 @@
import type { GeneratePreview } from '@/clients/previewService.js'
import type { Angle, ObjectIdentifier, PreviewId } from '@/domain/domain.js'
import type { InsertPreview } from '@/repositories/previews.js'
import crypto from 'crypto'
import { joinImages } from 'join-images'
export type GenerateAndStore360Preview = (
task: ObjectIdentifier
) => Promise<{ metadata: Record<Angle, PreviewId> }>
export const generateAndStore360PreviewFactory =
(deps: {
generatePreview: GeneratePreview
insertPreview: InsertPreview
}): GenerateAndStore360Preview =>
async (task: ObjectIdentifier) => {
const responseBody = await deps.generatePreview(task)
// metadata is key of angle and value of previewId
const metadata: Record<Angle, PreviewId> = {}
const allImgsArr: Buffer[] = []
let i = 0
for (const aKey in responseBody) {
const angle = aKey as Angle
const value = responseBody[angle]
if (!value) {
continue
}
const imgBuffer = Buffer.from(
value.replace(/^data:image\/\w+;base64,/, ''),
'base64'
)
const previewId = crypto
.createHash('md5')
.update(imgBuffer)
.digest('hex') as PreviewId
// Save first preview image
if (i++ === 0) {
await deps.insertPreview({ previewId, imgBuffer })
metadata[angle] = previewId
}
allImgsArr.push(imgBuffer)
}
// stitch 360 image
const fullImg = await joinImages(allImgsArr, {
direction: 'horizontal',
offset: 700,
margin: '0 700 0 700',
color: { alpha: 0, r: 0, g: 0, b: 0 }
})
const png = fullImg.png({ quality: 95 })
const buff = await png.toBuffer()
const fullImgId = crypto.createHash('md5').update(buff).digest('hex') as PreviewId
await deps.insertPreview({ previewId: fullImgId, imgBuffer: buff })
metadata['all' as Angle] = fullImgId
return { metadata }
}
@@ -1,88 +0,0 @@
import type { UpdateHealthcheckData } from '@/clients/execHealthcheck.js'
import {
metricDuration,
metricOperationErrors
} from '@/observability/prometheusMetrics.js'
import type {
GetNextUnstartedObjectPreview,
NotifyUpdate,
UpdatePreviewMetadata
} from '@/repositories/objectPreview.js'
import type { GenerateAndStore360Preview } from '@/services/360preview.js'
import type { Logger } from 'pino'
import type { LabelValues } from 'prom-client'
import { WorkStatus, type WorkToBeDone } from '@/domain/backgroundWorker.js'
export const pollForAndCreatePreviewFactory =
(deps: {
updateHealthcheckData: UpdateHealthcheckData
getNextUnstartedObjectPreview: GetNextUnstartedObjectPreview
generateAndStore360Preview: GenerateAndStore360Preview
updatePreviewMetadata: UpdatePreviewMetadata
notifyUpdate: NotifyUpdate
logger: Logger
}): WorkToBeDone =>
async () => {
try {
const task = await deps.getNextUnstartedObjectPreview()
// notify the healthcheck that we are still alive
deps.updateHealthcheckData()
if (!task) {
return WorkStatus.NOWORKFOUND
}
const logger = deps.logger.child({
projectId: task.streamId,
objectId: task.objectId
})
logger.info('Found next preview task for {projectId}/{objectId}')
let metricDurationEnd:
| (<T extends string>(labels?: LabelValues<T>) => number)
| undefined = undefined
if (metricDuration) {
metricDurationEnd = metricDuration.startTimer()
}
try {
const { metadata } = await deps.generateAndStore360Preview(task)
await deps.updatePreviewMetadata({
metadata,
streamId: task.streamId,
objectId: task.objectId
})
logger.info(
{ previewStatus: 'succeeded' },
'Preview generation completed. Status: {previewStatus}'
)
await deps.notifyUpdate({ streamId: task.streamId, objectId: task.objectId })
} catch (err) {
logger.error(
{ err, previewStatus: 'failed' },
'Preview generation completed. Status: {previewStatus}'
)
await deps.updatePreviewMetadata({
metadata: { err: err instanceof Error ? err.message : JSON.stringify(err) },
streamId: task.streamId,
objectId: task.objectId
})
metricOperationErrors?.labels('preview').inc()
return WorkStatus.FAILED
}
if (metricDurationEnd) {
metricDurationEnd({ op: 'preview' })
}
return WorkStatus.SUCCESS
} catch (err) {
if (metricOperationErrors) {
metricOperationErrors.labels('main_loop').inc()
}
deps.logger.error(err, 'Error executing task')
return WorkStatus.FAILED
}
}
@@ -1,87 +0,0 @@
import { LoadPageAndEvaluateScript } from '@/clients/puppeteer.js'
import type { ObjectIdentifier } from '@/domain/domain.js'
import { reduce } from 'lodash-es'
import type { Logger } from 'pino'
import { z } from 'zod'
export type GetScreenshot = (
params: ObjectIdentifier
) => Promise<{ [key: string]: string } | null>
export const getScreenshotFactory =
(deps: {
loadPageAndEvaluateScript: LoadPageAndEvaluateScript
logger: Logger
serviceOrigin: string
}): GetScreenshot =>
async (params) => {
const objectUrl = `${deps.serviceOrigin}/streams/${params.streamId}/objects/${params.objectId}`
const RenderOutputSchema = z.object({
duration: z.number(),
mem: z.object({ total: z.number() }),
scr: z.record(z.string())
})
type RenderOutput = z.infer<typeof RenderOutputSchema>
let renderOutput: RenderOutput
try {
// assume it is of type RenderOutput, and validate later
const rawRenderOutput = await deps.loadPageAndEvaluateScript(objectUrl)
renderOutput = await RenderOutputSchema.parseAsync(rawRenderOutput)
} catch (err) {
if (err instanceof z.ZodError) {
deps.logger.error(
err,
'Error generating preview. Expected output was not returned.'
)
} else {
deps.logger.error(err, 'Error generating preview.')
}
return null
}
deps.logger.info(
{
durationSeconds: renderOutput.duration,
totalMemoryMB: renderOutput.mem.total / 1000000,
resultingImages: {
count: Object.keys(renderOutput.scr || {}).length,
totalStringSize: reduce(
renderOutput.scr || {},
(acc: number, val: string) => acc + val.length,
0
)
}
},
`Generated preview.`
)
return renderOutput.scr
// return `
// <html><body>
// <div>Generated by: ${ret.userAgent}</div>
// <div>Duration in seconds: ${ret.duration}</div>
// <div>Memory in MB: ${ret.mem.total / 1000000}</div>
// <div>Used Memory in MB: ${ret.mem.used / 1000000}</div>
// <img height="200px" src="${ret.scr['-2']}" /><br />
// <img height="200px" src="${ret.scr['-1']}" /><br />
// <img height="200px" src="${ret.scr['0']}" /><br />
// <img height="200px" src="${ret.scr['1']}" /><br />
// <img height="200px" src="${ret.scr['2']}" /><br />
// </body></html>
// `
// const imageBuffer = new Buffer.from(
// b64Image.replace(/^data:image\/\w+;base64,/, ''),
// 'base64'
// )
// // await page.waitForTimeout(500);
// //var response = await page.screenshot({
// // type: 'png',
// // clip: {x: 0, y: 0, width: 800, height: 800}
// //});
// return imageBuffer
}
@@ -1,9 +0,0 @@
declare const brand: unique symbol
export type Brand<T, TBrand extends string> = T & { [brand]: TBrand }
export const isCastableToBrand = <TBrand extends string>(
val: string | undefined | null
): val is TBrand => {
return !!val
}
-40
View File
@@ -1,40 +0,0 @@
function getIntFromEnv(envVarKey: string, aDefault = '0'): number {
return parseInt(process.env[envVarKey] || aDefault)
}
function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean {
return ['1', 'true', true].includes(process.env[envVarKey] || aDefault.toString())
}
export const getAppPort = () => process.env['PORT'] || '3001'
export const getChromiumExecutablePath = () => {
if (isDevelopment()) return undefined // use default
return process.env['CHROMIUM_EXECUTABLE_PATH'] || '/usr/bin/google-chrome-stable'
}
export const getHealthCheckFilePath = () =>
process.env['HEALTHCHECK_FILE_PATH'] || '/tmp/last_successful_query'
export const getHost = () => process.env['HOST'] || '127.0.0.1'
export const getMetricsHost = () => process.env['METRICS_HOST'] || '127.0.0.1'
export const getLogLevel = () => process.env['LOG_LEVEL'] || 'info'
export const getMetricsPort = () => process.env['PROMETHEUS_METRICS_PORT'] || '9094'
export const getNodeEnv = () => process.env['NODE_ENV'] || 'production'
export const getPostgresConnectionString = () =>
process.env['PG_CONNECTION_STRING'] || 'postgres://speckle:speckle@127.0.0.1/speckle'
export const getPostgresMaxConnections = () =>
getIntFromEnv('POSTGRES_MAX_CONNECTIONS_PREVIEW_SERVICE', '2')
export const getConnectionAcquireTimeoutMillis = () =>
getIntFromEnv('POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS', '16000')
export const getConnectionCreateTimeoutMillis = () =>
getIntFromEnv('POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS', '5000')
export const getPreviewTimeout = () => getIntFromEnv('PREVIEW_TIMEOUT', '3600000')
export const getPuppeteerUserDataDir = () => {
if (isDevelopment()) return undefined // use default
return process.env['USER_DATA_DIR'] || '/tmp/puppeteer'
}
export const isDevelopment = () =>
getNodeEnv() === 'development' || getNodeEnv() === 'dev'
export const isLogPretty = () => getBooleanFromEnv('LOG_PRETTY')
export const isProduction = () => getNodeEnv() === 'production'
export const isTest = () => getNodeEnv() === 'test'
export const isDevOrTestEnv = () => isDevelopment() || isTest()
export const serviceOrigin = () => `http://${getHost()}:${getAppPort()}`
export const shouldBeHeadless = () => !getBooleanFromEnv('PREVIEWS_HEADED')
@@ -1,25 +0,0 @@
import { ErrorRequestHandler } from 'express'
import { isNaN, isObject, isString } from 'lodash-es'
export const errorHandler: ErrorRequestHandler = (err, req, res) => {
if (
isObject(err) &&
'status' in err &&
typeof err.status === 'number' &&
!isNaN(err.status)
) {
res.status(err?.status)
} else {
res.status(500)
}
res.setHeader('Content-Type', 'application/json')
if (req.app.get('env') === 'development') {
res.send(JSON.stringify(err, undefined, 2))
} else if (isObject(err) && 'message' in err && isString(err.message)) {
res.send(JSON.stringify({ message: err.message }))
} else {
res.send(JSON.stringify({ message: 'Internal Server Error' }))
}
}
@@ -1,7 +0,0 @@
import express from 'express'
export const isSimpleTextRequested = (req: express.Request) =>
req.headers.accept === 'text/plain'
export const simpleTextOrJsonContentType = (req: express.Request) =>
isSimpleTextRequested(req) ? 'text/plain' : 'application/json'
@@ -1,9 +0,0 @@
import path from 'path'
import { fileURLToPath } from 'url'
export const getDirname = (importMetaUrl: string) => {
const __filename = fileURLToPath(importMetaUrl)
const __dirname = path.dirname(__filename)
return __dirname
}
@@ -1,50 +0,0 @@
import { Transform, type TransformCallback } from 'stream'
// A stream that converts database objects stream to "{id}\t{data_json}\n" stream or a json stream of obj.data fields
export class SpeckleObjectsStream extends Transform {
isFirstObject: boolean
simpleText: boolean
constructor(simpleText: boolean) {
super({ writableObjectMode: true })
this.simpleText = simpleText
if (!this.simpleText) this.push('[')
this.isFirstObject = true
}
_transform(
dbObj: { id: string; dataText: unknown; data: unknown },
_encoding: BufferEncoding,
callback: TransformCallback
) {
let objData = dbObj.dataText
if (objData === undefined) objData = JSON.stringify(dbObj.data)
try {
if (this.simpleText) {
this.push(`${dbObj.id}\t`)
this.push(objData)
this.push('\n')
} else {
// JSON output
if (!this.isFirstObject) this.push(',')
this.push(objData)
this.isFirstObject = false
}
callback()
} catch (e) {
if (typeof e === 'undefined' || e === null || e instanceof Error) {
callback(e)
} else {
callback(new Error(JSON.stringify(e)))
}
}
}
_flush(callback: TransformCallback) {
if (!this.simpleText) this.push(']')
callback()
}
}
@@ -1,3 +0,0 @@
# Acceptance tests
This directory contains acceptance tests for the Preview Service.
@@ -1,195 +0,0 @@
import { acceptanceTest } from '#/helpers/testExtensions.js'
import { ObjectPreview, type ObjectPreviewRow } from '@/repositories/objectPreview.js'
import { Previews } from '@/repositories/previews.js'
import cryptoRandomString from 'crypto-random-string'
import { afterEach, beforeEach, describe, expect } from 'vitest'
import { promises as fs } from 'fs'
import { OBJECTS_TABLE_NAME } from '#/migrations/migrations.js'
import type { Angle } from '@/domain/domain.js'
import { testLogger as logger } from '@/observability/logging.js'
import { parse } from 'csv-parse'
import iconv from 'iconv-lite'
import { createReadStream } from 'fs'
import { PutObjectCommand, PutObjectCommandInput, S3Client } from '@aws-sdk/client-s3'
import { finished } from 'stream/promises'
const getS3Config = () => {
return {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || ''
},
endpoint: process.env.S3_ENDPOINT || '',
forcePathStyle: true,
// s3ForcePathStyle: true,
// signatureVersion: 'v4',
region: process.env.S3_REGION || 'us-east-1'
}
}
describe.sequential('Acceptance', () => {
describe.sequential('Trigger and wait for the preview-service', () => {
beforeEach(() => {
// const dbName = inject('dbName')
logger.info('🤜 running acceptance test before-each')
})
afterEach(() => {
logger.info('🤛 running acceptance test after-each')
})
// we use integration test and not e2e test because we don't need the server
acceptanceTest(
'loads data, waits for completion, extracts rendered image',
{
timeout: 300000 //5 minutes
},
async ({ context }) => {
const { db } = context
// load data
const streamId = cryptoRandomString({ length: 10 })
const processFile = async (csvFileName: string) => {
const objectRows: Record<string, unknown>[] = []
// Initialize the parser
const parser = createReadStream(csvFileName)
.pipe(iconv.decodeStream('utf8'))
.pipe(
parse({
delimiter: ',',
quote: '"',
// escape: null,
// eslint-disable-next-line camelcase
skip_empty_lines: true,
// eslint-disable-next-line camelcase
auto_parse: true
})
)
parser.on('readable', () => {
let record: unknown
while ((record = parser.read()) !== null) {
if (!Array.isArray(record)) throw new Error('Invalid record')
if (record.length !== 7) throw new Error('Invalid record length')
// "id","speckleType","totalChildrenCount","totalChildrenCountByDepth","createdAt","data","streamId"
const row = {
id: <unknown>record[0],
speckleType: <unknown>record[1],
totalChildrenCount: <unknown>record[2],
totalChildrenCountByDepth: <unknown>record[3],
createdAt: <unknown>record[4],
data: <unknown>JSON.parse(record[5] as string),
streamId //NOTE we override the stream ID to ensure it's loaded into the test stream
}
objectRows.push(row)
}
})
parser.on('end', () => {
//no-op, completion of the stream is handled by the finished(parser) call.
})
parser.on('error', (err) => {
expect(err).not.toBeDefined()
})
await finished(parser)
return objectRows
}
const objectId = 'b81d1d9295a995d9479186324b6f145a' //base object in tests/data/deltas.csv
try {
const objectsToInsert = await processFile('tests/data/deltas.csv')
await db.batchInsert(OBJECTS_TABLE_NAME, objectsToInsert)
} catch (err) {
expect(err, 'Error parsing CSV file.').toBeUndefined()
}
const objectPreviewRow = {
streamId,
objectId,
priority: 0,
previewStatus: 0
}
await ObjectPreview({ db }).insert(objectPreviewRow).onConflict().ignore()
//poll the database until the preview is ready
let objectPreviewResult: Pick<ObjectPreviewRow, 'preview' | 'previewStatus'>[] =
[]
while (
objectPreviewResult.length === 0 ||
objectPreviewResult[0].previewStatus !== 2
) {
objectPreviewResult = await ObjectPreview({ db })
.select(['preview', 'previewStatus'])
.where('streamId', streamId)
.andWhere('objectId', objectId)
logger.info(
{ result: objectPreviewResult, streamId, objectId },
'🔍 Polled object preview for a result for {streamId} and {objectId}'
)
// wait a second before polling again
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const previewData = await Previews({ db })
.select(['data'])
.where('id', objectPreviewResult[0].preview['all' as Angle])
.first()
logger.info({ previewData }, '🔍 Retrieved preview data')
if (!previewData) {
expect(previewData).toBeDefined()
expect(previewData).not.toBeNull()
return //HACK to appease typescript
}
if (!process.env.OUTPUT_FILE_PATH)
throw new Error('OUTPUT_FILE_PATH environment variable not set')
const outputFilePath = process.env.OUTPUT_FILE_PATH
const s3Config = getS3Config()
if (s3Config.credentials.accessKeyId && s3Config.credentials.secretAccessKey) {
logger.info(
{ outputFilePath },
'S3 credentials provided, saving to S3 at {outputFilePath}'
)
const s3Client = new S3Client(s3Config)
const params: PutObjectCommandInput = {
Bucket: 'github-action-speckle-preview-service-acceptance-test',
Key: outputFilePath,
Body: previewData.data,
ACL: 'public-read',
Metadata: {
// Defines metadata tags.
// 'x-amz-meta-my-key': 'your-value'
}
}
const uploadObject = async () => {
try {
const data = await s3Client.send(new PutObjectCommand(params))
logger.info(
'Successfully uploaded object: ' + params.Bucket + '/' + params.Key
)
return data
} catch (err) {
logger.error(err, 'Failed to upload object')
}
}
await uploadObject()
} else {
logger.info(
{ outputFilePath },
'No S3 credentials provided, saving to local file system at {outputFilePath}'
)
await fs.writeFile(outputFilePath, previewData.data)
}
}
)
})
})
File diff suppressed because one or more lines are too long
@@ -1,34 +0,0 @@
// example tests to confirm the servers are running and the API is working
import { getServerPort } from '#/helpers/helpers.js'
import { e2eTest } from '#/helpers/testExtensions.js'
import { describe } from 'vitest'
describe.concurrent('E2E', () => {
describe.concurrent('Example', () => {
e2eTest('should start a server on an unique port', async ({ context }) => {
const port = getServerPort(context.server)
console.log(`port1 : ${port}`)
await Promise.resolve()
})
e2eTest('should start a server on a different port', async ({ context }) => {
const port = getServerPort(context.server)
console.log(`port2 : ${port}`)
await Promise.resolve()
})
})
describe.concurrent('adding a job in the database', () => {
e2eTest('should create a preview', async ({ context }) => {
const port = getServerPort(context.server)
console.log(`port3 : ${port}`)
//TODO add an object in the object store
//TODO add a job in the database
//wait for the job in the database to be updated
//wait for the job in the database to be completed
//ensure the preview is created
//ensure the preview has all the required angles
await Promise.resolve()
})
})
})
@@ -1,49 +0,0 @@
import { startServer } from '@/server/server.js'
import http from 'http'
import type { AddressInfo } from 'net'
import { getPostgresConnectionString } from '@/utils/env.js'
export const startAndWaitOnServers = async () => {
let serverAddress: string | AddressInfo | null = null
let metricsServerAddress: string | AddressInfo | null = null
const { app, server, metricsServer } = await startServer({
serveOnRandomPort: true
})
server.on('listening', () => {
serverAddress = server.address()
})
metricsServer.on('listening', () => {
metricsServerAddress = metricsServer.address()
})
//HACK wait until both servers are available
while (!serverAddress || !metricsServerAddress) {
// wait for the servers to start
await new Promise((resolve) => setTimeout(resolve, 100))
}
return { app, server, metricsServer }
}
export const getServerPort = (server: http.Server) => {
const address = server.address()
if (address && typeof address !== 'string') {
return address.port
}
throw new Error('Server port is not available')
}
export const customizePostgresConnectionString = (databaseName?: string) => {
const originalPostgresConnectionString = getPostgresConnectionString()
if (!databaseName) return originalPostgresConnectionString
const originalPostgresUrl = new URL(originalPostgresConnectionString)
const protocol = originalPostgresUrl.protocol
const user = originalPostgresUrl.username
const pass = originalPostgresUrl.password
const host = originalPostgresUrl.hostname
const port = originalPostgresUrl.port
const origin = `${protocol}//${user}:${pass}@${host}:${port}`
return new URL(databaseName, origin).toString()
}
@@ -1,95 +0,0 @@
import { stopServer } from '@/server/server.js'
import { inject, test } from 'vitest'
import { getTestDb } from '#/helpers/testKnexClient.js'
import { startAndWaitOnServers } from '#/helpers/helpers.js'
import type { Knex } from 'knex'
import { Server } from 'http'
export interface AcceptanceTestContext {
context: {
db: Knex
}
}
// vitest reference: https://vitest.dev/guide/test-context#fixture-initialization
export const acceptanceTest = test.extend<AcceptanceTestContext>({
// this key has to match the top level key in the interface (i.e. `context`). Some vitest typing magic at work here.
context: [
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async ({ task, onTestFinished }, use) => {
const dbName = inject('dbName')
// equivalent of beforeEach
const db = getTestDb(dbName)
// schedule the cleanup. Runs regardless of test status, and runs after afterEach.
onTestFinished(async () => {
//no-op
})
// now run the test
await use({ db })
},
{ auto: true } // we want to run this for each databaseIntegrationTest, even if the context is not explicitly requested by the test
]
})
export interface DatabaseIntegrationTestContext {
context: {
db: Knex.Transaction
}
}
// vitest reference: https://vitest.dev/guide/test-context#fixture-initialization
export const databaseIntegrationTest = test.extend<DatabaseIntegrationTestContext>({
// this key has to match the top level key in the interface (i.e. `context`). Some vitest typing magic at work here.
context: [
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async ({ task, onTestFinished }, use) => {
const dbName = inject('dbName')
// equivalent of beforeEach
const db = await getTestDb(dbName).transaction()
// schedule the cleanup. Runs regardless of test status, and runs after afterEach.
onTestFinished(async () => {
await db.rollback()
})
// now run the test
await use({ db })
},
{ auto: true } // we want to run this for each databaseIntegrationTest, even if the context is not explicitly requested by the test
]
})
export interface E2ETestContext extends DatabaseIntegrationTestContext {
context: {
db: Knex.Transaction
server: Server
metricsServer: Server
}
}
// vitest reference: https://vitest.dev/guide/test-context#fixture-initialization
export const e2eTest = test.extend<E2ETestContext>({
// this key has to match the top level key in the interface (i.e. `context`). Some vitest typing magic at work here.
context: [
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async ({ task, onTestFinished }, use) => {
const dbName = inject('dbName')
// equivalent of beforeEach
const db = await getTestDb(dbName).transaction()
const { server, metricsServer } = await startAndWaitOnServers()
// schedule the cleanup. Runs regardless of test status, and runs after afterEach.
onTestFinished(async () => {
if (server) stopServer({ server })
if (metricsServer) stopServer({ server: metricsServer })
if (db) await db.rollback()
})
// now run the test
await use({ db, server, metricsServer })
},
{ auto: true } // we want to run this for each e2eTest, even if the context is not explicitly requested by the test
]
})
@@ -1,20 +0,0 @@
/* eslint-disable camelcase */
import { knex } from 'knex'
import { customizePostgresConnectionString } from '#/helpers/helpers.js'
export const getTestDb = (databaseName?: string) =>
knex({
client: 'pg',
connection: {
application_name: 'speckle_preview_service',
connectionString: customizePostgresConnectionString(databaseName)
},
pool: { min: 0, max: 2 }
// migrations are managed in the server package for production
// for tests, we are creating a new database for each test run so we can't use this default migration functionality
// migrations: {
// extension: '.ts',
// directory: path.resolve(__dirname, '../migrations'),
// loadExtensions: ['js', 'ts']
// }
})
@@ -1,79 +0,0 @@
/**
* These hooks are run once, before and after the test suite.
* It is configured via the vitest.config.ts file.
*/
import '@/bootstrap.js' // This has side-effects and has to be imported first
import { getTestDb } from '#/helpers/testKnexClient.js'
import { down, up } from '#/migrations/migrations.js'
import { testLogger as logger } from '@/observability/logging.js'
import cryptoRandomString from 'crypto-random-string'
import type { GlobalSetupContext } from 'vitest/node'
declare module 'vitest' {
export interface ProvidedContext {
dbName: string
}
}
const dbName =
process.env.TEST_DB || // in the acceptance tests we need to use a database name that is known prior to the test running
`preview_service_${cryptoRandomString({
length: 10,
type: 'alphanumeric'
})}`.toLocaleLowerCase() //postgres will automatically lower case new db names
let isDatabaseCreatedExternally = true
/**
* Global setup hook
* This hook is run once before any tests are run
* Defined in vitest.config.ts under test.globalSetup
*/
export async function setup({ provide }: GlobalSetupContext) {
logger.info('🏃🏻‍♀️‍➡️ Running vitest setup global hook')
const superUserDbClient = getTestDb()
const dbAlreadyExists = await superUserDbClient('pg_database')
.select('datname')
.where('datname', dbName)
if (!dbAlreadyExists.length) {
isDatabaseCreatedExternally = false
await superUserDbClient.raw(`CREATE DATABASE ${dbName}
WITH
OWNER = preview_service_test
ENCODING = 'UTF8'
TABLESPACE = pg_default
CONNECTION LIMIT = -1;`)
}
await superUserDbClient.destroy() // need to explicitly close the connection in clients to prevent hanging tests
// this provides the dbName to all tests, and can be accessed via inject('dbName'). NB: The test extensions already implement this, so use a test extension.
provide('dbName', dbName)
const db = getTestDb(dbName)
await up(db) //we need the migration to occur in our new database, so cannot use knex's built in migration functionality.
await db.destroy() // need to explicitly close the connection in clients to prevent hanging tests
logger.info(
`💁🏽‍♀️ Completed the vitest setup global hook. Database created at ${dbName}`
)
}
/**
* Global teardown hook
* This hook is run once after all tests are run
* Defined in vitest.config.ts under test.globalTeardown
*/
export async function teardown() {
logger.info('🏃🏻‍♀️ Running vitest teardown global hook')
const db = getTestDb(dbName)
await down(db) //we need the migration to occur in our named database, so cannot use knex's built in migration functionality.
await db.destroy() // need to explicitly close the connection in clients to prevent hanging tests
if (!isDatabaseCreatedExternally) {
//use connection without database to drop the db
const superUserDbClient = getTestDb()
await superUserDbClient.raw(`DROP DATABASE ${dbName};`)
await superUserDbClient.destroy() // need to explicitly close the connection in clients to prevent hanging tests
}
logger.info(
`✅ Completed the vitest teardown global hook. Destroyed database at ${dbName}`
)
}
@@ -1,38 +0,0 @@
import { databaseIntegrationTest } from '#/helpers/testExtensions.js'
import {
ObjectPreview,
getNextUnstartedObjectPreviewFactory
} from '@/repositories/objectPreview.js'
import cryptoRandomString from 'crypto-random-string'
import { describe, expect } from 'vitest'
describe.concurrent('Repositories: ObjectPreview', () => {
describe.concurrent('getNextUnstartedObjectPreview', () => {
databaseIntegrationTest(
'should return the next unstarted object preview',
async ({ context }) => {
const streamId = cryptoRandomString({ length: 10 })
const objectId = cryptoRandomString({ length: 10 })
const insertionObject = {
streamId,
objectId,
priority: 0,
previewStatus: 0
}
const sqlQuery = ObjectPreview({ db: context.db })
.insert(insertionObject)
.onConflict()
.ignore()
await context.db.raw(sqlQuery.toQuery())
const getNextUnstartedObjectPreview = getNextUnstartedObjectPreviewFactory({
db: context.db
})
const result = await getNextUnstartedObjectPreview()
expect(result).toBeDefined()
expect(result.streamId).toEqual(streamId)
expect(result.objectId).toEqual(objectId)
}
)
})
})
@@ -1,7 +0,0 @@
# Knex Migrations
This is not your regular knex migrations directory.
Because the test database is expected to be in a clean state before each test, we need to run migrations rollback and up before each run of tests and additionally rollback after each run.
Therefore we can just have one single migration file, and don't need to version it.
@@ -1,106 +0,0 @@
import type { Knex } from 'knex'
const OBJECT_PREVIEW_TABLE_NAME = 'object_preview'
const PREVIEWS_TABLE_NAME = 'previews'
export const OBJECTS_TABLE_NAME = 'objects'
const DB_NAME_PREFIX = 'preview_service_'
const getDatabaseName = (deps: { db: Knex }) => {
return deps.db.raw<{ rows: { datname: string }[] }>(
`SELECT current_database() as datname`
)
}
const getAllTableNames = (deps: { db: Knex }) => {
return deps.db.raw<{ rows: { tablename: string }[] }>(
`SELECT tablename FROM pg_tables WHERE schemaname='public'`
)
}
const throwIfDbNameDoesNotStartWithPrefix = async (deps: { db: Knex }) => {
const { rows: dbNameRows } = await getDatabaseName(deps)
const dbName = dbNameRows[0].datname
if (!dbName.startsWith(DB_NAME_PREFIX)) {
throw new Error(
`Database name does not start with "${DB_NAME_PREFIX}", it is unsafe to migrate to test schema. Aborting.`
)
}
}
const hasExpectedTableNames = (params: { tableNames: string[] }) => {
const { tableNames } = params
return (
tableNames.length === 3 &&
[OBJECT_PREVIEW_TABLE_NAME, OBJECTS_TABLE_NAME, PREVIEWS_TABLE_NAME].every((t) =>
tableNames.includes(t)
)
)
}
const throwIfNotSafeToMigrateUp = async (deps: { db: Knex }) => {
await throwIfDbNameDoesNotStartWithPrefix(deps)
const { rows } = await getAllTableNames(deps)
const tableNames = rows.map((x) => x.tablename)
if (tableNames.length > 0 && !hasExpectedTableNames({ tableNames })) {
throw new Error(
`Database has unexpected tables, it is unsafe to migrate to test schema. Aborting. Tables found: ${tableNames.join(
', '
)}`
)
}
}
const throwIfNotSafeToMigrateDown = async (deps: { db: Knex }) => {
await throwIfDbNameDoesNotStartWithPrefix(deps)
const { rows } = await getAllTableNames(deps)
const tableNames = rows.map((x) => x.tablename)
if (!hasExpectedTableNames({ tableNames })) {
throw new Error(
`Database already has unexpected tables, it is unsafe to migrate to test schema. Aborting. Tables found: ${tableNames.join(
', '
)}`
)
}
}
export const up = async (db: Knex) => {
await throwIfNotSafeToMigrateUp({ db })
await db.schema.createTable(OBJECT_PREVIEW_TABLE_NAME, (table) => {
table.string('streamId', 10) //ignoring fk on streams table for simplicity
table.string('objectId').notNullable()
table.integer('previewStatus').notNullable().defaultTo(0) //TODO should be an enum
table.integer('priority').notNullable().defaultTo(1)
table.timestamp('lastUpdate').notNullable().defaultTo(db.fn.now())
table.jsonb('preview')
table.primary(['streamId', 'objectId'])
table.index(['previewStatus', 'priority', 'lastUpdate'])
})
await db.schema.createTable(PREVIEWS_TABLE_NAME, (table) => {
table.string('id').primary()
table.binary('data')
})
await db.schema.createTable(OBJECTS_TABLE_NAME, (table) => {
table.string('id')
table.string('streamId', 10) //ignoring fk on streams table for simplicity
table.string('speckleType', 1024).defaultTo('Base').notNullable()
table.integer('totalChildrenCount')
table.jsonb('totalChildrenCountByDepth')
table.timestamp('createdAt').defaultTo(db.fn.now())
table.jsonb('data')
table.index('id')
table.index('streamId')
table.primary(['streamId', 'id'])
})
}
export const down = async (db: Knex) => {
await throwIfNotSafeToMigrateDown({ db })
await db.schema.dropTable(OBJECT_PREVIEW_TABLE_NAME)
await db.schema.dropTable(PREVIEWS_TABLE_NAME)
await db.schema.dropTable(OBJECTS_TABLE_NAME)
}
@@ -1,48 +0,0 @@
import { logger } from '@/observability/logging.js'
import { pollForAndCreatePreviewFactory } from '@/services/pollForPreview.js'
import { describe, expect, it } from 'vitest'
describe.concurrent('Polling for preview', () => {
describe.concurrent('pollForAndCreatePreview', () => {
it('calls all component functions with expected parameters', async () => {
const called: Record<string, number> = {}
const pollForAndCreatePreview = pollForAndCreatePreviewFactory({
updateHealthcheckData: () => {
called['updateHealthcheckData'] = called['updateHealthcheckData']++ || 1
},
getNextUnstartedObjectPreview: async () =>
Promise.resolve({
streamId: 'streamId',
objectId: 'objectId'
}),
generateAndStore360Preview: async (task) => {
called['generateAndStore360Preview'] =
called['generateAndStore360Preview']++ || 1
expect(task).toEqual({ streamId: 'streamId', objectId: 'objectId' })
return Promise.resolve({ metadata: { all: 'myJoinedUpPreviewId' } })
},
updatePreviewMetadata: async (params) => {
called['updatePreviewMetadata'] = called['updatePreviewMetadata']++ || 1
expect(params).toEqual({
metadata: { all: 'myJoinedUpPreviewId' },
streamId: 'streamId',
objectId: 'objectId'
})
return Promise.resolve()
},
notifyUpdate: async (task) => {
called['notifyUpdate'] = called['notifyUpdate']++ || 1
expect(task).toEqual({ streamId: 'streamId', objectId: 'objectId' })
return Promise.resolve()
},
logger
})
await pollForAndCreatePreview()
expect(called['updateHealthcheckData']).toBeGreaterThanOrEqual(1)
expect(called['generateAndStore360Preview']).toBeGreaterThanOrEqual(1)
expect(called['updatePreviewMetadata']).toBeGreaterThanOrEqual(1)
expect(called['notifyUpdate']).toBeGreaterThanOrEqual(1)
})
})
})
@@ -1,47 +0,0 @@
import { LoadPageAndEvaluateScript } from '@/clients/puppeteer.js'
import { logger } from '@/observability/logging.js'
import { getScreenshotFactory } from '@/services/screenshot.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('Screenshot', () => {
afterEach(() => {
vi.restoreAllMocks()
})
describe('with Puppeteer returning a valid responses', () => {
const loadPageAndEvaluateScript: LoadPageAndEvaluateScript = (
urlOfObjectToScreenshot
) => {
//NOTE if this expectation fails it won't get explicitly captured by vitest. Instead we get null output from getScreenshot.
expect(urlOfObjectToScreenshot).toBe(
'http://localhost:0000/streams/streamId/objects/objectId'
)
return Promise.resolve({
duration: 1000,
mem: { total: 500, used: 400 },
userAgent: 'Test Testerson',
scr: {
'0': 'data:image/png;base64,foobar',
'1': 'data:image/png;base64,foobar'
}
})
}
it('receives the screenshot', async () => {
const getScreenshot = getScreenshotFactory({
loadPageAndEvaluateScript,
logger,
serviceOrigin: 'http://localhost:0000'
})
const screenshot = await getScreenshot({
streamId: 'streamId',
objectId: 'objectId'
})
if (!screenshot) {
expect(screenshot).not.toBe(null)
return //to avoid TS error
}
expect(screenshot['0']).toBe('data:image/png;base64,foobar')
expect(screenshot['1']).toBe('data:image/png;base64,foobar')
})
})
})
+55 -48
View File
@@ -1,12 +1,16 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
"paths": {
"@/*": ["./src/*"]
},
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
@@ -14,96 +18,99 @@
"target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "node16" /* Specify what module code is generated. */,
"rootDir": "./" /* Specify the root folder within your source files. */,
"moduleResolution": "node16" /* Specify how TypeScript looks up a file from a given module specifier. */,
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
"paths": {
"@/*": ["./src/*"],
"#/*": ["./tests/*"]
},
"module": "ES2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node",
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"ts-node": {
"swc": true
},
"include": ["src/**/*", "tests/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "coverage", "reports"]
}
}
-20
View File
@@ -1,20 +0,0 @@
import path from 'path'
import { configDefaults, defineConfig } from 'vitest/config'
export default defineConfig({
test: {
exclude: [...configDefaults.exclude],
globalSetup: ['./tests/hooks/globalSetup.ts'],
// reporters: ['verbose', 'hanging-process'] //uncomment to debug hanging processes etc.
sequence: {
shuffle: true,
concurrent: true
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'#': path.resolve(__dirname, './tests')
}
}
})
@@ -1,72 +0,0 @@
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const path = require('path')
const yargs = require('yargs')
const env = yargs.argv.env
const filename = 'viewer'
let outputFile, mode
if (env === 'build') {
mode = 'production'
outputFile = filename + '.min.js'
} else {
mode = 'development'
outputFile = filename + '.js'
}
/**
* @type {import('webpack').Configuration}
*/
const config = {
mode,
entry: path.resolve(path.join(__dirname, 'renderPage', 'src', 'app.js')),
target: 'web',
devtool: 'source-map',
output: {
path: path.resolve(path.join(__dirname, 'dist', 'public', 'render')),
filename: outputFile
},
module: {
rules: [
{
test: /(\.jsx|\.js|\.ts|\.tsx)$/,
use: {
loader: 'babel-loader'
},
exclude: /(node_modules|bower_components)/
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource'
}
]
},
plugins: [
new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
new HtmlWebpackPlugin({
title: 'Speckle Viewer Example',
template: 'renderPage/src/example.html',
filename: 'index.html',
favicon: 'renderPage/src/favicon.ico'
})
],
resolve: {
modules: [
path.resolve('../../node_modules'),
path.resolve('./node_modules'),
path.resolve('.renderPage/src')
],
extensions: ['.json', '.js']
},
devServer: {
contentBase: path.join(__dirname, 'example'),
compress: false,
port: 9000,
serveIndex: true,
writeToDisk: true
}
}
module.exports = config
@@ -2,10 +2,10 @@ import {
ApiToken,
PersonalApiToken,
TokenResourceAccessDefinition,
TokenResourceIdentifierType,
TokenScope,
UserServerAppToken
} from '@/modules/core/domain/tokens/types'
import { TokenResourceIdentifierInput } from '@/modules/core/graph/generated/graphql'
import { TokenValidationResult } from '@/modules/core/helpers/types'
import { NullableKeysToOptional, Optional, ServerScope } from '@speckle/shared'
import { SetOptional } from 'type-fest'
@@ -60,6 +60,11 @@ export type UpdateApiToken = (
token: Partial<ApiToken>
) => Promise<ApiToken>
export type TokenResourceIdentifierInput = {
id: string
type: TokenResourceIdentifierType
}
export type CreateAndStoreUserToken = (params: {
userId: string
name: string
@@ -11,6 +11,30 @@ export type CreateObjectPreview = (
params: Pick<ObjectPreview, 'streamId' | 'objectId' | 'priority'>
) => Promise<void>
export type ObjectPreviewInput = Pick<
ObjectPreview,
'streamId' | 'objectId' | 'priority'
>
export type StoreObjectPreview = (params: ObjectPreviewInput) => Promise<void>
export type UpsertObjectPreview = (params: {
objectPreview: ObjectPreview
}) => Promise<void>
export type ObjectPreviewRequest = {
url: string
token: string
jobId: string
}
export type Preview = {
id: string
data: Buffer
}
export type StorePreview = (params: { preview: Preview }) => Promise<void>
export type RequestObjectPreview = (params: ObjectPreviewRequest) => Promise<void>
export type GetPreviewImage = (params: {
previewId: string
}) => Promise<Nullable<Buffer>>
+148 -249
View File
@@ -1,263 +1,162 @@
/* istanbul ignore file */
import { validateScopes, authorizeResolver } from '@/modules/shared'
import { moduleLogger, previewLogger as logger } from '@/observability/logging'
import { consumePreviewResultFactory } from '@/modules/previews/resultListener'
import { makeOgImage } from '@/modules/previews/ogImage'
import { moduleLogger } from '@/observability/logging'
import { messageProcessor } from '@/modules/previews/resultListener'
import { db } from '@/db/knex'
import {
getObjectPreviewBufferOrFilepathFactory,
sendObjectPreviewFactory,
checkStreamPermissionsFactory
} from '@/modules/previews/services/management'
import {
getObjectPreviewInfoFactory,
createObjectPreviewFactory,
getPreviewImageFactory
} from '@/modules/previews/repository/previews'
import {
getCommitFactory,
getPaginatedBranchCommitsItemsFactory,
legacyGetPaginatedStreamCommitsPageFactory
} from '@/modules/core/repositories/commits'
disablePreviews,
getPreviewServiceRedisUrl,
getRedisUrl,
getServerOrigin
} from '@/modules/shared/helpers/envHelper'
import Bull from 'bull'
import Redis, { RedisOptions } from 'ioredis'
import { createBullBoard } from 'bull-board'
import { BullMQAdapter } from 'bull-board/bullMQAdapter'
import { authMiddlewareCreator } from '@/modules/shared/middleware'
import { Roles, TIME } from '@speckle/shared'
import { validateServerRoleBuilderFactory } from '@/modules/shared/authz'
import { getRolesFactory } from '@/modules/shared/repositories/roles'
import { previewRouterFactory } from '@/modules/previews/rest/router'
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getPaginatedBranchCommitsItemsByNameFactory } from '@/modules/core/services/commit/retrieval'
import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches'
import { getFormattedObjectFactory } from '@/modules/core/repositories/objects'
import { previewResultPayload } from '@speckle/shared/dist/commonjs/previews/job.js'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { listenFor } from '@/modules/core/utils/dbNotificationListener'
import { disablePreviews } from '@/modules/shared/helpers/envHelper'
import { corsMiddlewareFactory } from '@/modules/core/configs/cors'
import {
storePreviewFactory,
upsertObjectPreviewFactory
} from '@/modules/previews/repository/previews'
import { getObjectCommitsWithStreamIdsFactory } from '@/modules/core/repositories/commits'
import prometheusClient from 'prom-client'
import { initializeMetrics } from '@/modules/previews/observability/metrics'
const httpErrorImage = (httpErrorCode: number) =>
require.resolve(`#/assets/previews/images/preview_${httpErrorCode}.png`)
const getPreviewQueues = (params: { responseQueueName: string }) => {
const { responseQueueName } = params
let client: Redis
let subscriber: Redis
const redisUrl = getPreviewServiceRedisUrl() ?? getRedisUrl()
const noPreviewImage = require.resolve('#/assets/previews/images/no_preview.png')
const opts = {
// redisOpts here will contain at least a property of connectionName which will identify the queue based on its name
createClient(type: string, redisOpts: RedisOptions) {
switch (type) {
case 'client':
if (!client) {
client = new Redis(redisUrl, redisOpts)
}
return client
case 'subscriber':
if (!subscriber) {
subscriber = new Redis(redisUrl, {
...redisOpts,
maxRetriesPerRequest: null,
enableReadyCheck: false
})
}
return subscriber
case 'bclient':
return new Redis(redisUrl, {
...redisOpts,
maxRetriesPerRequest: null,
enableReadyCheck: false
})
default:
throw new Error('Unexpected connection type: ' + type)
}
}
}
const previewRequestQueue = new Bull('preview-service-jobs', opts)
// these events are published on the job queue, results come back on the response queue
previewRequestQueue.on('error', (err) => {
logger.error({ err }, 'Preview generation failed')
})
previewRequestQueue.on('failed', (job, err) => {
const jobId = 'jobId' in job.data ? job.data.jobId : undefined
logger.error({ err, jobId }, 'Preview job {jobId} failed.')
})
previewRequestQueue.on('active', (job) => {
const jobId = 'jobId' in job.data ? job.data.jobId : undefined
logger.info({ jobId }, 'Preview job {jobId} processing started.')
})
const previewResponseQueue = new Bull(responseQueueName, opts)
return { previewRequestQueue, previewResponseQueue }
}
export const init: SpeckleModule['init'] = ({ app, isInitial }) => {
if (disablePreviews()) {
moduleLogger.warn('📸 Object preview module is DISABLED')
} else {
moduleLogger.info('📸 Init object preview module')
}
app.options('/preview/:streamId/:angle?', corsMiddlewareFactory())
app.get('/preview/:streamId/:angle?', corsMiddlewareFactory(), async (req, res) => {
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
if (!hasPermissions) {
// return res.status( httpErrorCode ).end()
return res.sendFile(httpErrorImage(httpErrorCode))
}
const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({
db: projectDb
})
const { commits } = await getCommitsByStreamId({
streamId: req.params.streamId,
limit: 1,
ignoreGlobalsBranch: true,
cursor: undefined
})
if (!commits || commits.length === 0) {
return res.sendFile(noPreviewImage)
}
const lastCommit = commits[0]
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: createObjectPreviewFactory({ db: projectDb }),
getPreviewImage: getPreviewImageFactory({ db: projectDb }),
logger: req.log
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
lastCommit.referencedObject,
req.params.angle
)
})
app.options(
'/preview/:streamId/branches/:branchName/:angle?',
corsMiddlewareFactory()
)
app.get(
'/preview/:streamId/branches/:branchName/:angle?',
corsMiddlewareFactory(),
async (req, res) => {
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
if (!hasPermissions) {
// return res.status( httpErrorCode ).end()
return res.sendFile(httpErrorImage(httpErrorCode))
}
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
let commitsObj
try {
const getCommitsByBranchName = getPaginatedBranchCommitsItemsByNameFactory({
getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }),
getPaginatedBranchCommitsItems: getPaginatedBranchCommitsItemsFactory({
db: projectDb
})
})
commitsObj = await getCommitsByBranchName({
streamId: req.params.streamId,
branchName: req.params.branchName,
limit: 1,
cursor: undefined
})
} catch {
commitsObj = {}
}
const { commits } = commitsObj
if (!commits || commits.length === 0) {
return res.sendFile(noPreviewImage)
}
const lastCommit = commits[0]
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: createObjectPreviewFactory({ db: projectDb }),
getPreviewImage: getPreviewImageFactory({ db: projectDb }),
logger: req.log
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
lastCommit.referencedObject,
req.params.angle
)
}
)
app.options('/preview/:streamId/commits/:commitId/:angle?', corsMiddlewareFactory())
app.get(
'/preview/:streamId/commits/:commitId/:angle?',
corsMiddlewareFactory(),
async (req, res) => {
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
if (!hasPermissions) {
// return res.status( httpErrorCode ).end()
return res.sendFile(httpErrorImage(httpErrorCode))
}
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const getCommit = getCommitFactory({ db: projectDb })
const commit = await getCommit(req.params.commitId, {
streamId: req.params.streamId
})
if (!commit) return res.sendFile(noPreviewImage)
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: createObjectPreviewFactory({ db: projectDb }),
getPreviewImage: getPreviewImageFactory({ db: projectDb }),
logger: req.log
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
commit.referencedObject,
req.params.angle
)
}
)
app.options('/preview/:streamId/objects/:objectId/:angle?', corsMiddlewareFactory())
app.get(
'/preview/:streamId/objects/:objectId/:angle?',
corsMiddlewareFactory(),
async (req, res) => {
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions } = await checkStreamPermissions(req)
if (!hasPermissions) {
return res.status(403).end()
}
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: createObjectPreviewFactory({ db: projectDb }),
getPreviewImage: getPreviewImageFactory({ db: projectDb }),
logger: req.log
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
req.params.objectId,
req.params.angle
)
}
)
if (isInitial) {
listenFor('preview_generation_update', messageProcessor)
if (disablePreviews()) {
moduleLogger.warn('📸 Object preview module is DISABLED')
} else {
moduleLogger.info('📸 Init object preview module')
}
const responseQueueName = `preview-service-results-${
new URL(getServerOrigin()).hostname
}`
const { previewRequestQueue, previewResponseQueue } = getPreviewQueues({
responseQueueName
})
const { previewJobsProcessedSummary } = initializeMetrics({
registers: [prometheusClient.register],
previewRequestQueue
})
const router = createBullBoard([
new BullMQAdapter(previewRequestQueue),
new BullMQAdapter(previewResponseQueue)
]).router
app.use(
'/api/admin/preview-jobs',
async (req, res, next) => {
await authMiddlewareCreator([
validateServerRoleBuilderFactory({ getRoles: getRolesFactory({ db }) })({
requiredRole: Roles.Server.Admin
})
])(req, res, next)
},
router
)
const previewRouter = previewRouterFactory({
previewRequestQueue,
responseQueueName
})
app.use(previewRouter)
previewResponseQueue.process(async (payload, done) => {
const parsedMessage = previewResultPayload.safeParse(payload.data)
if (!parsedMessage.success) {
logger.error(
{ payload: payload.data, reason: parsedMessage.error },
'Failed to parse previewResult payload'
)
done(parsedMessage.error)
return
}
const [projectId, objectId] = parsedMessage.data.jobId.split('.')
const projectDb = await getProjectDbClient({ projectId })
await consumePreviewResultFactory({
logger,
storePreview: storePreviewFactory({ db: projectDb }),
upsertObjectPreview: upsertObjectPreviewFactory({ db: projectDb }),
getObjectCommitsWithStreamIds: getObjectCommitsWithStreamIdsFactory({
db: projectDb
})
})({
projectId,
objectId,
previewResult: parsedMessage.data
})
previewJobsProcessedSummary.observe(
{ status: parsedMessage.data.status },
parsedMessage.data.result.durationSeconds * TIME.second
)
done()
})
}
}
@@ -0,0 +1,52 @@
import Bull from 'bull'
import { type Registry, Counter, Summary, Gauge } from 'prom-client'
export const initializeMetrics = (params: {
registers: Registry[]
previewRequestQueue: Bull.Queue
}) => {
const { registers, previewRequestQueue } = params
// add a metric to gauge the length of the preview job queue
registers.forEach((r) =>
r.removeSingleMetric('speckle_server_preview_jobs_queue_pending')
)
new Gauge({
name: 'speckle_server_preview_jobs_queue_pending',
help: 'Number of preview jobs waiting in the job queue',
async collect() {
this.set(await previewRequestQueue.count())
}
})
registers.forEach((r) => r.removeSingleMetric('speckle_server_preview_jobs_count'))
const previewJobsCounter = new Counter({
name: 'speckle_server_preview_jobs_count',
help: 'Total number of preview jobs which have been requested to be processed.'
})
registers.forEach((r) =>
r.removeSingleMetric('speckle_server_preview_jobs_request_failed_count')
)
const previewJobsFailedCounter = new Counter({
name: 'speckle_server_preview_jobs_request_failed_count',
help: 'Total number of preview jobs which have been requested but failed to be processed.'
})
registers.forEach((r) =>
r.removeSingleMetric('speckle_server_preview_jobs_processed_duration_seconds')
)
const previewJobsProcessedSummary = new Summary<'status'>({
name: 'speckle_server_preview_jobs_processed_duration_seconds',
help: 'Duration of preview job processing, in seconds',
labelNames: ['status']
})
previewRequestQueue.on('added', () => {
previewJobsCounter.inc()
})
previewRequestQueue.on('failed', () => {
previewJobsFailedCounter.inc()
})
return { previewJobsProcessedSummary }
}
@@ -0,0 +1,15 @@
import { RequestObjectPreview } from '@/modules/previews/domain/operations'
import type { Queue } from 'bull'
export const requestObjectPreviewFactory =
({
responseQueue,
queue
}: {
responseQueue: string
queue: Queue
}): RequestObjectPreview =>
async ({ jobId, token, url }) => {
const payload = { jobId, token, url, responseQueue }
await queue.add(payload, { removeOnComplete: true, attempts: 3 })
}
@@ -1,9 +1,10 @@
/* istanbul ignore file */
import { buildTableHelper } from '@/modules/core/dbSchema'
import {
CreateObjectPreview,
GetObjectPreviewInfo,
GetPreviewImage
GetPreviewImage,
StoreObjectPreview,
StorePreview,
UpsertObjectPreview
} from '@/modules/previews/domain/operations'
import {
ObjectPreview as ObjectPreviewRecord,
@@ -37,8 +38,8 @@ export const getObjectPreviewInfoFactory =
.first()
}
export const createObjectPreviewFactory =
({ db }: { db: Knex }): CreateObjectPreview =>
export const storeObjectPreviewFactory =
({ db }: { db: Knex }): StoreObjectPreview =>
async ({
streamId,
objectId,
@@ -51,11 +52,29 @@ export const createObjectPreviewFactory =
priority,
previewStatus: 0
}
const sqlQuery =
tables.objectPreview(db).insert(insertionObject).toString() +
' on conflict do nothing'
const sqlQuery = tables
.objectPreview(db)
.insert(insertionObject)
.onConflict()
.ignore()
await db.raw(sqlQuery)
await sqlQuery
}
export const storePreviewFactory =
({ db }: { db: Knex }): StorePreview =>
async ({ preview }) => {
await tables.previews(db).insert(preview).onConflict().ignore()
}
export const upsertObjectPreviewFactory =
({ db }: { db: Knex }): UpsertObjectPreview =>
async ({ objectPreview }) => {
await tables
.objectPreview(db)
.insert(objectPreview)
.onConflict(['streamId', 'objectId'])
.merge()
}
export const getPreviewImageFactory =
@@ -0,0 +1,306 @@
import { Router } from 'express'
import cors from 'cors'
import { validateScopes, authorizeResolver } from '@/modules/shared'
import { makeOgImage } from '@/modules/previews/ogImage'
import { db } from '@/db/knex'
import {
getObjectPreviewBufferOrFilepathFactory,
sendObjectPreviewFactory,
checkStreamPermissionsFactory
} from '@/modules/previews/services/management'
import {
getObjectPreviewInfoFactory,
getPreviewImageFactory,
storeObjectPreviewFactory
} from '@/modules/previews/repository/previews'
import {
getCommitFactory,
getPaginatedBranchCommitsItemsFactory,
legacyGetPaginatedStreamCommitsPageFactory
} from '@/modules/core/repositories/commits'
import {
getStreamCollaboratorsFactory,
getStreamFactory
} from '@/modules/core/repositories/streams'
import { getPaginatedBranchCommitsItemsByNameFactory } from '@/modules/core/services/commit/retrieval'
import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches'
import { getFormattedObjectFactory } from '@/modules/core/repositories/objects'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { createObjectPreviewFactory } from '@/modules/previews/services/createObjectPreview'
import { createAppTokenFactory } from '@/modules/core/services/tokens'
import {
storeApiTokenFactory,
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory,
storeUserServerAppTokenFactory
} from '@/modules/core/repositories/tokens'
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
import { requestObjectPreviewFactory } from '@/modules/previews/queues/previews'
import { Queue } from 'bull'
import { Knex } from 'knex'
const httpErrorImage = (httpErrorCode: number) =>
require.resolve(`#/assets/previews/images/preview_${httpErrorCode}.png`)
const noPreviewImage = require.resolve('#/assets/previews/images/no_preview.png')
const buildCreateObjectPreviewFunction = ({
projectDb,
previewRequestQueue,
responseQueueName
}: {
projectDb: Knex
previewRequestQueue: Queue
responseQueueName: string
}) => {
return createObjectPreviewFactory({
requestObjectPreview: requestObjectPreviewFactory({
queue: previewRequestQueue,
responseQueue: responseQueueName
}),
serverOrigin: getServerOrigin(),
storeObjectPreview: storeObjectPreviewFactory({ db: projectDb }),
getStreamCollaborators: getStreamCollaboratorsFactory({ db }),
createAppToken: createAppTokenFactory({
storeApiToken: storeApiTokenFactory({ db }),
storeTokenScopes: storeTokenScopesFactory({ db }),
storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({
db
}),
storeUserServerAppToken: storeUserServerAppTokenFactory({ db })
})
})
}
export const previewRouterFactory = ({
previewRequestQueue,
responseQueueName
}: {
previewRequestQueue: Queue
responseQueueName: string
}): Router => {
const app = Router()
app.options('/preview/:streamId/:angle?', cors())
app.get('/preview/:streamId/:angle?', cors(), async (req, res) => {
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
if (!hasPermissions) {
// return res.status( httpErrorCode ).end()
return res.sendFile(httpErrorImage(httpErrorCode))
}
const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({
db: projectDb
})
const { commits } = await getCommitsByStreamId({
streamId: req.params.streamId,
limit: 1,
ignoreGlobalsBranch: true,
cursor: undefined
})
if (!commits || commits.length === 0) {
return res.sendFile(noPreviewImage)
}
const lastCommit = commits[0]
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
logger: req.log,
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: buildCreateObjectPreviewFunction({
projectDb,
previewRequestQueue,
responseQueueName
}),
getPreviewImage: getPreviewImageFactory({ db: projectDb })
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
lastCommit.referencedObject,
req.params.angle
)
})
app.options('/preview/:streamId/branches/:branchName/:angle?', cors())
app.get(
'/preview/:streamId/branches/:branchName/:angle?',
cors(),
async (req, res) => {
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
if (!hasPermissions) {
// return res.status( httpErrorCode ).end()
return res.sendFile(httpErrorImage(httpErrorCode))
}
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
let commitsObj
try {
const getCommitsByBranchName = getPaginatedBranchCommitsItemsByNameFactory({
getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }),
getPaginatedBranchCommitsItems: getPaginatedBranchCommitsItemsFactory({
db: projectDb
})
})
commitsObj = await getCommitsByBranchName({
streamId: req.params.streamId,
branchName: req.params.branchName,
limit: 1,
cursor: undefined
})
} catch {
commitsObj = {}
}
const { commits } = commitsObj
if (!commits || commits.length === 0) {
return res.sendFile(noPreviewImage)
}
const lastCommit = commits[0]
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
logger: req.log,
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: buildCreateObjectPreviewFunction({
projectDb,
previewRequestQueue,
responseQueueName
}),
getPreviewImage: getPreviewImageFactory({ db: projectDb })
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
lastCommit.referencedObject,
req.params.angle
)
}
)
app.options('/preview/:streamId/commits/:commitId/:angle?', cors())
app.get('/preview/:streamId/commits/:commitId/:angle?', cors(), async (req, res) => {
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
if (!hasPermissions) {
// return res.status( httpErrorCode ).end()
return res.sendFile(httpErrorImage(httpErrorCode))
}
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const getCommit = getCommitFactory({ db: projectDb })
const commit = await getCommit(req.params.commitId, {
streamId: req.params.streamId
})
if (!commit) return res.sendFile(noPreviewImage)
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
logger: req.log,
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: buildCreateObjectPreviewFunction({
projectDb,
previewRequestQueue,
responseQueueName
}),
getPreviewImage: getPreviewImageFactory({ db: projectDb })
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
commit.referencedObject,
req.params.angle
)
})
app.options('/preview/:streamId/objects/:objectId/:angle?', cors())
app.get('/preview/:streamId/objects/:objectId/:angle?', cors(), async (req, res) => {
const checkStreamPermissions = checkStreamPermissionsFactory({
validateScopes,
authorizeResolver,
// getting the stream from the main DB, cause it needs to join on roles
getStream: getStreamFactory({ db })
})
const { hasPermissions } = await checkStreamPermissions(req)
if (!hasPermissions) {
return res.status(403).end()
}
const projectDb = await getProjectDbClient({ projectId: req.params.streamId })
const getObjectPreviewBufferOrFilepath = getObjectPreviewBufferOrFilepathFactory({
logger: req.log,
getObject: getFormattedObjectFactory({ db: projectDb }),
getObjectPreviewInfo: getObjectPreviewInfoFactory({ db: projectDb }),
createObjectPreview: buildCreateObjectPreviewFunction({
projectDb,
previewRequestQueue,
responseQueueName
}),
getPreviewImage: getPreviewImageFactory({ db: projectDb })
})
const sendObjectPreview = sendObjectPreviewFactory({
// getting the stream from the projectDb here, to handle preview data properly
getStream: getStreamFactory({ db: projectDb }),
getObjectPreviewBufferOrFilepath,
makeOgImage
})
return sendObjectPreview(
req,
res,
req.params.streamId,
req.params.objectId,
req.params.angle
)
})
return app
}
@@ -3,6 +3,13 @@ import { MessageType } from '@/modules/core/utils/dbNotificationListener'
import { getObjectCommitsWithStreamIdsFactory } from '@/modules/core/repositories/commits'
import { publish } from '@/modules/shared/utils/subscriptions'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { PreviewResultPayload } from '@speckle/shared/dist/commonjs/previews/job.js'
import { throwUncoveredError } from '@speckle/shared'
import type { Logger } from '@/observability/logging'
import crypto from 'crypto'
import { StorePreview, UpsertObjectPreview } from '@/modules/previews/domain/operations'
import { joinImages } from 'join-images'
import { GetObjectCommitsWithStreamIds } from '@/modules/core/domain/commits/operations'
const payloadRegexp = /^([\w\d]+):([\w\d]+):([\w\d]+)$/i
@@ -39,3 +46,120 @@ export const messageProcessor = async (msg: MessageType) => {
)
)
}
export const consumePreviewResultFactory =
({
logger,
upsertObjectPreview,
storePreview,
getObjectCommitsWithStreamIds
}: {
logger: Logger
upsertObjectPreview: UpsertObjectPreview
storePreview: StorePreview
getObjectCommitsWithStreamIds: GetObjectCommitsWithStreamIds
}) =>
async ({
projectId,
objectId,
previewResult
}: {
projectId: string
objectId: string
previewResult: PreviewResultPayload
}) => {
const streamId = projectId
const lastUpdate = new Date()
const priority = 0
const previewStatus = 2
const log = logger.child({
...previewResult,
projectId: streamId
})
switch (previewResult.status) {
case 'error':
await upsertObjectPreview({
objectPreview: {
objectId,
streamId,
lastUpdate,
preview: { err: previewResult.reason },
priority,
previewStatus
}
})
log.error('Preview generation failed for {jobId}.')
// store preview error in the db
break
case 'success':
log.info('Consumed preview generation {status} message payload for {jobId}.')
const preview: Record<string, string> = {}
const allImgsArr: Buffer[] = []
let i = 0
for (const [angle, value] of Object.entries(previewResult.result.screenshots)) {
const data = Buffer.from(
value.replace(/^data:image\/\w+;base64,/, ''),
'base64'
)
// @ts-expect-error this is a mismatch with node 18 and 22 types. upgrading to new node will fix it
const id = crypto.createHash('md5').update(data).digest('hex')
if (i++ === 0) {
await storePreview({ preview: { id, data } })
preview[angle] = id
}
allImgsArr.push(data)
}
const fullImg = await joinImages(allImgsArr, {
direction: 'horizontal',
offset: 700,
margin: '0 700 0 700',
color: { alpha: 0, r: 0, g: 0, b: 0 }
})
const png = fullImg.png({ quality: 95 })
const buff = await png.toBuffer()
// @ts-expect-error this is a mismatch with node 18 and 22 types. upgrading to new node will fix it
const fullImgId = crypto.createHash('md5').update(buff).digest('hex')
await storePreview({ preview: { id: fullImgId, data: buff } })
preview['all'] = fullImgId
await upsertObjectPreview({
objectPreview: {
objectId,
streamId,
lastUpdate,
preview,
priority,
previewStatus
}
})
const commits = await getObjectCommitsWithStreamIds([objectId], {
streamIds: [streamId]
})
if (!commits.length) break
await Promise.all(
commits.map((c) =>
publish(ProjectSubscriptions.ProjectVersionsPreviewGenerated, {
projectVersionsPreviewGenerated: {
versionId: c.id,
projectId: c.streamId,
objectId
}
})
)
)
break
default:
throwUncoveredError(previewResult)
}
}
@@ -0,0 +1,51 @@
import { DefaultAppIds } from '@/modules/auth/defaultApps'
import { GetStreamCollaborators } from '@/modules/core/domain/streams/operations'
import { CreateAndStoreAppToken } from '@/modules/core/domain/tokens/operations'
import {
CreateObjectPreview,
RequestObjectPreview,
StoreObjectPreview
} from '@/modules/previews/domain/operations'
import { Roles, Scopes } from '@speckle/shared'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
export const createObjectPreviewFactory =
({
getStreamCollaborators,
createAppToken,
requestObjectPreview,
storeObjectPreview,
serverOrigin
}: {
getStreamCollaborators: GetStreamCollaborators
serverOrigin: string
createAppToken: CreateAndStoreAppToken
requestObjectPreview: RequestObjectPreview
storeObjectPreview: StoreObjectPreview
}): CreateObjectPreview =>
async ({ streamId, objectId, priority }) => {
const owners = await getStreamCollaborators(streamId, Roles.Stream.Owner)
// there is always an owner, this is safe
const userId = owners[0].id
// we're running the preview generation in the name of a project owner
const token = await createAppToken({
appId: DefaultAppIds.Web,
name: `preview-${streamId}@${objectId}`,
userId,
scopes: [Scopes.Streams.Read],
lifespan: 120 * 60 * 1000, // for now, lets make this valid for 2 hours
limitResources: [
{
id: streamId,
type: TokenResourceIdentifierType.Project
}
]
})
const url = new URL(
`/projects/${streamId}/models/${objectId}`,
serverOrigin
).toString()
await requestObjectPreview({ jobId: `${streamId}.${objectId}`, token, url })
await storeObjectPreview({ streamId, objectId, priority })
}
@@ -0,0 +1,58 @@
import { LimitedUserWithStreamRole } from '@/modules/core/domain/streams/types'
import {
ObjectPreviewInput,
ObjectPreviewRequest
} from '@/modules/previews/domain/operations'
import { createObjectPreviewFactory } from '@/modules/previews/services/createObjectPreview'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
describe('object preview @previews', () => {
describe('createObjectPreviewFactory creates a function, that', () => {
it('requests and stores an object preview', async () => {
const appToken = cryptoRandomString({ length: 40 })
const streamOwner: LimitedUserWithStreamRole = {
id: cryptoRandomString({ length: 10 }),
avatar: null,
bio: null,
company: null,
createdAt: new Date(),
name: 'Preview User',
role: Roles.Server.User,
streamRole: Roles.Stream.Owner,
verified: true
}
let userId: string | undefined = undefined
let objectPreviewInput: ObjectPreviewInput | undefined = undefined
const serverOrigin = 'https://example.org'
let objectPreviewRequest: ObjectPreviewRequest | undefined = undefined
const createObjectPreview = createObjectPreviewFactory({
serverOrigin,
getStreamCollaborators: async () => [streamOwner],
createAppToken: async (tokenArgs) => {
userId = tokenArgs.userId
return appToken
},
storeObjectPreview: async (objectPreview) => {
objectPreviewInput = objectPreview
},
requestObjectPreview: async (previewRequest) => {
objectPreviewRequest = previewRequest
}
})
const objectId = cryptoRandomString({ length: 32 })
const streamId = cryptoRandomString({ length: 10 })
const priority = 0
await createObjectPreview({ objectId, streamId, priority })
expect(objectPreviewInput).to.deep.equal({ objectId, streamId, priority })
expect(userId).to.deep.equal(streamOwner.id)
expect(objectPreviewRequest).to.deep.equal({
url: `${serverOrigin}/projects/${streamId}/models/${objectId}`,
jobId: `${streamId}.${objectId}`,
token: appToken
})
})
})
})
+1
View File
@@ -306,6 +306,7 @@ export const streamWritePermissionsPipelineFactory = (
validateServerRoleBuilderFactory(deps)({ requiredRole: Roles.Server.Guest }),
validateScope({ requiredScope: Scopes.Streams.Write }),
validateRequiredStreamFactory(deps),
validateStreamRoleBuilderFactory(deps)({ requiredRole: Roles.Stream.Contributor }),
validateResourceAccess
]
@@ -86,6 +86,10 @@ export function getRedisUrl() {
return getStringFromEnv('REDIS_URL')
}
export const getPreviewServiceRedisUrl = (): string | undefined => {
return process.env['PREVIEW_SERVICE_REDIS_URL']
}
export function getOidcDiscoveryUrl() {
return getStringFromEnv('OIDC_DISCOVERY_URL')
}
@@ -18,15 +18,20 @@ describe('Observability', () => {
metricsPageBody = await metricsResponse.text()
})
const testCases = [
// Apollo server
'speckle_server_apollo_calls',
// express-prom-bundle
'speckle_server_request_duration',
// Express (error handling middleware)
'speckle_server_request_errors',
// http server
'speckle_server_active_connections',
// apollo subscriptions
'speckle_server_apollo_connect',
'speckle_server_apollo_clients',
'speckle_server_apollo_graphql_total_subscription_operations',
'speckle_server_apollo_graphql_total_subscription_responses',
'speckle_server_active_connections',
// knex
'speckle_server_knex_free',
'speckle_server_knex_used',
'speckle_server_knex_pending',
@@ -39,6 +44,7 @@ describe('Observability', () => {
'speckle_server_knex_connection_acquisition_errors',
'speckle_server_knex_connection_usage_duration',
'speckle_server_knex_connection_pool_reaping_duration',
// high frequency metrics
'nodejs_heap_size_total_bytes_high_frequency',
'nodejs_heap_size_used_bytes_high_frequency',
'nodejs_external_memory_bytes_high_frequency',
@@ -51,7 +57,12 @@ describe('Observability', () => {
'knex_remaining_capacity_high_frequency',
'process_cpu_user_seconds_total_high_frequency',
'process_cpu_system_seconds_total_high_frequency',
'process_cpu_seconds_total_high_frequency'
'process_cpu_seconds_total_high_frequency',
// preview service
'speckle_server_preview_jobs_queue_pending',
'speckle_server_preview_jobs_count',
'speckle_server_preview_jobs_request_failed_count',
'speckle_server_preview_jobs_processed_duration_seconds'
]
testCases.forEach((testCase) =>
+5 -1
View File
@@ -65,7 +65,8 @@
"@speckle/shared": "workspace:^",
"ajv": "^8.12.0",
"bcrypt": "^5.0.0",
"bull": "^4.8.5",
"bull": "^4.16.4",
"bull-board": "^2.1.3",
"busboy": "^1.4.0",
"compression": "^1.7.4",
"connect-redis": "^6.1.1",
@@ -88,6 +89,7 @@
"graphql-subscriptions": "^2.0.0",
"graphql-tag": "^2.12.6",
"ioredis": "^5.2.2",
"join-images": "^1.1.5",
"jose": "^5.6.3",
"knex": "^2.5.1",
"libsodium-wrappers": "^0.7.13",
@@ -164,6 +166,7 @@
"@types/mock-require": "^2.0.1",
"@types/module-alias": "^2.0.1",
"@types/netmask": "^2.0.0",
"@types/node": "^18.19.38",
"@types/node-cron": "^3.0.2",
"@types/nodemailer": "^6.4.5",
"@types/passport": "^1.0.16",
@@ -190,6 +193,7 @@
"chai-http": "^4.3.0",
"concurrently": "^7.0.0",
"cross-env": "^7.0.3",
"csv-parse": "^5.6.0",
"deep-equal-in-any-order": "^1.1.15",
"enforce-unique": "^1.3.0",
"eslint": "^8.11.0",
+7 -7
View File
@@ -8,17 +8,17 @@ declare module 'express' {
}
}
declare module 'express-serve-static-core' {
interface Request {
context: AuthContext
mixpanel: ReturnType<typeof mixpanel>
}
}
declare module 'http' {
interface IncomingMessage {
context?: AuthContext
}
}
declare module 'express-serve-static-core' {
interface Request {
context: AuthContext
mixpanel: ReturnType<typeof mixpanel>
}
}
export {}
+13
View File
@@ -0,0 +1,13 @@
import { PreviewResult } from './job.js'
export interface PreviewGenerator {
takeScreenshot: TakeScreenshot
load: Load
}
export type TakeScreenshot = () => Promise<PreviewResult>
export type LoadArgs = { url: string; token: string }
export type Load = (args: LoadArgs) => Promise<void>
export type { PreviewResult } from './job.js'
+49
View File
@@ -0,0 +1,49 @@
import z from 'zod'
const job = z.object({
jobId: z.string()
})
export const jobPayload = job.merge(
z.object({
url: z.string(),
token: z.string(),
responseQueue: z.string()
})
)
export type JobPayload = z.infer<typeof jobPayload>
const previewResult = z.object({
durationSeconds: z.number().describe('Duration to generate the preview, in seconds'),
screenshots: z.record(z.string(), z.string())
})
export type PreviewResult = z.infer<typeof previewResult>
const previewSuccessPayload = job.merge(
z.object({
status: z.literal('success'),
result: previewResult
})
)
export type PreviewSuccessPayload = z.infer<typeof previewSuccessPayload>
const previewErrorPayload = job.merge(
z.object({
status: z.literal('error'),
reason: z.string(),
result: z.object({
durationSeconds: z
.number()
.describe('Duration spent processing the job before erroring, in seconds')
})
})
)
export const previewResultPayload = z.discriminatedUnion('status', [
previewSuccessPayload,
previewErrorPayload
])
export type PreviewResultPayload = z.infer<typeof previewResultPayload>
@@ -766,6 +766,15 @@ Generate the environment variables for Speckle server and Speckle objects deploy
name: {{ default .Values.secretName .Values.redis.connectionString.secretName }}
key: {{ default "redis_url" .Values.redis.connectionString.secretKey }}
{{- if .Values.preview_service.dedicatedPreviewsQueue }}
- name: PREVIEW_SERVICE_REDIS_URL
valueFrom:
secretKeyRef:
name: {{ default .Values.secretName .Values.redis.previewServiceConnectionString.secretName }}
key: {{ default "preview_service_redis_url" .Values.redis.previewServiceConnectionString.secretKey }}
{{- end }}
# *** PostgreSQL Database ***
- name: POSTGRES_URL
valueFrom:
@@ -44,4 +44,7 @@ secrets:
{{- if .Values.featureFlags.gendoAIModuleEnabled }}
- name: {{ default .Values.secretName .Values.server.gendoAI.key.secretName }}
{{- end }}
{{- if .Values.preview_service.dedicatedPreviewsQueue }}
- name: {{ default .Values.secretName .Values.redis.previewServiceConnectionString.secretName }}
{{- end }}
{{- end -}}
@@ -35,7 +35,9 @@ spec:
command:
- node
- -e
- {{ printf "process.exit(Date.now() - require('fs').readFileSync('/tmp/last_successful_query', 'utf8') > %s)" .Values.preview_service.puppeteer.timeoutMilliseconds }}
# just a dummy output for now, not yet sure how to do liveliness with the new setup.
# if there is no job in the queue, the service does nothing, so the prev solution would not work
- {{ printf "console.log(%s)" .Values.preview_service.puppeteer.timeoutMilliseconds }}
resources:
requests:
@@ -55,17 +57,6 @@ spec:
runAsNonRoot: true
runAsUser: 800
volumeMounts:
{{- if .Values.db.useCertificate }}
- name: postgres-certificate
mountPath: /postgres-certificate
{{- end }}
{{- if .Values.featureFlags.workspacesMultiRegionEnabled }}
- name: multi-region-config
mountPath: /multi-region-config
readOnly: true
{{- end }}
env:
- name: HOST
value: '127.0.0.1' # Only accept connections from localhost, as preview service does not need to be exposed outside the container.
@@ -77,30 +68,29 @@ spec:
- name: PROMETHEUS_METRICS_PORT
value: {{ .Values.preview_service.monitoring.metricsPort | quote }}
- name: PG_CONNECTION_STRING
# *** Redis ***
{{- if .Values.preview_service.dedicatedPreviewsQueue }}
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: {{ default .Values.secretName .Values.db.connectionString.secretName }}
key: {{ default "postgres_url" .Values.db.connectionString.secretKey }}
{{- if .Values.preview_service.postgresMaxConnections }}
- name: POSTGRES_MAX_CONNECTIONS_PREVIEW_SERVICE
value: {{ .Values.preview_service.postgresMaxConnections | quote }}
name: {{ default .Values.secretName .Values.redis.previewServiceConnectionString.secretName }}
key: {{ default "preview_service_redis_url" .Values.redis.previewServiceConnectionString.secretKey }}
{{- else }}
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: {{ default .Values.secretName .Values.redis.connectionString.secretName }}
key: {{ default "redis_url" .Values.redis.connectionString.secretKey }}
{{- end }}
- name: POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS
value: {{ .Values.db.connectionCreateTimeoutMillis | quote }}
- name: POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS
value: {{ .Values.db.connectionAcquireTimeoutMillis | quote }}
- name: LOG_LEVEL
value: {{ .Values.preview_service.logLevel | quote }}
- name: LOG_PRETTY
value: {{ .Values.preview_service.logPretty | quote }}
{{- if .Values.db.useCertificate }}
- name: NODE_EXTRA_CA_CERTS
value: "/postgres-certificate/ca-certificate.crt"
{{- end }}
{{- if .Values.preview_service.puppeteer.userDataDirectory }}
- name: USER_DATA_DIR
value: {{ .Values.preview_service.puppeteer.userDataDirectory | quote }}
@@ -110,12 +100,6 @@ spec:
- name: PREVIEW_TIMEOUT
value: {{ .Values.preview_service.puppeteer.timeoutMilliseconds | quote }}
{{- end }}
{{- if .Values.featureFlags.workspacesMultiRegionEnabled }}
- name: FF_WORKSPACES_MULTI_REGION_ENABLED
value: {{ .Values.featureFlags.workspacesMultiRegionEnabled | quote }}
- name: MULTI_REGION_CONFIG_PATH
value: "/multi-region-config/multi-region-config.json"
{{- end }}
{{- if .Values.preview_service.affinity }}
affinity: {{- include "speckle.renderTpl" (dict "value" .Values.preview_service.affinity "context" $) | nindent 8 }}
@@ -146,17 +130,3 @@ spec:
# Should be > preview generation time ( 1 hour for good measure )
terminationGracePeriodSeconds: 3600
volumes:
{{- if .Values.db.useCertificate }}
- name: postgres-certificate
configMap:
name: postgres-certificate
{{- end }}
{{- if .Values.featureFlags.workspacesMultiRegionEnabled }}
- name: multi-region-config
secret:
secretName: {{ .Values.multiRegion.config.secretName }}
items:
- key: {{ .Values.multiRegion.config.secretKey }}
path: "multi-region-config.json"
{{- end }}

Some files were not shown because too many files have changed in this diff Show More