0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

feat(apps): transpile via django (#18201)

This commit is contained in:
Marius Andra 2023-11-07 21:56:59 +00:00 committed by GitHub
parent 2ce7ea6b0c
commit 06e993818d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 659 additions and 79 deletions

View File

@ -34,3 +34,5 @@
!share/GeoLite2-City.mmdb !share/GeoLite2-City.mmdb
!hogvm/python !hogvm/python
!unit.json !unit.json
!plugin-transpiler/src
!plugin-transpiler/*.*

View File

@ -62,6 +62,24 @@ runs:
run: | run: |
sudo apt-get update && sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl sudo apt-get update && sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8.x.x
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- name: Install plugin-transpiler
shell: bash
run: |
cd plugin-transpiler
pnpm install
pnpm run build
- uses: syphar/restore-virtualenv@v1 - uses: syphar/restore-virtualenv@v1
id: cache-backend-tests id: cache-backend-tests
with: with:

View File

@ -68,6 +68,7 @@ jobs:
- mypy.ini - mypy.ini
- pytest.ini - pytest.ini
- frontend/src/queries/schema.json # Used for generating schema.py - frontend/src/queries/schema.json # Used for generating schema.py
- plugin-transpiler/src # Used for transpiling plugins
# Make sure we run if someone is explicitly change the workflow # Make sure we run if someone is explicitly change the workflow
- .github/workflows/ci-backend.yml - .github/workflows/ci-backend.yml
- .github/actions/run-backend-tests/action.yml - .github/actions/run-backend-tests/action.yml

1
.gitignore vendored
View File

@ -55,3 +55,4 @@ gen/
upgrade/ upgrade/
hogvm/typescript/dist hogvm/typescript/dist
.wokeignore .wokeignore
plugin-transpiler/dist

View File

@ -0,0 +1,3 @@
## Plugin Transpiler
This project transpiles frontend plugins and site apps.

13
plugin-transpiler/build.mjs Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env node
import * as esbuild from 'esbuild'
;(async function build() {
let result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
})
if (!result.errors.length) {
// eslint-disable-next-line no-console
console.log('Build succeeded')
}
})()

View File

@ -0,0 +1,21 @@
{
"name": "plugin-transpiler",
"version": "1.0.0",
"description": "Transpiles site apps TSX to browser JS via stdin/stdout",
"main": "transpile.mjs",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc -b && node build.mjs",
"start:dist": "node dist/index.js",
"start": "npm run build && npm run start:dist"
},
"author": "PostHog Inc.",
"license": "MIT",
"devDependencies": {
"@babel/standalone": "^7.23.2",
"@types/babel__standalone": "^7.1.6",
"@types/node": "^20.8.9",
"esbuild": "^0.19.5",
"typescript": "^5.2.2"
}
}

View File

@ -0,0 +1,340 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
devDependencies:
'@babel/standalone':
specifier: ^7.23.2
version: 7.23.2
'@types/babel__standalone':
specifier: ^7.1.6
version: 7.1.6
'@types/node':
specifier: ^20.8.9
version: 20.8.9
esbuild:
specifier: ^0.19.5
version: 0.19.5
typescript:
specifier: ^5.2.2
version: 5.2.2
packages:
/@babel/helper-string-parser@7.22.5:
resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-validator-identifier@7.22.20:
resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/parser@7.23.0:
resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
'@babel/types': 7.23.0
dev: true
/@babel/standalone@7.23.2:
resolution: {integrity: sha512-VJNw7OS26JvB6rE9XpbT6uQeQIEBWU5eeHGS4VR/+/4ZoKdLBXLcy66ZVJ/9IBkK1RMp8B0cohvhzdKWtJAGmg==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/types@7.23.0:
resolution: {integrity: sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-string-parser': 7.22.5
'@babel/helper-validator-identifier': 7.22.20
to-fast-properties: 2.0.0
dev: true
/@esbuild/android-arm64@0.19.5:
resolution: {integrity: sha512-5d1OkoJxnYQfmC+Zd8NBFjkhyCNYwM4n9ODrycTFY6Jk1IGiZ+tjVJDDSwDt77nK+tfpGP4T50iMtVi4dEGzhQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.19.5:
resolution: {integrity: sha512-bhvbzWFF3CwMs5tbjf3ObfGqbl/17ict2/uwOSfr3wmxDE6VdS2GqY/FuzIPe0q0bdhj65zQsvqfArI9MY6+AA==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.19.5:
resolution: {integrity: sha512-9t+28jHGL7uBdkBjL90QFxe7DVA+KGqWlHCF8ChTKyaKO//VLuoBricQCgwhOjA1/qOczsw843Fy4cbs4H3DVA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.19.5:
resolution: {integrity: sha512-mvXGcKqqIqyKoxq26qEDPHJuBYUA5KizJncKOAf9eJQez+L9O+KfvNFu6nl7SCZ/gFb2QPaRqqmG0doSWlgkqw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.19.5:
resolution: {integrity: sha512-Ly8cn6fGLNet19s0X4unjcniX24I0RqjPv+kurpXabZYSXGM4Pwpmf85WHJN3lAgB8GSth7s5A0r856S+4DyiA==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.19.5:
resolution: {integrity: sha512-GGDNnPWTmWE+DMchq1W8Sd0mUkL+APvJg3b11klSGUDvRXh70JqLAO56tubmq1s2cgpVCSKYywEiKBfju8JztQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.19.5:
resolution: {integrity: sha512-1CCwDHnSSoA0HNwdfoNY0jLfJpd7ygaLAp5EHFos3VWJCRX9DMwWODf96s9TSse39Br7oOTLryRVmBoFwXbuuQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.19.5:
resolution: {integrity: sha512-o3vYippBmSrjjQUCEEiTZ2l+4yC0pVJD/Dl57WfPwwlvFkrxoSO7rmBZFii6kQB3Wrn/6GwJUPLU5t52eq2meA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.19.5:
resolution: {integrity: sha512-lrWXLY/vJBzCPC51QN0HM71uWgIEpGSjSZZADQhq7DKhPcI6NH1IdzjfHkDQws2oNpJKpR13kv7/pFHBbDQDwQ==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.19.5:
resolution: {integrity: sha512-MkjHXS03AXAkNp1KKkhSKPOCYztRtK+KXDNkBa6P78F8Bw0ynknCSClO/ztGszILZtyO/lVKpa7MolbBZ6oJtQ==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.19.5:
resolution: {integrity: sha512-42GwZMm5oYOD/JHqHska3Jg0r+XFb/fdZRX+WjADm3nLWLcIsN27YKtqxzQmGNJgu0AyXg4HtcSK9HuOk3v1Dw==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.19.5:
resolution: {integrity: sha512-kcjndCSMitUuPJobWCnwQ9lLjiLZUR3QLQmlgaBfMX23UEa7ZOrtufnRds+6WZtIS9HdTXqND4yH8NLoVVIkcg==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.19.5:
resolution: {integrity: sha512-yJAxJfHVm0ZbsiljbtFFP1BQKLc8kUF6+17tjQ78QjqjAQDnhULWiTA6u0FCDmYT1oOKS9PzZ2z0aBI+Mcyj7Q==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.19.5:
resolution: {integrity: sha512-5u8cIR/t3gaD6ad3wNt1MNRstAZO+aNyBxu2We8X31bA8XUNyamTVQwLDA1SLoPCUehNCymhBhK3Qim1433Zag==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.19.5:
resolution: {integrity: sha512-Z6JrMyEw/EmZBD/OFEFpb+gao9xJ59ATsoTNlj39jVBbXqoZm4Xntu6wVmGPB/OATi1uk/DB+yeDPv2E8PqZGw==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.19.5:
resolution: {integrity: sha512-psagl+2RlK1z8zWZOmVdImisMtrUxvwereIdyJTmtmHahJTKb64pAcqoPlx6CewPdvGvUKe2Jw+0Z/0qhSbG1A==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.19.5:
resolution: {integrity: sha512-kL2l+xScnAy/E/3119OggX8SrWyBEcqAh8aOY1gr4gPvw76la2GlD4Ymf832UCVbmuWeTf2adkZDK+h0Z/fB4g==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.19.5:
resolution: {integrity: sha512-sPOfhtzFufQfTBgRnE1DIJjzsXukKSvZxloZbkJDG383q0awVAq600pc1nfqBcl0ice/WN9p4qLc39WhBShRTA==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.19.5:
resolution: {integrity: sha512-dGZkBXaafuKLpDSjKcB0ax0FL36YXCvJNnztjKV+6CO82tTYVDSH2lifitJ29jxRMoUhgkg9a+VA/B03WK5lcg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.19.5:
resolution: {integrity: sha512-dWVjD9y03ilhdRQ6Xig1NWNgfLtf2o/STKTS+eZuF90fI2BhbwD6WlaiCGKptlqXlURVB5AUOxUj09LuwKGDTg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.19.5:
resolution: {integrity: sha512-4liggWIA4oDgUxqpZwrDhmEfAH4d0iljanDOK7AnVU89T6CzHon/ony8C5LeOdfgx60x5cnQJFZwEydVlYx4iw==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.19.5:
resolution: {integrity: sha512-czTrygUsB/jlM8qEW5MD8bgYU2Xg14lo6kBDXW6HdxKjh8M5PzETGiSHaz9MtbXBYDloHNUAUW2tMiKW4KM9Mw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@types/babel__core@7.20.3:
resolution: {integrity: sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==}
dependencies:
'@babel/parser': 7.23.0
'@babel/types': 7.23.0
'@types/babel__generator': 7.6.6
'@types/babel__template': 7.4.3
'@types/babel__traverse': 7.20.3
dev: true
/@types/babel__generator@7.6.6:
resolution: {integrity: sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==}
dependencies:
'@babel/types': 7.23.0
dev: true
/@types/babel__standalone@7.1.6:
resolution: {integrity: sha512-JRVq2H02irW6GDxkHepsdZVjor87SZo4eDsxoBtSbswf7HWtVYNxCmJDBkJbFNFWGOuzsxATZV8xZ2PI9sBiIw==}
dependencies:
'@types/babel__core': 7.20.3
dev: true
/@types/babel__template@7.4.3:
resolution: {integrity: sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==}
dependencies:
'@babel/parser': 7.23.0
'@babel/types': 7.23.0
dev: true
/@types/babel__traverse@7.20.3:
resolution: {integrity: sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==}
dependencies:
'@babel/types': 7.23.0
dev: true
/@types/node@20.8.9:
resolution: {integrity: sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==}
dependencies:
undici-types: 5.26.5
dev: true
/esbuild@0.19.5:
resolution: {integrity: sha512-bUxalY7b1g8vNhQKdB24QDmHeY4V4tw/s6Ak5z+jJX9laP5MoQseTOMemAr0gxssjNcH0MCViG8ONI2kksvfFQ==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/android-arm': 0.19.5
'@esbuild/android-arm64': 0.19.5
'@esbuild/android-x64': 0.19.5
'@esbuild/darwin-arm64': 0.19.5
'@esbuild/darwin-x64': 0.19.5
'@esbuild/freebsd-arm64': 0.19.5
'@esbuild/freebsd-x64': 0.19.5
'@esbuild/linux-arm': 0.19.5
'@esbuild/linux-arm64': 0.19.5
'@esbuild/linux-ia32': 0.19.5
'@esbuild/linux-loong64': 0.19.5
'@esbuild/linux-mips64el': 0.19.5
'@esbuild/linux-ppc64': 0.19.5
'@esbuild/linux-riscv64': 0.19.5
'@esbuild/linux-s390x': 0.19.5
'@esbuild/linux-x64': 0.19.5
'@esbuild/netbsd-x64': 0.19.5
'@esbuild/openbsd-x64': 0.19.5
'@esbuild/sunos-x64': 0.19.5
'@esbuild/win32-arm64': 0.19.5
'@esbuild/win32-ia32': 0.19.5
'@esbuild/win32-x64': 0.19.5
dev: true
/to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
dev: true
/typescript@5.2.2:
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true

View File

@ -0,0 +1,46 @@
import { transform } from '@babel/standalone'
import { presets } from './presets'
process.stdin.setEncoding('utf8')
let type: 'site' | 'frontend' = 'site'
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i]
if (arg === '--type' && process.argv[i + 1]) {
type = process.argv[++i] as any
if (type !== 'site' && type !== 'frontend') {
console.error(`Unknown app type: ${type}`)
process.exit(1)
}
} else {
console.error(`Unknown argument: ${arg}`)
process.exit(1)
}
}
const { wrapper, ...options } = presets[type]
let code = ''
process.stdin.on('readable', () => {
let chunk: string | Buffer
while ((chunk = process.stdin.read())) {
code += chunk
}
})
process.stdin.on('end', () => {
try {
let output = transform(code, options).code
if (output) {
if (wrapper) {
output = wrapper(output)
}
process.stdout.write(output, 'utf8')
} else {
throw new Error('Could not transpile code')
}
} catch (error: any) {
console.error(error.message)
process.exit(1)
}
})

View File

@ -0,0 +1,25 @@
export const presets = {
site: {
envName: 'production',
code: true,
babelrc: false,
configFile: false,
filename: 'site.ts',
presets: [['typescript', { isTSX: false, allExtensions: true }], 'env'],
wrapper: (code: string): string => `(function () {let exports={};${code};return exports;})`,
},
frontend: {
envName: 'production',
code: true,
babelrc: false,
configFile: false,
filename: 'frontend.tsx',
plugins: ['transform-react-jsx'],
presets: [
['typescript', { isTSX: true, allExtensions: true }],
['env', { targets: { esmodules: false } }],
],
wrapper: (code: string): string =>
`"use strict";\nexport function getFrontendApp (require) { let exports = {}; ${code}; return exports; }`,
},
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020", // Target a recent version of ECMAScript.
"module": "CommonJS", // Use CommonJS module system for Node.js.
"outDir": "./dist", // Output directory for the compiled JavaScript files.
"strict": true, // Enable all strict type-checking options.
"esModuleInterop": true, // Allows default imports from modules with no default export.
"skipLibCheck": true, // Skip type checking of declaration files.
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file.
"resolveJsonModule": true, // Allow importing .json files as modules.
"moduleResolution": "node" // Use Node.js-style module resolution.
},
"include": [
"src/**/*.ts", // Include all TypeScript files in the 'src' directory and its subdirectories.
"src/**/*.tsx" // Include all TSX files, if you have any.
],
"exclude": [
"node_modules" // Exclude node_modules from the compilation.
]
}

