Add support for the built-in Trigger.dev tunnel for local development

This commit is contained in:
Eric Allam
2023-12-14 13:50:23 +00:00
parent 922520cefb
commit b756305c9a
29 changed files with 1144 additions and 37 deletions
+2
View File
@@ -14,6 +14,8 @@
"ignore": [
"webapp",
"emails",
"proxy",
"yalt",
"@trigger.dev/database"
],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/yalt": patch
"@trigger.dev/cli": patch
---
Add support for the built-in Trigger.dev tunnel (Yalt)
+3
View File
@@ -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<typeof EnvironmentSchema>;
+69
View File
@@ -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) });
}
+1
View File
@@ -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",
+2
View File
@@ -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/**/*",
];
},
+2
View File
@@ -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/*"]
},
+1
View File
@@ -0,0 +1 @@
SECRET_KEY=
+13
View File
@@ -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
+172
View File
@@ -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/
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}
+3
View File
@@ -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.
+17
View File
@@ -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:*"
}
}
+258
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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<T> = {
resolve: (value: T) => void;
reject: (error: Error) => void;
};
export class YaltConnection implements DurableObject {
private socket?: WebSocket;
private responseResolvers: Record<string, Resolver<Response>> = {};
constructor(
private state: DurableObjectState,
private env: Env,
) {}
async fetch(request: Request<unknown, CfProperties<unknown>>): Promise<Response> {
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<Response> {
if (!this.socket) {
return createErrorResponse();
}
if (this.socket.readyState !== WebSocket.READY_STATE_OPEN) {
return createErrorResponse();
}
const id = crypto.randomUUID();
const promise = new Promise<Response>((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' },
},
);
+107
View File
@@ -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 `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* 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/*"]
}
}
}
+24
View File
@@ -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"]
+4 -1
View File
@@ -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"
}
}
}
+51 -10
View File
@@ -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) {
+44 -4
View File
@@ -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<typeof WhoamiResponseSchema>;
export class TriggerApi {
constructor(
private apiKey: string,
private baseUrl: string
) {}
) { }
async supportsTunneling(): Promise<boolean> {
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<WhoamiResponse | undefined> {
const response = await fetch(`${this.baseUrl}/api/v1/whoami`, {
+4 -1
View File
@@ -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"]
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RuntimeEnvironment" ADD COLUMN "tunnelId" TEXT;
+2
View File
@@ -316,6 +316,8 @@ model RuntimeEnvironment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tunnelId String?
endpoints Endpoint[]
jobVersions JobVersion[]
events EventRecord[]
+1
View File
@@ -0,0 +1 @@
## @trigger.dev/yalt
+52
View File
@@ -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"
}
}
+178
View File
@@ -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<typeof RequestMesssage>;
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<typeof ResponseMessage>;
export const ClientMessages = z.discriminatedUnion("type", [ResponseMessage]);
export const ServerMessages = z.discriminatedUnion("type", [RequestMesssage]);
export type ClientMessage = z.infer<typeof ClientMessages>;
export type ServerMessage = z.infer<typeof ServerMessages>;
export async function createRequestMessage(id: string, request: Request): Promise<RequestMessage> {
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<ResponseMessage> {
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<string> {
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));
}
+16
View File
@@ -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"]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfigPackage } from "@trigger.dev/tsup";
export default defineConfigPackage;
-8
View File
@@ -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",
+101 -13
View File
@@ -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'}