diff --git a/.github/actions/check_test_project_fixture/action.yml b/.github/actions/check_test_project_fixture/action.yml deleted file mode 100644 index 43374e7ea0e7..000000000000 --- a/.github/actions/check_test_project_fixture/action.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: Check test project fixture -description: Determines if the test project fixture should be rebuilt -runs: - using: node20 - main: check_test_project_fixture.mjs -inputs: - labels: - required: true diff --git a/.github/actions/check_test_project_fixture/check_test_project_fixture.mjs b/.github/actions/check_test_project_fixture/check_test_project_fixture.mjs deleted file mode 100644 index 970763d3f91f..000000000000 --- a/.github/actions/check_test_project_fixture/check_test_project_fixture.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-env es6, node */ -import { getInput } from '@actions/core' - -// If the PR has the "fixture-ok" label, just pass. -const { labels } = JSON.parse(getInput('labels')) -const hasFixtureOkLabel = labels.some((label) => label.name === 'fixture-ok') - -if (hasFixtureOkLabel) { - console.log('Skipping check because of the "fixture-ok" label') -} else { - // Check if the PR rebuilds the fixture. If it does, that's enough. - const { exec, getExecOutput } = await import('@actions/exec') - await exec('git fetch origin main') - const { stdout } = await getExecOutput('git diff origin/main --name-only') - const changedFiles = stdout.toString().trim().split('\n').filter(Boolean) - const didRebuildFixture = changedFiles.some((file) => - file.startsWith('__fixtures__/test-project') - ) - - if (didRebuildFixture) { - console.log( - [ - // Empty space here (and in subsequent console logs) - // because git fetch origin main prints to stdout. - '', - "The fixture's been rebuilt", - ].join('\n') - ) - } else { - // If it doesn't, does it need to be rebuilt? If not, no problem. Otherwise, throw. - const shouldRebuildFixture = changedFiles.some( - (file) => - file.startsWith('packages/cli/src/commands/generate') || - file.startsWith('packages/cli/src/commands/setup') || - file.startsWith('packages/cli-helpers/src/') || - file.startsWith('packages/create-redwood-app/template') || - file.startsWith('packages/auth-providers/dbAuth/setup') - ) - - if (!shouldRebuildFixture) { - console.log(['', "The fixture doesn't need to be rebuilt"].join('\n')) - } else { - console.log( - [ - '', - 'This PR changes generate or setup commands, or the create-redwood-app template.', - 'That usually means the test-project fixture needs to be rebuilt.', - `If you know that it doesn't, add the "fixture-ok" label. Otherwise, rebuild the fixture and commit the changes:`, - '', - ' yarn rebuild-test-project-fixture', - '', - ].join('\n') - ) - - process.exitCode = 1 - } - } -} diff --git a/.github/actions/check_test_project_fixture/package.json b/.github/actions/check_test_project_fixture/package.json deleted file mode 100644 index 03bc407905ca..000000000000 --- a/.github/actions/check_test_project_fixture/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "check_test_project_fixture", - "private": true, - "dependencies": { - "@actions/core": "1.10.1", - "@actions/exec": "1.1.1" - }, - "packageManager": "yarn@4.0.2" -} diff --git a/.github/actions/check_test_project_fixture/yarn.lock b/.github/actions/check_test_project_fixture/yarn.lock deleted file mode 100644 index 67423743c42c..000000000000 --- a/.github/actions/check_test_project_fixture/yarn.lock +++ /dev/null @@ -1,66 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"@actions/core@npm:1.10.1": - version: 1.10.1 - resolution: "@actions/core@npm:1.10.1" - dependencies: - "@actions/http-client": "npm:^2.0.1" - uuid: "npm:^8.3.2" - checksum: 8c0/7a61446697a23dcad3545cf0634dedbdedf20ae9a0ee6ee977554589a15deb4a93593ee48a41258933d58ce0778f446b0d2c162b60750956fb75e0b9560fb832 - languageName: node - linkType: hard - -"@actions/exec@npm:1.1.1": - version: 1.1.1 - resolution: "@actions/exec@npm:1.1.1" - dependencies: - "@actions/io": "npm:^1.0.1" - checksum: 8c0/4a09f6bdbe50ce68b5cf8a7254d176230d6a74bccf6ecc3857feee209a8c950ba9adec87cc5ecceb04110182d1c17117234e45557d72fde6229b7fd3a395322a - languageName: node - linkType: hard - -"@actions/http-client@npm:^2.0.1": - version: 2.0.1 - resolution: "@actions/http-client@npm:2.0.1" - dependencies: - tunnel: "npm:^0.0.6" - checksum: 8c0/b58987ba2f53d7988f612ede7ff834573a3360c21f8fdea9fea92f26ada0fd0efafb22aa7d83f49c18965a5b765775d5253e2edb8d9476d924c4b304ef726b67 - languageName: node - linkType: hard - -"@actions/io@npm:^1.0.1": - version: 1.1.2 - resolution: "@actions/io@npm:1.1.2" - checksum: 8c0/61c871bbee1cf58f57917d9bb2cf6bb7ea4dc40de3f65c7fb4ec619ceff57fc98f56be9cca2d476b09e7a96e1cba0d88cd125c4f690d384b9483935186f256c1 - languageName: node - linkType: hard - -"check_test_project_fixture@workspace:.": - version: 0.0.0-use.local - resolution: "check_test_project_fixture@workspace:." - dependencies: - "@actions/core": "npm:1.10.1" - "@actions/exec": "npm:1.1.1" - languageName: unknown - linkType: soft - -"tunnel@npm:^0.0.6": - version: 0.0.6 - resolution: "tunnel@npm:0.0.6" - checksum: 8c0/e27e7e896f2426c1c747325b5f54efebc1a004647d853fad892b46d64e37591ccd0b97439470795e5262b5c0748d22beb4489a04a0a448029636670bfd801b75 - languageName: node - linkType: hard - -"uuid@npm:^8.3.2": - version: 8.3.2 - resolution: "uuid@npm:8.3.2" - bin: - uuid: dist/bin/uuid - checksum: 8c0/bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 - languageName: node - linkType: hard diff --git a/.github/workflows/check-test-project-fixture.yml b/.github/workflows/check-test-project-fixture.yml index 2a681dfb36a3..5808634283a3 100644 --- a/.github/workflows/check-test-project-fixture.yml +++ b/.github/workflows/check-test-project-fixture.yml @@ -20,14 +20,47 @@ jobs: - name: Enable Corepack run: corepack enable - - uses: actions/setup-node@v4 + - name: ⬢ Set up Node.js + uses: actions/setup-node@v4 with: node-version: 20 - - run: yarn install - working-directory: ./.github/actions/check_test_project_fixture + - name: 🐈 Set up yarn cache + if: "!contains(github.event.pull_request.labels.*.name, 'fixture-ok')" + uses: ./.github/actions/set-up-yarn-cache - - name: Check test project fixture - uses: ./.github/actions/check_test_project_fixture - with: - labels: '{ "labels": ${{ toJSON(github.event.pull_request.labels) }} }' + - name: 🐈 Yarn install + if: "!contains(github.event.pull_request.labels.*.name, 'fixture-ok')" + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: 🔨 Build + if: "!contains(github.event.pull_request.labels.*.name, 'fixture-ok')" + run: yarn build + + - name: Rebuild test-project fixture + if: "!contains(github.event.pull_request.labels.*.name, 'fixture-ok')" + run: yarn rebuild-test-project-fixture + env: + REDWOOD_DISABLE_TELEMETRY: 1 + YARN_ENABLE_IMMUTABLE_INSTALLS: false + + - name: Check for changed files + if: "!contains(github.event.pull_request.labels.*.name, 'fixture-ok')" + run: | + if [ $(git status --porcelain | wc -l) -gt 0 ]; then + echo 'Updating the test project fixture caused files to change' + echo 'Please run `yarn rebuild-test-project-fixture` locally and commit any changes' + echo + echo 'Deleted files:' + git status --porcelain | grep '^ D ' | awk '{print " " $2}' + echo + echo 'Modified files:' + git status --porcelain | grep '^ M ' | awk '{print " " $2}' + echo + echo 'Untracked files:' + git status --porcelain | grep '^?? ' | awk '{print " " $2}' + echo + exit 1; + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07f9d0a4ebee..0a6d830aa149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,54 +179,55 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} - - name: 📁 Create a temporary directory - id: createpath - run: | - project_path=$(mktemp -d -t redwood.XXXXXX) - echo "::set-output name=project_path::$project_path" - framework_path=$(pwd) - echo "::set-output name=framework_path::$framework_path" + - name: 🔨 Build + run: yarn build + + - name: 🌲 Install Cypress + run: yarn cypress install - name: 🌲 Create a Redwood App - run: | - ./tasks/run-e2e ${{ steps.createpath.outputs.project_path }} \ - --no-start \ - --bundler ${{ matrix.bundler }} + id: crwa env: YARN_ENABLE_IMMUTABLE_INSTALLS: false - - - name: 🐙 Git init in the Redwood App directory run: | + project_path=$(mktemp -d -t redwood.XXXXXX) + echo "project-path=$project_path" >> $GITHUB_OUTPUT + git config --global user.email "you@example.com" git config --global user.name "Your Name" - git init --initial-branch main && git add . - git commit -a --message=init - working-directory: ${{ steps.createpath.outputs.project_path }} - - name: Start server in background - run: yarn rw dev --no-generate --fwd="--no-open" & - working-directory: ${{ steps.createpath.outputs.project_path }} + ./tasks/run-e2e "$project_path" \ + --bundler ${{ matrix.bundler }} \ + --no-build-framework \ + --no-start - - name: 🌲 Install Cypress - run: yarn run cypress install + - name: Start the dev server in the background + run: | + yarn rw dev --no-generate --fwd="--no-open" 2>&1 | tee dev_server.log & + working-directory: ${{ steps.crwa.outputs.project-path }} - - name: 🌲 Run cypress - uses: cypress-io/github-action@v5 + - name: 🌲 Run Cypress + uses: cypress-io/github-action@v6 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CYPRESS_RW_PATH: "${{ steps.createpath.outputs.project_path }}" + CYPRESS_RW_PATH: ${{ steps.crwa.outputs.project-path }} with: - # We've already installed dependencies. - install: false - env: true browser: chrome - record: false + env: true + install: false wait-on: 'http://[::1]:8910' working-directory: ./tasks/e2e spec: | cypress/e2e/01-tutorial/*.cy.js cypress/e2e/04-logger/*.cy.js + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ matrix.bundler }}-logs + path: | + ${{ steps.crwa.outputs.project-path }}/dev_server.log + ${{ steps.crwa.outputs.project-path }}/e2e.log + tutorial-e2e-skip: needs: detect-changes if: needs.detect-changes.outputs.onlydocs == 'true' diff --git a/__fixtures__/test-project/.redwood/README.md b/__fixtures__/test-project/.redwood/README.md index f22b586a47cc..8829edb84776 100644 --- a/__fixtures__/test-project/.redwood/README.md +++ b/__fixtures__/test-project/.redwood/README.md @@ -18,7 +18,6 @@ You don't need to commit any other contents of this directory to your version co | :---------------- | :------- | | commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | | schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | -| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | | telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | | test.db | The sqlite database used when running tests. | @@ -32,6 +31,7 @@ You don't need to commit any other contents of this directory to your version co | telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | | types | Stores the results of type generation. | | updateCheck | Stores a file which contains the results of checking for Redwood updates. | +| studio | Used to store data for `rw studio` | We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. diff --git a/__fixtures__/test-project/web/package.json b/__fixtures__/test-project/web/package.json index 089dd533bc0c..86f8113bf78a 100644 --- a/__fixtures__/test-project/web/package.json +++ b/__fixtures__/test-project/web/package.json @@ -23,9 +23,9 @@ "@redwoodjs/vite": "6.0.7", "@types/react": "18.2.37", "@types/react-dom": "18.2.15", - "autoprefixer": "^10.4.16", + "autoprefixer": "^10.4.17", "postcss": "^8.4.33", - "postcss-loader": "^7.3.4", + "postcss-loader": "^8.0.0", "prettier-plugin-tailwindcss": "0.4.1", "tailwindcss": "^3.4.1" } diff --git a/docs/docs/studio.md b/docs/docs/studio.md new file mode 100644 index 000000000000..c37d896fe072 --- /dev/null +++ b/docs/docs/studio.md @@ -0,0 +1,110 @@ +--- +description: RedwoodJS Studio is a package used during development to gain runtime insights into a project. +--- + +# Studio + +RedwoodJS Studio is a package used during development to gain runtime insights into a project. + +## Motivation + +Redwood provides tools that lets developers "get to work on what makes your application special, instead of wasting cycles choosing and re-choosing various technologies and configurations."[1](https://github.com/redwoodjs/redwood/blob/main/README.md). + +Much happens while your app processes a request: Invoke a function; handle a GraphQL request; resolve the request with a service; build and execute a SQL statement; connect to the database; handle the query response; further resolve the response so in contains all the data needed; return the result ... and more. + +While [logging](https://redwoodjs.com/docs/logger) can show you some of these steps, there is no easy way to see how they relate to each other, compare, or break down individual timings. Observability needed to debug, iterate, try out, and refactor your code is lacking. + +We hope Studio helps solve this problem with an observability tool that combines: + +* Tracing with OpenTelemetry (service and GraphQL) + +* SQL statement logging + +* general metrics (how many invocations) + +* GraphiQL playground with impersonated authentication + +With Studio, it is easier to: + +* identify slow running SQL statements without reviewing captured log files + +* identify and improve N+1 queries by comparing before and after traces + +* impersonate the user authentication headers in GraphiQL + +Redwood Studio is a command line tool which offers a web UI aimed at providing insights into your application via OpenTelemetry ingestion and other development conveniences like auth-impersonation within GraphiQL. + +### Demo +
+ +
+ +### Setup +There is no setup needed to begin using the studio; simply execute the following command to start the studio at `localhost:4318`: +```bash +yarn rw studio +``` +The first time you run this command it will likely install the studio package which may take a small amount of time. + +#### OpenTelemetry +If you want studio to pick up telemetry from you app automatically please ensure you've setup opentelemetry. A guide on this can be found [here](https://community.redwoodjs.com/t/opentelemetry-support-experimental/4772?u=josh-walker-gm) + +### Features +#### TOML +The following TOML options are now available which can control the studio behaviour. +```toml +[studio.graphiql.authImpersonation] + # authProvider = undefined (default value) + jwtSecret = 'secret' + # userId = undefined (default value) + # email = undefined (default value) + # roles = undefined (default value) +``` + +#### GraphiQL Auth Impersonation + +##### DbAuth + +Requires `SESSION_SECRET` envar for cookie encryption. + +TOML example: + +```toml +[studio.graphiql.authImpersonation] + authProvider = "dbAuth" + email = "user@example.com" + userId = "1" +``` + +##### Netlify + +Since Netlify does not expose the JWT secret used to sign the token in production, impersonation requires a `jwtSecret` to encode and decode the auth token. + +TOML example: + +```toml +[studio.graphiql.authImpersonation] + authProvider = "netlify" + email = "user@example.com" + userId = "1" + jwtSecret = "some-secret-setting" +``` + +##### Supabase + +Requires `SUPABASE_JWT_SECRET` envar for JWT signing. + +TOML example: + +```toml +[studio.graphiql.authImpersonation] + authProvider = "supabase" + email = "user@example.com" + userId = "1" +``` + +#### Database File +Studio stores the ingested telemetry to `studio/prisma.db` within the `.redwood` folder. You should not need to touch this file other than if you wish to delete it to erase any existing telemetry data. + +## Availability +The setup command is currently available from the `canary` version of Redwood. You can try this out in a new project by running `yarn rw upgrade --tag canary` and following any general upgrade steps recommend on the [forums](https://community.redwoodjs.com/c/announcements/releases-and-upgrade-guides/18). diff --git a/docs/sidebars.js b/docs/sidebars.js index 2c877425e401..4be162237cfa 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -185,6 +185,7 @@ module.exports = { 'serverless-functions', 'services', 'storybook', + 'studio', 'testing', 'toast-notifications', { diff --git a/package.json b/package.json index 1b8ac389f7bd..6c9b2dd030b1 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "boxen": "5.1.2", "core-js": "3.34.0", "cypress": "13.6.1", + "cypress-fail-fast": "7.1.0", "cypress-wait-until": "3.0.1", "dependency-cruiser": "15.5.0", "dotenv": "16.3.1", diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index 3de776455599..29fd4aedf5ed 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { fork } from 'child_process' import type { ChildProcess } from 'child_process' +import { fork } from 'child_process' import fs from 'fs' import path from 'path' @@ -13,12 +13,16 @@ import { debounce } from 'lodash' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { buildApi } from '@redwoodjs/internal/dist/build/api' +import { + buildApi, + cleanApiBuild, + rebuildApi, +} from '@redwoodjs/internal/dist/build/api' import { loadAndValidateSdls } from '@redwoodjs/internal/dist/validateSchema' import { - getPaths, ensurePosixPath, getConfig, + getPaths, resolveFile, } from '@redwoodjs/project-config' @@ -43,9 +47,6 @@ dotenv.config({ path: rwjsPaths.base, }) -// TODO: -// 1. Move this file out of the HTTP server, and place it in the CLI? - let httpServerProcess: ChildProcess const killApiServer = () => { @@ -63,19 +64,32 @@ const validate = async () => { console.error(c.red(e?.message)) console.log(c.redBright('-'.repeat(40))) - delayRestartServer.cancel() + debouncedBuild.cancel() + debouncedRebuild.cancel() return false } } -const rebuildApiServer = () => { +const buildAndRestart = async ({ + rebuild = false, + clean = false, +}: { rebuild?: boolean; clean?: boolean } = {}) => { try { // Shutdown API server killApiServer() const buildTs = Date.now() process.stdout.write(c.dim(c.italic('Building... '))) - buildApi() + + if (clean) { + await cleanApiBuild() + } + + if (rebuild) { + await rebuildApi() + } else { + await buildApi() + } console.log(c.dim(c.italic('Took ' + (Date.now() - buildTs) + ' ms'))) const forkOpts = { @@ -148,11 +162,18 @@ const rebuildApiServer = () => { // this usually happens when running RedwoodJS generator commands. // Local writes are very fast, but writes in e2e environments are not, // so allow the default to be adjust with a env-var. -const delayRestartServer = debounce( - rebuildApiServer, +const debouncedRebuild = debounce( + () => buildAndRestart({ rebuild: true }), process.env.RWJS_DELAY_RESTART ? parseInt(process.env.RWJS_DELAY_RESTART, 10) - : 5 + : 500 +) + +const debouncedBuild = debounce( + () => buildAndRestart({ rebuild: false }), + process.env.RWJS_DELAY_RESTART + ? parseInt(process.env.RWJS_DELAY_RESTART, 10) + : 500 ) // NOTE: the file comes through as a unix path, even on windows @@ -165,7 +186,7 @@ const IGNORED_API_PATHS = [ ].map((path) => ensurePosixPath(path)) chokidar - .watch(rwjsPaths.api.base, { + .watch([rwjsPaths.api.src], { persistent: true, ignoreInitial: true, ignored: (file: string) => { @@ -188,7 +209,11 @@ chokidar }, }) .on('ready', async () => { - rebuildApiServer() + // First time + await buildAndRestart({ + clean: true, + rebuild: false, + }) await validate() }) .on('all', async (eventName, filePath) => { @@ -199,20 +224,30 @@ chokidar return } - // We validate here, so that developers will see the error - // As they're running the dev server - if (filePath.includes('.sdl')) { - const isValid = await validate() + if (eventName) { + if (filePath.includes('.sdl')) { + // We validate here, so that developers will see the error + // As they're running the dev server + const isValid = await validate() - // Exit early if not valid - if (!isValid) { - return + // Exit early if not valid + if (!isValid) { + return + } } } console.log( c.dim(`[${eventName}] ${filePath.replace(rwjsPaths.api.base, '')}`) ) - delayRestartServer.cancel() - delayRestartServer() + + if (eventName === 'add' || eventName === 'unlink') { + debouncedBuild.cancel() + debouncedRebuild.cancel() + debouncedBuild() + } else { + // If files have just changed, then rebuild + debouncedRebuild.cancel() + debouncedRebuild() + } }) diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index 99e889f99075..8cd69ef840e4 100644 --- a/packages/auth-providers/dbAuth/api/src/shared.ts +++ b/packages/auth-providers/dbAuth/api/src/shared.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import type { APIGatewayProxyEvent } from 'aws-lambda' -import { getEventHeader } from '@redwoodjs/api' +import { getEventHeader, isFetchApiRequest } from '@redwoodjs/api' import { getConfig, getConfigPath } from '@redwoodjs/project-config' import * as DbAuthError from './errors' @@ -36,9 +36,38 @@ const getPort = () => { return getConfig(configPath).api.port } -// @TODO: reimplement eventGraphiQLHeadersCookie -// Needs a re-implementation on the studio side, because using -// body to send Auth headers requires this function to be async +// When in development environment, check for auth impersonation cookie +// if user has generated graphiql headers +const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent | Request) => { + if (process.env.NODE_ENV === 'development') { + const impersationationHeader = getEventHeader( + event, + 'rw-studio-impersonation-cookie' + ) + + if (impersationationHeader) { + return impersationationHeader + } + + // TODO: Remove code below when we remove the old way of passing the cookie + // from Studio, and decide it's OK to break compatibility with older Studio + // versions + try { + if (!isFetchApiRequest(event)) { + const jsonBody = JSON.parse(event.body ?? '{}') + return ( + jsonBody?.extensions?.headers?.cookie || + jsonBody?.extensions?.headers?.Cookie + ) + } + } catch { + // sometimes the event body isn't json + return + } + } + + return +} // decrypts session text using old CryptoJS algorithm (using node:crypto library) const legacyDecryptSession = (encryptedText: string) => { @@ -68,8 +97,7 @@ export const extractCookie = (event: APIGatewayProxyEvent | Request) => { // @TODO Disabling Studio Auth impersonation: it uses body instead of headers // this feels a bit off, but also requires the parsing to become async - // return eventGraphiQLHeadersCookie(event) || eventHeadersCookie(event) - return getEventHeader(event, 'Cookie') + return eventGraphiQLHeadersCookie(event) || getEventHeader(event, 'Cookie') } function extractEncryptedSessionFromHeader( @@ -191,7 +219,7 @@ export const webAuthnSession = (event: APIGatewayProxyEvent | Request) => { return null } - const webAuthnCookie = cookieHeader.split(';').find((cook) => { + const webAuthnCookie = cookieHeader.split(';').find((cook: string) => { return cook.split('=')[0].trim() === 'webAuthn' }) diff --git a/packages/babel-config/jest.config.js b/packages/babel-config/jest.config.js index a191690e9a42..ca095a6ad325 100644 --- a/packages/babel-config/jest.config.js +++ b/packages/babel-config/jest.config.js @@ -1,3 +1,3 @@ module.exports = { - testPathIgnorePatterns: ['fixtures'], + testPathIgnorePatterns: ['fixtures', 'dist/*'], } diff --git a/packages/babel-config/package.json b/packages/babel-config/package.json index 11bca75dfbe5..1b3275eff0f9 100644 --- a/packages/babel-config/package.json +++ b/packages/babel-config/package.json @@ -47,6 +47,7 @@ "devDependencies": { "@types/babel-plugin-tester": "9.0.9", "@types/babel__core": "7.20.4", + "@types/node": "20.10.4", "babel-plugin-tester": "11.0.4", "esbuild": "0.19.9", "jest": "29.7.0" diff --git a/packages/babel-config/src/__tests__/api.test.ts b/packages/babel-config/src/__tests__/api.test.ts index a3c7af764927..6fef1a715cb6 100644 --- a/packages/babel-config/src/__tests__/api.test.ts +++ b/packages/babel-config/src/__tests__/api.test.ts @@ -1,7 +1,8 @@ import { vol } from 'memfs' -import { getPaths, ensurePosixPath } from '@redwoodjs/project-config' +import { ensurePosixPath, getPaths } from '@redwoodjs/project-config' +import type { PluginList } from '../api' import { getApiSideBabelConfigPath, getApiSideBabelPlugins, @@ -87,7 +88,7 @@ describe('api', () => { ) const apiSideBabelConfigPath = getApiSideBabelConfigPath() - expect(ensurePosixPath(apiSideBabelConfigPath)).toMatch( + expect(ensurePosixPath(apiSideBabelConfigPath || '')).toMatch( '/redwood-app/api/babel.config.js' ) }) @@ -185,9 +186,17 @@ describe('api', () => { }, ]) + type ModuleResolverConfig = { + root: string[] + alias: Record + cwd: string + loglevel: string + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const [_, babelPluginModuleResolverConfig] = apiSideBabelPlugins.find( (plugin) => plugin[0] === 'babel-plugin-module-resolver' - ) + )! as [any, ModuleResolverConfig, any] expect(babelPluginModuleResolverConfig).toMatchObject({ alias: { @@ -238,12 +247,12 @@ describe('api', () => { }) }) -function getPluginAliases(plugins) { +function getPluginAliases(plugins: PluginList) { return plugins.reduce((pluginAliases, plugin) => { if (plugin.length !== 3) { return pluginAliases } return [...pluginAliases, plugin[2]] - }, []) + }, [] as any) } diff --git a/packages/babel-config/src/__tests__/prebuildApiFile.test.ts b/packages/babel-config/src/__tests__/prebuildApiFile.test.ts index 3bfadf7a4368..24fd5126f0c3 100644 --- a/packages/babel-config/src/__tests__/prebuildApiFile.test.ts +++ b/packages/babel-config/src/__tests__/prebuildApiFile.test.ts @@ -2,25 +2,25 @@ import path from 'path' import compat from 'core-js-compat' -import { getPaths, getConfig } from '@redwoodjs/project-config' +import { getConfig } from '@redwoodjs/project-config' import { BABEL_PLUGIN_TRANSFORM_RUNTIME_OPTIONS, - getApiSideBabelPlugins, - prebuildApiFile, TARGETS_NODE, + getApiSideBabelPlugins, + transformWithBabel, } from '../api' const RWJS_CWD = path.join(__dirname, '__fixtures__/redwood-app') process.env.RWJS_CWD = RWJS_CWD -let code +let code: string describe('api prebuild ', () => { describe('polyfills unsupported functionality', () => { - beforeAll(() => { + beforeAll(async () => { const apiFile = path.join(RWJS_CWD, 'api/src/lib/polyfill.js') - code = prebuildApiFileWrapper(apiFile) + code = await prebuildApiFileWrapper(apiFile) }) describe('ES features', () => { @@ -371,7 +371,7 @@ describe('api prebuild ', () => { }) it('includes source maps', () => { - const sourceMaps = code.split('\n').pop() + const sourceMaps = code.split('\n').pop() as string const sourceMapsMatcher = '//# sourceMappingURL=data:application/json;charset=utf-8;base64,' @@ -386,16 +386,16 @@ describe('api prebuild ', () => { expect(sources).toMatchInlineSnapshot(` [ - "../../../../../api/src/lib/polyfill.js", + "polyfill.js", ] `) }) }) describe('uses core-js3 aliasing', () => { - beforeAll(() => { + beforeAll(async () => { const apiFile = path.join(RWJS_CWD, 'api/src/lib/transform.js') - code = prebuildApiFileWrapper(apiFile) + code = await prebuildApiFileWrapper(apiFile) }) it('works', () => { @@ -425,9 +425,9 @@ describe('api prebuild ', () => { }) describe('typescript', () => { - beforeAll(() => { + beforeAll(async () => { const apiFile = path.join(RWJS_CWD, 'api/src/lib/typescript.ts') - code = prebuildApiFileWrapper(apiFile) + code = await prebuildApiFileWrapper(apiFile) }) it('transpiles ts to js', () => { @@ -437,9 +437,9 @@ describe('api prebuild ', () => { }) describe('auto imports', () => { - beforeAll(() => { + beforeAll(async () => { const apiFile = path.join(RWJS_CWD, 'api/src/lib/autoImports.ts') - code = prebuildApiFileWrapper(apiFile) + code = await prebuildApiFileWrapper(apiFile) }) it('auto imports', () => { @@ -451,7 +451,7 @@ describe('api prebuild ', () => { test('core-js polyfill list', () => { const { list } = compat({ targets: { node: TARGETS_NODE }, - version: BABEL_PLUGIN_TRANSFORM_RUNTIME_OPTIONS.corejs.version, + version: BABEL_PLUGIN_TRANSFORM_RUNTIME_OPTIONS.corejs.version.toString(), }) /** @@ -463,105 +463,97 @@ describe('api prebuild ', () => { * Some "ES Next" polyfills have landed in v12+ Node.js versions. */ expect(list).toMatchInlineSnapshot(` - [ - "esnext.array.last-index", - "esnext.array.last-item", - "esnext.composite-key", - "esnext.composite-symbol", - "esnext.map.delete-all", - "esnext.map.every", - "esnext.map.filter", - "esnext.map.find", - "esnext.map.find-key", - "esnext.map.from", - "esnext.map.group-by", - "esnext.map.includes", - "esnext.map.key-by", - "esnext.map.key-of", - "esnext.map.map-keys", - "esnext.map.map-values", - "esnext.map.merge", - "esnext.map.of", - "esnext.map.reduce", - "esnext.map.some", - "esnext.map.update", - "esnext.math.clamp", - "esnext.math.deg-per-rad", - "esnext.math.degrees", - "esnext.math.fscale", - "esnext.math.iaddh", - "esnext.math.imulh", - "esnext.math.isubh", - "esnext.math.rad-per-deg", - "esnext.math.radians", - "esnext.math.scale", - "esnext.math.seeded-prng", - "esnext.math.signbit", - "esnext.math.umulh", - "esnext.number.from-string", - "esnext.observable", - "esnext.promise.try", - "esnext.reflect.define-metadata", - "esnext.reflect.delete-metadata", - "esnext.reflect.get-metadata", - "esnext.reflect.get-metadata-keys", - "esnext.reflect.get-own-metadata", - "esnext.reflect.get-own-metadata-keys", - "esnext.reflect.has-metadata", - "esnext.reflect.has-own-metadata", - "esnext.reflect.metadata", - "esnext.set.add-all", - "esnext.set.delete-all", - "esnext.set.difference", - "esnext.set.every", - "esnext.set.filter", - "esnext.set.find", - "esnext.set.from", - "esnext.set.intersection", - "esnext.set.is-disjoint-from", - "esnext.set.is-subset-of", - "esnext.set.is-superset-of", - "esnext.set.join", - "esnext.set.map", - "esnext.set.of", - "esnext.set.reduce", - "esnext.set.some", - "esnext.set.symmetric-difference", - "esnext.set.union", - "esnext.string.at", - "esnext.string.code-points", - "esnext.symbol.observable", - "esnext.symbol.pattern-match", - "esnext.weak-map.delete-all", - "esnext.weak-map.from", - "esnext.weak-map.of", - "esnext.weak-set.add-all", - "esnext.weak-set.delete-all", - "esnext.weak-set.from", - "esnext.weak-set.of", - ] - `) + [ + "esnext.array.last-index", + "esnext.array.last-item", + "esnext.composite-key", + "esnext.composite-symbol", + "esnext.map.delete-all", + "esnext.map.every", + "esnext.map.filter", + "esnext.map.find", + "esnext.map.find-key", + "esnext.map.from", + "esnext.map.group-by", + "esnext.map.includes", + "esnext.map.key-by", + "esnext.map.key-of", + "esnext.map.map-keys", + "esnext.map.map-values", + "esnext.map.merge", + "esnext.map.of", + "esnext.map.reduce", + "esnext.map.some", + "esnext.map.update", + "esnext.math.clamp", + "esnext.math.deg-per-rad", + "esnext.math.degrees", + "esnext.math.fscale", + "esnext.math.iaddh", + "esnext.math.imulh", + "esnext.math.isubh", + "esnext.math.rad-per-deg", + "esnext.math.radians", + "esnext.math.scale", + "esnext.math.seeded-prng", + "esnext.math.signbit", + "esnext.math.umulh", + "esnext.number.from-string", + "esnext.observable", + "esnext.promise.try", + "esnext.reflect.define-metadata", + "esnext.reflect.delete-metadata", + "esnext.reflect.get-metadata", + "esnext.reflect.get-metadata-keys", + "esnext.reflect.get-own-metadata", + "esnext.reflect.get-own-metadata-keys", + "esnext.reflect.has-metadata", + "esnext.reflect.has-own-metadata", + "esnext.reflect.metadata", + "esnext.set.add-all", + "esnext.set.delete-all", + "esnext.set.difference", + "esnext.set.every", + "esnext.set.filter", + "esnext.set.find", + "esnext.set.from", + "esnext.set.intersection", + "esnext.set.is-disjoint-from", + "esnext.set.is-subset-of", + "esnext.set.is-superset-of", + "esnext.set.join", + "esnext.set.map", + "esnext.set.of", + "esnext.set.reduce", + "esnext.set.some", + "esnext.set.symmetric-difference", + "esnext.set.union", + "esnext.string.at", + "esnext.string.code-points", + "esnext.symbol.observable", + "esnext.symbol.pattern-match", + "esnext.weak-map.delete-all", + "esnext.weak-map.from", + "esnext.weak-map.of", + "esnext.weak-set.add-all", + "esnext.weak-set.delete-all", + "esnext.weak-set.from", + "esnext.weak-set.of", + ] + `) }) }) /** - * A copy of prebuildApiFiles from packages/internal/src/build/api.ts - * This will be re-architected, but doing so now would introduce breaking changes. + * We no longer prebuild files as part of the build process + * This is so we can test the babel configuration in isolation */ -export const prebuildApiFileWrapper = (srcFile: string) => { - const redwoodProjectPaths = getPaths() - +export const prebuildApiFileWrapper = async (srcFile: string) => { const plugins = getApiSideBabelPlugins({ openTelemetry: getConfig().experimental.opentelemetry.enabled, }) - const relativePathFromSrc = path.relative(redwoodProjectPaths.base, srcFile) - - const dstPath = path - .join(redwoodProjectPaths.generated.prebuild, relativePathFromSrc) - .replace(/\.(ts)$/, '.js') - - const result = prebuildApiFile(srcFile, dstPath, plugins) + const result = await transformWithBabel(srcFile, plugins) if (!result?.code) { throw new Error(`Couldn't prebuild ${srcFile}`) diff --git a/packages/babel-config/src/api.ts b/packages/babel-config/src/api.ts index c719e9c30ef0..53c05cc56801 100644 --- a/packages/babel-config/src/api.ts +++ b/packages/babel-config/src/api.ts @@ -1,19 +1,20 @@ -import fs from 'fs' +import { existsSync } from 'fs' +import fs from 'fs/promises' import path from 'path' -import { transform } from '@babel/core' -import type { PluginItem, TransformOptions } from '@babel/core' +import type { PluginOptions, PluginTarget, TransformOptions } from '@babel/core' +import { transformAsync } from '@babel/core' import { getPaths } from '@redwoodjs/project-config' import type { RegisterHookOptions } from './common' import { - registerBabel, CORE_JS_VERSION, RUNTIME_CORE_JS_VERSION, getCommonPlugins, - parseTypeScriptConfigFiles, getPathsFromTypeScriptConfig, + parseTypeScriptConfigFiles, + registerBabel, } from './common' export const TARGETS_NODE = '20.10' @@ -65,17 +66,22 @@ export const BABEL_PLUGIN_TRANSFORM_RUNTIME_OPTIONS = { version: RUNTIME_CORE_JS_VERSION, } +// Plugin shape: [ ["Target", "Options", "name"] ], +// a custom "name" can be supplied so that user's do not accidentally overwrite +// Redwood's own plugins when they specify their own. +export type PluginList = Array +type PluginShape = + | [PluginTarget, PluginOptions, undefined | string] + | [PluginTarget, PluginOptions] + export const getApiSideBabelPlugins = ( { openTelemetry } = { openTelemetry: false, } ) => { - // Plugin shape: [ ["Target", "Options", "name"] ], - // a custom "name" is supplied so that user's do not accidentally overwrite - // Redwood's own plugins when they specify their own. const tsConfig = parseTypeScriptConfigFiles() - const plugins: TransformOptions['plugins'] = [ + const plugins: Array = [ ...getCommonPlugins(), // Needed to support `/** @jsxImportSource custom-jsx-library */` // comments in JSX files @@ -130,17 +136,17 @@ export const getApiSideBabelPlugins = ( undefined, 'rwjs-babel-otel-wrapping', ], - ].filter(Boolean) as PluginItem[] + ] - return plugins + return plugins.filter(Boolean) as PluginList // ts doesn't play nice with filter(Boolean) } export const getApiSideBabelConfigPath = () => { const p = path.join(getPaths().api.base, 'babel.config.js') - if (fs.existsSync(p)) { + if (existsSync(p)) { return p } else { - return undefined + return } } @@ -188,23 +194,17 @@ export const registerApiSideBabelHook = ({ }) } -export const prebuildApiFile = ( +export const transformWithBabel = async ( srcPath: string, - // we need to know dstPath as well - // so we can generate an inline, relative sourcemap - dstPath: string, plugins: TransformOptions['plugins'] ) => { - const code = fs.readFileSync(srcPath, 'utf-8') + const code = await fs.readFile(srcPath, 'utf-8') const defaultOptions = getApiSideDefaultBabelConfig() - const result = transform(code, { + const result = transformAsync(code, { ...defaultOptions, cwd: getPaths().api.base, filename: srcPath, - // we set the sourceFile (for the sourcemap) as a correct, relative path - // this is why this function (prebuildFile) must know about the dstPath - sourceFileName: path.relative(path.dirname(dstPath), srcPath), // we need inline sourcemaps at this level // because this file will eventually be fed to esbuild // when esbuild finds an inline sourcemap, it tries to "combine" it @@ -212,29 +212,6 @@ export const prebuildApiFile = ( sourceMaps: 'inline', plugins, }) - return result -} - -// TODO (STREAMING) I changed the prebuildApiFile function in https://github.com/redwoodjs/redwood/pull/7672/files -// but we had to revert. For this branch temporarily, I'm going to add a new function -// This is used in building routeHooks -export const transformWithBabel = ( - srcPath: string, - plugins: TransformOptions['plugins'] -) => { - const code = fs.readFileSync(srcPath, 'utf-8') - const defaultOptions = getApiSideDefaultBabelConfig() - const result = transform(code, { - ...defaultOptions, - cwd: getPaths().api.base, - filename: srcPath, - // we need inline sourcemaps at this level - // because this file will eventually be fed to esbuild - // when esbuild finds an inline sourcemap, it tries to "combine" it - // so the final sourcemap (the one that esbuild generates) combines both mappings - sourceMaps: 'inline', - plugins, - }) return result } diff --git a/packages/babel-config/src/common.ts b/packages/babel-config/src/common.ts index b05f18a42a16..5aeb57b5ad49 100644 --- a/packages/babel-config/src/common.ts +++ b/packages/babel-config/src/common.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' -import type { TransformOptions, PluginItem } from '@babel/core' +import type { PluginItem, PluginOptions, TransformOptions } from '@babel/core' import { parseConfigFileTextToJson } from 'typescript' import { getPaths } from '@redwoodjs/project-config' @@ -61,7 +61,7 @@ if (!RUNTIME_CORE_JS_VERSION) { ) } -export const getCommonPlugins = () => { +export const getCommonPlugins = (): Array<[string, PluginOptions]> => { return [ ['@babel/plugin-transform-class-properties', { loose: true }], // Note: The private method loose mode configuration setting must be the diff --git a/packages/babel-config/src/index.ts b/packages/babel-config/src/index.ts index 0c4d675cb117..7658f3e7a67c 100644 --- a/packages/babel-config/src/index.ts +++ b/packages/babel-config/src/index.ts @@ -9,7 +9,6 @@ export { getApiSideBabelPlugins, getApiSideBabelPresets, getApiSideDefaultBabelConfig, - prebuildApiFile, registerApiSideBabelHook, transformWithBabel, } from './api' diff --git a/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-directory-named-imports.test.ts b/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-directory-named-imports.test.ts index 6f50c9ee1de7..14ed1918c6df 100644 --- a/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-directory-named-imports.test.ts +++ b/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-directory-named-imports.test.ts @@ -65,7 +65,7 @@ describe('directory named imports', () => { }, ], ], - }).code + })?.code expect(babeled).toMatch(output) }) }) diff --git a/packages/babel-config/tsconfig.json b/packages/babel-config/tsconfig.json index 91b48264c7cf..3b0700b39db5 100644 --- a/packages/babel-config/tsconfig.json +++ b/packages/babel-config/tsconfig.json @@ -4,8 +4,11 @@ "baseUrl": ".", "rootDir": "src", "outDir": "dist", + "types": ["node", "jest"], }, "include": ["src"], + // Excluding cypress types, because it interferes with expect in tests + "exclude": ["node_modules/cypress/types/*", "**/__tests__/__fixtures__/*"], "references": [ { "path": "../project-config" } ] diff --git a/packages/cli-helpers/src/lib/index.ts b/packages/cli-helpers/src/lib/index.ts index d55f3fa08123..1b294a27f9e5 100644 --- a/packages/cli-helpers/src/lib/index.ts +++ b/packages/cli-helpers/src/lib/index.ts @@ -100,7 +100,8 @@ export const writeFile = ( target: string, contents: string, { existingFiles = 'FAIL' }: { existingFiles?: ExistingFiles } = {}, - // TODO: Remove type cast + // TODO: Remove type cast by finding all places `writeFile` is used and + // making sure a proper task is passed in task: ListrTaskWrapper = {} as ListrTaskWrapper< never, Renderer @@ -115,7 +116,7 @@ export const writeFile = ( } if (exists && existingFiles === 'SKIP') { - task.skip() + task.skip(`Skipping update of \`./${path.relative(base, target)}\``) return } diff --git a/packages/cli/src/commands/buildHandler.js b/packages/cli/src/commands/buildHandler.js index 5c801f2dd2b4..9d3367d11ade 100644 --- a/packages/cli/src/commands/buildHandler.js +++ b/packages/cli/src/commands/buildHandler.js @@ -7,7 +7,7 @@ import { rimraf } from 'rimraf' import terminalLink from 'terminal-link' import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { buildApi } from '@redwoodjs/internal/dist/build/api' +import { buildApi, cleanApiBuild } from '@redwoodjs/internal/dist/build/api' import { generate } from '@redwoodjs/internal/dist/generate/generate' import { loadAndValidateSdls } from '@redwoodjs/internal/dist/validateSchema' import { detectPrerenderRoutes } from '@redwoodjs/prerender/detection' @@ -100,8 +100,9 @@ export const handler = async ({ }, side.includes('api') && { title: 'Building API...', - task: () => { - const { errors, warnings } = buildApi() + task: async () => { + await cleanApiBuild() + const { errors, warnings } = await buildApi() if (errors.length) { console.error(errors) @@ -183,7 +184,10 @@ export const handler = async ({ 'file://' + rwjsPaths.web.routes )}.` ) + + return } + // Running a separate process here, otherwise it wouldn't pick up the // generated Prisma Client due to require module caching await execa('yarn rw prerender', { diff --git a/packages/cli/src/commands/deploy/__tests__/nftPack.test.js b/packages/cli/src/commands/deploy/__tests__/nftPack.test.js index ab3168fab06c..b1d90a728ce8 100644 --- a/packages/cli/src/commands/deploy/__tests__/nftPack.test.js +++ b/packages/cli/src/commands/deploy/__tests__/nftPack.test.js @@ -1,32 +1,32 @@ -import path from 'path' - -import fs from 'fs-extra' - -import { buildApi } from '@redwoodjs/internal/dist/build/api' import { findApiDistFunctions } from '@redwoodjs/internal/dist/files' import * as nftPacker from '../packing/nft' -const FIXTURE_PATH = path.resolve( - __dirname, - '../../../../../../__fixtures__/example-todo-main' -) - -let functionDistFiles - -beforeAll(() => { - process.env.RWJS_CWD = FIXTURE_PATH - - // Actually build the fixture, if we need it - if (!fs.existsSync(path.join(FIXTURE_PATH, 'api/dist/functions'))) { - buildApi() +jest.mock('@redwoodjs/internal/dist/files', () => { + return { + findApiDistFunctions: () => { + return [ + '/Users/carmack/dev/redwood/__fixtures__/example-todo-main/api/dist/functions/graphql.js', + '/Users/carmack/dev/redwood/__fixtures__/example-todo-main/api/dist/functions/healthz/healthz.js', + '/Users/carmack/dev/redwood/__fixtures__/example-todo-main/api/dist/functions/invalid/x.js', + '/Users/carmack/dev/redwood/__fixtures__/example-todo-main/api/dist/functions/nested/nested.js', + '/Users/carmack/dev/redwood/__fixtures__/example-todo-main/api/dist/functions/x/index.js', + ] + }, } - - functionDistFiles = findApiDistFunctions() }) -afterAll(() => { - delete process.env.RWJS_CWD +jest.mock('@redwoodjs/project-config', () => { + return { + getPaths: () => { + return { + base: '/Users/carmack/dev/redwood/__fixtures__/example-todo-main/', + } + }, + ensurePosixPath: (path) => { + return path.replace(/\\/g, '/') + }, + } }) test('Check packager detects all functions', () => { @@ -40,7 +40,7 @@ test('Check packager detects all functions', () => { }) test('Creates entry file for nested functions correctly', () => { - const nestedFunction = functionDistFiles.find((fPath) => + const nestedFunction = findApiDistFunctions().find((fPath) => fPath.includes('nested') ) @@ -56,7 +56,7 @@ test('Creates entry file for nested functions correctly', () => { }) test('Creates entry file for top level functions correctly', () => { - const graphqlFunction = functionDistFiles.find((fPath) => + const graphqlFunction = findApiDistFunctions().find((fPath) => fPath.includes('graphql') ) diff --git a/packages/cli/src/commands/experimental/setupSentry.js b/packages/cli/src/commands/experimental/setupSentry.js deleted file mode 100644 index 740dc67cbf2e..000000000000 --- a/packages/cli/src/commands/experimental/setupSentry.js +++ /dev/null @@ -1,29 +0,0 @@ -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' - -import { getEpilogue } from './util' - -export const command = 'setup-sentry' - -export const description = 'Setup Sentry error and performance tracking' - -export const EXPERIMENTAL_TOPIC_ID = 4880 - -export const builder = (yargs) => { - yargs - .option('force', { - alias: 'f', - default: false, - description: 'Overwrite existing sentry.js config files', - type: 'boolean', - }) - .epilogue(getEpilogue(command, description, EXPERIMENTAL_TOPIC_ID, true)) -} - -export const handler = async (options) => { - recordTelemetryAttributes({ - command: 'experimental setup-sentry', - force: options.force, - }) - const { handler } = await import('./setupSentryHandler.js') - return handler(options) -} diff --git a/packages/cli/src/commands/experimental/studioHandler.js b/packages/cli/src/commands/experimental/studioHandler.js deleted file mode 100644 index 4ca9d7000d2b..000000000000 --- a/packages/cli/src/commands/experimental/studioHandler.js +++ /dev/null @@ -1,50 +0,0 @@ -import fs from 'fs-extra' - -import { getConfigPath } from '@redwoodjs/project-config' - -import { writeFile } from '../../lib' -import { isModuleInstalled, installRedwoodModule } from '../../lib/packages' - -import { command, description, EXPERIMENTAL_TOPIC_ID } from './studio' -import { printTaskEpilogue } from './util' - -export const handler = async (options) => { - printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) - try { - // Check the module is installed - if (!isModuleInstalled('@redwoodjs/studio')) { - console.log( - 'The studio package is not installed, installing it for you, this may take a moment...' - ) - await installRedwoodModule('@redwoodjs/studio') - console.log('Studio package installed successfully.') - - console.log('Adding config to redwood.toml...') - const redwoodTomlPath = getConfigPath() - const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - - if (!configContent.includes('[experimental.studio]')) { - // Use string replace to preserve comments and formatting - writeFile( - redwoodTomlPath, - configContent.concat(`\n[experimental.studio]\n enabled = true\n`), - { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - console.log( - `The [experimental.studio] config block already exists in your 'redwood.toml' file.` - ) - } - } - - // Import studio and start it - const { start } = await import('@redwoodjs/studio') - await start({ open: options.open }) - } catch (e) { - console.log('Cannot start the development studio') - console.log(e) - process.exit(1) - } -} diff --git a/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template b/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template index a73897fac5cc..26e9d28c8af7 100644 --- a/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template +++ b/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template @@ -23,12 +23,12 @@ const resource = Resource.default().merge( }) ) -const studioPort = getConfig().experimental.studio.basePort +const studioPort = getConfig().studio.basePort const exporter = new OTLPTraceExporter({ // Update this URL to point to where your OTLP compatible collector is listening // The redwood development studio (`yarn rw exp studio`) can collect your // telemetry at `http://127.0.0.1:/v1/traces` (default PORT is 4318) - url: `http://127.0.0.1:${studioPort}/v1/traces`, + url: `http://127.0.0.1:${studioPort}/.redwood/functions/otel-trace`, concurrencyLimit: 64, }) diff --git a/packages/cli/src/commands/generate/__tests__/helpers.test.js b/packages/cli/src/commands/generate/__tests__/helpers.test.js index 1714901af7f0..4ab7310e733f 100644 --- a/packages/cli/src/commands/generate/__tests__/helpers.test.js +++ b/packages/cli/src/commands/generate/__tests__/helpers.test.js @@ -534,7 +534,7 @@ describe('mapPrismaScalarToPagePropTsType', () => { }) it('maps scalar type Decimal to TS type number', () => { - expect(helpers.mapPrismaScalarToPagePropTsType('Float')).toBe('number') + expect(helpers.mapPrismaScalarToPagePropTsType('Decimal')).toBe('number') }) it('maps scalar type DateTime to TS type string', () => { diff --git a/packages/cli/src/commands/setup/monitoring/monitoring.ts b/packages/cli/src/commands/setup/monitoring/monitoring.ts new file mode 100644 index 000000000000..56d875ab50c2 --- /dev/null +++ b/packages/cli/src/commands/setup/monitoring/monitoring.ts @@ -0,0 +1,17 @@ +import terminalLink from 'terminal-link' +import type { Argv } from 'yargs' + +import * as sentryCommand from './sentry/sentry.js' + +export const command = 'monitoring ' +export const description = 'Set up monitoring in your Redwood app' +export function builder(yargs: Argv) { + return yargs + .command(sentryCommand) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#setup-graphql' + )}` + ) +} diff --git a/packages/cli/src/commands/setup/monitoring/sentry/sentry.ts b/packages/cli/src/commands/setup/monitoring/sentry/sentry.ts new file mode 100644 index 000000000000..d736e079f768 --- /dev/null +++ b/packages/cli/src/commands/setup/monitoring/sentry/sentry.ts @@ -0,0 +1,30 @@ +import type { Argv } from 'yargs' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +export const command = 'sentry' + +export const description = 'Setup Sentry error and performance tracking' + +export const builder = (yargs: Argv) => { + return yargs.option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing Sentry config files', + type: 'boolean', + }) +} + +export interface Args { + force: boolean +} + +export async function handler({ force }: Args) { + recordTelemetryAttributes({ + command: 'setup monitoring sentry', + force, + }) + + const { handler } = await import('./sentryHandler.js') + return handler({ force }) +} diff --git a/packages/cli/src/commands/experimental/setupSentryHandler.js b/packages/cli/src/commands/setup/monitoring/sentry/sentryHandler.ts similarity index 72% rename from packages/cli/src/commands/experimental/setupSentryHandler.js rename to packages/cli/src/commands/setup/monitoring/sentry/sentryHandler.ts index 3f1874f1e9f1..607a6d28a506 100644 --- a/packages/cli/src/commands/experimental/setupSentryHandler.js +++ b/packages/cli/src/commands/setup/monitoring/sentry/sentryHandler.ts @@ -7,23 +7,22 @@ import { addApiPackages, addEnvVarTask, addWebPackages, - colors as c, + colors, getPaths, isTypeScriptProject, prettify, writeFilesTask, } from '@redwoodjs/cli-helpers' -import { getConfigPath } from '@redwoodjs/project-config' import { errorTelemetry } from '@redwoodjs/telemetry' -import { writeFile } from '../../lib' +import type { Args } from './sentry' -const PATHS = getPaths() +const rwPaths = getPaths() -export const handler = async ({ force }) => { +export const handler = async ({ force }: Args) => { const extension = isTypeScriptProject() ? 'ts' : 'js' - const notes = [] + const notes: Array = [] const tasks = new Listr([ addApiPackages(['@envelop/sentry@5', '@sentry/node@7']), @@ -35,28 +34,29 @@ export const handler = async ({ force }) => { ), { title: 'Setting up Sentry on the API and web sides', - task: () => - writeFilesTask( + task: () => { + return writeFilesTask( { - [path.join(PATHS.api.lib, `sentry.${extension}`)]: fs + [path.join(rwPaths.api.lib, `sentry.${extension}`)]: fs .readFileSync( path.join(__dirname, 'templates/sentryApi.ts.template') ) .toString(), - [path.join(PATHS.web.src, 'lib', `sentry.${extension}`)]: fs + [path.join(rwPaths.web.src, 'lib', `sentry.${extension}`)]: fs .readFileSync( path.join(__dirname, 'templates/sentryWeb.ts.template') ) .toString(), }, { existingFiles: force ? 'OVERWRITE' : 'SKIP' } - ), + ) + }, }, { title: 'Implementing the Envelop plugin', task: (ctx) => { const graphqlHandlerPath = path.join( - PATHS.api.functions, + rwPaths.api.functions, `graphql.${extension}` ) @@ -103,7 +103,7 @@ export const handler = async ({ force }) => { title: "Replacing Redwood's Error boundary", task: () => { const contentLines = fs - .readFileSync(PATHS.web.app) + .readFileSync(rwPaths.web.app) .toString() .split('\n') @@ -135,50 +135,29 @@ export const handler = async ({ force }) => { contentLines.splice(0, 0, "import Sentry from 'src/lib/sentry'") fs.writeFileSync( - PATHS.web.app, + rwPaths.web.app, prettify('App.tsx', contentLines.join('\n')) ) }, }, - { - title: 'Adding config to redwood.toml...', - task: (_ctx, task) => { - const redwoodTomlPath = getConfigPath() - const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - if (!configContent.includes('[experimental.sentry]')) { - // Use string replace to preserve comments and formatting - writeFile( - redwoodTomlPath, - configContent.concat(`\n[experimental.sentry]\n enabled = true\n`), - { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - task.skip( - `The [experimental.sentry] config block already exists in your 'redwood.toml' file.` - ) - } - }, - }, { title: 'One more thing...', task: (ctx) => { notes.push( - c.green( + colors.green( 'You will need to add `SENTRY_DSN` to `includeEnvironmentVariables` in redwood.toml.' ) ) if (ctx.addEnvelopPluginSkipped) { notes.push( - `${c.underline( + `${colors.underline( 'Make sure you implement the Sentry Envelop plugin:' )} https://redwoodjs.com/docs/cli-commands#sentry-envelop-plugin` ) } else { notes.push( - "Check out RedwoodJS forums' for more: https://community.redwoodjs.com/t/sentry-error-and-performance-monitoring-experimental/4880" + 'Check out the RedwoodJS forums for more: https://community.redwoodjs.com/t/sentry-error-and-performance-monitoring-experimental/4880' ) } }, @@ -189,8 +168,28 @@ export const handler = async ({ force }) => { await tasks.run() console.log(notes.join('\n')) } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) + if (isErrorWithMessage(e)) { + errorTelemetry(process.argv, e.message) + console.error(colors.error(e.message)) + } + + if (isErrorWithExitCode(e)) { + process.exit(e.exitCode) + } + + process.exit(1) } } + +function isErrorWithMessage(e: unknown): e is { message: string } { + return !!e && typeof e === 'object' && 'message' in e +} + +function isErrorWithExitCode(e: unknown): e is { exitCode: number } { + return ( + !!e && + typeof e === 'object' && + 'exitCode' in e && + typeof e.exitCode === 'number' + ) +} diff --git a/packages/cli/src/commands/experimental/studio.js b/packages/cli/src/commands/studio.js similarity index 55% rename from packages/cli/src/commands/experimental/studio.js rename to packages/cli/src/commands/studio.js index 644a350644b0..dd88a23dbe05 100644 --- a/packages/cli/src/commands/experimental/studio.js +++ b/packages/cli/src/commands/studio.js @@ -1,24 +1,18 @@ import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { getEpilogue } from './util' - export const command = 'studio' export const description = 'Run the Redwood development studio' -export const EXPERIMENTAL_TOPIC_ID = 4771 - export function builder(yargs) { - yargs - .option('open', { - default: true, - description: 'Open the studio in your browser', - }) - .epilogue(getEpilogue(command, description, EXPERIMENTAL_TOPIC_ID, true)) + yargs.option('open', { + default: true, + description: 'Open the studio in your browser', + }) } export async function handler(options) { recordTelemetryAttributes({ - command: 'experimental studio', + command: 'studio', open: options.open, }) const { handler } = await import('./studioHandler.js') diff --git a/packages/cli/src/commands/studioHandler.js b/packages/cli/src/commands/studioHandler.js new file mode 100644 index 000000000000..51c1d862e4f9 --- /dev/null +++ b/packages/cli/src/commands/studioHandler.js @@ -0,0 +1,27 @@ +import { setTomlSetting } from '@redwoodjs/cli-helpers' + +import { isModuleInstalled, installModule } from '../lib/packages' + +export const handler = async (options) => { + try { + // Check the module is installed + if (!isModuleInstalled('@redwoodjs/studio')) { + console.log( + 'The studio package is not installed, installing it for you, this may take a moment...' + ) + await installModule('@redwoodjs/studio', '11.0.0') + console.log('Studio package installed successfully.') + + console.log('Adding config to redwood.toml...') + setTomlSetting('studio', 'enabled', true) + } + + // Import studio and start it + const { serve } = await import('@redwoodjs/studio') + await serve({ open: options.open }) + } catch (e) { + console.log('Cannot start the development studio') + console.log(e) + process.exit(1) + } +} diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index c0130ea2feac..d2a16cb0e867 100644 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -27,6 +27,7 @@ import * as prismaCommand from './commands/prisma' import * as recordCommand from './commands/record' import * as serveCommand from './commands/serve' import * as setupCommand from './commands/setup' +import * as studioCommand from './commands/studio' import * as testCommand from './commands/test' import * as tstojsCommand from './commands/ts-to-js' import * as typeCheckCommand from './commands/type-check' @@ -206,6 +207,7 @@ async function runYargs() { .command(recordCommand) .command(serveCommand) .command(setupCommand) + .command(studioCommand) .command(testCommand) .command(tstojsCommand) .command(typeCheckCommand) diff --git a/packages/create-redwood-app/templates/js/.redwood/README.md b/packages/create-redwood-app/templates/js/.redwood/README.md index f22b586a47cc..8829edb84776 100644 --- a/packages/create-redwood-app/templates/js/.redwood/README.md +++ b/packages/create-redwood-app/templates/js/.redwood/README.md @@ -18,7 +18,6 @@ You don't need to commit any other contents of this directory to your version co | :---------------- | :------- | | commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | | schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | -| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | | telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | | test.db | The sqlite database used when running tests. | @@ -32,6 +31,7 @@ You don't need to commit any other contents of this directory to your version co | telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | | types | Stores the results of type generation. | | updateCheck | Stores a file which contains the results of checking for Redwood updates. | +| studio | Used to store data for `rw studio` | We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. diff --git a/packages/create-redwood-app/templates/js/api/src/lib/auth.js b/packages/create-redwood-app/templates/js/api/src/lib/auth.js index f98fe93a960c..4b0b88784d47 100644 --- a/packages/create-redwood-app/templates/js/api/src/lib/auth.js +++ b/packages/create-redwood-app/templates/js/api/src/lib/auth.js @@ -23,3 +23,10 @@ export const hasRole = ({ roles }) => { export const requireAuth = ({ roles }) => { return isAuthenticated() } + +export const getCurrentUser = async () => { + throw new Error( + 'Auth is not set up yet. See https://redwoodjs.com/docs/authentication ' + + 'to get started' + ) +} diff --git a/packages/create-redwood-app/templates/ts/.redwood/README.md b/packages/create-redwood-app/templates/ts/.redwood/README.md index f22b586a47cc..8829edb84776 100644 --- a/packages/create-redwood-app/templates/ts/.redwood/README.md +++ b/packages/create-redwood-app/templates/ts/.redwood/README.md @@ -18,7 +18,6 @@ You don't need to commit any other contents of this directory to your version co | :---------------- | :------- | | commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | | schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | -| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | | telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | | test.db | The sqlite database used when running tests. | @@ -32,6 +31,7 @@ You don't need to commit any other contents of this directory to your version co | telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | | types | Stores the results of type generation. | | updateCheck | Stores a file which contains the results of checking for Redwood updates. | +| studio | Used to store data for `rw studio` | We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. diff --git a/packages/create-redwood-app/templates/ts/api/src/lib/auth.ts b/packages/create-redwood-app/templates/ts/api/src/lib/auth.ts index f98fe93a960c..4b0b88784d47 100644 --- a/packages/create-redwood-app/templates/ts/api/src/lib/auth.ts +++ b/packages/create-redwood-app/templates/ts/api/src/lib/auth.ts @@ -23,3 +23,10 @@ export const hasRole = ({ roles }) => { export const requireAuth = ({ roles }) => { return isAuthenticated() } + +export const getCurrentUser = async () => { + throw new Error( + 'Auth is not set up yet. See https://redwoodjs.com/docs/authentication ' + + 'to get started' + ) +} diff --git a/packages/internal/src/__tests__/build_api.test.ts b/packages/internal/src/__tests__/build_api.test.ts index deda22f781aa..38945bf3d282 100644 --- a/packages/internal/src/__tests__/build_api.test.ts +++ b/packages/internal/src/__tests__/build_api.test.ts @@ -6,10 +6,11 @@ import * as babel from '@babel/core' import { getApiSideBabelPlugins, getApiSideDefaultBabelConfig, + transformWithBabel, } from '@redwoodjs/babel-config' import { ensurePosixPath, getPaths } from '@redwoodjs/project-config' -import { cleanApiBuild, prebuildApiFiles } from '../build/api' +import { cleanApiBuild } from '../build/api' import { findApiFiles } from '../files' const FIXTURE_PATH = path.resolve( @@ -17,6 +18,33 @@ const FIXTURE_PATH = path.resolve( '../../../../__fixtures__/example-todo-main' ) +// @NOTE: we no longer prebuild files into the .redwood/prebuild folder +// However, prebuilding in the tests is still helpful for us to validate +// that everything is working as expected. +export const prebuildApiFiles = async (srcFiles: string[]) => { + const rwjsPaths = getPaths() + const plugins = getApiSideBabelPlugins() + + return Promise.all( + srcFiles.map(async (srcPath) => { + const relativePathFromSrc = path.relative(rwjsPaths.base, srcPath) + const dstPath = path + .join(rwjsPaths.generated.prebuild, relativePathFromSrc) + .replace(/\.(ts)$/, '.js') + + const result = await transformWithBabel(srcPath, plugins) + if (!result?.code) { + throw new Error(`Could not prebuild ${srcPath}`) + } + + fs.mkdirSync(path.dirname(dstPath), { recursive: true }) + fs.writeFileSync(dstPath, result.code) + + return dstPath + }) + ) +} + const cleanPaths = (p) => { return ensurePosixPath(path.relative(FIXTURE_PATH, p)) } @@ -25,12 +53,12 @@ const cleanPaths = (p) => { let prebuiltFiles let relativePaths -beforeAll(() => { +beforeAll(async () => { process.env.RWJS_CWD = FIXTURE_PATH cleanApiBuild() const apiFiles = findApiFiles() - prebuiltFiles = prebuildApiFiles(apiFiles) + prebuiltFiles = await prebuildApiFiles(apiFiles) relativePaths = prebuiltFiles .filter((x) => typeof x !== 'undefined') @@ -80,6 +108,10 @@ test.skip('api prebuild transforms gql with `babel-plugin-graphql-tag`', () => { .filter((p) => p.endsWith('todos.sdl.js')) .pop() + if (!p) { + throw new Error('No built files') + } + const code = fs.readFileSync(p, 'utf-8') expect(code.includes('import gql from "graphql-tag";')).toEqual(false) expect(code.includes('gql`')).toEqual(false) @@ -99,7 +131,7 @@ test('jest mock statements also handle', () => { cwd: getPaths().api.base, // We override the plugins, to match packages/testing/config/jest/api/index.js plugins: getApiSideBabelPlugins({ forJest: true }), - }).code + })?.code // Step 2: check that output has correct import statement path expect(outputForJest).toContain('import dog from "../../lib/dog"') diff --git a/packages/internal/src/build/api.ts b/packages/internal/src/build/api.ts index 4ca1ba05a20a..e8770d6baf83 100644 --- a/packages/internal/src/build/api.ts +++ b/packages/internal/src/build/api.ts @@ -1,85 +1,91 @@ -import fs from 'fs' -import path from 'path' - -import * as esbuild from 'esbuild' -import { removeSync } from 'fs-extra' +import type { BuildContext, BuildOptions, PluginBuild } from 'esbuild' +import { build, context } from 'esbuild' +import { remove } from 'fs-extra' import { getApiSideBabelPlugins, - prebuildApiFile, + transformWithBabel, } from '@redwoodjs/babel-config' -import { getPaths, getConfig } from '@redwoodjs/project-config' +import { getConfig, getPaths } from '@redwoodjs/project-config' import { findApiFiles } from '../files' -export const buildApi = () => { - // TODO: Be smarter about caching and invalidating files, - // but right now we just delete everything. - cleanApiBuild() +let BUILD_CTX: BuildContext | null = null + +export const buildApi = async () => { + // Reset the build context for rebuilding + // No need to wait for promise to resolve + BUILD_CTX?.dispose() + BUILD_CTX = null + + return transpileApi(findApiFiles()) +} - const srcFiles = findApiFiles() +export const rebuildApi = async () => { + const apiFiles = findApiFiles() - const prebuiltFiles = prebuildApiFiles(srcFiles).filter( - (path): path is string => path !== undefined - ) + if (!BUILD_CTX) { + BUILD_CTX = await context(getEsbuildOptions(apiFiles)) + } - return transpileApi(prebuiltFiles) + return BUILD_CTX.rebuild() } -export const cleanApiBuild = () => { +export const cleanApiBuild = async () => { const rwjsPaths = getPaths() - removeSync(rwjsPaths.api.dist) - removeSync(path.join(rwjsPaths.generated.prebuild, 'api')) + return remove(rwjsPaths.api.dist) } -/** - * Remove RedwoodJS "magic" from a user's code leaving JavaScript behind. - */ -export const prebuildApiFiles = (srcFiles: string[]) => { - const rwjsPaths = getPaths() - const rwjsConfig = getConfig() - const plugins = getApiSideBabelPlugins({ - openTelemetry: - rwjsConfig.experimental.opentelemetry.enabled && - rwjsConfig.experimental.opentelemetry.wrapApi, - }) - - return srcFiles.map((srcPath) => { - const relativePathFromSrc = path.relative(rwjsPaths.base, srcPath) - const dstPath = path - .join(rwjsPaths.generated.prebuild, relativePathFromSrc) - .replace(/\.(ts)$/, '.js') - - const result = prebuildApiFile(srcPath, dstPath, plugins) - if (!result?.code) { - // TODO: Figure out a better way to return these programatically. - console.warn('Error:', srcPath, 'could not prebuilt.') - - return undefined - } - - fs.mkdirSync(path.dirname(dstPath), { recursive: true }) - fs.writeFileSync(dstPath, result.code) - - return dstPath - }) +const runRwBabelTransformsPlugin = { + name: 'rw-esbuild-babel-transform', + setup(build: PluginBuild) { + const rwjsConfig = getConfig() + + build.onLoad({ filter: /\.(js|ts|tsx|jsx)$/ }, async (args) => { + // @TODO Implement LRU cache? Unsure how much of a performance benefit its going to be + // Generate a CRC of file contents, then save it to LRU cache with a limit + // without LRU cache, the memory usage can become unbound + const transformedCode = await transformWithBabel( + args.path, + getApiSideBabelPlugins({ + openTelemetry: + rwjsConfig.experimental.opentelemetry.enabled && + rwjsConfig.experimental.opentelemetry.wrapApi, + }) + ) + + if (transformedCode?.code) { + return { + contents: transformedCode.code, + loader: 'js', + } + } + + throw new Error(`Could not transform file: ${args.path}`) + }) + }, +} + +export const transpileApi = async (files: string[]) => { + return build(getEsbuildOptions(files)) } -export const transpileApi = (files: string[], options = {}) => { +function getEsbuildOptions(files: string[]): BuildOptions { const rwjsPaths = getPaths() - return esbuild.buildSync({ + return { absWorkingDir: rwjsPaths.api.base, entryPoints: files, platform: 'node', target: 'node20', format: 'cjs', + allowOverwrite: true, bundle: false, + plugins: [runRwBabelTransformsPlugin], outdir: rwjsPaths.api.dist, // setting this to 'true' will generate an external sourcemap x.js.map // AND set the sourceMappingURL comment // (setting it to 'external' will ONLY generate the file, but won't add the comment) sourcemap: true, - ...options, - }) + } } diff --git a/packages/internal/src/generate/watch.ts b/packages/internal/src/generate/watch.ts index ab4cc7232adf..7a564046825a 100644 --- a/packages/internal/src/generate/watch.ts +++ b/packages/internal/src/generate/watch.ts @@ -35,7 +35,7 @@ import { const rwjsPaths = getPaths() -const watcher = chokidar.watch('**/src/**/*.{ts,js,jsx,tsx}', { +const watcher = chokidar.watch('(web|api)/src/**/*.{ts,js,jsx,tsx}', { persistent: true, ignored: ['node_modules', '.redwood'], ignoreInitial: true, diff --git a/packages/project-config/src/__tests__/config.test.ts b/packages/project-config/src/__tests__/config.test.ts index 4ae182850c27..4f1509757358 100644 --- a/packages/project-config/src/__tests__/config.test.ts +++ b/packages/project-config/src/__tests__/config.test.ts @@ -67,20 +67,6 @@ describe('getConfig', () => { "streamingSsr": { "enabled": false, }, - "studio": { - "basePort": 4318, - "graphiql": { - "authImpersonation": { - "authProvider": undefined, - "email": undefined, - "jwtSecret": "secret", - "roles": undefined, - "userId": undefined, - }, - "endpoint": "graphql", - }, - "inMemory": false, - }, "useSDLCodeGenForGraphQLTypes": false, }, "generate": { @@ -95,6 +81,20 @@ describe('getConfig', () => { "notifications": { "versionUpdates": [], }, + "studio": { + "basePort": 4318, + "graphiql": { + "authImpersonation": { + "authProvider": undefined, + "email": undefined, + "jwtSecret": "secret", + "roles": undefined, + "userId": undefined, + }, + "endpoint": "graphql", + }, + "inMemory": false, + }, "web": { "a11y": true, "apiUrl": "/.redwood/functions", @@ -116,8 +116,8 @@ describe('getConfig', () => { const config = getConfig(path.join(__dirname, './fixtures/redwood.toml')) expect(config.web.port).toEqual(8888) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual('graphql') + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql') }) describe('with studio configs', () => { @@ -126,27 +126,23 @@ describe('getConfig', () => { path.join(__dirname, './fixtures/redwood.studio.toml') ) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual( - 'graphql-endpoint' - ) + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql-endpoint') }) it('merges studio configs with dbAuth impersonation', () => { const config = getConfig( path.join(__dirname, './fixtures/redwood.studio.dbauth.toml') ) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual('graphql') - expect( - config.experimental.studio.graphiql?.authImpersonation?.authProvider - ).toEqual('dbAuth') - expect( - config.experimental.studio.graphiql?.authImpersonation?.email - ).toEqual('user@example.com') - expect( - config.experimental.studio.graphiql?.authImpersonation?.userId - ).toEqual('1') + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql') + expect(config.studio.graphiql?.authImpersonation?.authProvider).toEqual( + 'dbAuth' + ) + expect(config.studio.graphiql?.authImpersonation?.email).toEqual( + 'user@example.com' + ) + expect(config.studio.graphiql?.authImpersonation?.userId).toEqual('1') }) it('merges studio configs with supabase impersonation', () => { @@ -154,20 +150,18 @@ describe('getConfig', () => { path.join(__dirname, './fixtures/redwood.studio.supabase.toml') ) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual('graphql') - expect( - config.experimental.studio.graphiql?.authImpersonation?.authProvider - ).toEqual('supabase') - expect( - config.experimental.studio.graphiql?.authImpersonation?.email - ).toEqual('supauser@example.com') - expect( - config.experimental.studio.graphiql?.authImpersonation?.userId - ).toEqual('1') - expect( - config.experimental.studio.graphiql?.authImpersonation?.jwtSecret - ).toEqual('supa-secret') + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql') + expect(config.studio.graphiql?.authImpersonation?.authProvider).toEqual( + 'supabase' + ) + expect(config.studio.graphiql?.authImpersonation?.email).toEqual( + 'supauser@example.com' + ) + expect(config.studio.graphiql?.authImpersonation?.userId).toEqual('1') + expect(config.studio.graphiql?.authImpersonation?.jwtSecret).toEqual( + 'supa-secret' + ) }) }) diff --git a/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml b/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml index ab084302c179..638b8f319dbb 100644 --- a/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml +++ b/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml @@ -1,10 +1,10 @@ [web] port = 8888 -[experimental.studio] +[studio] inMemory = false - [experimental.studio.graphiql] + [studio.graphiql] endpoint = "graphql" - [experimental.studio.graphiql.authImpersonation] + [studio.graphiql.authImpersonation] authProvider = "dbAuth" email = "user@example.com" userId = "1" diff --git a/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml b/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml index 2e1bf5b7b774..dc0792646587 100644 --- a/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml +++ b/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml @@ -1,10 +1,10 @@ [web] port = 8888 -[experimental.studio] +[studio] inMemory = false - [experimental.studio.graphiql] + [studio.graphiql] endpoint = "graphql" - [experimental.studio.graphiql.authImpersonation] + [studio.graphiql.authImpersonation] authProvider = "supabase" email = "supauser@example.com" jwtSecret = "supa-secret" diff --git a/packages/project-config/src/__tests__/fixtures/redwood.studio.toml b/packages/project-config/src/__tests__/fixtures/redwood.studio.toml index c7d68eb4cd06..65803423fb73 100644 --- a/packages/project-config/src/__tests__/fixtures/redwood.studio.toml +++ b/packages/project-config/src/__tests__/fixtures/redwood.studio.toml @@ -1,6 +1,6 @@ [web] port = 8888 -[experimental.studio] +[studio] inMemory = false - [experimental.studio.graphiql] + [studio.graphiql] endpoint = "graphql-endpoint" diff --git a/packages/project-config/src/config.ts b/packages/project-config/src/config.ts index e92b7617a0d6..e8dcdfda4146 100644 --- a/packages/project-config/src/config.ts +++ b/packages/project-config/src/config.ts @@ -98,13 +98,13 @@ export interface Config { notifications: { versionUpdates: string[] } + studio: StudioConfig experimental: { opentelemetry: { enabled: boolean wrapApi: boolean apiSdk?: string } - studio: StudioConfig cli: { autoInstall: boolean plugins: CLIPlugin[] @@ -165,25 +165,25 @@ const DEFAULT_CONFIG: Config = { notifications: { versionUpdates: [], }, + studio: { + basePort: 4318, + inMemory: false, + graphiql: { + endpoint: 'graphql', + authImpersonation: { + authProvider: undefined, + userId: undefined, + email: undefined, + roles: undefined, + jwtSecret: 'secret', + }, + }, + }, experimental: { opentelemetry: { enabled: false, wrapApi: true, }, - studio: { - basePort: 4318, - inMemory: false, - graphiql: { - endpoint: 'graphql', - authImpersonation: { - authProvider: undefined, - userId: undefined, - email: undefined, - roles: undefined, - jwtSecret: 'secret', - }, - }, - }, cli: { autoInstall: true, plugins: [ diff --git a/packages/router/src/__tests__/util.test.ts b/packages/router/src/__tests__/util.test.ts index 0d0841ae87d4..27c8cb2617fe 100644 --- a/packages/router/src/__tests__/util.test.ts +++ b/packages/router/src/__tests__/util.test.ts @@ -427,4 +427,26 @@ describe('replaceParams', () => { '/a/1/c/2/e' ) }) + + // See link below for the rules + // https://blog.lunatech.com/posts/2009-02-03-what-every-web-developer-must-know-about-url-encoding + it('properly encodes search parameters', () => { + expect(replaceParams('/search', { q: 'foo bar' })).toEqual( + '/search?q=foo+bar' + ) + + expect(replaceParams('/index-value', { 's&p500': '2024-01-17' })).toEqual( + '/index-value?s%26p500=2024-01-17' + ) + + expect(replaceParams('/search', { q: 'home & garden' })).toEqual( + '/search?q=home+%26+garden' + ) + + expect(replaceParams('/dir', { path: '/Users/rob/Photos' })).toEqual( + '/dir?path=%2FUsers%2Frob%2FPhotos' + ) + + expect(replaceParams('/calc', { expr: '1+2' })).toEqual('/calc?expr=1%2B2') + }) }) diff --git a/packages/router/src/util.ts b/packages/router/src/util.ts index 27b358c9ecd6..8aa198e8b59b 100644 --- a/packages/router/src/util.ts +++ b/packages/router/src/util.ts @@ -341,8 +341,11 @@ export function replaceParams( }) // Append any unnamed params as search params. - if (queryParams.length) { - path += `?${queryParams.join('&')}` + if (extraArgKeys.length) { + const extraArgs = Object.fromEntries( + extraArgKeys.map((key) => [key, `${args[key]}`]) + ) + path += `?${new URLSearchParams(extraArgs).toString()}` } return path diff --git a/packages/studio/README.md b/packages/studio/README.md deleted file mode 100644 index 00fc5207b1d9..000000000000 --- a/packages/studio/README.md +++ /dev/null @@ -1,187 +0,0 @@ -# Redwood Studio [Experimental] - -RedwoodJS Studio is an experimental package used during development to gain runtime insights into a project. - -## Motivation - -Redwood provides tools that lets developers "get to work on what makes your application special, instead of wasting cycles choosing and re-choosing various technologies and configurations."[1](https://github.com/redwoodjs/redwood/blob/main/README.md). - -Much happens while your app processes a request: Invoke a function; handle a GraphQL request; resolve the request with a service; build and execute a SQL statement; connect to the database; handle the query response; further resolve the response so in contains all the data needed; return the result ... and more. - -While [logging](https://redwoodjs.com/docs/logger) can show you some of these steps, there is no easy way to see how the relate to each other, compare, or break down individual timings. Observability needed to debug, iterate, try out, and refactor your code is lacking. - -We hope Studio helps solve this problem with an observability tool that combines: - -* Tracing with OpenTelemetry (service and GraphQL) -* SQL statement logging -* general metrics (how many invocations) -* GraphiQL playground with impersonated authentication - -With Studio, it is easier to: - -* identify slow running SQL statements without reviewing captured log files -* identify and improve N+1 queries by comparing before and after traces -* impersonate the user authentication headers in GraphiQL - -## Running Studio - -To run the redwood studio simply execute the following redwood cli command: -```bash -yarn rw experimental studio -``` - -## Studio Config - -You may provide the following configuration options in your `redwood.toml` file to control the behaviour of the studio. - -```toml -[experimental.studio] - # Determines whether the studio should run with an in memory database or persist the data to a file in your project within `./redwood` - inMemory = false - -[experimental.studio.graphiql] - endpoint = 'graphql' - -[experimental.studio.graphiql.authImpersonation] - # authProvider = undefined (default value) - jwtSecret = 'secret' - # userId = undefined (default value) - # email = undefined (default value) - # roles = undefined (default value) -``` - -## OpenTelemetry Ingestion - -The redwood studio can ingest your OpenTelemetry data and indeed requires this data to power the insights that it is able to provide. - -To enable ingestion of OpenTelemetry tracing into the studio please provide the following export location for your tracing data within `opentelemetry.js` to be `http://127.0.0.1:4318/v1/traces` which is the default generated by the OpenTelemetry setup command. -```ts -const exporter = new OTLPTraceExporter({ - // Update this URL to point to where your OTLP compatible collector is listening - // The redwood development studio (`yarn rw exp studio`) can collect your telemetry at `http://127.0.0.1:4318/v1/traces` - url: 'http://127.0.0.1:4318/v1/traces', -}) -``` - -## GraphiQL Auth Impersonation - -### DbAuth - -Requires `SESSION_SECRET` envar for cookie encryption. - -TOML example: - -```toml -[web] - port = 8888 -[experimental.studio] - inMemory = false -[experimental.studio.graphiql] - endpoint = "graphql" -[experimental.studio.graphiql.authImpersonation] - authProvider = "dbAuth" - email = "user@example.com" - userId = "1" -``` - -### Netlify - -Since Netlify does not expose the JWT secret used to sign the token in production, impersonation requires a `jwtSecret` to encode and decode the auth token. - -TOML example: - -```toml -[web] - port = 8888 -[experimental.studio] - inMemory = false -[experimental.studio.graphiql] - endpoint = "graphql" -[experimental.studio.graphiql.authImpersonation] - authProvider = "netlify" - email = "user@example.com" - userId = "1" - jwtSecret = "some-secret-setting" -``` - -### Supabase - -Requires `SUPABASE_JWT_SECRET` envar for JWT signing. - -TOML example: - -```toml -[web] - port = 8888 -[experimental.studio] - inMemory = false -[experimental.studio.graphiql] - endpoint = "graphql" -[experimental.studio.graphiql.authImpersonation] - authProvider = "supabase" - email = "user@example.com" - userId = "1" -``` - -## Future - -Since Studio is experiment, its feature set will change. Some will be added, others improved, and several perhaps removed. - -Some ideas to improve the Studio are: - -* More metric widgets - * time from launch - * count of queries/services/functions - * etc -* Annotations - * add warnings on slow queries - * add warning on possible N+1 -* Charts and visualizations - * Line charts of request over time - * Histograms of executing timings per request -* Track errors - * Capture api-side errors for review - * Perhaps via Envelop GraphQL plugin -* Instrument web side -* ChatGPT UI to ask questions about schema -* Prisma ER diagrams -* Send api logs via Pino transport -* Search - * Prisma - * Tracing - * Errors -* Notification on warnings - * issue to fix like n+1 -* Custom dashboards with specific charts, or service or GraphQL focus -* Intelligence - * anomalies - * trends - * warn on issues before they happen -* More tags and data per request - * Prisma version - * RedwoodJS Version - -## Troubleshooting -If you have problems relating to the `@swc` packages then please try adding the following configuration to your `.yarnrc.yml` - -```yml -supportedArchitectures: - os: - - darwin - - linux - - win32 - cpu: - - arm64 - - arm - - x64 - - ia32 - libc: - - glibc - - musl -``` - -## Contributing - -We welcome your [feedback](https://community.redwoodjs.com/t/redwood-studio-experimental/4771) and also your contributions to improve Studio. - -For more [information on contributing](https://github.com/redwoodjs/redwood/blob/main/CONTRIBUTING.md) see: https://github.com/redwoodjs/redwood/blob/main/CONTRIBUTING.md diff --git a/packages/studio/api/database.ts b/packages/studio/api/database.ts deleted file mode 100644 index 95c7925e22ff..000000000000 --- a/packages/studio/api/database.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'path' - -import { open } from 'sqlite' -import type { Database } from 'sqlite' -import sqlite3 from 'sqlite3' - -import { getPaths, getConfig } from '@redwoodjs/project-config' - -let db: Database - -export const getDatabase = async () => { - // Switch between in-memory and file-based database based on toml config - const filename = getConfig().experimental.studio.inMemory - ? ':memory:' - : path.join(getPaths().generated.base, 'studio.db') - - if (db === undefined) { - db = await open({ - filename, - driver: sqlite3.Database, - }) - } - return db -} diff --git a/packages/studio/api/fastify/plugins/withApiProxy.ts b/packages/studio/api/fastify/plugins/withApiProxy.ts deleted file mode 100644 index e07846cae68a..000000000000 --- a/packages/studio/api/fastify/plugins/withApiProxy.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FastifyHttpProxyOptions } from '@fastify/http-proxy' -import httpProxy from '@fastify/http-proxy' -import type { FastifyInstance } from 'fastify' - -export interface ApiProxyOptions { - apiUrl: string - apiHost: string - rewritePrefix?: string -} - -const withApiProxy = async ( - fastify: FastifyInstance, - { apiUrl, apiHost, rewritePrefix }: ApiProxyOptions -) => { - const proxyOpts: FastifyHttpProxyOptions = { - upstream: apiHost, - prefix: apiUrl, - rewritePrefix, - disableCache: true, - } - - fastify.register(httpProxy, proxyOpts) - - return fastify -} - -export default withApiProxy diff --git a/packages/studio/api/fastify/spanIngester.ts b/packages/studio/api/fastify/spanIngester.ts deleted file mode 100644 index 8653777166d5..000000000000 --- a/packages/studio/api/fastify/spanIngester.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { FastifyInstance } from 'fastify' - -import { getDatabase } from '../database' -import { retypeSpan } from '../services/span' -import type { - RawAttribute, - RestructuredAttributes, - RawEvent, - RestructuredEvent, - RestructuredSpan, - ResourceSpan, -} from '../types' - -function restructureAttributes(rawAttributes: RawAttribute[]) { - const restructuredAttributes: RestructuredAttributes = {} - for (const rawAttribute of rawAttributes) { - // Value is typically under a key such as "boolValue", "stringValue", etc. just take whatever one we find - const keys = Object.keys(rawAttribute.value) - const valueIdentifier = keys.length > 0 ? keys[0] : undefined - if (valueIdentifier === undefined) { - continue - } - switch (valueIdentifier) { - case 'stringValue': - restructuredAttributes[rawAttribute.key] = rawAttribute.value - .stringValue as string - break - case 'intValue': - restructuredAttributes[rawAttribute.key] = parseInt( - rawAttribute.value.intValue as string - ) - break - case 'boolValue': - restructuredAttributes[rawAttribute.key] = rawAttribute.value - .boolValue as boolean - break - default: - // If value is "{}" pass null instead, otherwise just pass whatever it happens to be - restructuredAttributes[rawAttribute.key] = rawAttribute.value.value - ? JSON.stringify(rawAttribute.value.value) - : null - break - } - } - return restructuredAttributes -} - -function restructureEvents(rawEvents: RawEvent[]) { - const restructuredEvents: RestructuredEvent[] = [] - for (const rawEvent of rawEvents) { - const restructuredEvent: RestructuredEvent = { - name: rawEvent.name, - time: rawEvent.timeUnixNano, - attributes: restructureAttributes(rawEvent.attributes), - } - restructuredEvents.push(restructuredEvent) - } - return restructuredEvents -} - -export default async function routes(fastify: FastifyInstance, _options: any) { - fastify.post('/v1/traces', async (request, _reply) => { - const data: { resourceSpans: ResourceSpan[] } = request.body as any - - const db = await getDatabase() - const spanInsertStatement = await db.prepare( - 'INSERT INTO span (id, trace, parent, name, kind, status_code, status_message, start_nano, end_nano, duration_nano, events, attributes, resources) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, json(?), json(?), json(?)) RETURNING id;' - ) - - // TODO: Consider better typing here` - const spans: RestructuredSpan[] = [] - - // TODO: Consider less nesting if possible - for (const resourceSpan of data.resourceSpans) { - const resources = restructureAttributes(resourceSpan.resource.attributes) - for (const scopeSpan of resourceSpan.scopeSpans) { - for (const span of scopeSpan.spans) { - const restructuredSpan: RestructuredSpan = { - // Include the standard properties - trace: span.traceId, - id: span.spanId, - parent: span.parentSpanId, - name: span.name, - kind: span.kind, - statusCode: span.status?.code, - statusMessage: span.status?.message, - startNano: span.startTimeUnixNano, - endNano: span.endTimeUnixNano, - // Compute and store a duration for ease in analytics - durationNano: Number( - BigInt(span.endTimeUnixNano) - BigInt(span.startTimeUnixNano) - ).toString(), - } - - // TODO: Consider better handling of events - if (span.events) { - restructuredSpan.events = restructureEvents(span.events) - } - // Add attributes - if (span.attributes) { - restructuredSpan.attributes = restructureAttributes(span.attributes) - } - if (resources) { - restructuredSpan.resourceAttributes = resources - } - spans.push(restructuredSpan) - } - } - } - - for (const span of spans) { - // Insert the span - const spanInsertResult = await spanInsertStatement.get( - span.id, - span.trace, - span.parent, - span.name, - span.kind, - span.statusCode, - span.statusMessage, - span.startNano, - span.endNano, - span.durationNano, - JSON.stringify(span.events), - JSON.stringify(span.attributes), - JSON.stringify(span.resourceAttributes) - ) - if (spanInsertResult.id) { - await retypeSpan(undefined, { id: spanInsertResult.id }) - } - return spanInsertResult - } - - return {} - }) -} diff --git a/packages/studio/api/fastify/yoga.ts b/packages/studio/api/fastify/yoga.ts deleted file mode 100644 index 5a874c28ad8b..000000000000 --- a/packages/studio/api/fastify/yoga.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import type { YogaServerInstance } from 'graphql-yoga' - -export default async function routes( - fastify: FastifyInstance, - { - yoga, - }: { - yoga: YogaServerInstance< - { - req: FastifyRequest - reply: FastifyReply - }, - {} - > - } -) { - fastify.route({ - url: '/graphql', - method: ['GET', 'POST', 'OPTIONS'], - handler: async (req, reply) => { - const response = await yoga.handleNodeRequest(req, { - req, - reply, - }) - for (const [name, value] of response.headers) { - reply.header(name, value) - } - reply.status(response.status) - reply.send(response.body) - return reply - }, - }) -} diff --git a/packages/studio/api/graphql/yoga.ts b/packages/studio/api/graphql/yoga.ts deleted file mode 100644 index b90ea06c46f3..000000000000 --- a/packages/studio/api/graphql/yoga.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify' -import { JSONDefinition, JSONResolver } from 'graphql-scalars' -import { createYoga, createSchema } from 'graphql-yoga' - -import { authProvider, generateAuthHeaders } from '../services/auth' -import { - spanTypeTimeline, - spanTreeMapData, - spanTypeTimeSeriesData, -} from '../services/charts' -import { studioConfig, webConfig } from '../services/config' -import { span, spans } from '../services/explore/span' -import { traces, trace, traceCount } from '../services/explore/trace' -import { seriesTypeBarList, modelsAccessedList } from '../services/lists' -import { - mails, - truncate as truncateMails, - getMailRenderers as mailRenderers, - getMailTemplates as mailTemplates, - getMailComponents as mailComponents, - getRenderedMail as mailRenderedMail, -} from '../services/mail' -import { prismaQuerySpans } from '../services/prismaSpans' -import { retypeSpans, truncateSpans } from '../services/span' -import { getAncestorSpans, getDescendantSpans } from '../services/util' - -export const setupYoga = (fastify: FastifyInstance) => { - const schema = createSchema<{ - req: FastifyRequest - reply: FastifyReply - }>({ - typeDefs: /* GraphQL */ ` - ${JSONDefinition} - - # HTTP - type HttpSpan { - id: String! - span: Span - } - - # GraphQL - type GraphQLSpan { - id: String! - span: Span - } - - # Traces - type Trace { - id: String - spans: [Span] - } - - # Spans - type Span { - # From OTEL - id: String - trace: String - parent: String - name: String - kind: Int - statusCode: Int - statusMessage: String - startNano: String - endNano: String - durationNano: String - events: [JSON] - attributes: JSON - resources: JSON - - # Enrichments - type: String - brief: String - descendantSpans: [Span] - ancestorSpans: [Span] - } - - type SpanTypeTimelineData { - data: [JSON] - keys: [String!] - index: String - legend: JSON - axisLeft: JSON - axisBottom: JSON - } - - # Charts - Line Time Series - type TimeSeriesType { - ts: String! - generic: Float - graphql: Float - http: Float - prisma: Float - redwoodfunction: Float - redwoodservice: Float - sql: Float - } - - # Lists - Series Type Lists - type SeriesTypeList { - series_type: String! - series_name: String - quantity: Int! - } - - type ModelsAccessedList { - model: String! - model_count: Int! - } - - type PrismaQuerySpan { - id: String - trace: String - parent_id: String - parent_trace: String - name: String - method: String - model: String - prisma_name: String - start_nano: String - end_nano: String - duration_nano: String - duration_ms: String - duration_sec: String - db_statement: String - } - - type GraphQLSpan { - id: String - parent: String - name: String - field_name: String - type_name: String - start_nano: String - end_nano: String - duration_nano: String - } - - type GraphiQLConfig { - endpoint: String - authImpersonation: AuthImpersonationConfig - } - - type AuthImpersonationConfig { - authProvider: String - userId: String - email: String - roles: [String] - jwtSecret: String - } - - type StudioConfig { - basePort: Int! - inMemory: Boolean - graphiql: GraphiQLConfig - } - - type WebConfig { - graphqlEndpoint: String - } - - type AuthHeaders { - authProvider: String - cookie: String - authorization: String - } - - # Mail - type Mail { - id: String - data: JSON - envelope: JSON - created_at: Int - } - type MailTemplate { - id: Int! - name: String! - path: String! - updatedAt: Int! - } - type MailRenderer { - id: Int! - name: String! - isDefault: Boolean! - updatedAt: Int! - } - type MailTemplateComponent { - id: Int! - mailTemplateId: Int! - name: String! - propsTemplate: String - updatedAt: Int! - } - type RenderedMail { - html: String - text: String - error: String - } - - type Query { - prismaQueries(id: String!): [PrismaQuerySpan]! - authProvider: String - studioConfig: StudioConfig - webConfig: WebConfig - generateAuthHeaders(userId: String): AuthHeaders - - # Explore - Tracing - traceCount: Int - trace(traceId: String): Trace - traces(searchFilter: String): [Trace] - - # Explore - Span - span(spanId: String!): Span - spans(searchFilter: String): [Span] - - # Charts - spanTypeTimeline( - timeLimit: Int! - timeBucket: Int! - ): SpanTypeTimelineData - spanTypeTimeSeriesData(timeLimit: Int!): [TimeSeriesType] - - # Lists - seriesTypeBarList(timeLimit: Int!): [SeriesTypeList] - modelsAccessedList(timeLimit: Int!): [ModelsAccessedList] - - # Maps - spanTreeMapData(spanId: String): JSON - - # Mail - mails: [Mail] - mailTemplates: [MailTemplate] - mailRenderers: [MailRenderer] - mailComponents: [MailTemplateComponent] - mailRenderedMail( - componentId: Int! - rendererId: Int! - propsJSON: String - ): RenderedMail - } - - type Mutation { - retypeSpans: Boolean! - truncateSpans: Boolean! - truncateMails: Boolean! - } - `, - resolvers: { - JSON: JSONResolver, - Mutation: { - retypeSpans, - truncateSpans, - truncateMails, - }, - Query: { - studioConfig, - webConfig, - authProvider, - generateAuthHeaders, - prismaQueries: prismaQuerySpans, - // Explore - Tracing - traceCount, - trace, - traces, - // Explore - Span - span, - spans, - // Charts - spanTypeTimeline, - spanTypeTimeSeriesData, - // Lists - modelsAccessedList, - seriesTypeBarList, - // Maps - spanTreeMapData, - // Mail - mails, - mailTemplates, - mailRenderers, - mailComponents, - mailRenderedMail, - }, - Span: { - descendantSpans: async (span, _args, _ctx) => { - return getDescendantSpans(span.id) - }, - ancestorSpans: async (span, _args, _ctx) => { - return getAncestorSpans(span.id) - }, - }, - }, - }) - - const yoga = createYoga<{ - req: FastifyRequest - reply: FastifyReply - }>({ - schema, - logging: { - debug: (...args) => args.forEach((arg) => fastify.log.debug(arg)), - info: (...args) => args.forEach((arg) => fastify.log.info(arg)), - warn: (...args) => args.forEach((arg) => fastify.log.warn(arg)), - error: (...args) => args.forEach((arg) => fastify.log.error(arg)), - }, - graphiql: true, - }) - - return yoga -} diff --git a/packages/studio/api/index.ts b/packages/studio/api/index.ts deleted file mode 100644 index 2459917ad4d1..000000000000 --- a/packages/studio/api/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -import path from 'node:path' - -import fastifyStatic from '@fastify/static' -import Fastify from 'fastify' -import type { FastifyInstance } from 'fastify' -import open from 'open' - -import withApiProxy from './fastify/plugins/withApiProxy' -import spanRoutes from './fastify/spanIngester' -import yogaRoutes from './fastify/yoga' -import { setupYoga } from './graphql/yoga' -import { getStudioConfig, getWebConfig } from './lib/config' -import { rewriteWebToUsePort } from './lib/rewriteWebToUsePort' -import { - registerMailRelatedWatchers, - startServer as startMailServer, - stopServer as stopMailServer, -} from './mail' -import { runMigrations } from './migrations' - -const HOST = 'localhost' - -let fastify: FastifyInstance - -export const start = async ( - { open: autoOpen }: { open: boolean } = { open: false } -) => { - process.on('SIGTERM', async () => { - await stop() - }) - process.on('SIGINT', async () => { - await stop() - }) - process.on('beforeExit', async () => { - await stop() - }) - - // DB Setup - await runMigrations() - - // Fasitfy Setup - fastify = Fastify({ - logger: { - level: 'info', - timestamp: () => `,"time":"${new Date(Date.now()).toISOString()}"`, - }, - disableRequestLogging: true, - }) - - // Plugins - - // Graphql Proxy - Takes studio "/proxies/graphql" and forwards to the projects graphql endpoint - const webConfig = getWebConfig() - const graphqlEndpoint = - webConfig.apiGraphQLUrl ?? - `http://${webConfig.host}:${webConfig.port}${webConfig.apiUrl}/graphql` - fastify = await withApiProxy(fastify, { - apiHost: `http://${webConfig.host}:${webConfig.port}`, - apiUrl: `/proxies/graphql`, - // Strip the initial scheme://host:port from the graphqlEndpoint - rewritePrefix: '/' + graphqlEndpoint.split('/').slice(3).join('/'), - }) - - const studioPort = getStudioConfig().basePort - const webPath = path.join(__dirname, '..', '..', 'dist', 'web') - - rewriteWebToUsePort(webPath, studioPort) - - // GraphQL - const yogaServer = setupYoga(fastify) - - // Routes - fastify.register(spanRoutes) - fastify.register(yogaRoutes, { yoga: yogaServer }) - // Statically serve the web side (React) - fastify.register(fastifyStatic, { root: webPath }) - - fastify.listen({ port: studioPort, host: HOST }) - fastify.ready(() => { - console.log(`Studio API listening on ${HOST}:${studioPort}`) - - if (autoOpen) { - open(`http://${HOST}:${studioPort}`) - } - }) - - // SMTP Server - console.log("Starting Studio's SMTP Server...") - startMailServer() - registerMailRelatedWatchers() -} - -const stop = async () => { - await fastify?.close() - await stopMailServer() -} diff --git a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts deleted file mode 100644 index 653ea878864a..000000000000 --- a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { v4 as uuidv4 } from 'uuid' - -import { SESSION_SECRET } from '../envars' - -const isNumeric = (id: string) => { - return /^\d+$/.test(id) -} - -export const getDBAuthHeader = async (userId?: string) => { - if (!userId) { - throw new Error('Require an unique id to generate session cookie') - } - - if (!SESSION_SECRET) { - throw new Error( - 'dbAuth requires a SESSION_SECRET environment variable that is used to encrypt session cookies. Use `yarn rw g secret` to create one, then add to your `.env` file. DO NOT check this variable in your version control system!!' - ) - } - - const { - default: { encryptSession }, - } = await import('@redwoodjs/auth-dbauth-api') - - const id = isNumeric(userId) ? parseInt(userId) : userId - const cookie = encryptSession(JSON.stringify({ id }) + ';' + uuidv4()) - - return { - authProvider: 'dbAuth', - cookie: `session=${cookie}`, - authorization: `Bearer ${userId}`, - } -} diff --git a/packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts deleted file mode 100644 index 0e6cd6aaa90c..000000000000 --- a/packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import jwt from 'jsonwebtoken' - -const getExpiryTime = () => { - return Date.now() + 3600 * 1000 -} - -export const getNetlifyAuthHeader = ( - userId?: string, - email?: string, - secret?: string -) => { - const payload = { - exp: getExpiryTime(), - sub: userId ?? 'test-user-id', - email: email ?? 'user@example.com', - app_metadata: { - provider: 'email', - }, - user_metadata: {}, - roles: [], - } - - // in dev, Netlify simply decodes as there is no access to the actual secret used to sign the JWT - if (!secret) { - throw new Error('No secret provided for Netlify auth provider') - } - const token = jwt.sign(payload, secret) - - return { - authProvider: 'netlify', - authorization: `Bearer ${token}`, - } -} diff --git a/packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts deleted file mode 100644 index 8ce443e7a550..000000000000 --- a/packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import jwt from 'jsonwebtoken' - -import { SUPABASE_JWT_SECRET } from '../envars' - -const getExpiryTime = () => { - return Date.now() + 3600 * 1000 -} - -export const getSupabaseAuthHeader = (userId?: string, email?: string) => { - if (!SUPABASE_JWT_SECRET) { - throw new Error('SUPABASE_JWT_SECRET env var is not set.') - } - - const payload = { - aud: 'authenticated', - exp: getExpiryTime(), - sub: userId ?? 'test-user-id', - email: email ?? 'user@example.com', - app_metadata: { - provider: 'email', - }, - user_metadata: {}, - role: 'authenticated', - roles: [], - } - - const token = jwt.sign(payload, SUPABASE_JWT_SECRET) - - return { - authProvider: 'supabase', - authorization: `Bearer ${token}`, - } -} diff --git a/packages/studio/api/lib/config.ts b/packages/studio/api/lib/config.ts deleted file mode 100644 index 0b9683b81149..000000000000 --- a/packages/studio/api/lib/config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getConfig } from '@redwoodjs/project-config' - -import type { ApiConfig, StudioConfig, WebConfig } from '../types' - -export const getApiConfig = (): ApiConfig => { - return getConfig().api -} - -export const getWebConfig = (): WebConfig => { - const web = getConfig().web - const apiUrl = web.apiUrl - - // Construct the graphql url from apiUrl by default - // But if apiGraphQLUrl is specified, use that instead - const studioConfig = getStudioConfig() - const graphql = studioConfig.graphiql?.endpoint ?? 'graphql' - const graphqlEndpoint = - web.apiGraphQLUrl ?? `http://${web.host}:${web.port}${apiUrl}/${graphql}` - - const webConfigWithGraphQlEndpoint = { - ...getConfig().web, - graphqlEndpoint, - } - - return webConfigWithGraphQlEndpoint -} - -export const getStudioConfig = (): StudioConfig => { - return getConfig().experimental.studio -} diff --git a/packages/studio/api/lib/envars.ts b/packages/studio/api/lib/envars.ts deleted file mode 100644 index cf7ed04bfaab..000000000000 --- a/packages/studio/api/lib/envars.ts +++ /dev/null @@ -1,18 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import dotenv from 'dotenv' - -import { getPaths } from '@redwoodjs/internal' - -const getRedwoodAppEnvVars = () => { - const basePath = getPaths().base - const envPath = path.join(basePath, '.env') - const envFile = fs.readFileSync(envPath, 'utf8') - const buf = Buffer.from(envFile) - - return dotenv.parse(buf) -} - -export const SESSION_SECRET = getRedwoodAppEnvVars().SESSION_SECRET -export const SUPABASE_JWT_SECRET = getRedwoodAppEnvVars().SUPABASE_JWT_SECRET diff --git a/packages/studio/api/lib/filtering.ts b/packages/studio/api/lib/filtering.ts deleted file mode 100644 index d19c8c707ea1..000000000000 --- a/packages/studio/api/lib/filtering.ts +++ /dev/null @@ -1,85 +0,0 @@ -function isValidColumn(column: string) { - return [ - 'id', - 'trace', - 'parent', - 'name', - 'type', - 'start', - 'end', - 'duration', - ].includes(column) -} - -function renameColumn(column: string) { - if (column === 'start') { - return 'start_nano' - } - if (column === 'end') { - return 'end_nano' - } - if (column === 'duration') { - return 'duration_nano' - } - if (column === 'status') { - return 'status_code' - } - return column -} - -export function extractFiltersFromString(filterString: string) { - const filters: any = {} - - const searchFilters = filterString.split(' ') - - // Handle `limit` - const limitFilters = searchFilters.filter((filter) => - filter.startsWith('limit:') - ) - if (limitFilters.length > 1) { - throw new Error('Cannot contain more than one limit') - } else if (limitFilters?.length === 1) { - const limitNumber = parseInt(limitFilters[0].split(':')[1]) - if (isNaN(limitNumber)) { - throw new Error('Limit must be a number') - } - filters.limit = limitNumber - } - - // Handle `sort` - const sortFilters = searchFilters.filter((filter) => - filter.startsWith('sort:') - ) - const sorts = [] - for (const sortFilter of sortFilters) { - const sortColumn = sortFilter.split(':')[1] - if (!isValidColumn(sortColumn)) { - throw new Error(`Cannot sort by ${sortColumn}`) - } - const sortType = sortFilter.split(':')[2] - if (!['asc', 'desc'].includes(sortType)) { - throw new Error(`Cannot sort by ${sortType}`) - } - sorts.push({ - column: renameColumn(sortColumn), - type: sortType.toUpperCase(), - }) - } - filters.sorts = sorts - - // Specific filters - const whereKeys = ['name', 'type', 'id', 'trace', 'parent', 'status'] - filters.where = {} - for (const whereKey of whereKeys) { - const whereFilters = searchFilters.filter((filter) => - filter.startsWith(`${whereKey}:`) - ) - if (whereFilters.length > 1) { - throw new Error(`Cannot contain more than one ${whereKey} filter`) - } else if (whereFilters.length === 1) { - filters.where[renameColumn(whereKey)] = whereFilters[0].split(':')[1] - } - } - - return filters -} diff --git a/packages/studio/api/lib/rewriteWebToUsePort.ts b/packages/studio/api/lib/rewriteWebToUsePort.ts deleted file mode 100644 index ca50d76be309..000000000000 --- a/packages/studio/api/lib/rewriteWebToUsePort.ts +++ /dev/null @@ -1,12 +0,0 @@ -import fs from 'fs' -import path from 'path' - -export function rewriteWebToUsePort(webPath: string, studioPort: number) { - const indexHtmlPath = path.join(webPath, 'index.html') - let indexHtml = fs.readFileSync(indexHtmlPath, 'utf8') - indexHtml = indexHtml.replace( - 'RWJS_STUDIO_BASE_PORT=4318', - 'RWJS_STUDIO_BASE_PORT=' + studioPort - ) - fs.writeFileSync(indexHtmlPath, indexHtml) -} diff --git a/packages/studio/api/lib/sql.ts b/packages/studio/api/lib/sql.ts deleted file mode 100644 index 7ab3bf529c84..000000000000 --- a/packages/studio/api/lib/sql.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * SUPER WARNING: Beware this does not escape all values! - * If you do sql inject then congrats on hacking into your own local telemetry data 🎉 - */ -export function generateSelectWithFilters( - select: string, - table: string, - filters: any -) { - const sorts = [] - - // Extract out sorts - if (filters.sorts) { - sorts.push(...filters.sorts) - delete filters.sorts - } - - // Parameters must be prefixed with `$` for sqlite - const sqlFilters: any = {} - Object.keys(filters).forEach((key) => { - if (filters[key]) { - sqlFilters[`$${key}`] = filters[key] - } - }) - - const where = Object.keys(sqlFilters.$where) - .map((key) => { - const value = sqlFilters.$where[key] - if (value.includes('%') || value.includes('_')) { - return `${key} LIKE '${value}'` - } - return `${key} = '${value}'` - }) - .join(' AND ') - delete sqlFilters.$where - - // Return the SQL and the filters for execution with .all or .get etc - return [ - `SELECT ${select} FROM ${table} ${where ? `WHERE ${where}` : ''} ${ - sorts.length > 0 - ? `ORDER BY ${sorts - .map((sort) => { - return `${sort.column} ${sort.type}` - }) - .join(',')}` - : '' - } ${sqlFilters.$limit ? 'LIMIT $limit' : ''} `, - sqlFilters, - ] -} diff --git a/packages/studio/api/mail/index.ts b/packages/studio/api/mail/index.ts deleted file mode 100644 index 12674f42540b..000000000000 --- a/packages/studio/api/mail/index.ts +++ /dev/null @@ -1,493 +0,0 @@ -import path from 'node:path' - -import * as swc from '@swc/core' -import chokidar from 'chokidar' -import fs from 'fs-extra' -import { simpleParser as simpleMailParser } from 'mailparser' -import { SMTPServer } from 'smtp-server' - -import { getPaths } from '@redwoodjs/project-config' - -import { getDatabase } from '../database' -import { getStudioConfig } from '../lib/config' - -let smtpServer: SMTPServer - -async function insertMailIntoDatabase(mail: any, envelope: any) { - const db = await getDatabase() - const sql = ` - INSERT INTO mail (data, envelope) VALUES (?, ?); - ` - await db.run(sql, [JSON.stringify(mail), JSON.stringify(envelope)]) -} - -export function startServer() { - smtpServer = new SMTPServer({ - banner: 'RedwoodJS Studio SMTP Server', - authOptional: true, - hideSTARTTLS: true, - onData(stream, session, callback) { - simpleMailParser(stream, {}, async (err, mail) => { - if (err) { - console.error('Error parsing mail:') - console.error(err) - } else { - await insertMailIntoDatabase(mail, session.envelope) - } - callback() - }) - }, - }) - - const port = getStudioConfig().basePort + 1 - - smtpServer.listen(port, undefined, () => { - console.log('Studio SMTP Server listening on ' + port) - }) -} - -export async function stopServer() { - await new Promise((resolve) => { - smtpServer.close(() => { - resolve(null) - }) - }) -} - -export function registerMailRelatedWatchers() { - // NOTE: So we clear the dist directory on each build so for now I'm just going to - // watch the dist directory and when it changes I'll reload the mailer and - // mail templates. I would bet this is not ideal in terms of performance. - - const distWatcher = chokidar.watch('**/*.*', { - cwd: getPaths().api.dist, - ignoreInitial: true, - usePolling: true, - interval: 500, - }) - process.on('SIGINT', async () => { - await distWatcher.close() - }) - - // I had to turn on polling to get the watcher to work so now I'm not sure this - // debounce is necessary - especially since the debounce is shorter than the poll - // interval. I'm going to leave it for now. - let debounceTimer: NodeJS.Timeout | undefined = undefined - const listenOnEventsForDist = ['ready', 'add', 'change'] - for (let i = 0; i < listenOnEventsForDist.length; i++) { - distWatcher.on(listenOnEventsForDist[i], async () => { - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(async () => { - await updateMailAnalysis() - }, 250) - }) - } -} - -async function updateMailAnalysis() { - console.log('Reanalysing mailer and mail templates...') - try { - await updateMailRenderers() - await updateMailTemplates() - } catch (error) { - console.error('Error updating mailer and mail templates:') - console.error(error) - console.error( - 'You may need to rebuild your redwood app or restart the studio' - ) - } -} - -function getFilesInDir(dir: string) { - const files: string[] = [] - const dirFiles = fs.readdirSync(dir) - for (const file of dirFiles) { - if (fs.statSync(path.join(dir, file)).isDirectory()) { - files.push(...getFilesInDir(path.join(dir, file))) - } else { - files.push(path.join(dir, file)) - } - } - return files -} - -export async function updateMailTemplates() { - const mailTemplateDistDir = path.join(getPaths().api.dist, 'mail') - if (!fs.existsSync(mailTemplateDistDir)) { - return - } - - const distFiles = getFilesInDir(mailTemplateDistDir).filter((file) => - file.endsWith('.js') - ) - const srcFiles = getFilesInDir(path.join(getPaths().api.src, 'mail')).filter( - // The src file must have a corresponding dist file - (file) => { - const correspondingDistEntry = - file - .replace(path.join('api', 'src'), path.join('api', 'dist')) - .substring(0, file.lastIndexOf('.') + 1) + '.js' - return distFiles.includes(correspondingDistEntry) - } - ) - - const db = await getDatabase() - - // Clear out any mail template that are no longer in the mailer - await db.run( - `DELETE FROM mail_template WHERE path NOT IN (${srcFiles - .map(() => '?') - .join(',')});`, - srcFiles - ) - - // Insert the mail templates - for (let i = 0; i < srcFiles.length; i++) { - const nameWithExt = path.basename(srcFiles[i]) - const name = nameWithExt.substring(0, nameWithExt.lastIndexOf('.')) - - const existingTemplate = await db.get( - `SELECT id FROM mail_template WHERE path = ?;`, - srcFiles[i] - ) - if (existingTemplate) { - // Update the values - await db.run( - `UPDATE mail_template SET name = ?, updated_at = ? WHERE id = ?;`, - [name, Date.now(), existingTemplate.id] - ) - } else { - // Insert the values - await db.run( - `INSERT INTO mail_template (name, path, updated_at) VALUES (?, ?, ?);`, - [name, srcFiles[i], Date.now()] - ) - } - - const templateId = - existingTemplate?.id ?? - ( - await db.get( - `SELECT id FROM mail_template WHERE path = ?;`, - srcFiles[i] - ) - )?.id - - // Get the components from the AST of the src file - const components = getMailTemplateComponents(srcFiles[i]) - - // Insert the components - for (let j = 0; j < components.length; j++) { - const existingComponent = await db.get( - `SELECT id FROM mail_template_component WHERE mail_template_id = ? AND name = ?;`, - [templateId, components[j].name] - ) - if (existingComponent) { - // Update the values - await db.run( - `UPDATE mail_template_component SET props_template = ?, updated_at = ? WHERE id = ?;`, - [components[j].propsTemplate, Date.now(), existingComponent.id] - ) - } else { - // Insert the values - await db.run( - `INSERT INTO mail_template_component (mail_template_id, name, props_template, updated_at) VALUES (?, ?, ?, ?);`, - [ - templateId, - components[j].name, - components[j].propsTemplate, - Date.now(), - ] - ) - } - } - - // Delete any components that are no longer in the src file - await db.run( - `DELETE FROM mail_template_component WHERE mail_template_id = ? AND name NOT IN (${components - .map(() => '?') - .join(',')});`, - [templateId, ...components.map((c) => c.name)] - ) - } - console.log(` - Analysed ${srcFiles.length} mail templates`) - - // Delete any mail template components that no longer have a corresponding mail template - await db.run( - `DELETE FROM mail_template_component WHERE mail_template_id NOT IN (SELECT id FROM mail_template);` - ) -} - -function generatePropsTemplate(param: swc.Param | swc.Pattern | null) { - // No param means no props template - if (!param) { - return null - } - - // Get the pattern - const pattern = param.type === 'Parameter' ? param.pat : param - if (!pattern) { - return null - } - - // Attempt to generate a props template from the pattern - let propsTemplate = 'Provide your props here as JSON' - try { - switch (pattern.type) { - case 'Identifier': - propsTemplate = `{${pattern.value}: ?}` - break - case 'AssignmentPattern': - if (pattern.left.type === 'ObjectPattern') { - propsTemplate = `{${pattern.left.properties - .map((p: any) => { - return `\n "${p.key.value}": ?` - }) - .join(',')}\n}` - } - break - case 'ObjectPattern': - propsTemplate = `{${pattern.properties - .map((p: any) => { - return `\n "${p.key.value}": ?` - }) - .join(',')}\n}` - break - } - } catch (_error) { - // ignore for now, we'll fallback to the generic props template - } - - // Fallback to a generic props template if we can't figure out anything more helpful - return propsTemplate -} - -function extractNameAndPropsTemplate( - component: swc.ModuleItem, - functionsAndVariables: swc.ModuleItem[] -): { - name: string - propsTemplate: string | null -} { - switch (component.type) { - case 'ExportDeclaration': - // Arrow functions - if (component.declaration.type === 'VariableDeclaration') { - // We only support the identifier type for now - const identifier = component.declaration.declarations[0].id - if (identifier.type !== 'Identifier') { - throw new Error('Unexpected identifier type: ' + identifier.type) - } - // We only support arrow and normal functions for now - const expression = component.declaration.declarations[0].init - if (!expression) { - throw new Error('Unexpected undefined expression') - } - if ( - expression.type !== 'ArrowFunctionExpression' && - expression.type !== 'FunctionExpression' - ) { - throw new Error('Unexpected expression type: ' + expression.type) - } - return { - name: identifier.value, - propsTemplate: generatePropsTemplate(expression.params[0] ?? null), - } - } - - // Normal functions - if (component.declaration.type === 'FunctionDeclaration') { - return { - name: component.declaration.identifier.value, - propsTemplate: generatePropsTemplate( - component.declaration.params[0] ?? null - ), - } - } - - // Throw for anything else - throw new Error( - 'Unexpected declaration type: ' + component.declaration.type - ) - - case 'ExportDefaultExpression': - // Arrow functions - if (component.expression.type === 'ArrowFunctionExpression') { - return { - name: 'default', - propsTemplate: generatePropsTemplate( - component.expression.params[0] ?? null - ), - } - } - - // Variables defined elsewhere and then exported as default - if (component.expression.type === 'Identifier') { - const expression = component.expression - const variable = functionsAndVariables.find((v) => { - return ( - (v.type === 'FunctionDeclaration' && - v.identifier.value === expression.value) || // function - (v.type === 'VariableDeclaration' && - v.declarations[0].type === 'VariableDeclarator' && - v.declarations[0].id.type === 'Identifier' && - v.declarations[0].id.value === expression.value) // variable - ) - }) - if (variable) { - if (variable.type === 'FunctionDeclaration') { - return { - name: variable.identifier.value + ' (default)', - propsTemplate: generatePropsTemplate(variable.params[0] ?? null), - } - } - if (variable.type === 'VariableDeclaration') { - if (variable.declarations[0].id.type !== 'Identifier') { - throw new Error( - 'Unexpected identifier type: ' + - variable.declarations[0].id.type - ) - } - if ( - variable.declarations[0].init?.type !== 'FunctionExpression' && - variable.declarations[0].init?.type !== 'ArrowFunctionExpression' - ) { - throw new Error( - 'Unexpected init type: ' + variable.declarations[0].init?.type - ) - } - return { - name: variable.declarations[0].id.value + ' (default)', - propsTemplate: generatePropsTemplate( - variable.declarations[0].init?.params[0] ?? null - ), - } - } - } - } - - // Throw for anything else - throw new Error( - 'Unexpected expression type: ' + component.expression.type - ) - - case 'ExportDefaultDeclaration': - // Normal functions - if (component.decl.type === 'FunctionExpression') { - let name = 'default' - if (component.decl.identifier) { - name = component.decl.identifier.value - } - return { - name, - propsTemplate: generatePropsTemplate( - component.decl.params[0] ?? null - ), - } - } - - // Throw for anything else - throw new Error('Unexpected declaration type: ' + component.decl.type) - - default: - throw new Error('Unexpected component type: ' + component.type) - } -} - -function getMailTemplateComponents(templateFilePath: string) { - const ast = swc.parseFileSync(templateFilePath, { - syntax: templateFilePath.endsWith('.js') ? 'ecmascript' : 'typescript', - tsx: templateFilePath.endsWith('.tsx') || templateFilePath.endsWith('.jsx'), - }) - - const components: { name: string; propsTemplate: string | null }[] = [] - const functionsAndVariables = ast.body.filter((node: any) => { - return ( - node.type === 'VariableDeclaration' || node.type === 'FunctionDeclaration' - ) - }) - - const exportedComponents = ast.body.filter((node: any) => { - return [ - 'ExportDeclaration', - 'ExportDefaultDeclaration', - 'ExportDefaultExpression', - ].includes(node.type) - }) - for (let i = 0; i < exportedComponents.length; i++) { - try { - const { propsTemplate, name } = extractNameAndPropsTemplate( - exportedComponents[i], - functionsAndVariables - ) - components.push({ - name, - propsTemplate, - }) - } catch (error) { - console.error( - `Error extracting template component name and props template from ${templateFilePath}:` - ) - console.error(error) - } - } - - return components -} - -export async function updateMailRenderers() { - try { - const mailerFilePath = path.join(getPaths().api.dist, 'lib', 'mailer.js') - if (!fs.existsSync(mailerFilePath)) { - return - } - - // This is not particularly memory efficient, it'll grow each time the mailer is reloaded - // I do not currently believe there is a way to invalidate the module load cache - const suffix = `studio_${Date.now()}` - const importPath = mailerFilePath.replace('.js', `.${suffix}.js`) - fs.copyFileSync(mailerFilePath, importPath) - const mailer = (await import(`file://${importPath}`)).mailer - fs.removeSync(importPath) - const renderers = Object.keys(mailer.renderers) - const defaultRenderer = mailer.config.rendering.default - - const db = await getDatabase() - // Delete any renderers that are no longer in the mailer - const deleteSql = ` - DELETE FROM mail_renderer WHERE name NOT IN (${renderers - .map(() => '?') - .join(',')}); - ` - await db.run(deleteSql, renderers) - - for (let i = 0; i < renderers.length; i++) { - const existingRenderer = await db.get( - `SELECT id FROM mail_renderer WHERE name = ?;`, - renderers[i] - ) - if (existingRenderer) { - // Update the values - await db.run( - `UPDATE mail_renderer SET is_default = ?, updated_at = ? WHERE id = ?;`, - [ - renderers[i] === defaultRenderer ? 1 : 0, - Date.now(), - existingRenderer.id, - ] - ) - } else { - // Insert the values - await db.run( - `INSERT INTO mail_renderer (name, is_default, updated_at) VALUES (?, ?, ?);`, - [renderers[i], renderers[i] === defaultRenderer ? 1 : 0, Date.now()] - ) - } - } - } catch (error) { - console.error('Error reloading mailer:') - console.error(error) - } -} diff --git a/packages/studio/api/migrations.ts b/packages/studio/api/migrations.ts deleted file mode 100644 index 32c25f01785b..000000000000 --- a/packages/studio/api/migrations.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { Database } from 'sqlite' -import type sqlite3 from 'sqlite3' - -import { getDatabase } from './database' - -export async function runMigrations() { - const db = await getDatabase() - - await setupTables(db) - await setupViews(db) - - // span type and brief - await migrate000(db) - - // initial mail table - await migrate001(db) - - // -} - -async function migrate000(db: Database) { - const user_version = (await db.get(`PRAGMA user_version;`))['user_version'] - if (user_version !== 0) { - return - } - - // NOTE: PRAGMA user_version does not support prepared statement variables - const sql = ` - BEGIN TRANSACTION; - ALTER TABLE span ADD COLUMN type TEXT(255) DEFAULT NULL; - ALTER TABLE span ADD COLUMN brief TEXT(255) DEFAULT NULL; - PRAGMA user_version = ${user_version + 1}; - COMMIT; - ` - await db.exec(sql) -} - -async function migrate001(db: Database) { - const user_version = (await db.get(`PRAGMA user_version;`))['user_version'] - if (user_version !== 1) { - return - } - - // NOTE: PRAGMA user_version does not support variables - const sql = ` - BEGIN TRANSACTION; - CREATE TABLE IF NOT EXISTS mail ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - data JSON, - envelope JSON, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - CREATE TABLE IF NOT EXISTS mail_template ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - path TEXT UNIQUE, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - CREATE TABLE IF NOT EXISTS mail_template_component ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mail_template_id INTEGER NOT NULL, - name TEXT NOT NULL, - props_template TEXT, - updated_at INTEGER DEFAULT (strftime('%s', 'now')), - UNIQUE(mail_template_id, name) - ); - CREATE TABLE IF NOT EXISTS mail_renderer ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - is_default INTEGER DEFAULT 0, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - PRAGMA user_version = ${user_version + 1}; - COMMIT; - ` - await db.exec(sql) -} - -const setupTables = async ( - db: Database -) => { - // BIGINT for UnixNano times will break in 239 years (Fri Apr 11 2262 23:47:16 GMT+0000) - const spanTableSQL = ` - CREATE TABLE IF NOT EXISTS - span ( - id TEXT PRIMARY KEY, - trace TEXT NOT NULL, - parent TEXT, - name TEXT, - kind INTEGER, - status_code INTEGER, - status_message TEXT, - start_nano BIGINT, - end_nano BIGINT, - duration_nano BIGINT, - events JSON, - attributes JSON, - resources JSON - ); - ` - await db.exec(spanTableSQL) -} - -const setupViews = async ( - db: Database -) => { - const prismaQueriesView = ` - CREATE VIEW IF NOT EXISTS prisma_queries as SELECT DISTINCT - s.id, - s.trace, - s.parent as parent_id, - p.trace as parent_trace, - s.name, - json_extract(p. "attributes", '$.method') AS method, - json_extract(p. "attributes", '$.model') AS model, - json_extract(p. "attributes", '$.name') AS prisma_name, - s.start_nano, - s.end_nano, - s.duration_nano, - cast((s.duration_nano / 1000000.000) as REAL) as duration_ms, - cast((s.duration_nano / 1000000000.0000) as number) as duration_sec, - json_extract(s. "attributes", '$."db.statement"') AS db_statement - FROM - span s - JOIN span p ON s.trace = p.trace - WHERE - s. "name" = 'prisma:engine:db_query' - AND - p. "name" = 'prisma:client:operation' - ORDER BY s.start_nano desc, s.parent; -` - await db.exec(prismaQueriesView) - - const SQLSpansView = ` - CREATE VIEW IF NOT EXISTS sql_spans AS - SELECT DISTINCT - *, - cast((duration_nano / 1000000.000) as REAL) as duration_ms, - cast((duration_nano / 1000000000.0000) as number) as duration_sec - FROM - span - WHERE - json_extract(attributes, '$."db.statement"') IS NOT NULL - ORDER BY start_nano desc; -` - await db.exec(SQLSpansView) - - const graphQLSpansView = `CREATE VIEW IF NOT EXISTS graphql_spans AS - SELECT - id, - parent, - name, - json_extract(ATTRIBUTES, '$."graphql.resolver.fieldName"') AS field_name, - json_extract(ATTRIBUTES, '$."graphql.resolver.typeName"') AS type_name, - start_nano, - end_nano, - duration_nano - FROM - span - WHERE - field_name IS NOT NULL - OR type_name IS NOT NULL - ORDER BY - start_nano DESC;` - - await db.exec(graphQLSpansView) -} diff --git a/packages/studio/api/services/auth.ts b/packages/studio/api/services/auth.ts deleted file mode 100644 index bde31bad8090..000000000000 --- a/packages/studio/api/services/auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getDBAuthHeader } from '../lib/authProviderEncoders/dbAuthEncoder' -import { getNetlifyAuthHeader } from '../lib/authProviderEncoders/netlifyAuthEncoder' -import { getSupabaseAuthHeader } from '../lib/authProviderEncoders/supabaseAuthEncoder' -import { getStudioConfig } from '../lib/config' - -export const authProvider = async (_parent: unknown) => { - return getStudioConfig().graphiql?.authImpersonation?.authProvider -} - -export const generateAuthHeaders = async ( - _parent: unknown, - { userId }: { userId?: string } -) => { - const studioConfig = getStudioConfig() - - const provider = studioConfig.graphiql?.authImpersonation?.authProvider - const impersonateUserId = studioConfig.graphiql?.authImpersonation?.userId - const email = studioConfig.graphiql?.authImpersonation?.email - const secret = studioConfig.graphiql?.authImpersonation?.jwtSecret - - if (provider == 'dbAuth') { - return getDBAuthHeader(userId || impersonateUserId) - } - if (provider == 'netlify') { - return getNetlifyAuthHeader(userId || impersonateUserId, email, secret) - } - - if (provider == 'supabase') { - return getSupabaseAuthHeader(userId || impersonateUserId, email) - } - - return {} -} diff --git a/packages/studio/api/services/charts.ts b/packages/studio/api/services/charts.ts deleted file mode 100644 index 537a6ac4f0e0..000000000000 --- a/packages/studio/api/services/charts.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { getDatabase } from '../database' - -import { getDescendantSpans, getSpan } from './util' - -export async function spanTypeTimeSeriesData( - _parent: unknown, - { - timeLimit, - }: { - timeLimit: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare(` - SELECT - ts, - json_patch (json_object('ts', ts), - json_group_object (series_type, - duration_msec)) AS chartdata - FROM ( - SELECT - datetime (start_nano / 1000000000, - 'unixepoch', - 'utc') AS ts, - replace(coalesce(TYPE, 'generic'), '-', '') AS series_type, - sum(duration_nano / 1000000.0) AS duration_msec - FROM - span - GROUP BY - ts, - series_type - ORDER BY - start_nano ASC, - series_type) - WHERE - ts >= datetime ('now', ?, 'utc') - GROUP BY - ts - ORDER BY - ts ASC; - `) - - const result = await stmt.all(`-${timeLimit} seconds`) - await stmt.finalize() - const chartData = result.map((row) => JSON.parse(row['chartdata'])) - - return chartData -} - -export async function spanTypeTimeline( - _parent: unknown, - { - timeLimit, - timeBucket, - }: { - timeLimit: number - timeBucket: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare( - ` - SELECT *, FLOOR(start_nano / 1000000) AS start_milli FROM span - WHERE start_nano >= ?; - ` - ) - const result = await stmt.all(Date.now() - timeLimit * 1e9) - await stmt.finalize() - - const data: any[] = [] - - const typesWithStartMilli = result.map((span) => ({ - type: span.type, - start_milli: span.start_milli, - })) - const types = [ - ...new Set( - typesWithStartMilli.map((span) => - span.type === null ? 'generic' : span.type - ) - ), - ] - - const steps = Math.floor(timeLimit / timeBucket) - const now = Date.now() - for (let i = 0; i < steps; i++) { - const ago = (i + 1) * timeBucket - const windowStart = now - ago * 1e3 - const windowEnd = windowStart + timeBucket * 1e3 - const bucketSpans = typesWithStartMilli.filter( - (span) => span.start_milli >= windowStart && span.start_milli < windowEnd - ) - const bucketSpansCount = types.reduce((acc, type) => { - acc[type] = bucketSpans.filter((span) => span.type === type).length - return acc - }, {} as Record) - data.push({ - ago: (i + 1) * timeBucket, - ...bucketSpansCount, - }) - } - data.forEach((d) => { - types.map((t) => { - d[`${t}Color`] = 'hsl(176, 70%, 50%)' - }) - }) - - const keys = types - const index = 'ago' - const legend = { - dataFrom: 'keys', - anchor: 'bottom-right', - direction: 'column', - justify: false, - translateX: 120, - translateY: 0, - itemsSpacing: 2, - itemWidth: 100, - itemHeight: 20, - itemDirection: 'left-to-right', - itemOpacity: 0.85, - symbolSize: 20, - effects: [ - { - on: 'hover', - style: { - itemOpacity: 1, - }, - }, - ], - } - const axisLeft = { - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legend: 'Count', - legendPosition: 'middle', - legendOffset: -40, - } - const axisBottom = { - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legend: 'Seconds Ago', - legendPosition: 'middle', - legendOffset: 32, - } - - return { - data, - keys, - index, - legend, - axisLeft, - axisBottom, - } -} - -function buildTree(objects: any[], id: string) { - const tree: any = {} - - const root = objects.find((o) => o.id === id) - tree.id = root.id - tree.parent = root.parent - tree.name = root.name - tree.durationMilli = root.duration_nano / 1e6 - - const children = objects.filter((o) => o.parent === id) - if (children.length > 0) { - tree.children = children.map((c) => buildTree(objects, c.id)) - } - - return tree -} - -export async function spanTreeMapData( - _parent: unknown, - { spanId }: { spanId: string } -) { - const rootSpan = await getSpan(spanId) - const descendantSpans = await getDescendantSpans(spanId) - return buildTree([...descendantSpans, rootSpan], spanId) -} diff --git a/packages/studio/api/services/config.ts b/packages/studio/api/services/config.ts deleted file mode 100644 index 501bacaceb9a..000000000000 --- a/packages/studio/api/services/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getApiConfig, getStudioConfig, getWebConfig } from '../lib/config' -import type { ApiConfig, StudioConfig, WebConfig } from '../types' - -export const apiConfig = async (_parent: unknown): Promise => { - return getApiConfig() -} - -export const webConfig = async (_parent: unknown): Promise => { - return getWebConfig() -} - -export const studioConfig = async (_parent: unknown): Promise => { - return getStudioConfig() -} diff --git a/packages/studio/api/services/explore/graphql.ts b/packages/studio/api/services/explore/graphql.ts deleted file mode 100644 index 93aa77a069ae..000000000000 --- a/packages/studio/api/services/explore/graphql.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { getDatabase } from '../../database' -import { restructureSpan } from '../span' - -export const graphqlCount = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT COUNT(1) FROM span WHERE - json_extract(attributes, \'$."graphql.operation.type"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.name"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.operationName"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.result"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.error"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.variables"\') IS NOT NULL - ;` - ) - const result = await stmt.get() - await stmt.finalize() - - return result['COUNT(1)'] -} - -export const graphqlSpans = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE - json_extract(attributes, \'$."graphql.operation.type"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.name"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.operationName"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.result"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.error"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.variables"\') IS NOT NULL - ;` - ) - const result = await stmt.all() - await stmt.finalize() - - return result.map((span: any) => { - return { id: span.id, span: restructureSpan(span) } - }) -} - -export const graphqlSpan = async ( - _parent: unknown, - { spanId }: { spanId: string } -) => { - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE - id = ? AND ( - json_extract(attributes, \'$."graphql.operation.type"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.name"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.operationName"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.result"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.error"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.variables"\') IS NOT NULL - );` - ) - const result = await stmt.get(spanId) - await stmt.finalize() - - return { id: result.id, span: restructureSpan(result) } -} diff --git a/packages/studio/api/services/explore/span.ts b/packages/studio/api/services/explore/span.ts deleted file mode 100644 index de4c922c3133..000000000000 --- a/packages/studio/api/services/explore/span.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { GraphQLError } from 'graphql' - -import { getDatabase } from '../../database' -import { extractFiltersFromString } from '../../lib/filtering' -import { generateSelectWithFilters } from '../../lib/sql' -import { restructureSpan } from '../span' - -export const span = async ( - _parent: unknown, - { spanId }: { spanId: string } -) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE id=?;') - const result = await stmt.get(spanId) - await stmt.finalize() - - return restructureSpan(result) -} - -export const spans = async ( - _parent: unknown, - { searchFilter }: { searchFilter?: string } -) => { - let filters: any = {} - try { - filters = searchFilter ? extractFiltersFromString(searchFilter) : {} - } catch (error) { - throw new GraphQLError(error as string) - } - - const db = await getDatabase() - const [sql, sqlFilters] = generateSelectWithFilters('*', 'span', filters) - - // To debug uncomment the following line - // console.log('spans', sql, { ...sqlFilters }) - - const result = await db.all(sql, { ...sqlFilters }) - return result.map(restructureSpan) -} diff --git a/packages/studio/api/services/explore/trace.ts b/packages/studio/api/services/explore/trace.ts deleted file mode 100644 index 6775eb89fcc9..000000000000 --- a/packages/studio/api/services/explore/trace.ts +++ /dev/null @@ -1,68 +0,0 @@ -// import { GraphQLError } from 'graphql' - -import { getDatabase } from '../../database' -// import { extractFiltersFromString } from '../../lib/filtering' -// import { generateSelectWithFilters } from '../../lib/sql' -import { restructureSpan } from '../span' - -export const traceCount = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare( - 'SELECT COUNT(DISINCT trace) AS trace_count FROM span;' - ) - const result = await stmt.get() - await stmt.finalize() - - return result['trace_count'] -} - -export const traces = async ( - _parent: unknown - // { searchFilter }: { searchFilter?: string } -) => { - // let filters: any = {} - // try { - // filters = searchFilter ? extractFiltersFromString(searchFilter) : {} - // } catch (error) { - // throw new GraphQLError(error as string) - // } - - // We cannot only select a subset of spans because we might miss spans which belong to returned traces - // TODO: We should first get a list of traceIds with the filters and then get all the spans for those traces. - // delete filters.limit - - const db = await getDatabase() - // const [sql, sqlFilters] = generateSelectWithFilters('*', 'span', filters) - - // To debug uncomment the following line - // console.log('traces', sql, { ...sqlFilters }) - - const result = await db.all('SELECT * FROM span;') - - const traceIds = [...new Set(result.map((span: any) => span.trace))] - const traces = [] - for (const traceId of traceIds) { - const traceSpans = result.filter((span: any) => span.trace === traceId) - traces.push({ - id: traceId, - spans: traceSpans.map((span: any) => restructureSpan(span)), - }) - } - - return traces -} - -export const trace = async ( - _parent: unknown, - { traceId }: { traceId: string } -) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE trace=?;') - const result = await stmt.all(traceId) - await stmt.finalize() - - return { - id: traceId, - spans: result.map((span: any) => restructureSpan(span)), - } -} diff --git a/packages/studio/api/services/graphqlSpans.ts b/packages/studio/api/services/graphqlSpans.ts deleted file mode 100644 index 6d6071926d7e..000000000000 --- a/packages/studio/api/services/graphqlSpans.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getDatabase } from '../database' - -export const graphQLSpans = async (_parent: any) => { - const db = await getDatabase() - - const stmt = await db.prepare(`SELECT * FROM graphql_spans;`) - - const result = await stmt.all() - await stmt.finalize() - - return result -} - -export const graphQLSpanCount = async (_parent: any) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT COUNT(1) FROM graphql_spans;') - const result = await stmt.get() - await stmt.finalize() - - return result['COUNT(1)'] -} diff --git a/packages/studio/api/services/lists.ts b/packages/studio/api/services/lists.ts deleted file mode 100644 index 01873e51d708..000000000000 --- a/packages/studio/api/services/lists.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getDatabase } from '../database' - -export async function seriesTypeBarList( - _parent: unknown, - { - timeLimit, - }: { - timeLimit: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare(` - SELECT - TYPE AS series_type, - CASE - WHEN instr(brief, '/*') > 0 THEN - substr(substr(brief, 1, instr(brief, '/*') - 1), 0, 255) - ELSE - brief - END AS series_name, - count(brief) AS quantity - FROM - span - WHERE - datetime (start_nano / 1000000000, 'unixepoch', 'utc') >= datetime ('now', ?, 'utc') - AND brief IS NOT NULL - GROUP BY - series_type, - series_name - ORDER BY - quantity DESC; - `) - - const result = await stmt.all(`-${timeLimit} seconds`) - await stmt.finalize() - - return result -} - -export async function modelsAccessedList( - _parent: unknown, - { - timeLimit, - }: { - timeLimit: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare(` - SELECT - model, - count(model) AS model_count - FROM - prisma_queries - WHERE - datetime (start_nano / 1000000000, 'unixepoch', 'utc') >= datetime ('now', ?, 'utc') - GROUP BY - model - ORDER BY - model_count DESC, model ASC; - `) - - const result = await stmt.all(`-${timeLimit} seconds`) - await stmt.finalize() - - return result -} diff --git a/packages/studio/api/services/mail.ts b/packages/studio/api/services/mail.ts deleted file mode 100644 index da85b81702ff..000000000000 --- a/packages/studio/api/services/mail.ts +++ /dev/null @@ -1,234 +0,0 @@ -import path from 'node:path' - -import fs from 'fs-extra' - -import { getPaths } from '@redwoodjs/project-config' - -import { getDatabase } from '../database' - -export async function mails() { - const db = await getDatabase() - const sql = ` - SELECT - id, - data, - envelope, - created_at - FROM - mail - ORDER BY - created_at DESC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: row.id, - data: JSON.parse(row.data), - envelope: JSON.parse(row.envelope), - created_at: row.created_at, - } - }) -} - -export async function getRenderedMail( - _parent: unknown, - { - componentId, - rendererId, - propsJSON, - }: { componentId: number; rendererId: number; propsJSON?: string } -) { - const db = await getDatabase() - try { - // Get the component and the component's template - const component = await db.get( - ` - SELECT - name, - props_template, - mail_template_id - FROM - mail_template_component - WHERE - id = ? - ; - `, - componentId - ) - if (!component) { - throw new Error(`Component not found`) - } - - // Get the template - const template = await db.get( - ` - SELECT - path - FROM - mail_template - WHERE - id = ? - ; - `, - component.mail_template_id - ) - if (!template) { - throw new Error(`Template not found`) - } - - // Get the renderer - const renderer = await db.get( - ` - SELECT - name - FROM - mail_renderer - WHERE - id = ? - ; - `, - rendererId - ) - if (!renderer) { - throw new Error(`Renderer not found`) - } - - // Import the template component - const templateComponentDistPath = - template.path - .replace(path.join('api', 'src'), path.join('api', 'dist')) - .substring(0, template.path.lastIndexOf('.') + 1) + '.js' - - const templateImportPath = templateComponentDistPath.replace( - '.js', - `.studio_${Date.now()}.js` - ) - fs.copyFileSync(templateComponentDistPath, templateImportPath) - const templateComponent = (await import(`file://${templateImportPath}`)) - .default - fs.removeSync(templateImportPath) - - const Component = - component.name.indexOf('default') !== -1 - ? templateComponent.default - : templateComponent[component.name] - - // Import the mailer - const mailerFilePath = path.join(getPaths().api.dist, 'lib', 'mailer.js') - const mailerImportPath = mailerFilePath.replace( - '.js', - `.studio_${Date.now()}.js` - ) - fs.copyFileSync(mailerFilePath, mailerImportPath) - const mailer = (await import(`file://${mailerImportPath}`)).mailer - fs.removeSync(mailerImportPath) - - // Render the component - const props = propsJSON ? JSON.parse(propsJSON) : {} - const renderResult = await mailer.renderers[renderer.name].render( - Component(props), - {} // TODO: We need a way for the user to specify the render options - ) - - return { - html: renderResult.html, - text: renderResult.text, - } - } catch (error) { - return { - error: (error as Error).message, - } - } -} - -export async function getMailRenderers() { - const db = await getDatabase() - const sql = ` - SELECT - id, - name, - is_default, - updated_at - FROM - mail_renderer - ORDER BY - name ASC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: parseInt(row.id), - name: row.name, - isDefault: row.is_default === 1, - updatedAt: row.updated_at, - } - }) -} - -export async function getMailTemplates() { - const db = await getDatabase() - const sql = ` - SELECT - id, - name, - path, - updated_at - FROM - mail_template - ORDER BY - name ASC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: parseInt(row.id), - name: row.name, - path: row.path, - updatedAt: row.updated_at, - } - }) -} - -export async function getMailComponents() { - const db = await getDatabase() - const sql = ` - SELECT - id, - mail_template_id, - name, - props_template, - updated_at - FROM - mail_template_component - ORDER BY - name ASC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: parseInt(row.id), - mailTemplateId: parseInt(row.mail_template_id), - name: row.name, - propsTemplate: row.props_template, - updatedAt: row.updated_at, - } - }) -} - -export async function truncate() { - const db = await getDatabase() - const sql = ` - DELETE FROM mail; - ` - try { - await db.exec(sql) - } catch (error) { - console.error(error) - return false - } - return true -} diff --git a/packages/studio/api/services/prismaSpans.ts b/packages/studio/api/services/prismaSpans.ts deleted file mode 100644 index 3d1cb4e64bd5..000000000000 --- a/packages/studio/api/services/prismaSpans.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getDatabase } from '../database' - -export const prismaQuerySpans = async ( - _parent: any, - { id }: { id: string } -) => { - const db = await getDatabase() - - const stmt = await db.prepare( - 'SELECT * FROM prisma_queries WHERE trace = ? OR parent_trace = ? ORDER BY start_nano asc;' - ) - - const result = await stmt.all(id, id) - await stmt.finalize() - - return result -} diff --git a/packages/studio/api/services/span.ts b/packages/studio/api/services/span.ts deleted file mode 100644 index a809a14018f4..000000000000 --- a/packages/studio/api/services/span.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { getDatabase } from '../database' - -export const restructureSpan = (span: any) => { - if (span == null) { - return null - } - return { - id: span.id, - trace: span.trace, - parent: span.parent, - name: span.name, - kind: span.kind, - statusCode: span.status_code, - statusMessage: span.status_message, - startNano: span.start_nano, - endNano: span.end_nano, - durationNano: span.duration_nano, - events: JSON.parse(span.events), - attributes: JSON.parse(span.attributes), - resources: JSON.parse(span.resources), - type: span.type, - brief: span.brief, - } -} - -export async function retypeSpan(_parent: unknown, { id }: { id: number }) { - const db = await getDatabase() - - let lastID = undefined - - // HTTP Requests - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'http', - brief = substr(json_extract(attributes, '$.\"http.method\"') || ' ' || json_extract(attributes, '$.\"http.url\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"http.method\"') IS NOT NULL AND - id = ?; - `, - id - ) - ).lastID - - // GraphQL Requests - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'graphql', - brief = substr(COALESCE(json_extract(attributes, '$.\"graphql.operation.name\"'), json_extract(attributes, '$.\"graphql.execute.operationName\"')), 0, 255) - WHERE - ( - json_extract(attributes, '$.\"graphql.operation.type\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.name\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.operationName\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.result\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.error\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.variables\"') IS NOT NULL - ) AND - id = ?; - `, - id - ) - ).lastID - - // SQL Statements - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'sql', - brief = substr(json_extract(attributes, '$.\"db.statement\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"db.statement\"') IS NOT NULL AND - id = ?; - `, - id - ) - ).lastID - - // Prisma Operations - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'prisma', - brief = substr(json_extract(attributes, '$.\"name\"'), 0, 255) - WHERE - name LIKE 'prisma:client:operation%' AND - id = ?; - `, - id - ) - ).lastID - - // Redwood Services - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'redwood-service', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/services/') - + LENGTH('/services/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:services%' AND - id = ?; - `, - id - ) - ).lastID - - // Redwood Functions - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'redwood-function', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/functions/') - + LENGTH('/functions/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:functions%' AND - id = ?; - `, - id - ) - ).lastID - - return lastID === undefined -} - -export async function retypeSpans(_parent: unknown) { - const db = await getDatabase() - - // HTTP Requests - await db.run(` - UPDATE span SET - type = 'http', - brief = substr(json_extract(attributes, '$.\"http.method\"') || ' ' || json_extract(attributes, '$.\"http.url\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"http.method\"') IS NOT NULL; - `) - - // GraphQL Requests - await db.run(` - UPDATE span SET - type = 'graphql', - brief = substr(COALESCE(json_extract(attributes, '$.\"graphql.operation.name\"'), json_extract(attributes, '$.\"graphql.execute.operationName\"')), 0, 255) - WHERE ( - json_extract(attributes, '$.\"graphql.operation.type\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.name\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.operationName\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.result\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.error\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.variables\"') IS NOT NULL - ); - `) - - // SQL Statements - await db.run(` - UPDATE span SET - type = 'sql', - brief = substr(json_extract(attributes, '$.\"db.statement\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"db.statement\"') IS NOT NULL; - `) - - // Prisma Operations - await db.run(` - UPDATE span SET - type = 'prisma', - brief = substr(json_extract(attributes, '$.\"name\"'), 0, 255) - WHERE - name LIKE 'prisma:client:operation%'; - `) - - // Redwood Services - await db.run(` - UPDATE span SET - type = 'redwood-service', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/services/') - + LENGTH('/services/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:services%'; - `) - - // Redwood Functions - await db.run(` - UPDATE span SET - type = 'redwood-function', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/functions/') - + LENGTH('/functions/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:functions%'; - `) - - return true -} - -export async function truncateSpans(_parent: unknown) { - const db = await getDatabase() - await db.exec(` - DELETE FROM span - `) - return true -} diff --git a/packages/studio/api/services/sqlSpans.ts b/packages/studio/api/services/sqlSpans.ts deleted file mode 100644 index 44602c9f832e..000000000000 --- a/packages/studio/api/services/sqlSpans.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getDatabase } from '../database' - -export const sqlSpans = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM sql_spans;') - const spans = await stmt.all() - await stmt.finalize() - - return spans.map((span) => restructureSpan(span)) -} - -export const sqlCount = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT COUNT(1) FROM sql_spans;') - const result = await stmt.get() - await stmt.finalize() - - return result['COUNT(1)'] -} - -const restructureSpan = (span: any) => { - const restructuredSpan = { - id: span.id, - trace: span.trace, - parent: span.parent, - name: span.name, - kind: span.kind, - statusCode: span.status_code, - statusMessage: span.status_message, - startNano: span.start_nano, - endNano: span.end_nano, - durationNano: span.duration_nano, - events: span.events, - attributes: span.attributes, - resources: span.resources, - } - return restructuredSpan -} diff --git a/packages/studio/api/services/util.ts b/packages/studio/api/services/util.ts deleted file mode 100644 index 59845d70b31e..000000000000 --- a/packages/studio/api/services/util.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getDatabase } from '../database' - -import { restructureSpan } from './span' - -async function getAncestorSpanIDs(spanId: string): Promise { - // Note: generated with GPT because I am not a SQL expert - const query = ` - WITH RECURSIVE span_hierarchy AS ( - SELECT id, parent - FROM span - WHERE id = ? - UNION ALL - SELECT s.id, s.parent - FROM span s - JOIN span_hierarchy sh ON s.id = sh.parent - ) - SELECT id, parent - FROM span_hierarchy; - ` - - const db = await getDatabase() - const stmt = await db.prepare(query, spanId) - const result = await stmt.all() - await stmt.finalize() - - // Remove the span itself from the result - return result.map((row) => row.id).filter((id) => id !== spanId) -} - -export async function getAncestorSpans(spanId: string): Promise { - const ancestorSpanIDs = await getAncestorSpanIDs(spanId) - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE id IN (${ancestorSpanIDs - .map(() => '?') - .join(', ')});` - ) - const result = await stmt.all(...ancestorSpanIDs) - await stmt.finalize() - return result.map((span) => restructureSpan(span)) -} - -async function getDescendantSpanIDs(spanId: string): Promise { - // Note: generated with GPT because I am not a SQL expert - const query = ` - WITH RECURSIVE span_hierarchy AS ( - SELECT id, parent - FROM span - WHERE id = ? - UNION ALL - SELECT s.id, s.parent - FROM span s - JOIN span_hierarchy sh ON s.parent = sh.id - ) - SELECT id, parent - FROM span_hierarchy; - ` - - const db = await getDatabase() - const stmt = await db.prepare(query, spanId) - const result = await stmt.all() - await stmt.finalize() - - // Remove the span itself from the result - return result.map((row) => row.id).filter((id) => id !== spanId) -} - -export async function getDescendantSpans(spanId: string): Promise { - const descendantSpanIDs = await getDescendantSpanIDs(spanId) - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE id IN (${descendantSpanIDs - .map(() => '?') - .join(', ')});` - ) - const result = await stmt.all(...descendantSpanIDs) - await stmt.finalize() - return result.map((span) => restructureSpan(span)) -} - -export async function getChildSpans(spanId: string): Promise { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE parent=?;') - const result = await stmt.all(spanId) - await stmt.finalize() - return result -} - -export async function getSpan(spanId: string): Promise { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE id=?;') - const result = await stmt.get(spanId) - await stmt.finalize() - return result -} diff --git a/packages/studio/api/types.ts b/packages/studio/api/types.ts deleted file mode 100644 index 9a5536374d60..000000000000 --- a/packages/studio/api/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -export interface ResourceSpan { - scopeSpans: ScopeSpan[] - resource: { - attributes: RawAttribute[] - } -} - -export interface ScopeSpan { - scope: { - name: string - } - spans: RawSpan[] -} - -export interface RawSpan { - traceId: string - spanId: string - parentSpanId: string - name: string - kind: number - startTimeUnixNano: string - endTimeUnixNano: string - attributes?: RawAttribute[] - events?: RawEvent[] - status?: { - code?: number - message?: string - } -} - -export interface RawAttribute { - key: string - value: { - stringValue?: string - intValue?: string - boolValue?: boolean - value?: any - } -} - -export interface RawEvent { - timeUnixNano: string - name: string - attributes: RawAttribute[] -} - -export interface RestructuredAttributes { - [key: string]: string | number | boolean | null -} - -export interface RestructuredEvent { - name: string - time: string - attributes: RestructuredAttributes -} - -export interface RestructuredSpan { - trace: string - id: string - parent: string - name: string - kind: number - statusCode?: number - statusMessage?: string - startNano: string - endNano: string - durationNano: string - events?: RestructuredEvent[] - attributes?: RestructuredAttributes - resourceAttributes?: RestructuredAttributes -} - -export interface ApiConfig { - title: string - name?: string - host: string - port: number - path: string - // target: TargetEnum.NODE - schemaPath: string - serverConfig: string - debugPort?: number -} - -export interface WebConfig { - title: string - name?: string - host: string - port: number - path: string - // target: TargetEnum.BROWSER - // bundler: BundlerEnum - includeEnvironmentVariables: string[] - /** - * Specify the URL to your api-server. - * This can be an absolute path proxied on the current domain (`/.netlify/functions`), - * or a fully qualified URL (`https://api.example.org:8911/functions`). - * - * Note: This should not include the path to the GraphQL Server. - **/ - apiUrl: string - /** - * Optional: FQDN or absolute path to the GraphQL serverless function, without the trailing slash. - * This will override the apiUrl configuration just for the graphql function - * Example: `./redwood/functions/graphql` or `https://api.redwoodjs.com/graphql` - */ - apiGraphQLUrl?: string - - fastRefresh: boolean - a11y: boolean - sourceMap: boolean - graphqlEndpoint?: string -} - -export interface GraphiQLStudioConfig { - endpoint?: string - authImpersonation?: AuthImpersonationConfig -} - -export interface AuthImpersonationConfig { - authProvider?: string - jwtSecret?: string - userId?: string - email?: string - roles?: string[] -} - -export interface StudioConfig { - basePort: number - inMemory: boolean - graphiql?: GraphiQLStudioConfig -} - -export type SpanType = - | 'http' - | 'sql' - | 'graphql' - | 'prisma' - | 'redwood-service' - | null diff --git a/packages/studio/build.mjs b/packages/studio/build.mjs deleted file mode 100644 index 3061224c5289..000000000000 --- a/packages/studio/build.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import fs from 'node:fs' - -import * as esbuild from 'esbuild' -import fg from 'fast-glob' - -// Get source files -const sourceFiles = fg.sync(['./api/**/*.ts']) - -// Build general source files -const result = await esbuild.build({ - entryPoints: sourceFiles, - outdir: 'dist/api', - - format: 'cjs', - platform: 'node', - target: ['node20'], - - logLevel: 'info', - - // For visualizing dist. - // See https://esbuild.github.io/api/#metafile and https://esbuild.github.io/analyze/. - metafile: true, -}) - -fs.writeFileSync('meta.json', JSON.stringify(result.metafile, null, 2)) diff --git a/packages/studio/package.json b/packages/studio/package.json deleted file mode 100644 index aec9f3bff4ff..000000000000 --- a/packages/studio/package.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "name": "@redwoodjs/studio", - "version": "6.0.7", - "description": "Redwood's development studio", - "repository": { - "type": "git", - "url": "https://github.com/redwoodjs/redwood.git", - "directory": "packages/studio" - }, - "license": "MIT", - "main": "dist/api/index.js", - "files": [ - "dist" - ], - "scripts": { - "build": "yarn build:api && yarn build:web", - "build:api": "yarn node ./build.mjs && yarn build:types", - "build:pack": "yarn pack -o redwoodjs-studio.tgz", - "build:types": "tsc --build --verbose", - "build:watch": "nodemon --watch api --ext \"js,ts,tsx\" --ignore dist/api --exec \"yarn build\"", - "build:web": "cd web && vite build", - "prepublishOnly": "NODE_ENV=production yarn build" - }, - "dependencies": { - "@babel/runtime-corejs3": "7.23.6", - "@fastify/http-proxy": "9.3.0", - "@fastify/static": "6.12.0", - "@fastify/url-data": "5.4.0", - "@redwoodjs/internal": "6.0.7", - "@redwoodjs/project-config": "6.0.7", - "@swc/cli": "0.1.62", - "@swc/core": "1.3.60", - "ansi-colors": "4.1.3", - "chokidar": "3.5.3", - "core-js": "3.34.0", - "dotenv": "16.3.1", - "fast-json-parse": "1.0.3", - "fastify": "4.24.3", - "fastify-raw-body": "4.3.0", - "graphql": "16.8.1", - "graphql-scalars": "1.22.4", - "graphql-yoga": "5.1.0", - "jsonwebtoken": "9.0.2", - "lodash": "4.17.21", - "mailparser": "3.6.5", - "pretty-bytes": "5.6.0", - "qs": "6.11.2", - "smtp-server": "3.13.0", - "split2": "4.2.0", - "sqlite": "5.1.1", - "sqlite3": "5.1.6", - "uuid": "9.0.1", - "yargs": "17.7.2" - }, - "devDependencies": { - "@apollo/client": "3.8.8", - "@babel/cli": "7.23.4", - "@babel/core": "^7.22.20", - "@graphiql/plugin-explorer": "0.1.22", - "@graphiql/toolkit": "0.8.4", - "@headlessui/react": "1.7.15", - "@heroicons/react": "2.0.18", - "@nivo/bar": "0.83.0", - "@nivo/core": "0.83.0", - "@nivo/tooltip": "0.83.0", - "@nivo/treemap": "0.83.0", - "@tailwindcss/forms": "0.5.3", - "@tremor/react": "3.4.1", - "@types/aws-lambda": "8.10.126", - "@types/jsonwebtoken": "9.0.5", - "@types/lodash": "4.14.201", - "@types/mailparser": "3", - "@types/qs": "6.9.11", - "@types/react": "18.2.37", - "@types/react-dom": "18.2.15", - "@types/react-grid-layout": "1", - "@types/smtp-server": "3", - "@types/split2": "4.2.3", - "@types/uuid": "9.0.7", - "@types/yargs": "17.0.32", - "@vitejs/plugin-react": "4.2.1", - "autoprefixer": "10.4.16", - "aws-lambda": "1.0.7", - "buffer": "6.0.3", - "graphiql": "3.0.10", - "jest": "29.7.0", - "json-bigint-patch": "0.0.8", - "postcss": "8.4.31", - "pretty-ms": "7.0.1", - "react": "0.0.0-experimental-e5205658f-20230913", - "react-dom": "0.0.0-experimental-e5205658f-20230913", - "react-error-boundary": "4.0.11", - "react-grid-layout": "1.3.4", - "react-router-dom": "6.8.1", - "react-split-pane": "0.1.92", - "react-toastify": "9.1.3", - "tailwindcss": "3.3.5", - "typescript": "5.3.3", - "use-url-search-params": "2.5.1", - "vite": "4.5.1" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.60", - "@swc/core-darwin-x64": "1.3.60", - "@swc/core-linux-arm-gnueabihf": "1.3.60", - "@swc/core-linux-arm64-gnu": "1.3.60", - "@swc/core-linux-arm64-musl": "1.3.60", - "@swc/core-linux-x64-gnu": "1.3.60", - "@swc/core-linux-x64-musl": "1.3.60", - "@swc/core-win32-arm64-msvc": "1.3.60", - "@swc/core-win32-ia32-msvc": "1.3.60", - "@swc/core-win32-x64-msvc": "1.3.60" - }, - "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json deleted file mode 100644 index 6872f71d5add..000000000000 --- a/packages/studio/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.compilerOption.json", - "compilerOptions": { - "baseUrl": "./", - "rootDir": "api", - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "outDir": "dist/api", - }, - "include": ["api/**/*"], - "references": [{ "path": "../internal" }] -} diff --git a/packages/studio/web/.gitignore b/packages/studio/web/.gitignore deleted file mode 100644 index a547bf36d8d1..000000000000 --- a/packages/studio/web/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/packages/studio/web/index.html b/packages/studio/web/index.html deleted file mode 100644 index 4deac417d4d3..000000000000 --- a/packages/studio/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - RedwoodJS Studio - - - - -
- - - diff --git a/packages/studio/web/postcss.config.cjs b/packages/studio/web/postcss.config.cjs deleted file mode 100644 index 33ad091d26d8..000000000000 --- a/packages/studio/web/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/packages/studio/web/src/BarLists/ModelsAccessedList.tsx b/packages/studio/web/src/BarLists/ModelsAccessedList.tsx deleted file mode 100644 index 156437fdc108..000000000000 --- a/packages/studio/web/src/BarLists/ModelsAccessedList.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import { - BarList, - Card, - // Color, - Select, - SelectItem, - Title, - Bold, - Flex, - Text, -} from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_MODELS_ACCESSED_LIST = gql` - query QUERY_GET_MODELS_ACCESSED_LIST($timeLimit: Int!) { - modelsAccessedList(timeLimit: $timeLimit) { - model - model_count - } - } -` - -export default function ModelsAccessedList({ - name = 'Models Accessed List', - timeLimit, -}: { - name: string - timeLimit: number -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_MODELS_ACCESSED_LIST, { - variables: { - timeLimit: refreshSecondsAgo, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const agos = [30, 60, 120, 240, 480] - - const barListData = data.modelsAccessedList.map((item: any) => ({ - name: item.model, - value: item.model_count, - href: '', - icon: '', - })) - - return ( - - - {name} - - - - - Model - - - Count - - - - - ) -} diff --git a/packages/studio/web/src/BarLists/SeriesTypeBarList.tsx b/packages/studio/web/src/BarLists/SeriesTypeBarList.tsx deleted file mode 100644 index be728dacae0e..000000000000 --- a/packages/studio/web/src/BarLists/SeriesTypeBarList.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import { - BarList, - Card, - // Color, - Select, - SelectItem, - Title, - Bold, - Flex, - Text, -} from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SERIES_TYPE_BAR_LIST = gql` - query QUERY_GET_SERIES_TYPE_BAR_LIST($timeLimit: Int!) { - seriesTypeBarList(timeLimit: $timeLimit) { - quantity - series_name - series_type - } - } -` - -export default function SeriesTypeBarList({ - name = 'Bar List', - timeLimit, -}: { - name: string - timeLimit: number -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_SERIES_TYPE_BAR_LIST, { - variables: { - timeLimit: refreshSecondsAgo, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const agos = [30, 60, 120, 240, 480] - - const barListData = data.seriesTypeBarList.map((item: any) => ({ - name: item.series_name, - value: item.quantity, - href: '', - icon: function () { - switch (item.series_type) { - case 'graphql': - return ( - - - - ) - case 'prisma': - return ( - - - - ) - case 'sql': - return ( - - - - - - - ) - case 'http': - return ( - - - - ) - case 'generic': - return ( - - - - ) - case 'redwood-service': - return ( - - - - - ) - case 'redwood-function': - return ( - - - - - ) - default: - return ( - - - - ) - } - }, - })) - - return ( - - - {name} - - - - - Type - - - Count - - - - - ) -} diff --git a/packages/studio/web/src/Charts/SpanTreeMapChart.tsx b/packages/studio/web/src/Charts/SpanTreeMapChart.tsx deleted file mode 100644 index 1de3b3808373..000000000000 --- a/packages/studio/web/src/Charts/SpanTreeMapChart.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' - -import { ResponsiveTreeMap } from '@nivo/treemap' -import { useNavigate } from 'react-router-dom' - -export default function SpanTreeMapChart({ data }: { data: any }) { - const navigate = useNavigate() - - return ( - { - event.preventDefault() - if (event.button === 0) { - // Move to span view - if (event.ctrlKey) { - navigate(`/explorer/span/${node.data.id}`) - return - } - // Go up to parent span - if (event.shiftKey && node.data.parent != null) { - navigate(`/explorer/map/${node.data.parent}`) - return - } - // Go down to child span - navigate(`/explorer/map/${node.data.id}`) - return - } - }} - /> - ) -} diff --git a/packages/studio/web/src/Charts/SpanTypeBarChart.tsx b/packages/studio/web/src/Charts/SpanTypeBarChart.tsx deleted file mode 100644 index 23adcc1bb339..000000000000 --- a/packages/studio/web/src/Charts/SpanTypeBarChart.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react' - -import { useQuery, gql } from '@apollo/client' -import { ResponsiveBar } from '@nivo/bar' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SPAN_TYPE_TIMELINE = gql` - query GetSpanTypeTimeline($timeLimit: Int!, $timeBucket: Int!) { - spanTypeTimeline(timeLimit: $timeLimit, timeBucket: $timeBucket) { - data - keys - index - legend - axisLeft - axisBottom - } - } -` - -export default function SpanTypeBarChart({ - timeLimit, - timeBucket, -}: { - timeLimit: number - timeBucket: number -}) { - const { loading, error, data } = useQuery(QUERY_GET_SPAN_TYPE_TIMELINE, { - variables: { timeLimit, timeBucket }, - pollInterval: timeBucket * 1_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - return ( -
- -
- ) -} diff --git a/packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx b/packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx deleted file mode 100644 index c3e90bd57795..000000000000 --- a/packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import type { Color } from '@tremor/react' -import { Card, Select, SelectItem, Flex, BarChart, Title } from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SPAN_TYPE_TIMESERIES = gql` - query QUERY_GET_SPAN_TYPE_TIMESERIES( - $timeLimit: Int! - $showGeneric: Boolean! - $showGraphql: Boolean! - $showHttp: Boolean! - $showPrisma: Boolean! - $showRedwoodFunction: Boolean! - $showRedwoodService: Boolean! - $showSql: Boolean! - ) { - spanTypeTimeSeriesData(timeLimit: $timeLimit) { - generic @include(if: $showGeneric) - graphql @include(if: $showGraphql) - http @include(if: $showHttp) - prisma @include(if: $showPrisma) - redwoodfunction @include(if: $showRedwoodFunction) - redwoodservice @include(if: $showRedwoodService) - sql @include(if: $showSql) - ts - } - } -` - -export default function SpanTypeTimeSeriesBarChart({ - name = 'Time Series Bar Chart', - timeLimit, - showGeneric = false, - showGraphql = false, - showHttp = false, - showPrisma = false, - showRedwoodFunction = false, - showRedwoodService = false, - showSql = false, -}: { - name: string - timeLimit: number - showGeneric?: boolean - showGraphql?: boolean - showHttp?: boolean - showPrisma?: boolean - showRedwoodFunction?: boolean - showRedwoodService?: boolean - showSql?: boolean -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_SPAN_TYPE_TIMESERIES, { - variables: { - timeLimit: refreshSecondsAgo, - showGeneric, - showGraphql, - showHttp, - showPrisma, - showRedwoodFunction, - showRedwoodService, - showSql, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const categories = [] - const colors = [] as Color[] - - const agos = [30, 60, 120, 240, 480] - - if (showGeneric) { - categories.push('generic') - colors.push('amber') - } - if (showGraphql) { - categories.push('graphql') - colors.push('pink') - } - if (showHttp) { - categories.push('http') - colors.push('emerald') - } - if (showPrisma) { - categories.push('prisma') - colors.push('lime') - } - if (showRedwoodFunction) { - categories.push('redwoodfunction') - colors.push('blue') - } - if (showRedwoodService) { - categories.push('redwoodservice') - colors.push('rose') - } - if (showSql) { - categories.push('sql') - colors.push('purple') - } - - return ( - - - {name} - - - - - ) -} diff --git a/packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx b/packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx deleted file mode 100644 index ca101ad8ad1d..000000000000 --- a/packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import type { Color } from '@tremor/react' -import { Card, Select, SelectItem, Flex, LineChart, Title } from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SPAN_TYPE_TIMESERIES = gql` - query QUERY_GET_SPAN_TYPE_TIMESERIES( - $timeLimit: Int! - $showGeneric: Boolean! - $showGraphql: Boolean! - $showHttp: Boolean! - $showPrisma: Boolean! - $showRedwoodFunction: Boolean! - $showRedwoodService: Boolean! - $showSql: Boolean! - ) { - spanTypeTimeSeriesData(timeLimit: $timeLimit) { - generic @include(if: $showGeneric) - graphql @include(if: $showGraphql) - http @include(if: $showHttp) - prisma @include(if: $showPrisma) - redwoodfunction @include(if: $showRedwoodFunction) - redwoodservice @include(if: $showRedwoodService) - sql @include(if: $showSql) - ts - } - } -` - -export default function SpanTypeTimeSeriesChart({ - name = 'Time Series Chart', - timeLimit, - showGeneric = false, - showGraphql = false, - showHttp = false, - showPrisma = false, - showRedwoodFunction = false, - showRedwoodService = false, - showSql = false, -}: { - name: string - timeLimit: number - showGeneric?: boolean - showGraphql?: boolean - showHttp?: boolean - showPrisma?: boolean - showRedwoodFunction?: boolean - showRedwoodService?: boolean - showSql?: boolean -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_SPAN_TYPE_TIMESERIES, { - variables: { - timeLimit: refreshSecondsAgo, - showGeneric, - showGraphql, - showHttp, - showPrisma, - showRedwoodFunction, - showRedwoodService, - showSql, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const categories = [] - const colors = [] as Color[] - - if (showGeneric) { - categories.push('generic') - colors.push('amber') - } - if (showGraphql) { - categories.push('graphql') - colors.push('pink') - } - if (showHttp) { - categories.push('http') - colors.push('emerald') - } - if (showPrisma) { - categories.push('prisma') - colors.push('lime') - } - if (showRedwoodFunction) { - categories.push('redwoodfunction') - colors.push('blue') - } - if (showRedwoodService) { - categories.push('redwoodservice') - colors.push('rose') - } - if (showSql) { - categories.push('sql') - colors.push('purple') - } - - const agos = [30, 60, 120, 240, 480] - - const dataFormatter = (number: number) => - `${Intl.NumberFormat('us').format(number).toString()} ms` - - return ( - - - {name} - - - - - ) -} diff --git a/packages/studio/web/src/Components/CountCard.tsx b/packages/studio/web/src/Components/CountCard.tsx deleted file mode 100644 index 2672c4970d2a..000000000000 --- a/packages/studio/web/src/Components/CountCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' - -import { EllipsisHorizontalIcon } from '@heroicons/react/24/outline' -import { NavLink } from 'react-router-dom' - -import LoadingSpinner from './LoadingSpinner' - -function CountCard({ - title, - icon: Icon, - colouring, - link, - loading, - value, - error, -}: { - title: string - icon: React.ForwardRefExoticComponent> - colouring: string - link: string - loading: boolean - value: any - error: any -}) { - return ( -
-
-
-
-

- {title} -

-
-
-

- {value ? ( - value - ) : error ? ( - 'error' - ) : loading ? ( - - ) : ( -

-
-
- - {' '} - View all - {title} stats - -
-
-
-
- ) -} - -export default CountCard diff --git a/packages/studio/web/src/Components/Event/ErrorEventLink.tsx b/packages/studio/web/src/Components/Event/ErrorEventLink.tsx deleted file mode 100644 index a2fe466e7d4c..000000000000 --- a/packages/studio/web/src/Components/Event/ErrorEventLink.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' - -import { LinkIcon } from '@heroicons/react/20/solid' -import { Bold, Card, Flex, Text } from '@tremor/react' -import { Link } from 'react-router-dom' - -export default function ErrorEventLink({ - event, - spanId, -}: { - event: any - spanId: string -}) { - const attributeCount = Object.keys(event.attributes || {}).length - return ( - - -
- - {event.name}( - {attributeCount === 1 - ? '1 attribute' - : `${attributeCount} attributes`} - ) - - - {new Date(Number(event.time / BigInt(1e6))).toISOString()} - -
- - - -
-
- ) -} diff --git a/packages/studio/web/src/Components/Event/EventModal.tsx b/packages/studio/web/src/Components/Event/EventModal.tsx deleted file mode 100644 index 24fcddc51c05..000000000000 --- a/packages/studio/web/src/Components/Event/EventModal.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { Fragment, useRef, useState } from 'react' - -import { Transition, Dialog } from '@headlessui/react' -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' -import { - Bold, - Button, - Card, - Flex, - List, - ListItem, - Text, - Title, -} from '@tremor/react' - -import { displayTextOrJSON } from '../../util/ui' - -function DetailsModel({ open, setOpen, event }: any) { - const cancelButtonRef = useRef(null) - const data = Object.entries(event.attributes ?? {}).map(([name, value]) => ({ - name, - value, - })) - return ( - - - -
- - -
-
- - - - Event Information - - - Name - {event.name} - - - Time - {new Date(Number(event.time / BigInt(1e6))).toISOString()} - - - - Attributes - - - {data?.map((d) => ( - - {d.name} - {displayTextOrJSON(d.value)} - - ))} - - - - - - - -
-
-
-
- ) -} - -export default function EventModal({ event }: { event: any }) { - const [open, setOpen] = useState(false) - - const attributeCount = Object.keys(event.attributes || {}).length - return ( - - -
- - {event.name}( - {attributeCount === 1 - ? '1 attribute' - : `${attributeCount} attributes`} - ) - - - {new Date(Number(event.time / BigInt(1e6))).toISOString()} - -
- setOpen(true)} - /> - -
-
- ) -} diff --git a/packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx b/packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx deleted file mode 100644 index ec6cc8e4eedf..000000000000 --- a/packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' - -import { BarsArrowUpIcon } from '@heroicons/react/20/solid' -import { Card, Flex, Title, Italic, Text } from '@tremor/react' - -import FeatureLink from './FeatureLink' - -export default function AncestorFeatureList({ features }: { features: any[] }) { - return ( - - - - - Ancestor Features - - {features.length === 0 ? ( - - None found... - - ) : ( - <> - {features.map((feature: any) => ( - - ))} - - )} - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/CustomIcons.tsx b/packages/studio/web/src/Components/Feature/CustomIcons.tsx deleted file mode 100644 index ed9d719230ad..000000000000 --- a/packages/studio/web/src/Components/Feature/CustomIcons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' - -export default function CustomIcons({ customs }: { customs: any[] }) { - if (customs.length === 0) { - return <> - } - - return ( - <> - {customs.map((custom, index) => { - const Icon = custom.icon - return ( -
- 0 ? 'md:ml-2 sm:ml-0' : '' - }`} - aria-hidden="true" - /> - {custom.value} -
- ) - })} - - ) -} diff --git a/packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx b/packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx deleted file mode 100644 index 6ef24813cc29..000000000000 --- a/packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' - -import { BarsArrowDownIcon } from '@heroicons/react/20/solid' -import { Card, Flex, Title, Italic, Text } from '@tremor/react' - -import FeatureLink from './FeatureLink' - -export default function DescendantFeatureList({ - features, -}: { - features: any[] -}) { - return ( - - - - - Descendant Features - - {features.length === 0 ? ( - - None found... - - ) : ( - <> - {features.map((feature: any) => ( - - ))} - - )} - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/FeatureLink.tsx b/packages/studio/web/src/Components/Feature/FeatureLink.tsx deleted file mode 100644 index 59db92aed7e3..000000000000 --- a/packages/studio/web/src/Components/Feature/FeatureLink.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' - -import { LinkIcon } from '@heroicons/react/20/solid' -import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline' -import { Card, Flex } from '@tremor/react' -import { Link } from 'react-router-dom' - -import { featureDisplayNames, featureIcons, featureColours } from './features' - -export default function FeatureLink({ feature }: { feature: any }) { - const Icon = featureIcons.get(feature.type) || QuestionMarkCircleIcon - return ( - - - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/TraceFeatureList.tsx b/packages/studio/web/src/Components/Feature/TraceFeatureList.tsx deleted file mode 100644 index 4cb73c7e2758..000000000000 --- a/packages/studio/web/src/Components/Feature/TraceFeatureList.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' - -import { Bars3Icon } from '@heroicons/react/20/solid' -import { Card, Flex, Italic, Title, Text } from '@tremor/react' - -import FeatureLink from './FeatureLink' - -export default function TraceFeatureList({ features }: { features: any[] }) { - return ( - - - - - Trace Features - - {features.length === 0 ? ( - - None found... - - ) : ( - <> - {features.map((feature: any) => ( - - ))} - - )} - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/features.ts b/packages/studio/web/src/Components/Feature/features.ts deleted file mode 100644 index 1b36dbb8f02a..000000000000 --- a/packages/studio/web/src/Components/Feature/features.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - CircleStackIcon, - CodeBracketIcon, - ShareIcon, -} from '@heroicons/react/24/outline' - -export const featureDisplayNames = new Map([ - ['sql', 'SQL'], - ['http', 'HTTP'], - ['prisma', 'Prisma'], - ['redwood-service', 'RedwoodJS Service'], - ['redwood-function', 'RedwoodJS Function'], - ['graphql', 'GraphQL'], -]) - -export const featureIcons = new Map([ - ['sql', CircleStackIcon], - ['http', CodeBracketIcon], - ['prisma', CodeBracketIcon], - ['redwood-service', CodeBracketIcon], - ['redwood-function', CodeBracketIcon], - ['graphql', ShareIcon], -]) - -export const featureColours = new Map([ - ['sql', 'text-cyan-500'], - ['http', 'text-black'], - ['prisma', 'text-[#5a67d8]'], - ['redwood-service', 'text-[#370617]'], - ['redwood-function', 'text-[#370617]'], - ['graphql', 'text-fuchsia-500'], -]) diff --git a/packages/studio/web/src/Components/LoadingSpinner.tsx b/packages/studio/web/src/Components/LoadingSpinner.tsx deleted file mode 100644 index 847c687762d6..000000000000 --- a/packages/studio/web/src/Components/LoadingSpinner.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -import { Button } from '@tremor/react' - -function LoadingSpinner({ colour }: { colour?: string }) { - const _fill = colour ? `fill-[${colour}]` : 'fill-sinopia' - return ( -
-
- ) -} - -export default LoadingSpinner diff --git a/packages/studio/web/src/Components/Mail/MailRenderer.tsx b/packages/studio/web/src/Components/Mail/MailRenderer.tsx deleted file mode 100644 index ad41b193e8d5..000000000000 --- a/packages/studio/web/src/Components/Mail/MailRenderer.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' - -import { - ComputerDesktopIcon, - DevicePhoneMobileIcon, - DeviceTabletIcon, -} from '@heroicons/react/20/solid' -import { - DocumentChartBarIcon, - DocumentTextIcon, - CodeBracketIcon, - ArrowPathIcon, -} from '@heroicons/react/24/outline' -import { - Text, - Card, - Button, - Flex, - Select, - SelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Italic, -} from '@tremor/react' - -import ErrorPanel from '../Panels/ErrorPanel' - -// Note: the "+2" is to account for the borders -const PREVIEW_DIMENSIONS = [ - { - label: 'Desktop', - width: null, - height: null, - icon: ComputerDesktopIcon, - }, - { - label: 'iPhone 12 Pro', - width: 390 + 2, - height: 844 + 2, - icon: DevicePhoneMobileIcon, - }, - { - label: 'Pixel 5', - width: 393 + 2, - height: 851 + 2, - icon: DevicePhoneMobileIcon, - }, - { - label: 'iPad Air', - width: 820 + 2, - height: 1180 + 2, - icon: DeviceTabletIcon, - }, - { - label: 'Surface Pro 7', - width: 912 + 2, - height: 1368 + 2, - icon: DeviceTabletIcon, - }, -] - -function MailPreview({ - html, - text, - error, - additionalTabHeaders, - additionalTabPanels, -}: { - html: string | null - text: string | null - error?: any - additionalTabHeaders?: React.ReactElement - additionalTabPanels?: React.ReactElement[] -}) { - const iframeRef = useRef(null) - - const [selectedTabIndex, setSelectedTabIndex] = useState(0) - const [selectedPreviewDimension, setSelectedPreviewDimension] = useState( - PREVIEW_DIMENSIONS[0] - ) - const [isPreviewHorizontal, setIsPreviewHorizontal] = useState(false) - const [iframeWidth, setIframeWidth] = useState('100%') - const [iframeHeight, setIframeHeight] = useState('100%') - const [iframeContentHeight, setIframeContentHeight] = useState(0) - - useEffect(() => { - if (selectedPreviewDimension.label === 'Desktop') { - setIframeWidth('100%') - setIframeHeight(`${iframeContentHeight}px`) - } else { - if (isPreviewHorizontal) { - setIframeWidth(`${selectedPreviewDimension.height}px`) - setIframeHeight(`${selectedPreviewDimension.width}px`) - } else { - setIframeWidth(`${selectedPreviewDimension.width}px`) - setIframeHeight(`${selectedPreviewDimension.height}px`) - } - } - }, [selectedPreviewDimension, isPreviewHorizontal, iframeContentHeight]) - - // Note: I just couldn't get the iframe to resize properly on its own - // so I'm just going to poll and update the height if it changes - setInterval(() => { - setIframeContentHeight( - (iframeRef.current?.contentWindow?.document.body?.scrollHeight ?? 0) + 82 - ) - }, 250) - - const preprocessedHTML = - html?.replace( - '', - "" - ) ?? '' - - if (error) { - return ( -
- -
- ) - } - - return ( - - - - - HTML - Text - Raw HTML - {additionalTabHeaders ?? <>} - -
- - -
-
- - - {preprocessedHTML ? ( -