View File

@ -1,6 +1,8 @@
import json import json
import os
import re import re
from typing import Any, Dict, List, Optional, Set, cast import subprocess
from typing import Any, Dict, List, Optional, Set, cast, Literal
import requests import requests
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@ -180,6 +182,24 @@ def _fix_formdata_config_json(request: request.Request, validated_data: dict):
validated_data["config"] = json.loads(request.POST["config"]) validated_data["config"] = json.loads(request.POST["config"])
def transpile(input_string: str, type: Literal["site", "frontend"] = "site") -> Optional[str]:
from posthog.settings.base_variables import BASE_DIR
transpiler_path = os.path.join(BASE_DIR, "plugin-transpiler/dist/index.js")
if type not in ["site", "frontend"]:
raise Exception('Invalid type. Must be "site" or "frontend".')
process = subprocess.Popen(
["node", transpiler_path, "--type", type], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout, stderr = process.communicate(input=input_string.encode())
if process.returncode != 0:
error = stderr.decode()
raise Exception(error)
return stdout.decode()
class PlainRenderer(renderers.BaseRenderer): class PlainRenderer(renderers.BaseRenderer):
format = "txt" format = "txt"
@ -355,25 +375,47 @@ class PluginViewSet(StructuredViewSetMixin, viewsets.ModelViewSet):
plugin = self.get_plugin_with_permissions(reason="source editing") plugin = self.get_plugin_with_permissions(reason="source editing")
sources: Dict[str, PluginSourceFile] = {} sources: Dict[str, PluginSourceFile] = {}
performed_changes = False performed_changes = False
for source in PluginSourceFile.objects.filter(plugin=plugin): for plugin_source_file in PluginSourceFile.objects.filter(plugin=plugin):
sources[source.filename] = source sources[plugin_source_file.filename] = plugin_source_file
for key, value in request.data.items(): for key, source in request.data.items():
transpiled = None
error = None
status = None
try:
if key == "site.ts":
transpiled = transpile(source, type="site")
status = PluginSourceFile.Status.TRANSPILED
elif key == "frontend.tsx":
transpiled = transpile(source, type="frontend")
status = PluginSourceFile.Status.TRANSPILED
except Exception as e:
error = str(e)
status = PluginSourceFile.Status.ERROR
if key not in sources: if key not in sources:
performed_changes = True performed_changes = True
sources[key], created = PluginSourceFile.objects.update_or_create( sources[key], created = PluginSourceFile.objects.update_or_create(
plugin=plugin, filename=key, defaults={"source": value} plugin=plugin,
filename=key,
defaults={
"source": source,
"transpiled": transpiled,
"status": status,
"error": error,
},
) )
elif sources[key].source != value: elif sources[key].source != source or sources[key].transpiled != transpiled or sources[key].error != error:
performed_changes = True performed_changes = True
if value is None: if source is None:
sources[key].delete() sources[key].delete()
del sources[key] del sources[key]
else: else:
sources[key].source = value sources[key].source = source
sources[key].status = None sources[key].transpiled = transpiled
sources[key].transpiled = None sources[key].status = status
sources[key].error = None sources[key].error = error
sources[key].save() sources[key].save()
response: Dict[str, str] = {} response: Dict[str, str] = {}
for _, source in sources.items(): for _, source in sources.items():
response[source.filename] = source.source response[source.filename] = source.source

View File

@ -37,6 +37,8 @@ def mocked_plugin_reload(*args, **kwargs):
@mock.patch("posthog.models.plugin.reload_plugins_on_workers", side_effect=mocked_plugin_reload) @mock.patch("posthog.models.plugin.reload_plugins_on_workers", side_effect=mocked_plugin_reload)
@mock.patch("requests.get", side_effect=mocked_plugin_requests_get) @mock.patch("requests.get", side_effect=mocked_plugin_requests_get)
class TestPluginAPI(APIBaseTest, QueryMatchingTest): class TestPluginAPI(APIBaseTest, QueryMatchingTest):
maxDiff = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
super().setUpTestData() super().setUpTestData()
@ -703,88 +705,122 @@ class TestPluginAPI(APIBaseTest, QueryMatchingTest):
self.assertEqual(response.json(), {"plugin.json": '{"name":"my plugin"}'}) self.assertEqual(response.json(), {"plugin.json": '{"name":"my plugin"}'})
self.assertEqual(mock_reload.call_count, 3) self.assertEqual(mock_reload.call_count, 3)
def test_create_plugin_frontend_source(self, mock_get, mock_reload): def test_transpile_plugin_frontend_source(self, mock_get, mock_reload):
self.assertEqual(mock_reload.call_count, 0) # Setup
assert mock_reload.call_count == 0
response = self.client.post( response = self.client.post(
"/api/organizations/@current/plugins/", "/api/organizations/@current/plugins/",
{"plugin_type": "source", "name": "myplugin"}, {"plugin_type": "source", "name": "myplugin"},
) )
self.assertEqual(response.status_code, 201) assert response.status_code == 201
id = response.json()["id"] id = response.json()["id"]
self.assertEqual( assert response.json() == {
response.json(), "id": id,
{ "plugin_type": "source",
"id": id, "name": "myplugin",
"plugin_type": "source", "description": None,
"name": "myplugin", "url": None,
"description": None, "config_schema": {},
"url": None, "tag": None,
"config_schema": {}, "icon": None,
"tag": None, "latest_tag": None,
"icon": None, "is_global": False,
"latest_tag": None, "organization_id": response.json()["organization_id"],
"is_global": False, "organization_name": self.CONFIG_ORGANIZATION_NAME,
"organization_id": response.json()["organization_id"], "capabilities": {},
"organization_name": self.CONFIG_ORGANIZATION_NAME, "metrics": {},
"capabilities": {}, "public_jobs": {},
"metrics": {}, }
"public_jobs": {},
},
)
self.assertEqual(Plugin.objects.count(), 1)
self.assertEqual(mock_reload.call_count, 0)
response = self.client.patch( assert Plugin.objects.count() == 1
assert mock_reload.call_count == 0
# Add first source file, frontend.tsx
self.client.patch(
f"/api/organizations/@current/plugins/{id}/update_source", f"/api/organizations/@current/plugins/{id}/update_source",
{"frontend.tsx": "export const scene = {}"}, {"frontend.tsx": "export const scene = {}"},
) )
assert Plugin.objects.count() == 1
assert PluginSourceFile.objects.count() == 1
assert mock_reload.call_count == 1
self.assertEqual(Plugin.objects.count(), 1) # Fetch transpiled source via API call
self.assertEqual(PluginSourceFile.objects.count(), 1)
self.assertEqual(mock_reload.call_count, 1)
plugin = Plugin.objects.get(pk=id) plugin = Plugin.objects.get(pk=id)
plugin_config = PluginConfig.objects.create(plugin=plugin, team=self.team, enabled=True, order=1) plugin_config = PluginConfig.objects.create(plugin=plugin, team=self.team, enabled=True, order=1)
# no frontend, since no pluginserver transpiles the code
response = self.client.get(f"/api/plugin_config/{plugin_config.id}/frontend") response = self.client.get(f"/api/plugin_config/{plugin_config.id}/frontend")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual( self.assertEqual(
response.content, response.content.decode("utf-8"),
b'export function getFrontendApp () { return {"transpiling": true} }', '"use strict";\nexport function getFrontendApp (require) { let exports = {}; '
'"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports.scene = void 0;\n'
"var scene = exports.scene = {};" # this is it
"; return exports; }",
) )
# mock the plugin server's transpilation # Check in the database
plugin_source = PluginSourceFile.objects.get(plugin_id=id) plugin_source = PluginSourceFile.objects.get(plugin_id=id)
self.assertEqual(plugin_source.status, None) assert plugin_source.source == "export const scene = {}"
self.assertEqual(plugin_source.transpiled, None) assert plugin_source.error is None
plugin_source.status = PluginSourceFile.Status.TRANSPILED assert plugin_source.transpiled == response.content.decode("utf-8")
plugin_source.transpiled = "'random transpiled frontend'" assert plugin_source.status == PluginSourceFile.Status.TRANSPILED
plugin_source.save()
# Can get the transpiled frontend # Updates work
response = self.client.get(f"/api/plugin_config/{plugin_config.id}/frontend")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"'random transpiled frontend'")
# Update the source frontend
self.client.patch( self.client.patch(
f"/api/organizations/@current/plugins/{id}/update_source", f"/api/organizations/@current/plugins/{id}/update_source",
{"frontend.tsx": "export const scene = { name: 'new' }"}, {"frontend.tsx": "export const scene = { name: 'new' }"},
) )
# It will clear the transpiled frontend
plugin_source = PluginSourceFile.objects.get(plugin_id=id) plugin_source = PluginSourceFile.objects.get(plugin_id=id)
self.assertEqual(plugin_source.source, "export const scene = { name: 'new' }") assert plugin_source.source == "export const scene = { name: 'new' }"
self.assertEqual(plugin_source.transpiled, None) assert plugin_source.error is None
assert (
# And reply that it's transpiling plugin_source.transpiled
response = self.client.get(f"/api/plugin_config/{plugin_config.id}/frontend") == (
self.assertEqual(response.status_code, 200) '"use strict";\nexport function getFrontendApp (require) { let exports = {}; "use strict";\n\n'
self.assertEqual( 'Object.defineProperty(exports, "__esModule", {\n value: true\n});\nexports.scene = void 0;\n'
response.content, "var scene = exports.scene = {\n name: 'new'\n};" # this is it
b'export function getFrontendApp () { return {"transpiling": true} }', "; return exports; }"
)
) )
assert plugin_source.status == PluginSourceFile.Status.TRANSPILED
# Errors as well
self.client.patch(
f"/api/organizations/@current/plugins/{id}/update_source",
{"frontend.tsx": "export const scene = { nam broken code foobar"},
)
plugin_source = PluginSourceFile.objects.get(plugin_id=id)
assert plugin_source.source == "export const scene = { nam broken code foobar"
assert plugin_source.transpiled is None
assert plugin_source.status == PluginSourceFile.Status.ERROR
assert (
plugin_source.error
== '/frontend.tsx: Unexpected token, expected "," (1:27)\n\n> 1 | export const scene = { nam broken code foobar\n | ^\n'
)
# Deletes work
self.client.patch(
f"/api/organizations/@current/plugins/{id}/update_source",
{"frontend.tsx": None},
)
try:
PluginSourceFile.objects.get(plugin_id=id)
assert False, "Should have thrown DoesNotExist"
except PluginSourceFile.DoesNotExist:
assert True
# Check that the syntax for "site.ts" is slightly different
self.client.patch(
f"/api/organizations/@current/plugins/{id}/update_source",
{"site.ts": "console.log('hello')"},
)
plugin_source = PluginSourceFile.objects.get(plugin_id=id)
assert plugin_source.source == "console.log('hello')"
assert plugin_source.error is None
assert (
plugin_source.transpiled
== "(function () {let exports={};\"use strict\";\n\nconsole.log('hello');;return exports;})"
)
assert plugin_source.status == PluginSourceFile.Status.TRANSPILED
def test_plugin_repository(self, mock_get, mock_reload): def test_plugin_repository(self, mock_get, mock_reload):
response = self.client.get("/api/organizations/@current/plugins/repository/") response = self.client.get("/api/organizations/@current/plugins/repository/")

