diff --git a/.changeset/config.json b/.changeset/config.json index 5e8716336..20abe0764 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,6 +14,8 @@ "ignore": [ "webapp", "emails", + "proxy", + "yalt", "@trigger.dev/database" ], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { diff --git a/.changeset/new-panthers-do.md b/.changeset/new-panthers-do.md new file mode 100644 index 000000000..5cb40d4eb --- /dev/null +++ b/.changeset/new-panthers-do.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/yalt": patch +"@trigger.dev/cli": patch +--- + +Add support for the built-in Trigger.dev tunnel (Yalt) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 2027f880d..0af231049 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -64,6 +64,9 @@ const EnvironmentSchema = z.object({ DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10), DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1), + + TUNNEL_HOST: z.string().optional(), + TUNNEL_SECRET_KEY: z.string().optional(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/routes/api.v1.tunnels.ts b/apps/webapp/app/routes/api.v1.tunnels.ts new file mode 100644 index 000000000..6c0986159 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.tunnels.ts @@ -0,0 +1,69 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { env } from "~/env.server"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { YaltApiClient } from "@trigger.dev/yalt"; +import { logger } from "~/services/logger.server"; +import { prisma } from "~/db.server"; + +// This is for HEAD requests to check if the API supports tunneling +export async function loader({ request }: LoaderFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!env.TUNNEL_HOST || !env.TUNNEL_SECRET_KEY) { + return json({ error: "Tunneling is not supported" }, { status: 501 }); + } + + return json({ ok: true }); +} + +export async function action({ request }: LoaderFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (authenticationResult.environment.type !== "DEVELOPMENT") { + return json({ error: "Tunneling is only supported in development" }, { status: 501 }); + } + + if (!env.TUNNEL_HOST || !env.TUNNEL_SECRET_KEY) { + return json({ error: "Tunneling is not supported" }, { status: 501 }); + } + + const yaltClient = new YaltApiClient(env.TUNNEL_HOST, env.TUNNEL_SECRET_KEY); + + let tunnelId = authenticationResult.environment.tunnelId; + + if (!tunnelId) { + try { + tunnelId = await yaltClient.createTunnel(); + + await prisma.runtimeEnvironment.update({ + where: { + id: authenticationResult.environment.id, + }, + data: { + tunnelId, + }, + }); + } catch (error) { + logger.error("Failed to create tunnel", { error }); + + return json({ error: "Failed to create tunnel" }, { status: 500 }); + } + } + + if (!tunnelId) { + return json({ error: "Failed to create tunnel" }, { status: 500 }); + } + + return json({ url: yaltClient.connectUrl(tunnelId) }); +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index c2b2c41c6..d3fd08542 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -72,6 +72,7 @@ "@trigger.dev/core-backend": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/sdk": "workspace:*", + "@trigger.dev/yalt": "workspace:*", "@types/pg": "8.6.6", "@uiw/react-codemirror": "^4.19.5", "@whatwg-node/fetch": "^0.9.14", diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js index e1e0c2d40..d183b450b 100644 --- a/apps/webapp/remix.config.js +++ b/apps/webapp/remix.config.js @@ -15,6 +15,7 @@ module.exports = { "@trigger.dev/core-backend", "@trigger.dev/sdk", "@trigger.dev/billing", + "@trigger.dev/yalt", "emails", "highlight.run", "random-words", @@ -24,6 +25,7 @@ module.exports = { "../../packages/core/src/**/*", "../../packages/core-backend/src/**/*", "../../packages/trigger-sdk/src/**/*", + "../../packages/yalt/src/**/*", "../../packages/emails/src/**/*", ]; }, diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index bd08a2f27..de26bf4b1 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -27,6 +27,8 @@ "@trigger.dev/core-backend/*": ["../../packages/core-backend/src/*"], "@trigger.dev/database": ["../../packages/database/src/index"], "@trigger.dev/database/*": ["../../packages/database/src/*"], + "@trigger.dev/yalt": ["../../packages/yalt/src/index"], + "@trigger.dev/yalt/*": ["../../packages/yalt/src/*"], "emails": ["../../packages/emails/src/index"], "emails/*": ["../../packages/emails/src/*"] }, diff --git a/apps/yalt/.dev.vars.example b/apps/yalt/.dev.vars.example new file mode 100644 index 000000000..77bf4c333 --- /dev/null +++ b/apps/yalt/.dev.vars.example @@ -0,0 +1 @@ +SECRET_KEY= \ No newline at end of file diff --git a/apps/yalt/.editorconfig b/apps/yalt/.editorconfig new file mode 100644 index 000000000..64ab2601f --- /dev/null +++ b/apps/yalt/.editorconfig @@ -0,0 +1,13 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = tab +tab_width = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_style = space diff --git a/apps/yalt/.gitignore b/apps/yalt/.gitignore new file mode 100644 index 000000000..3b0fe33c4 --- /dev/null +++ b/apps/yalt/.gitignore @@ -0,0 +1,172 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars +.wrangler/ diff --git a/apps/yalt/.prettierrc b/apps/yalt/.prettierrc new file mode 100644 index 000000000..5c7b5d3c7 --- /dev/null +++ b/apps/yalt/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 140, + "singleQuote": true, + "semi": true, + "useTabs": true +} diff --git a/apps/yalt/README.md b/apps/yalt/README.md new file mode 100644 index 000000000..e0af8571f --- /dev/null +++ b/apps/yalt/README.md @@ -0,0 +1,3 @@ +## Trigger.dev Yalt Server + +Yalt (Yet Another Local Tunnel) is the Trigger.dev tunneling service that powers the local development of Trigger.dev users. diff --git a/apps/yalt/package.json b/apps/yalt/package.json new file mode 100644 index 000000000..21ca1304e --- /dev/null +++ b/apps/yalt/package.json @@ -0,0 +1,17 @@ +{ + "name": "yalt", + "version": "0.0.1", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --port 8787", + "start": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/workers-types": "~4.20231121.0", + "wrangler": "^3.20.0" + }, + "dependencies": { + "@trigger.dev/yalt": "workspace:*" + } +} \ No newline at end of file diff --git a/apps/yalt/src/index.ts b/apps/yalt/src/index.ts new file mode 100644 index 000000000..6c1b3b052 --- /dev/null +++ b/apps/yalt/src/index.ts @@ -0,0 +1,258 @@ +import { ClientMessages, createRequestMessage } from '@trigger.dev/yalt'; + +export interface Env { + // environemnt variables + SECRET_KEY: string; + WORKER_HOST: string; + + // bindings + connections: DurableObjectNamespace; + tunnelIds: KVNamespace; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const route = routeUrl(url, env); + + switch (route.type) { + case 'management': { + return handleManagementRequest(request, env, ctx); + } + case 'tunnel': { + console.log(`Handling tunnel request for ${route.name}`); + + const id = await env.tunnelIds.get(route.name); + + if (!id) { + return new Response('Not Found', { status: 404 }); + } + + const tunnel = env.connections.get(env.connections.idFromString(id)); + return tunnel.fetch(request); + } + case 'not_found': { + return new Response('Not Found', { status: 404 }); + } + } + + return new Response('Not Found', { status: 404 }); + }, +}; + +type RouteDecision = + | { + type: 'management'; + } + | { + type: 'tunnel'; + name: string; + } + | { type: 'not_found' }; + +function routeUrl(url: URL, env: Env): RouteDecision { + if (url.host.startsWith('localhost:')) { + const searchParams = new URLSearchParams(url.search); + const name = searchParams.get('t'); + + if (name) { + return { type: 'tunnel', name }; + } + + return { type: 'management' }; + } + + if (!url.host.includes(env.WORKER_HOST)) { + return { type: 'not_found' }; + } + + const parts = url.host.split('.'); + + if (parts.length === 2) { + return { type: 'management' }; + } + + const tunnelName = parts[0]; + + if (tunnelName === 'admin') { + return { type: 'management' }; + } + + if (parts.length === 3) { + return { type: 'tunnel', name: parts[0] }; + } + + return { type: 'not_found' }; +} + +async function handleManagementRequest(request: Request, env: Env, ctx: ExecutionContext): Promise { + const authHeader = request.headers.get('authorization'); + if (!authHeader) { + return new Response('Authorization header is required', { status: 401 }); + } + + const authHeaderParts = authHeader.split(' '); + if (authHeaderParts.length !== 2) { + return new Response('Authorization header is invalid', { status: 401 }); + } + + const [authType, authKey] = authHeaderParts; + + if (authType !== 'Bearer') { + return new Response('Authorization header is invalid', { status: 401 }); + } + + if (authKey !== env.SECRET_KEY) { + return new Response('Authorization header is invalid', { status: 401 }); + } + + // Okay now we can actually handle the request + // We need to look at the path and see what we are doing + // POST /api/tunnels -> create a new tunnel + const url = new URL(request.url); + + if (url.pathname === '/api/tunnels' && request.method === 'POST') { + return handleCreateTunnel(request, env, ctx); + } + + return new Response('Not Found', { status: 404 }); +} + +async function handleCreateTunnel(request: Request, env: Env, ctx: ExecutionContext): Promise { + const tunnelId = env.connections.newUniqueId(); + const tunnelName = crypto.randomUUID(); + + await env.tunnelIds.put(tunnelName, tunnelId.toString()); + + return new Response(JSON.stringify({ id: tunnelName }), { status: 201 }); +} + +type Resolver = { + resolve: (value: T) => void; + reject: (error: Error) => void; +}; + +export class YaltConnection implements DurableObject { + private socket?: WebSocket; + private responseResolvers: Record> = {}; + + constructor( + private state: DurableObjectState, + private env: Env, + ) {} + + async fetch(request: Request>): Promise { + const url = new URL(request.url); + + switch (url.pathname) { + case '/connect': { + console.log(`Handling connect request`); + + // This is a request from the client to connect to the tunnel + if (request.headers.get('Upgrade') !== 'websocket') { + return new Response('expected websocket', { status: 400 }); + } + + const pair = new WebSocketPair(); + const [clientSocket, serverSocket] = Object.values(pair); + + await this.handleSocket(serverSocket); + + console.log(`Successfully connected to tunnel`); + + return new Response(null, { status: 101, webSocket: clientSocket }); + } + case '/api/trigger': { + return this.handleTunnelRequest(request); + } + default: { + return new Response('Not Found', { status: 404 }); + } + } + } + + private async handleSocket(socket: WebSocket) { + if (this.socket) { + this.socket.close(1000, 'replaced'); + } + + this.socket = socket; + + this.socket.addEventListener('message', (event) => { + // Here we need to listen for "response" messages from the client and send them to the server via stored promises on this object + const data = JSON.parse(typeof event.data === 'string' ? event.data : new TextDecoder('utf-8').decode(event.data)); + + const message = ClientMessages.safeParse(data); + + if (!message.success) { + console.error(message.error); + return; + } + + switch (message.data.type) { + case 'response': { + const { id, ...response } = message.data; + + const resolver = this.responseResolvers[id]; + + if (!resolver) { + console.error(`No resolver found for ${id}`); + return; + } + + delete this.responseResolvers[id]; + + resolver.resolve( + new Response(response.body, { + status: response.status, + headers: response.headers, + }), + ); + + break; + } + } + }); + + this.socket.accept(); + } + + private async handleTunnelRequest(request: Request): Promise { + if (!this.socket) { + return createErrorResponse(); + } + + if (this.socket.readyState !== WebSocket.READY_STATE_OPEN) { + return createErrorResponse(); + } + + const id = crypto.randomUUID(); + + const promise = new Promise((resolve, reject) => { + this.responseResolvers[id] = { resolve, reject }; + }); + + try { + const message = await createRequestMessage(id, request); + this.socket.send(JSON.stringify(message)); + } catch (error) { + console.error(error); + delete this.responseResolvers[id]; + + return createErrorResponse(); + } + + return promise; + } +} + +const createErrorResponse = () => + new Response( + JSON.stringify({ + message: 'Could not connect to your dev server. Make sure you are running the `npx @trigger.dev/cli@latest dev` command', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ); diff --git a/apps/yalt/tsconfig.json b/apps/yalt/tsconfig.json new file mode 100644 index 000000000..745718722 --- /dev/null +++ b/apps/yalt/tsconfig.json @@ -0,0 +1,107 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + "jsx": "react" /* Specify what JSX code is generated. */, + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "es2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + "types": [ + "@cloudflare/workers-types/2023-07-01" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + "resolveJsonModule": true /* Enable importing .json files */, + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, + "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + "noEmit": true /* Disable emitting files from a compilation. */, + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "paths": { + "@trigger.dev/yalt": ["../../packages/yalt/src/index"], + "@trigger.dev/yalt/*": ["../../packages/yalt/src/*"] + } + } +} diff --git a/apps/yalt/wrangler.toml b/apps/yalt/wrangler.toml new file mode 100644 index 000000000..fdd43401a --- /dev/null +++ b/apps/yalt/wrangler.toml @@ -0,0 +1,24 @@ +name = "yalt" +main = "src/index.ts" +compatibility_date = "2023-12-06" +workers_dev = false + +routes = [ + { pattern = "*.yalt.dev/*", zone_name = "yalt.dev" }, + { pattern = "yalt.dev/*", zone_name = "yalt.dev" }, +] + +[vars] +WORKER_HOST = "yalt.dev" + +[[kv_namespaces]] +binding = "tunnelIds" +id = "e8cf1fcc9ad34fa0819694d86afa97ca" + +[[durable_objects.bindings]] +name = "connections" +class_name = "YaltConnection" + +[[migrations]] +tag = "v1" +new_classes = ["YaltConnection"] diff --git a/packages/cli/package.json b/packages/cli/package.json index 22f3a918f..76001fe56 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,6 +41,7 @@ "@types/mock-fs": "^4.13.1", "@types/node": "16", "@types/node-fetch": "^2.6.2", + "@types/ws": "^8.5.3", "rimraf": "^3.0.2", "tsup": "^6.5.0", "type-fest": "^3.6.0", @@ -57,6 +58,7 @@ }, "dependencies": { "@trigger.dev/core": "workspace:*", + "@trigger.dev/yalt": "workspace:*", "@types/degit": "^2.8.3", "boxen": "^7.1.1", "chalk": "^5.2.0", @@ -85,9 +87,10 @@ "terminal-link": "^3.0.0", "tsconfck": "^2.1.2", "url": "^0.11.1", + "ws": "^8.11.0", "zod": "3.22.3" }, "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 0d92526e1..0ce8fc1c5 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -19,6 +19,7 @@ import { RequireKeys } from "../utils/requiredKeys"; import { Throttle } from "../utils/throttle"; import { TriggerApi } from "../utils/triggerApi"; import { wait } from "../utils/wait"; +import { YaltTunnel } from "@trigger.dev/yalt"; const asyncExecFile = util.promisify(childProcess.execFile); @@ -128,7 +129,8 @@ export async function devCommand(path: string, anyOptions: any) { telemetryClient.dev.serverRunning(path, resolvedOptions); // Setup tunnel - const endpointUrl = await resolveEndpointUrl(apiUrl, verifiedEndpoint); + const endpointUrl = await resolveEndpointUrl(apiUrl, apiKey, verifiedEndpoint); + if (!endpointUrl) { telemetryClient.dev.failed("failed_to_create_tunnel", resolvedOptions); return; @@ -444,7 +446,7 @@ function findServerUrls(resolvedOptions: ResolvedOptions, framework?: Framework) return urls; } -async function resolveEndpointUrl(apiUrl: string, endpoint: ServerEndpoint) { +async function resolveEndpointUrl(apiUrl: string, apiKey: string, endpoint: ServerEndpoint) { // use tunnel URL if provided if (endpoint.type === "tunnel") { return endpoint.url; @@ -457,18 +459,57 @@ async function resolveEndpointUrl(apiUrl: string, endpoint: ServerEndpoint) { return `http://${endpoint.hostname}:${endpoint.port}`; } - // Setup tunnel - const tunnelSpinner = ora(`🚇 Creating tunnel`).start(); - const tunnelUrl = await createTunnel(endpoint.hostname, endpoint.port, tunnelSpinner); + const triggerApi = new TriggerApi(apiKey, apiUrl); - if (tunnelUrl) { - tunnelSpinner.succeed(`🚇 Created tunnel: ${tunnelUrl}`); + const supportsTunneling = await triggerApi.supportsTunneling(); + + if (supportsTunneling) { + const tunnelSpinner = ora(`🚇 Creating Trigger.dev tunnel`).start(); + const tunnelUrl = await createNativeTunnel(endpoint.hostname, endpoint.port, triggerApi, tunnelSpinner); + + if (tunnelUrl) { + tunnelSpinner.succeed(`🚇 Trigger.dev tunnel ready`); + } + + return tunnelUrl; + } else { + // Setup tunnel + const tunnelSpinner = ora(`🚇 Creating tunnel`).start(); + const tunnelUrl = await createNgrokTunnel(endpoint.hostname, endpoint.port, tunnelSpinner); + + if (tunnelUrl) { + tunnelSpinner.succeed(`🚇 Created tunnel: ${tunnelUrl}`); + } + + return tunnelUrl; } - - return tunnelUrl; } -async function createTunnel(hostname: string, port: number, spinner: Ora) { +let yaltTunnel: YaltTunnel | null = null; + +async function createNativeTunnel(hostname: string, port: number, triggerApi: TriggerApi, spinner: Ora) { + try { + const response = await triggerApi.createTunnel(); + + // import WS dynamically + const WebSocket = await import("ws"); + + yaltTunnel = new YaltTunnel(response.url, `${hostname}:${port}`, { + WebSocket: WebSocket.default, + connectionTimeout: 1000, + maxRetries: 10 + }); + + await yaltTunnel.connect(); + + return `https://${response.url}`; + } catch (e) { + spinner.fail(`Failed to create tunnel.\n${e}`); + return; + } +} + +async function createNgrokTunnel(hostname: string, port: number, spinner: Ora) { try { return await ngrok.connect({ addr: `${hostname}:${port}` }); } catch (error: any) { diff --git a/packages/cli/src/utils/triggerApi.ts b/packages/cli/src/utils/triggerApi.ts index a8708f56d..e206d527f 100644 --- a/packages/cli/src/utils/triggerApi.ts +++ b/packages/cli/src/utils/triggerApi.ts @@ -24,9 +24,9 @@ export type EndpointData = { export type EndpointResponse = | { - ok: true; - data: EndpointData; - } + ok: true; + data: EndpointData; + } | { ok: false; error: string; retryable: boolean }; const RETRYABLE_PATTERN = /Could not connect to endpoint/i; @@ -55,13 +55,53 @@ const WhoamiResponseSchema = z.object({ userId: z.string().optional(), }); +const CreateTunnelResponseSchema = z.object({ + url: z.string(), +}); + export type WhoamiResponse = z.infer; export class TriggerApi { constructor( private apiKey: string, private baseUrl: string - ) {} + ) { } + + async supportsTunneling(): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/tunnels`, { + method: "HEAD", + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + }); + + return response.ok; + } + + async createTunnel() { + const response = await fetch(`${this.baseUrl}/api/v1/tunnels`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Could not create tunnel: ${response.status}`); + } + + const body = await response.json(); + + const parsedBody = CreateTunnelResponseSchema.safeParse(body); + + if (!parsedBody.success) { + throw new Error(`Could not create tunnel: ${parsedBody.error.message}`); + } + + return parsedBody.data; + } async whoami(): Promise { const response = await fetch(`${this.baseUrl}/api/v1/whoami`, { diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b765e2ade..a0999d380 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -46,7 +46,10 @@ "useDefineForClassFields": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "types": ["vitest/globals"] + "types": ["vitest/globals"], + "paths": { + "@trigger.dev/yalt": ["../yalt/src/index"] + } }, "exclude": ["node_modules"] } diff --git a/packages/database/prisma/migrations/20231214103115_add_tunnel_id_to_runtime_environment/migration.sql b/packages/database/prisma/migrations/20231214103115_add_tunnel_id_to_runtime_environment/migration.sql new file mode 100644 index 000000000..fe8830c22 --- /dev/null +++ b/packages/database/prisma/migrations/20231214103115_add_tunnel_id_to_runtime_environment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" ADD COLUMN "tunnelId" TEXT; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f8a52f746..433b7a692 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -316,6 +316,8 @@ model RuntimeEnvironment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + tunnelId String? + endpoints Endpoint[] jobVersions JobVersion[] events EventRecord[] diff --git a/packages/yalt/README.md b/packages/yalt/README.md new file mode 100644 index 000000000..e36b42a80 --- /dev/null +++ b/packages/yalt/README.md @@ -0,0 +1 @@ +## @trigger.dev/yalt diff --git a/packages/yalt/package.json b/packages/yalt/package.json new file mode 100644 index 000000000..c17e8005a --- /dev/null +++ b/packages/yalt/package.json @@ -0,0 +1,52 @@ +{ + "name": "@trigger.dev/yalt", + "version": "2.3.5", + "description": "yalt.dev client library", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.mjs", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && npm run build:tsup", + "build:tsup": "tsup --dts-resolve", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "dependencies": { + "zod": "3.22.3", + "partysocket": "^0.0.17" + }, + "devDependencies": { + "@trigger.dev/tsconfig": "workspace:*", + "@types/jest": "^29.5.3", + "@types/node": "18", + "jest": "^29.6.2", + "rimraf": "^3.0.2", + "ts-jest": "^29.1.1", + "tsup": "^8.0.1", + "@trigger.dev/tsup": "workspace:*", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/yalt/src/index.ts b/packages/yalt/src/index.ts new file mode 100644 index 000000000..ff783f8ef --- /dev/null +++ b/packages/yalt/src/index.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { WebSocket } from "partysocket"; + +export const RequestMesssage = z.object({ + type: z.literal("request"), + id: z.string(), + headers: z.record(z.string()), + method: z.string(), + url: z.string(), + body: z.string(), +}); + +export type RequestMessage = z.infer; + +export const ResponseMessage = z.object({ + type: z.literal("response"), + id: z.string(), + status: z.number(), + headers: z.record(z.string()), + body: z.string(), +}); + +export type ResponseMessage = z.infer; + +export const ClientMessages = z.discriminatedUnion("type", [ResponseMessage]); +export const ServerMessages = z.discriminatedUnion("type", [RequestMesssage]); + +export type ClientMessage = z.infer; +export type ServerMessage = z.infer; + +export async function createRequestMessage(id: string, request: Request): Promise { + const { headers, method, url } = request; + + const body = await request.text(); + + return { + type: "request", + id, + headers: Object.fromEntries(headers), + method, + url, + body, + }; +} + +async function createResponseMessage(id: string, response: Response): Promise { + const { headers, status } = response; + + const body = await response.text(); + + return { + type: "response", + id, + headers: Object.fromEntries(headers), + status, + body, + }; +} + +export class YaltApiClient { + constructor( + private host: string, + private apiKey: string + ) {} + + async createTunnel(): Promise { + const response = await fetch(`https://admin.${this.host}/api/tunnels`, { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${this.apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Could not create tunnel: ${response.status}`); + } + + const body = await response.json(); + + return body.id; + } + + connectUrl(id: string): string { + return `${id}.${this.host}`; + } +} + +export type YaltTunnelOptions = { + WebSocket?: any; + connectionTimeout?: number; + maxRetries?: number; +}; +export class YaltTunnel { + socket?: WebSocket; + + constructor( + private url: string, + private address: string, + private options: YaltTunnelOptions = {} + ) {} + + async connect() { + this.socket = new WebSocket(`wss://${this.url}/connect`, [], this.options); + + this.socket.addEventListener("open", () => {}); + + this.socket.addEventListener("close", (event) => {}); + + this.socket.addEventListener("message", async (event) => { + const data = JSON.parse( + typeof event.data === "string" ? event.data : new TextDecoder("utf-8").decode(event.data) + ); + + const message = ServerMessages.safeParse(data); + + if (!message.success) { + console.error(message.error); + return; + } + + switch (message.data.type) { + case "request": { + await this.handleRequest(message.data); + + break; + } + default: { + console.error(`Unknown message type: ${message.data.type}`); + } + } + }); + + this.socket.addEventListener("error", (event) => { + console.error(event); + }); + } + + private async handleRequest(request: RequestMessage) { + if (!this.socket) { + throw new Error("Socket is not connected"); + } + + const url = new URL(request.url); + // Construct the original url to be the same as the request URL but with a different hostname and using http instead of https + const originalUrl = new URL(`http://${this.address}${url.pathname}${url.search}${url.hash}`); + + let response: Response | null = null; + + try { + response = await fetch(originalUrl.href, { + method: request.method, + headers: request.headers, + body: request.body, + }); + } catch (error) { + // Return a 502 response + response = new Response( + JSON.stringify({ + message: `Could not connect to ${originalUrl.href}. Make sure you are running your local app server`, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + } + + try { + await sendResponse(request.id, response, this.socket); + } catch (error) { + console.error(error); + } + } +} + +async function sendResponse(id: string, response: Response, socket: WebSocket) { + const message = await createResponseMessage(id, response); + + return socket.send(JSON.stringify(message)); +} diff --git a/packages/yalt/tsconfig.json b/packages/yalt/tsconfig.json new file mode 100644 index 000000000..daa6330e2 --- /dev/null +++ b/packages/yalt/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@trigger.dev/tsconfig/node18.json", + "include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts"], + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": false, + "declarationMap": false, + "paths": { + "@trigger.dev/tsup/*": ["../../config-packages/tsup/src/*"], + "@trigger.dev/tsup": ["../../config-packages/tsup/src/index"] + }, + "lib": ["DOM", "DOM.Iterable"] + }, + "exclude": ["node_modules"] +} diff --git a/packages/yalt/tsup.config.ts b/packages/yalt/tsup.config.ts new file mode 100644 index 000000000..1d1b59a08 --- /dev/null +++ b/packages/yalt/tsup.config.ts @@ -0,0 +1,3 @@ +import { defineConfigPackage } from "@trigger.dev/tsup"; + +export default defineConfigPackage; diff --git a/perf/src/trigger.ts b/perf/src/trigger.ts index 3d79eb07f..a48728596 100644 --- a/perf/src/trigger.ts +++ b/perf/src/trigger.ts @@ -6,11 +6,6 @@ export const triggerClient = new TriggerClient({ apiUrl: process.env.TRIGGER_API_URL!, }); -const concurrencyLimit = triggerClient.defineConcurrencyLimit({ - id: `perf-test-shared`, - limit: 5, -}); - triggerClient.defineJob({ id: `perf-test-1`, name: `Perf Test 1`, @@ -18,7 +13,6 @@ triggerClient.defineJob({ trigger: eventTrigger({ name: "perf.test", }), - concurrencyLimit, run: async (payload, io, ctx) => { await io.runTask( "task-1", @@ -65,7 +59,6 @@ triggerClient.defineJob({ trigger: eventTrigger({ name: "perf.test", }), - concurrencyLimit: 5, run: async (payload, io, ctx) => { await io.runTask( "task-1", @@ -112,7 +105,6 @@ triggerClient.defineJob({ trigger: eventTrigger({ name: "perf.test", }), - concurrencyLimit, run: async (payload, io, ctx) => { await io.runTask( "task-1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48dae346d..2dcf8b28f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,7 @@ importers: '@trigger.dev/database': workspace:* '@trigger.dev/sdk': workspace:* '@trigger.dev/tailwind-config': workspace:* + '@trigger.dev/yalt': workspace:* '@types/bcryptjs': ^2.4.2 '@types/compression': ^1.7.2 '@types/eslint': ^8.4.6 @@ -277,6 +278,7 @@ importers: '@trigger.dev/core-backend': link:../../packages/core-backend '@trigger.dev/database': link:../../packages/database '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/yalt': link:../../packages/yalt '@types/pg': 8.6.6 '@uiw/react-codemirror': 4.19.5_th22fcplkuhrqjnlojwclcaim4 '@whatwg-node/fetch': 0.9.14 @@ -393,6 +395,17 @@ importers: tsconfig-paths: 3.14.1 typescript: 5.2.2 + apps/yalt: + specifiers: + '@cloudflare/workers-types': ~4.20231121.0 + '@trigger.dev/yalt': workspace:* + wrangler: ^3.20.0 + dependencies: + '@trigger.dev/yalt': link:../../packages/yalt + devDependencies: + '@cloudflare/workers-types': 4.20231121.0 + wrangler: 3.20.0 + config-packages/eslint-config-custom: specifiers: eslint: ^8.24.0 @@ -787,12 +800,14 @@ importers: specifiers: '@trigger.dev/core': workspace:* '@trigger.dev/tsconfig': workspace:* + '@trigger.dev/yalt': workspace:* '@types/degit': ^2.8.3 '@types/gradient-string': ^1.1.2 '@types/inquirer': ^9.0.3 '@types/mock-fs': ^4.13.1 '@types/node': '16' '@types/node-fetch': ^2.6.2 + '@types/ws': ^8.5.3 boxen: ^7.1.1 chalk: ^5.2.0 chokidar: ^3.5.3 @@ -825,9 +840,11 @@ importers: typescript: ^4.9.5 url: ^0.11.1 vitest: ^0.34.4 + ws: ^8.11.0 zod: 3.22.3 dependencies: '@trigger.dev/core': link:../core + '@trigger.dev/yalt': link:../yalt '@types/degit': 2.8.3 boxen: 7.1.1 chalk: 5.2.0 @@ -856,6 +873,7 @@ importers: terminal-link: 3.0.0 tsconfck: 2.1.2_typescript@4.9.5 url: 0.11.1 + ws: 8.12.0 zod: 3.22.3 devDependencies: '@trigger.dev/tsconfig': link:../../config-packages/tsconfig @@ -864,6 +882,7 @@ importers: '@types/mock-fs': 4.13.1 '@types/node': 16.18.11 '@types/node-fetch': 2.6.2 + '@types/ws': 8.5.4 rimraf: 3.0.2 tsup: 6.6.3_typescript@4.9.5 type-fest: 3.6.0 @@ -1289,6 +1308,33 @@ importers: typed-emitter: 2.1.0 typescript: 5.3.2 + packages/yalt: + specifiers: + '@trigger.dev/tsconfig': workspace:* + '@trigger.dev/tsup': workspace:* + '@types/jest': ^29.5.3 + '@types/node': '18' + jest: ^29.6.2 + partysocket: ^0.0.17 + rimraf: ^3.0.2 + ts-jest: ^29.1.1 + tsup: ^8.0.1 + typescript: ^5.3.0 + zod: 3.22.3 + dependencies: + partysocket: 0.0.17 + zod: 3.22.3 + devDependencies: + '@trigger.dev/tsconfig': link:../../config-packages/tsconfig + '@trigger.dev/tsup': link:../../config-packages/tsup + '@types/jest': 29.5.3 + '@types/node': 18.17.1 + jest: 29.6.2_@types+node@18.17.1 + rimraf: 3.0.2 + ts-jest: 29.1.1_3tyr5rkhps22cjyuxtwz6gzoky + tsup: 8.0.1_a5ztaafw5l4qfghy2hjjuynb34_typescript@5.3.2 + typescript: 5.3.2 + perf: specifiers: '@trigger.dev/cli': workspace:* @@ -16052,7 +16098,7 @@ packages: /axios/0.21.4_debug@4.3.2: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2_debug@4.3.2 + follow-redirects: 1.15.2 transitivePeerDependencies: - debug dev: false @@ -20880,18 +20926,6 @@ packages: debug: optional: true - /follow-redirects/1.15.2_debug@4.3.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dependencies: - debug: 4.3.2 - dev: false - /for-each/0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -25198,6 +25232,29 @@ packages: - utf-8-validate dev: true + /miniflare/3.20231030.4: + resolution: {integrity: sha512-7MBz0ArLuDop1WJGZC6tFgN6c5MRyDOIlxbm3yp0TRBpvDS/KsTuWCQcCjsxN4QQ5zvL3JTkuIZbQzRRw/j6ow==} + engines: {node: '>=16.13'} + hasBin: true + dependencies: + acorn: 8.10.0 + acorn-walk: 8.2.0 + capnp-ts: 0.7.0 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + source-map-support: 0.5.21 + stoppable: 1.1.0 + undici: 5.25.4 + workerd: 1.20231030.0 + ws: 8.12.0 + youch: 3.3.3 + zod: 3.22.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /minimal-polyfills/2.2.2: resolution: {integrity: sha512-eEOUq/LH/DbLWihrxUP050Wi7H/N/I2dQT98Ep6SqOpmIbk4sXOI4wqalve66QoZa+6oljbZWU6I6T4dehQGmw==} dev: false @@ -26732,6 +26789,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + /partysocket/0.0.17: + resolution: {integrity: sha512-8Re9nmgP2LzQhq+FBs9+BZNTjmMwoF4geEKlpH0lxW1JKp3FmplN74306afGH9EsOjdfcXqKY2VCZtc3iAHIow==} + dev: false + /pascal-case/3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: @@ -33070,6 +33131,33 @@ packages: - utf-8-validate dev: true + /wrangler/3.20.0: + resolution: {integrity: sha512-7mg25zJByhBmrfG+CbImSid7JNd5lxGovLA167ndtE8Yrqd3TUukrGWL8o0RCQIm0FUcgl2nCzWArJDShlZVKA==} + engines: {node: '>=16.17.0'} + hasBin: true + dependencies: + '@cloudflare/kv-asset-handler': 0.2.0 + '@esbuild-plugins/node-globals-polyfill': 0.2.3_esbuild@0.17.19 + '@esbuild-plugins/node-modules-polyfill': 0.2.2_esbuild@0.17.19 + blake3-wasm: 2.1.5 + chokidar: 3.5.3 + esbuild: 0.17.19 + miniflare: 3.20231030.4 + nanoid: 3.3.6 + path-to-regexp: 6.2.1 + resolve.exports: 2.0.2 + selfsigned: 2.4.1 + source-map: 0.6.1 + source-map-support: 0.5.21 + xxhash-wasm: 1.0.2 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /wrap-ansi/6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'}