View File

@ -26,7 +26,7 @@ WORKDIR /code
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm --version && \ RUN corepack enable && \
mkdir /tmp/pnpm-store && \ mkdir /tmp/pnpm-store && \
pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store --prod && \ pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store --prod && \
rm -rf /tmp/pnpm-store rm -rf /tmp/pnpm-store
@ -37,6 +37,22 @@ COPY babel.config.js tsconfig.json webpack.config.js ./
RUN pnpm build RUN pnpm build
#
# ---------------------------------------------------------
#
FROM node:18.12.1-bullseye-slim AS plugin-transpiler-build
WORKDIR /code
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
COPY plugin-transpiler/ plugin-transpiler/
WORKDIR /code/plugin-transpiler
RUN corepack enable && \
mkdir /tmp/pnpm-store && \
pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store && \
pnpm build && \
rm -rf /tmp/pnpm-store
# #
# --------------------------------------------------------- # ---------------------------------------------------------
# #

View File

@ -14,27 +14,23 @@
"metrics": [ "metrics": [
{ {
"match": { "match": {
"uri": [ "uri": ["/metrics"]
"/metrics"
]
}, },
"action": { "action": {
"pass": "applications/metrics" "pass": "applications/metrics"
} }
}, }
], ],
"status": [ "status": [
{ {
"match": { "match": {
"uri": [ "uri": ["/status"]
"/status"
]
}, },
"action": { "action": {
"proxy": "http://unix:/var/run/control.unit.sock" "proxy": "http://unix:/var/run/control.unit.sock"
} }
}, }
], ]
}, },
"applications": { "applications": {
"posthog": { "posthog": {
@ -52,6 +48,6 @@
"path": ".", "path": ".",
"module": "unit_metrics", "module": "unit_metrics",
"user": "nobody" "user": "nobody"
}, }
} }
} }