chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { cors } from "remix-utils/cors";
|
||||
|
||||
type CorsMethod = "GET" | "HEAD" | "PUT" | "PATCH" | "POST" | "DELETE";
|
||||
|
||||
type CorsOptions = {
|
||||
methods?: CorsMethod[];
|
||||
/** Defaults to 5 mins */
|
||||
maxAge?: number;
|
||||
origin?: boolean | string;
|
||||
credentials?: boolean;
|
||||
exposedHeaders?: string[];
|
||||
};
|
||||
|
||||
export async function apiCors(
|
||||
request: Request,
|
||||
response: Response,
|
||||
options: CorsOptions = { maxAge: 5 * 60 }
|
||||
): Promise<Response> {
|
||||
if (hasCorsHeaders(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return cors(request, response, options);
|
||||
}
|
||||
|
||||
export function makeApiCors(
|
||||
request: Request,
|
||||
options: CorsOptions = { maxAge: 5 * 60 }
|
||||
): (response: Response) => Promise<Response> {
|
||||
return (response: Response) => apiCors(request, response, options);
|
||||
}
|
||||
|
||||
function hasCorsHeaders(response: Response) {
|
||||
return response.headers.has("access-control-allow-origin");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const baseBoolEnv = z.preprocess((val) => {
|
||||
if (typeof val !== "string") {
|
||||
return val;
|
||||
}
|
||||
|
||||
return ["true", "1"].includes(val.toLowerCase().trim());
|
||||
}, z.boolean());
|
||||
|
||||
// Create a type-safe version that only accepts boolean defaults
|
||||
export const BoolEnv = baseBoolEnv as Omit<typeof baseBoolEnv, "default"> & {
|
||||
default: (value: boolean) => z.ZodDefault<typeof baseBoolEnv>;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type Prisma, type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
type BranchableEnvironmentInput = {
|
||||
type: RuntimeEnvironmentType;
|
||||
parentEnvironmentId: string | null;
|
||||
isBranchableEnvironment: boolean;
|
||||
};
|
||||
|
||||
export type BranchableEnvironmentType = Extract<RuntimeEnvironmentType, "PREVIEW" | "DEVELOPMENT">;
|
||||
|
||||
/**
|
||||
* The wire/form token for a branchable environment kind, as sent by the CLI and
|
||||
* dashboard forms.
|
||||
*/
|
||||
export type BranchableEnvironmentToken = "preview" | "development";
|
||||
|
||||
export function toBranchableEnvironmentType(
|
||||
env: BranchableEnvironmentToken
|
||||
): BranchableEnvironmentType {
|
||||
switch (env) {
|
||||
case "preview":
|
||||
return "PREVIEW";
|
||||
case "development":
|
||||
return "DEVELOPMENT";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma `where` fragment matching the *root* environment of a type — the
|
||||
* branchable parent, never a branch (branches always carry a `parentEnvironmentId`).
|
||||
* DEVELOPMENT roots are per-org-member, so pass `userId` to disambiguate between
|
||||
* members' dev environments.
|
||||
*
|
||||
* Use this instead of locating roots by their magic slug (`"dev"` / `"preview"`),
|
||||
* which is an instance identifier, not a reliable type discriminator. Whether the
|
||||
* matched root is actually branchable is a separate concern — gate it with
|
||||
* {@link isBranchableEnvironment} after the lookup.
|
||||
*/
|
||||
export function rootEnvironmentWhere(
|
||||
type: RuntimeEnvironmentType,
|
||||
opts?: { userId?: string }
|
||||
): Prisma.RuntimeEnvironmentWhereInput {
|
||||
return {
|
||||
type,
|
||||
parentEnvironmentId: null,
|
||||
...(type === "DEVELOPMENT" && opts?.userId ? { orgMember: { userId: opts.userId } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an environment is a branchable parent (i.e. branches can be created
|
||||
* under it), as opposed to a branch itself or a non-branchable environment.
|
||||
*
|
||||
* Branchability is split by type:
|
||||
* - A branch (any env with a `parentEnvironmentId`) is never itself branchable.
|
||||
* - DEVELOPMENT roots are always branchable — it's derivable from the structure,
|
||||
* so we don't trust the `isBranchableEnvironment` column for dev.
|
||||
* - PREVIEW roots use the `isBranchableEnvironment` column, which is the
|
||||
* long-standing source of truth (and may hold legacy non-branchable rows).
|
||||
* - STAGING / PRODUCTION are never branchable.
|
||||
*
|
||||
* The `parentEnvironmentId === null` guard is load-bearing: dev *branches* are
|
||||
* also `type === "DEVELOPMENT"`, so checking the type alone would misclassify
|
||||
* them. Always go through this helper rather than inlining the rule.
|
||||
*/
|
||||
export function isBranchableEnvironment(env: BranchableEnvironmentInput): boolean {
|
||||
if (env.parentEnvironmentId !== null) return false;
|
||||
if (env.type === "DEVELOPMENT") return true;
|
||||
if (env.type === "PREVIEW") return env.isBranchableEnvironment;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { GitMeta } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Search/filter params for the branches list pages. */
|
||||
export const BranchesOptions = z.object({
|
||||
search: z.string().optional(),
|
||||
showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(),
|
||||
page: z.preprocess((val) => Number(val), z.number()).optional(),
|
||||
});
|
||||
|
||||
/** Payload accepted by the create-branch service/action. */
|
||||
export const CreateBranchOptions = z.object({
|
||||
projectId: z.string(),
|
||||
env: z.enum(["preview", "development"]),
|
||||
branchName: z.string().min(1),
|
||||
git: GitMeta.optional(),
|
||||
});
|
||||
|
||||
/** The create-branch form schema (payload + the form's failure redirect path). */
|
||||
export const CreateBranchFormSchema = CreateBranchOptions.and(
|
||||
z.object({
|
||||
failurePath: z.string(),
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
const customTwMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
// Custom font sizes defined in tailwind.css (--text-xxs, --text-2sm)
|
||||
"font-size": [{ text: ["xxs", "2sm"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return customTwMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ColumnFormatType } from "@internal/clickhouse";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
|
||||
/**
|
||||
* Format a number as binary bytes (KiB, MiB, GiB, TiB)
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
const i = Math.min(
|
||||
Math.max(0, Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024))),
|
||||
units.length - 1
|
||||
);
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as decimal bytes (KB, MB, GB, TB)
|
||||
*/
|
||||
export function formatDecimalBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(
|
||||
Math.max(0, Math.floor(Math.log(Math.abs(bytes)) / Math.log(1000))),
|
||||
units.length - 1
|
||||
);
|
||||
return `${(bytes / Math.pow(1000, i)).toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a large number with human-readable suffix (K, M, B)
|
||||
*/
|
||||
export function formatQuantity(value: number): string {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`;
|
||||
if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
|
||||
if (abs >= 1_000) return `${(value / 1_000).toFixed(2)}K`;
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a dollar amount with adaptive precision — avoids trailing zeros.
|
||||
*/
|
||||
function formatCostAdaptive(dollars: number): string {
|
||||
if (dollars === 0) return "$0";
|
||||
const abs = Math.abs(dollars);
|
||||
if (abs >= 1000) return `$${dollars.toFixed(2)}`;
|
||||
if (abs >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (abs >= 0.01) return `$${dollars.toFixed(4)}`;
|
||||
if (abs >= 0.0001) return `$${dollars.toFixed(6)}`;
|
||||
return formatCurrencyAccurate(dollars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a value formatter function for a given column format type.
|
||||
* Used by chart tooltips, legend values, and big number cards.
|
||||
*/
|
||||
export function createValueFormatter(
|
||||
format?: ColumnFormatType
|
||||
): ((value: number) => string) | undefined {
|
||||
if (!format) return undefined;
|
||||
switch (format) {
|
||||
case "bytes":
|
||||
return (v) => formatBytes(v);
|
||||
case "decimalBytes":
|
||||
return (v) => formatDecimalBytes(v);
|
||||
case "percent":
|
||||
return (v) => `${v.toFixed(2)}%`;
|
||||
case "quantity":
|
||||
return (v) => formatQuantity(v);
|
||||
case "duration":
|
||||
return (v) => formatDurationMilliseconds(v, { style: "short" });
|
||||
case "durationSeconds":
|
||||
return (v) => formatDurationMilliseconds(v * 1000, { style: "short" });
|
||||
case "costInDollars":
|
||||
return (v) => formatCostAdaptive(v);
|
||||
case "cost":
|
||||
return (v) => formatCostAdaptive(v / 100);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Deterministic 0-99 bucket for an org id, stable across processes and deploys.
|
||||
* FNV-1a (non-crypto): we only need determinism + uniform spread, not collision
|
||||
* resistance. Used for nested percentage rollout: `hashBucket(orgId) < percentage`.
|
||||
* Ramping the percentage down keeps a strict subset (the low buckets), so an org
|
||||
* never flaps in and out as the dial moves.
|
||||
*/
|
||||
export function hashBucket(orgId: string): number {
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < orgId.length; i++) {
|
||||
hash ^= orgId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash % 100;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
|
||||
/**
|
||||
* Escape a value for CSV format.
|
||||
* - Wraps in quotes if the value contains commas, quotes, or newlines
|
||||
* - Escapes quotes by doubling them
|
||||
*/
|
||||
function escapeCSVValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const stringValue = typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
|
||||
// Check if we need to quote the value
|
||||
if (
|
||||
stringValue.includes(",") ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes("\n") ||
|
||||
stringValue.includes("\r")
|
||||
) {
|
||||
// Escape quotes by doubling them and wrap in quotes
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert query result rows to CSV format.
|
||||
*
|
||||
* @param rows - Array of row objects from query results
|
||||
* @param columns - Column metadata describing the result columns
|
||||
* @returns CSV string with header row and data rows
|
||||
*/
|
||||
export function rowsToCSV(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: OutputColumnMetadata[]
|
||||
): string {
|
||||
if (columns.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const columnNames = columns.map((col) => col.name);
|
||||
|
||||
// Header row
|
||||
const headerRow = columnNames.map(escapeCSVValue).join(",");
|
||||
|
||||
// Data rows
|
||||
const dataRows = rows.map((row) =>
|
||||
columnNames.map((name) => escapeCSVValue(row[name])).join(",")
|
||||
);
|
||||
|
||||
return [headerRow, ...dataRows].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert query result rows to JSON format.
|
||||
*
|
||||
* @param rows - Array of row objects from query results
|
||||
* @returns Formatted JSON string
|
||||
*/
|
||||
export function rowsToJSON(rows: Record<string, unknown>[]): string {
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser.
|
||||
*
|
||||
* @param content - The file content as a string
|
||||
* @param filename - The name for the downloaded file
|
||||
* @param mimeType - The MIME type of the file
|
||||
*/
|
||||
export function downloadFile(content: string, filename: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function isValidDatabaseUrl(url: string) {
|
||||
try {
|
||||
const databaseUrl = new URL(url);
|
||||
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
|
||||
|
||||
if (schemaFromSearchParam === "") {
|
||||
console.error(
|
||||
"Invalid Database URL: The schema search param can't have an empty value. To use the `public` schema, either omit the schema param entirely or specify it in full: `?schema=public`"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
export const calculateDurationInMs = (options: {
|
||||
seconds?: number;
|
||||
minutes?: number;
|
||||
hours?: number;
|
||||
days?: number;
|
||||
}) => {
|
||||
return (
|
||||
(options?.seconds ?? 0) * 1000 +
|
||||
(options?.minutes ?? 0) * 60 * 1000 +
|
||||
(options?.hours ?? 0) * 60 * 60 * 1000 +
|
||||
(options?.days ?? 0) * 24 * 60 * 60 * 1000
|
||||
);
|
||||
};
|
||||
|
||||
export async function parseDelay(value?: string | Date): Promise<Date | undefined> {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(value);
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return parseNaturalLanguageDuration(value);
|
||||
}
|
||||
|
||||
if (date.getTime() <= Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return date;
|
||||
} catch (_error) {
|
||||
return parseNaturalLanguageDuration(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Detects unpaired UTF-16 surrogate escape sequences in JSON-encoded text.
|
||||
*
|
||||
* Returns true if the input contains a `\uD8XX`/`\uD9XX`/`\uDAXX`/`\uDBXX`
|
||||
* high-surrogate escape not immediately followed by a `\uDC..`–`\uDF..` low
|
||||
* surrogate, or a `\uDC..`–`\uDF..` low surrogate not immediately preceded by
|
||||
* a high surrogate. Strict JSON parsers (e.g. ClickHouse `JSONEachRow`)
|
||||
* reject input containing such sequences.
|
||||
*
|
||||
* Surrogate hex ranges (case-insensitive — inputs from `JSON.stringify` are
|
||||
* lowercase):
|
||||
* - High surrogate (U+D800–U+DBFF): `\uD[8-B][0-9A-F][0-9A-F]`
|
||||
* - Low surrogate (U+DC00–U+DFFF): `\uD[C-F][0-9A-F][0-9A-F]`
|
||||
*/
|
||||
export function detectBadJsonStrings(jsonString: string): boolean {
|
||||
// Fast path: skip everything if no \u
|
||||
let idx = jsonString.indexOf("\\u");
|
||||
if (idx === -1) return false;
|
||||
|
||||
// Use a more efficient scanning strategy
|
||||
const length = jsonString.length;
|
||||
|
||||
while (idx !== -1 && idx < length - 5) {
|
||||
// Only check if we have enough characters left
|
||||
if (idx + 6 > length) break;
|
||||
|
||||
if (jsonString[idx + 1] === "u" && jsonString[idx + 2] === "d") {
|
||||
const third = jsonString[idx + 3];
|
||||
|
||||
// High surrogate check — third nibble is 8, 9, a, or b (U+D800–U+DBFF)
|
||||
if (
|
||||
/[89ab]/.test(third) &&
|
||||
/[0-9a-f]/.test(jsonString[idx + 4]) &&
|
||||
/[0-9a-f]/.test(jsonString[idx + 5])
|
||||
) {
|
||||
// Check for low surrogate after (need at least 6 more chars)
|
||||
if (idx + 12 > length) {
|
||||
return true; // Incomplete high surrogate (not enough chars left)
|
||||
}
|
||||
|
||||
if (
|
||||
jsonString[idx + 6] !== "\\" ||
|
||||
jsonString[idx + 7] !== "u" ||
|
||||
jsonString[idx + 8] !== "d" ||
|
||||
!/[c-f]/.test(jsonString[idx + 9]) ||
|
||||
!/[0-9a-f]/.test(jsonString[idx + 10]) ||
|
||||
!/[0-9a-f]/.test(jsonString[idx + 11])
|
||||
) {
|
||||
return true; // Incomplete high surrogate
|
||||
}
|
||||
}
|
||||
|
||||
// Low surrogate check — third nibble is c, d, e, or f (U+DC00–U+DFFF)
|
||||
if (
|
||||
/[c-f]/.test(third) &&
|
||||
/[0-9a-f]/.test(jsonString[idx + 4]) &&
|
||||
/[0-9a-f]/.test(jsonString[idx + 5])
|
||||
) {
|
||||
// Check for high surrogate before (need at least 6 chars before)
|
||||
if (idx < 6) {
|
||||
return true; // Incomplete low surrogate (not enough chars before)
|
||||
}
|
||||
|
||||
if (
|
||||
jsonString[idx - 6] !== "\\" ||
|
||||
jsonString[idx - 5] !== "u" ||
|
||||
jsonString[idx - 4] !== "d" ||
|
||||
!/[89ab]/.test(jsonString[idx - 3]) ||
|
||||
!/[0-9a-f]/.test(jsonString[idx - 2]) ||
|
||||
!/[0-9a-f]/.test(jsonString[idx - 1])
|
||||
) {
|
||||
return true; // Incomplete low surrogate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// More efficient next search - skip ahead by 2 to avoid overlapping matches
|
||||
idx = jsonString.indexOf("\\u", idx + 2);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { env } from "~/env.server";
|
||||
import { emailMatchesPattern } from "./emailPattern";
|
||||
|
||||
export function assertEmailAllowed(email: string) {
|
||||
if (!env.WHITELISTED_EMAILS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailMatchesPattern(env.WHITELISTED_EMAILS, email)) {
|
||||
// Surfaced verbatim on the login page. Name the actual policy so a
|
||||
// rejection on a restricted instance reads as configuration, not a bug.
|
||||
throw new Error("This email address isn't allowed to sign in on this instance.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Match an email against an operator-supplied pattern (ADMIN_EMAILS /
|
||||
* WHITELISTED_EMAILS), anchored to the whole address with `^(?:...)$` so the
|
||||
* pattern matches the entire email rather than a substring.
|
||||
*
|
||||
* The non-capturing group keeps top-level alternation working
|
||||
* (`a@x.com|b@x.com` stays two whole-string alternatives). Patterns that
|
||||
* already carry their own `^`/`$` anchors remain equivalent. A top-level
|
||||
* alternative that is just `@domain.tld` is expanded to "any mailbox at exactly
|
||||
* that domain".
|
||||
*
|
||||
* Dependency-free so it can be tested directly; callers pass the pattern from `env`.
|
||||
*/
|
||||
export function emailMatchesPattern(pattern: string, email: string): boolean {
|
||||
return new RegExp(`^(?:${expandDomainShorthand(pattern)})$`).test(email);
|
||||
}
|
||||
|
||||
function expandDomainShorthand(pattern: string): string {
|
||||
return splitTopLevelAlternatives(pattern)
|
||||
.map((alternative) => {
|
||||
const domain = alternative.match(/^@([A-Za-z0-9.-]+)$/)?.[1];
|
||||
return domain ? `[^@]+@${escapeRegExp(domain)}` : alternative;
|
||||
})
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function splitTopLevelAlternatives(pattern: string): string[] {
|
||||
const alternatives: string[] = [];
|
||||
let current = "";
|
||||
let escaped = false;
|
||||
let depth = 0;
|
||||
let inCharacterClass = false;
|
||||
|
||||
for (const char of pattern) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
current += char;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "[" && !inCharacterClass) {
|
||||
inCharacterClass = true;
|
||||
} else if (char === "]" && inCharacterClass) {
|
||||
inCharacterClass = false;
|
||||
} else if (!inCharacterClass && char === "(") {
|
||||
depth++;
|
||||
} else if (!inCharacterClass && char === ")" && depth > 0) {
|
||||
depth--;
|
||||
}
|
||||
|
||||
if (char === "|" && depth === 0 && !inCharacterClass) {
|
||||
alternatives.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
alternatives.push(current);
|
||||
return alternatives;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Matches Prisma `EnvironmentPauseSource.BILLING_LIMIT`. Safe for client and server bundles. */
|
||||
export const ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT = "BILLING_LIMIT" as const;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
const environmentSortOrder: RuntimeEnvironmentType[] = [
|
||||
"DEVELOPMENT",
|
||||
"STAGING",
|
||||
"PREVIEW",
|
||||
"PRODUCTION",
|
||||
];
|
||||
|
||||
type SortType = {
|
||||
type: RuntimeEnvironmentType;
|
||||
userName?: string | null;
|
||||
lastActivity?: Date | undefined;
|
||||
updatedAt?: Date | undefined;
|
||||
};
|
||||
|
||||
export function sortEnvironments<T extends SortType>(
|
||||
environments: T[],
|
||||
sortOrder?: RuntimeEnvironmentType[]
|
||||
): T[] {
|
||||
const order = sortOrder ?? environmentSortOrder;
|
||||
return environments.sort((a, b) => {
|
||||
const aIndex = order.indexOf(a.type);
|
||||
const bIndex = order.indexOf(b.type);
|
||||
|
||||
const difference = aIndex - bIndex;
|
||||
|
||||
if (difference === 0) {
|
||||
if (a.type === "DEVELOPMENT" && b.type === "DEVELOPMENT") {
|
||||
// Within the same env type, order by recency: most-recent dev activity
|
||||
// first, falling back to updatedAt when there's no recorded activity,
|
||||
// then to username when we have no timestamps at all.
|
||||
const aTime = (a.lastActivity ?? a.updatedAt)?.getTime();
|
||||
const bTime = (b.lastActivity ?? b.updatedAt)?.getTime();
|
||||
|
||||
if (aTime !== undefined && bTime !== undefined) {
|
||||
return bTime - aTime;
|
||||
}
|
||||
if (aTime !== undefined) return -1;
|
||||
if (bTime !== undefined) return 1;
|
||||
}
|
||||
|
||||
const usernameA = a.userName || "";
|
||||
const usernameB = b.userName || "";
|
||||
return usernameA.localeCompare(usernameB);
|
||||
}
|
||||
|
||||
return difference;
|
||||
});
|
||||
}
|
||||
|
||||
type FilterableEnvironment =
|
||||
| {
|
||||
type: RuntimeEnvironmentType;
|
||||
orgMemberId?: string;
|
||||
}
|
||||
| {
|
||||
type: RuntimeEnvironmentType;
|
||||
//intentionally vague so we can match anything
|
||||
orgMember?: Record<string, any>;
|
||||
};
|
||||
|
||||
export function filterOrphanedEnvironments<T extends FilterableEnvironment>(
|
||||
environments: T[]
|
||||
): T[] {
|
||||
return environments.filter((environment) => {
|
||||
if (environment.type !== "DEVELOPMENT") return true;
|
||||
|
||||
if ("orgMemberId" in environment) {
|
||||
return !!environment.orgMemberId;
|
||||
}
|
||||
|
||||
if ("orgMember" in environment) {
|
||||
return !!environment.orgMember;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function onlyDevEnvironments<T extends FilterableEnvironment>(environments: T[]): T[] {
|
||||
return environments.filter((e) => e.type === "DEVELOPMENT");
|
||||
}
|
||||
|
||||
export function exceptDevEnvironments<T extends FilterableEnvironment>(environments: T[]): T[] {
|
||||
return environments.filter((e) => e.type !== "DEVELOPMENT");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Calculate error fingerprint using Sentry-style normalization.
|
||||
* Groups similar errors together by normalizing dynamic values.
|
||||
*/
|
||||
export function calculateErrorFingerprint(error: unknown): string {
|
||||
if (!error || typeof error !== "object" || Array.isArray(error)) return "";
|
||||
|
||||
// This is a but ugly but…
|
||||
// 1. We can't use a schema here because it's a hot path and needs to be fast.
|
||||
// 2. It won't be an instanceof Error because it's from the database.
|
||||
const errorObj = error as any;
|
||||
const errorType = String(errorObj.type || errorObj.name || "Error");
|
||||
// Fall back to the error class name, then the raw serialized value, so
|
||||
// messageless errors (e.g. tagged errors) and non-Error throws (strings,
|
||||
// plain objects) still group by something distinctive instead of collapsing
|
||||
// into a single fingerprint. Message-bearing errors are unaffected.
|
||||
const message = String(errorObj.message || errorObj.name || errorObj.raw || "");
|
||||
const stack = String(errorObj.stack || errorObj.stacktrace || "");
|
||||
|
||||
// Normalize message to group similar errors
|
||||
const normalizedMessage = normalizeErrorMessage(message);
|
||||
|
||||
// Extract and normalize first few stack frames
|
||||
const normalizedStack = normalizeStackTrace(stack);
|
||||
|
||||
// Create fingerprint from type + normalized message + stack
|
||||
const fingerprintInput = `${errorType}:${normalizedMessage}:${normalizedStack}`;
|
||||
|
||||
// Use SHA-256 hash, take first 16 chars for compact storage
|
||||
return createHash("sha256").update(fingerprintInput).digest("hex").substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize error message by replacing dynamic values with placeholders.
|
||||
* This allows similar errors to be grouped together.
|
||||
*/
|
||||
export function normalizeErrorMessage(message: string): string {
|
||||
if (!message) return "";
|
||||
|
||||
return (
|
||||
message
|
||||
// UUIDs (8-4-4-4-12 format)
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
|
||||
// Run IDs (run_xxxxx format)
|
||||
.replace(/run_[a-zA-Z0-9]+/g, "<run-id>")
|
||||
// Task run friendly IDs (task_xxxxx or similar)
|
||||
.replace(/\b[a-z]+_[a-zA-Z0-9]{8,}\b/g, "<id>")
|
||||
// --- Specific patterns must run before generic numeric/path replacements ---
|
||||
// ISO 8601 timestamps
|
||||
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, "<timestamp>")
|
||||
// Unix timestamps (10 or 13 digits)
|
||||
.replace(/\b\d{10,13}\b/g, "<timestamp>")
|
||||
// URLs (before path regex, which would strip the URL's path component)
|
||||
.replace(/https?:\/\/[^\s]+/g, "<url>")
|
||||
// --- Generic replacements ---
|
||||
// Standalone numeric IDs (4+ digits)
|
||||
.replace(/\b\d{4,}\b/g, "<id>")
|
||||
// File paths (Unix style)
|
||||
.replace(/(?:\/[^/\s]+){2,}/g, "<path>")
|
||||
// File paths (Windows style)
|
||||
.replace(/[A-Z]:\\(?:[^\\]+\\)+[^\\]+/g, "<path>")
|
||||
// Email addresses
|
||||
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "<email>")
|
||||
// Memory addresses (0x...)
|
||||
.replace(/0x[0-9a-fA-F]{8,}/g, "<addr>")
|
||||
// Quoted strings with dynamic content
|
||||
.replace(/"[^"]{20,}"/g, '"<string>"')
|
||||
.replace(/'[^']{20,}'/g, "'<string>'")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stack trace by taking first few frames and removing dynamic parts.
|
||||
*/
|
||||
export function normalizeStackTrace(stack: string): string {
|
||||
if (!stack) return "";
|
||||
|
||||
// Take first 5 stack frames only
|
||||
const lines = stack.split("\n").slice(0, 5);
|
||||
|
||||
return lines
|
||||
.map((line) => {
|
||||
// Remove line and column numbers (file.ts:123:45 -> file.ts:_:_)
|
||||
line = line.replace(/:\d+:\d+/g, ":_:_");
|
||||
// Remove standalone numbers
|
||||
line = line.replace(/\b\d+\b/g, "_");
|
||||
// Remove file paths but keep filename
|
||||
line = line.replace(/(?:\/[^/\s]+)+\/([^/\s]+)/g, "$1");
|
||||
// Normalize whitespace
|
||||
line = line.trim();
|
||||
return line;
|
||||
})
|
||||
.filter((line) => line.length > 0)
|
||||
.join("|");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Extracts the client IP address from the X-Forwarded-For header.
|
||||
* Takes the last item in the header since ALB appends the real client IP by default.
|
||||
*/
|
||||
export function extractClientIp(xff: string | null): string | null {
|
||||
if (!xff) return null;
|
||||
|
||||
const parts = xff.split(",").map((p) => p.trim());
|
||||
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function extractDomain(input: string): string | null {
|
||||
try {
|
||||
const withProtocol = input.includes("://") ? input : `https://${input}`;
|
||||
const url = new URL(withProtocol);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function faviconUrl(domain: string, size: number = 128): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BatchId,
|
||||
generateFriendlyId,
|
||||
generateRunOpsId,
|
||||
RunId,
|
||||
} from "@trigger.dev/core/v3/isomorphic";
|
||||
import { isValidFriendlyId, makeFriendlyIdValidator } from "./friendlyId";
|
||||
|
||||
describe("isValidFriendlyId", () => {
|
||||
it("accepts every id generation the real generators produce", () => {
|
||||
// nanoid (legacy V1), cuid (run-engine), run-ops v1 (run-ops split)
|
||||
expect(isValidFriendlyId(generateFriendlyId("run"), "run")).toBe(true);
|
||||
expect(isValidFriendlyId(RunId.generate().friendlyId, "run")).toBe(true);
|
||||
expect(isValidFriendlyId(RunId.toFriendlyId(generateRunOpsId()), "run")).toBe(true);
|
||||
|
||||
expect(isValidFriendlyId(generateFriendlyId("batch"), "batch")).toBe(true);
|
||||
expect(isValidFriendlyId(BatchId.generate().friendlyId, "batch")).toBe(true);
|
||||
expect(isValidFriendlyId(BatchId.toFriendlyId(generateRunOpsId()), "batch")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts each valid body length (21 nanoid, 25 cuid, 26 run-ops v1, 27 legacy base62)", () => {
|
||||
expect(isValidFriendlyId("run_" + "a".repeat(21), "run")).toBe(true);
|
||||
expect(isValidFriendlyId("run_" + "a".repeat(25), "run")).toBe(true);
|
||||
expect(isValidFriendlyId("run_" + "a".repeat(26), "run")).toBe(true);
|
||||
expect(isValidFriendlyId("run_" + "a".repeat(27), "run")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts mixed-case (uppercase) legacy base62 bodies", () => {
|
||||
expect(isValidFriendlyId("run_2ABCdefGHI0123456789jklMN", "run")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects the wrong prefix", () => {
|
||||
expect(isValidFriendlyId(RunId.generate().friendlyId, "batch")).toBe(false);
|
||||
expect(isValidFriendlyId("batch_" + "a".repeat(25), "run")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a bare (unprefixed) id", () => {
|
||||
expect(isValidFriendlyId("a".repeat(25), "run")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects body lengths that match no generator", () => {
|
||||
for (const len of [0, 20, 22, 24, 28]) {
|
||||
expect(isValidFriendlyId("run_" + "a".repeat(len), "run")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-base62 characters in the body", () => {
|
||||
expect(isValidFriendlyId("run_" + "-".repeat(25), "run")).toBe(false);
|
||||
expect(isValidFriendlyId("run_" + "!".repeat(25), "run")).toBe(false);
|
||||
// an underscore in the body is not base62
|
||||
expect(isValidFriendlyId("run_" + "a".repeat(24) + "_", "run")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat the prefix separator as optional", () => {
|
||||
// "runX..." shares the "run" prefix but not the "run_" marker
|
||||
expect(isValidFriendlyId("run" + "a".repeat(25), "run")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeFriendlyIdValidator", () => {
|
||||
const validateRunId = makeFriendlyIdValidator("run", "Run");
|
||||
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
|
||||
|
||||
it("returns undefined for a valid id of any generation", () => {
|
||||
expect(validateRunId(generateFriendlyId("run"))).toBeUndefined();
|
||||
expect(validateRunId(RunId.generate().friendlyId)).toBeUndefined();
|
||||
expect(validateRunId(RunId.toFriendlyId(generateRunOpsId()))).toBeUndefined();
|
||||
expect(validateBatchId(BatchId.toFriendlyId(generateRunOpsId()))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a wrong prefix distinctly from a wrong shape", () => {
|
||||
expect(validateRunId("batch_" + "a".repeat(25))).toBe("Run IDs start with 'run_'");
|
||||
expect(validateRunId("run_" + "a".repeat(20))).toBe("That doesn't look like a valid run ID");
|
||||
});
|
||||
|
||||
it("derives the marker and label per entity", () => {
|
||||
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
|
||||
expect(validateWaitpointId("run_" + "a".repeat(25))).toBe(
|
||||
"Waitpoint IDs start with 'waitpoint_'"
|
||||
);
|
||||
expect(validateWaitpointId("waitpoint_" + "a".repeat(20))).toBe(
|
||||
"That doesn't look like a valid waitpoint ID"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CUID_LENGTH, RUN_OPS_ID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
// The body after `<prefix>_` is an alphanumeric id; four generator lengths
|
||||
// remain valid in existing data and must all be accepted: 21 (nanoid),
|
||||
// 25 (cuid), 26 (run-ops v1 base32hex), 27 (pre-cutover base62, kept so old
|
||||
// ids still pass filter validation). cuid/run-ops come from core so this
|
||||
// tracks any future change.
|
||||
const NANOID_BODY_LENGTH = 21;
|
||||
const LEGACY_BASE62_BODY_LENGTH = 27;
|
||||
const VALID_BODY_LENGTHS: ReadonlySet<number> = new Set([
|
||||
NANOID_BODY_LENGTH,
|
||||
CUID_LENGTH,
|
||||
RUN_OPS_ID_LENGTH,
|
||||
LEGACY_BASE62_BODY_LENGTH,
|
||||
]);
|
||||
|
||||
const ALPHANUMERIC = /^[0-9A-Za-z]+$/;
|
||||
|
||||
export function isValidFriendlyId(value: string, prefix: string): boolean {
|
||||
const marker = `${prefix}_`;
|
||||
if (!value.startsWith(marker)) return false;
|
||||
const body = value.slice(marker.length);
|
||||
return VALID_BODY_LENGTHS.has(body.length) && ALPHANUMERIC.test(body);
|
||||
}
|
||||
|
||||
export function makeFriendlyIdValidator(prefix: string, label: string) {
|
||||
const marker = `${prefix}_`;
|
||||
return (value: string): string | undefined => {
|
||||
if (!value.startsWith(marker)) return `${label} IDs start with '${marker}'`;
|
||||
if (!isValidFriendlyId(value, prefix)) {
|
||||
return `That doesn't look like a valid ${label.toLowerCase()} ID`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
type Options<R> = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
window?: "MINUTE" | "HOUR" | "DAY";
|
||||
data: { date: Date; value?: R }[];
|
||||
};
|
||||
|
||||
export function createTimeSeriesData<R>({ startDate, endDate, window = "DAY", data }: Options<R>) {
|
||||
const outputData: Array<{ date: Date; value?: R }> = [];
|
||||
const periodLength = periodLengthMs(window);
|
||||
const periodCount = Math.round((endDate.getTime() - startDate.getTime()) / periodLength);
|
||||
|
||||
for (let i = 0; i < periodCount; i++) {
|
||||
const periodStart = new Date(startDate);
|
||||
periodStart.setTime(periodStart.getTime() + i * periodLength);
|
||||
const periodEnd = new Date(startDate);
|
||||
periodEnd.setTime(periodEnd.getTime() + (i + 1) * periodLength);
|
||||
|
||||
const foundData = data.find((d) => {
|
||||
const time = d.date.getTime();
|
||||
const inRange = time >= periodStart.getTime() && time < periodEnd.getTime();
|
||||
return inRange;
|
||||
});
|
||||
if (!foundData) {
|
||||
outputData.push({
|
||||
date: periodStart,
|
||||
});
|
||||
} else {
|
||||
outputData.push({
|
||||
date: periodStart,
|
||||
value: foundData.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outputData;
|
||||
}
|
||||
|
||||
function periodLengthMs(window: "MINUTE" | "HOUR" | "DAY") {
|
||||
switch (window) {
|
||||
case "MINUTE":
|
||||
return 60_000;
|
||||
case "HOUR":
|
||||
return 3_600_000;
|
||||
case "DAY":
|
||||
return 86_400_000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ExternalScriptsFunction } from "remix-utils/external-scripts";
|
||||
|
||||
export type Handle = {
|
||||
scripts?: ExternalScriptsFunction;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export function throwNotFound(statusText: string): never {
|
||||
throw new Response(undefined, { status: 404, statusText });
|
||||
}
|
||||
|
||||
export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return {
|
||||
title: "400: Bad request",
|
||||
message: statusText ?? "The request was invalid.",
|
||||
};
|
||||
case 401:
|
||||
return {
|
||||
title: "401: Unauthorized",
|
||||
message: statusText ?? "Please sign in to continue.",
|
||||
};
|
||||
case 403:
|
||||
return {
|
||||
title: "403: Forbidden",
|
||||
message: statusText ?? "You don't have permission to access this resource.",
|
||||
};
|
||||
case 404:
|
||||
return {
|
||||
title: "404: Page not found",
|
||||
message: statusText ?? "The page you're looking for doesn't exist.",
|
||||
};
|
||||
case 429:
|
||||
return {
|
||||
title: "429: Too many requests",
|
||||
message: statusText ?? "Please wait a moment and try again.",
|
||||
};
|
||||
case 500:
|
||||
return {
|
||||
title: "500: Server error",
|
||||
message: statusText ?? "Something went wrong on our end. Please try again later.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: `${statusCode}: Error`,
|
||||
message: statusText ?? "An error occurred.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Resolve the TTL for an idempotency key.
|
||||
*
|
||||
* The TTL format is a string like "5m", "1h", "7d"
|
||||
*
|
||||
* @param ttl The TTL string
|
||||
* @returns The date when the key will expire
|
||||
* @throws If the TTL string is invalid
|
||||
*/
|
||||
export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | undefined {
|
||||
if (!ttl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = ttl.match(/^(\d+)([smhd])$/);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
switch (unit) {
|
||||
case "s":
|
||||
now.setSeconds(now.getSeconds() + parseInt(value, 10));
|
||||
break;
|
||||
case "m":
|
||||
now.setMinutes(now.getMinutes() + parseInt(value, 10));
|
||||
break;
|
||||
case "h":
|
||||
now.setHours(now.getHours() + parseInt(value, 10));
|
||||
break;
|
||||
case "d":
|
||||
now.setDate(now.getDate() + parseInt(value, 10));
|
||||
break;
|
||||
}
|
||||
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// An inviter can only assign a role at or below their own. The systemRoles
|
||||
// array is in canonical order (highest authority first), so array index drives
|
||||
// the ladder. Custom roles aren't in the table and are refused. Dependency-free
|
||||
// so the rule can be unit-tested directly.
|
||||
|
||||
export type LadderRole = { id: string };
|
||||
|
||||
export function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
|
||||
const level: Record<string, number> = {};
|
||||
roles.forEach((r, i) => {
|
||||
// Top of the array = highest level; larger number means more authority.
|
||||
level[r.id] = roles.length - i;
|
||||
});
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an inviter holding `inviterRoleId` may assign `invitedRoleId`.
|
||||
* A roleless inviter (`inviterRoleId == null`) and custom/unknown roles absent
|
||||
* from the ladder are all refused.
|
||||
*/
|
||||
export function isAtOrBelow(
|
||||
roles: ReadonlyArray<LadderRole>,
|
||||
inviterRoleId: string | null,
|
||||
invitedRoleId: string
|
||||
): boolean {
|
||||
if (!inviterRoleId) return false;
|
||||
const level = buildRoleLevel(roles);
|
||||
const inviter = level[inviterRoleId];
|
||||
const invited = level[invitedRoleId];
|
||||
if (inviter === undefined || invited === undefined) return false;
|
||||
return invited <= inviter;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export function safeJsonParse(json?: string): unknown {
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeJsonZodParse<T>(
|
||||
schema: z.Schema<T>,
|
||||
json: string
|
||||
): z.SafeParseReturnType<unknown, T> | undefined {
|
||||
const parsed = safeJsonParse(json);
|
||||
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return schema.safeParse(parsed);
|
||||
}
|
||||
|
||||
export async function safeJsonFromResponse(response: Response) {
|
||||
const json = await response.text();
|
||||
return safeJsonParse(json);
|
||||
}
|
||||
|
||||
export async function safeBodyFromResponse<T>(
|
||||
response: Response,
|
||||
schema: z.Schema<T>
|
||||
): Promise<T | undefined> {
|
||||
const json = await response.text();
|
||||
const unknownJson = safeJsonParse(json);
|
||||
|
||||
if (!unknownJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedJson = schema.safeParse(unknownJson);
|
||||
|
||||
if (parsedJson.success) {
|
||||
return parsedJson.data;
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeParseBodyFromResponse<T>(
|
||||
response: Response,
|
||||
schema: z.Schema<T>
|
||||
): Promise<z.SafeParseReturnType<unknown, T> | undefined> {
|
||||
try {
|
||||
const unknownJson = await response.json();
|
||||
|
||||
if (!unknownJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedJson = schema.safeParse(unknownJson);
|
||||
|
||||
return parsedJson;
|
||||
} catch (_error) {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Linearly interpolates between the min/max values, using t.
|
||||
* It can't go outside the range */
|
||||
export function lerp(min: number, max: number, t: number) {
|
||||
return min + (max - min) * clamp(t, 0, 1);
|
||||
}
|
||||
|
||||
/** Inverse lerp */
|
||||
export function inverseLerp(min: number, max: number, value: number) {
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
/** Clamps a value between a min and max */
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
|
||||
export type LogLevel = z.infer<typeof LogLevelSchema>;
|
||||
|
||||
export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
// Default styles for search highlighting
|
||||
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
|
||||
backgroundColor: "#facc15", // yellow-400
|
||||
color: "#000000",
|
||||
fontWeight: "500",
|
||||
borderRadius: "0.25rem",
|
||||
padding: "0 0.125rem",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Highlights all occurrences of a search term in text with consistent styling.
|
||||
* Case-insensitive search with regex special character escaping.
|
||||
*
|
||||
* @param text - The text to search within
|
||||
* @param searchTerm - The term to highlight (optional)
|
||||
* @param style - Optional custom inline styles for highlights
|
||||
* @returns React nodes with highlighted matches, or the original text if no matches
|
||||
*/
|
||||
export function highlightSearchText(
|
||||
text: string,
|
||||
searchTerm?: string,
|
||||
style: React.CSSProperties = DEFAULT_HIGHLIGHT_STYLES
|
||||
): ReactNode {
|
||||
if (!searchTerm || searchTerm.trim() === "") {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Defense in depth: limit search term length to prevent ReDoS and performance issues
|
||||
const MAX_SEARCH_LENGTH = 500;
|
||||
if (searchTerm.length > MAX_SEARCH_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Escape special regex characters in search term
|
||||
const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(escapedSearch, "gi");
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let matchCount = 0;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
// Add highlighted match
|
||||
parts.push(createElement("span", { key: `match-${matchCount}`, style }, match[0]));
|
||||
lastIndex = regex.lastIndex;
|
||||
matchCount++;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
}
|
||||
|
||||
// Convert ClickHouse kind to display level
|
||||
export function kindToLevel(kind: string, status: string): LogLevel {
|
||||
// ERROR can come from either kind or status
|
||||
if (kind === "LOG_ERROR" || status === "ERROR") {
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "DEBUG_EVENT":
|
||||
case "LOG_DEBUG":
|
||||
return "DEBUG";
|
||||
case "LOG_INFO":
|
||||
return "INFO";
|
||||
case "LOG_WARN":
|
||||
return "WARN";
|
||||
case "LOG_LOG":
|
||||
return "INFO"; // Changed from "LOG"
|
||||
case "SPAN":
|
||||
return "TRACE";
|
||||
case "ANCESTOR_OVERRIDE":
|
||||
case "SPAN_EVENT":
|
||||
default:
|
||||
return "INFO";
|
||||
}
|
||||
}
|
||||
|
||||
// Level badge color styles
|
||||
export function getLevelColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "text-error bg-error/10 border-error/20";
|
||||
case "WARN":
|
||||
return "text-warning bg-warning/10 border-warning/20";
|
||||
case "TRACE":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
|
||||
case "DEBUG":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// When proxying long-polling requests, content-encoding & content-length are added
|
||||
// erroneously (saying the body is gzipped when it's not) so we'll just remove
|
||||
// them to avoid content decoding errors in the browser.
|
||||
//
|
||||
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
// Similar-ish problem to https://github.com/wintercg/fetch/issues/23
|
||||
export async function longPollingFetch(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
rewriteResponseHeaders?: Record<string, string>
|
||||
) {
|
||||
let upstream: Response | undefined;
|
||||
try {
|
||||
upstream = await fetch(url, options);
|
||||
let response = upstream;
|
||||
|
||||
if (response.headers.get("content-encoding")) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-encoding");
|
||||
headers.delete("content-length");
|
||||
|
||||
response = new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
if (rewriteResponseHeaders) {
|
||||
const headers = new Headers(response.headers);
|
||||
|
||||
for (const [fromKey, toKey] of Object.entries(rewriteResponseHeaders)) {
|
||||
const value = headers.get(fromKey);
|
||||
if (value) {
|
||||
headers.set(toKey, value);
|
||||
headers.delete(fromKey);
|
||||
}
|
||||
}
|
||||
|
||||
response = new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Release upstream undici socket + buffers explicitly. Without this the
|
||||
// ReadableStream stays open and undici keeps buffering chunks into memory
|
||||
// until the upstream times out (see H1 isolation test — ~44 KB retained
|
||||
// per unconsumed-body fetch in RSS).
|
||||
try {
|
||||
await upstream?.body?.cancel();
|
||||
} catch {}
|
||||
|
||||
// AbortError is the expected path when downstream disconnects with a
|
||||
// propagated signal — treat as a clean client-close, not a server error.
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Response(null, { status: 499 });
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
logger.error("Network error:", { error: error.message });
|
||||
throw new Response("Network error occurred", { status: 503 });
|
||||
} else if (error instanceof Error) {
|
||||
logger.error("Fetch error:", { error: error.message });
|
||||
throw new Response(error.message, { status: 500 });
|
||||
} else {
|
||||
logger.error("Unknown error occurred during fetch");
|
||||
throw new Response("An unknown error occurred", { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { formatNumberCompact } from "./numberFormatter";
|
||||
|
||||
/** Format a per-token price as $/1M tokens. */
|
||||
export function formatModelPrice(pricePerToken: number | null): string {
|
||||
if (pricePerToken === null) return "—";
|
||||
const perMillion = pricePerToken * 1_000_000;
|
||||
if (perMillion < 0.01) return `$${perMillion.toFixed(4)}`;
|
||||
if (perMillion < 1) return `$${perMillion.toFixed(3)}`;
|
||||
return `$${perMillion.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Format a token count (context window, max output). */
|
||||
export function formatTokenCount(tokens: number | null): string {
|
||||
if (tokens === null) return "—";
|
||||
return formatNumberCompact(tokens);
|
||||
}
|
||||
|
||||
/** Format a dollar cost value. */
|
||||
export function formatModelCost(dollars: number): string {
|
||||
if (dollars === 0) return "$0";
|
||||
if (dollars < 0.01) return `$${dollars.toFixed(4)}`;
|
||||
if (dollars < 1) return `$${dollars.toFixed(3)}`;
|
||||
return `$${dollars.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Format a feature slug (snake_case) to Title Case. */
|
||||
export function formatFeature(slug: string): string {
|
||||
return slug
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** @deprecated Use formatFeature instead. */
|
||||
export const formatCapability = formatFeature;
|
||||
|
||||
/** Capitalize a provider name. */
|
||||
export function formatProviderName(provider: string): string {
|
||||
const names: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
google: "Google",
|
||||
meta: "Meta",
|
||||
mistral: "Mistral",
|
||||
cohere: "Cohere",
|
||||
ai21: "AI21",
|
||||
amazon: "Amazon",
|
||||
xai: "xAI",
|
||||
deepseek: "DeepSeek",
|
||||
qwen: "Qwen",
|
||||
perplexity: "Perplexity",
|
||||
nous: "Nous",
|
||||
};
|
||||
return names[provider.toLowerCase()] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const compactFormatter = Intl.NumberFormat("en", { notation: "compact", compactDisplay: "short" });
|
||||
|
||||
export const formatNumberCompact = (num: number): string => {
|
||||
return compactFormatter.format(num);
|
||||
};
|
||||
|
||||
const formatter = Intl.NumberFormat("en");
|
||||
|
||||
// Formatter for small decimal values that need more precision
|
||||
const preciseFormatter = Intl.NumberFormat("en", {
|
||||
minimumSignificantDigits: 1,
|
||||
maximumSignificantDigits: 6,
|
||||
});
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
// For very small numbers (between -1 and 1, exclusive), use precise formatting
|
||||
// to avoid rounding 0.000025 to 0
|
||||
if (num !== 0 && Math.abs(num) < 1) {
|
||||
return preciseFormatter.format(num);
|
||||
}
|
||||
return formatter.format(num);
|
||||
};
|
||||
|
||||
const roundedCurrencyFormatter = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currencyDisplay: "symbol",
|
||||
maximumFractionDigits: 0,
|
||||
currency: "USD",
|
||||
});
|
||||
const currencyFormatter = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currencyDisplay: "symbol",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
export const formatCurrency = (num: number, rounded: boolean): string => {
|
||||
return rounded ? roundedCurrencyFormatter.format(num) : currencyFormatter.format(num);
|
||||
};
|
||||
|
||||
const accurateCurrencyFormatter = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currencyDisplay: "symbol",
|
||||
minimumFractionDigits: 8,
|
||||
maximumFractionDigits: 8,
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
export function formatCurrencyAccurate(num: number): string {
|
||||
return accurateCurrencyFormatter.format(num);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function omit<T extends Record<string, unknown>, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): Omit<T, K> {
|
||||
const result: any = {};
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!keys.includes(key as K)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { IOPacket } from "@trigger.dev/core/v3/utils/ioSerialization";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
export class MetadataTooLargeError extends ServiceValidationError {
|
||||
constructor(message: string) {
|
||||
super(message, 413);
|
||||
this.name = "MetadataTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMetadataPacket(
|
||||
metadata: any,
|
||||
metadataType: string,
|
||||
maximumSize: number
|
||||
): IOPacket | undefined {
|
||||
let metadataPacket: IOPacket | undefined = undefined;
|
||||
|
||||
if (typeof metadata === "string") {
|
||||
metadataPacket = { data: metadata, dataType: metadataType };
|
||||
}
|
||||
|
||||
if (metadataType === "application/json") {
|
||||
metadataPacket = { data: JSON.stringify(metadata), dataType: "application/json" };
|
||||
}
|
||||
|
||||
if (!metadataPacket || !metadataPacket.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byteLength = Buffer.byteLength(metadataPacket.data, "utf8");
|
||||
|
||||
if (byteLength > maximumSize) {
|
||||
throw new MetadataTooLargeError(`Metadata exceeds maximum size of ${maximumSize} bytes`);
|
||||
}
|
||||
|
||||
return metadataPacket;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Attributes } from "@opentelemetry/api";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
export async function parseRequestJsonAsync(
|
||||
request: Request,
|
||||
attributes?: Attributes
|
||||
): Promise<unknown> {
|
||||
return await startActiveSpan(
|
||||
"parseRequestJsonAsync()",
|
||||
async (span) => {
|
||||
span.setAttribute("content-length", parseInt(request.headers.get("content-length") ?? "0"));
|
||||
span.setAttribute("content-type", request.headers.get("content-type") ?? "application/json");
|
||||
span.setAttribute("experiment.async", false);
|
||||
|
||||
const rawText = await startActiveSpan("request.text()", async () => {
|
||||
return await request.text();
|
||||
});
|
||||
|
||||
if (rawText.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return JSON.parse(rawText);
|
||||
},
|
||||
{
|
||||
attributes,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import type { RuntimeEnvironment, TaskRun, WorkerDeployment } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { Project } from "~/models/project.server";
|
||||
import { RUNS_BULK_INSPECTOR_OPEN_VALUE } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
|
||||
import { objectToSearchParams } from "./searchParams";
|
||||
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
|
||||
export type OrgForPath = Pick<Organization, "slug">;
|
||||
export type ProjectForPath = Pick<Project, "slug">;
|
||||
export type EnvironmentForPath = Pick<RuntimeEnvironment, "slug">;
|
||||
export type v3RunForPath = Pick<TaskRun, "friendlyId">;
|
||||
export type v3SpanForPath = Pick<TaskRun, "spanId">;
|
||||
export type DeploymentForPath = Pick<WorkerDeployment, "shortCode">;
|
||||
export type TaskForPath = {
|
||||
taskIdentifier: string;
|
||||
};
|
||||
|
||||
export const OrganizationParamsSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectParamSchema = OrganizationParamsSchema.extend({
|
||||
projectParam: z.string(),
|
||||
});
|
||||
|
||||
export const EnvironmentParamSchema = ProjectParamSchema.extend({
|
||||
envParam: z.string(),
|
||||
});
|
||||
|
||||
//v3
|
||||
export const v3TaskParamsSchema = EnvironmentParamSchema.extend({
|
||||
taskParam: z.string(),
|
||||
});
|
||||
|
||||
export const v3RunParamsSchema = EnvironmentParamSchema.extend({
|
||||
runParam: z.string(),
|
||||
});
|
||||
|
||||
export const v3SpanParamsSchema = v3RunParamsSchema.extend({
|
||||
spanParam: z.string(),
|
||||
});
|
||||
|
||||
export const v3RunStreamParamsSchema = v3RunParamsSchema.extend({
|
||||
streamKey: z.string(),
|
||||
});
|
||||
|
||||
export const v3DeploymentParams = EnvironmentParamSchema.extend({
|
||||
deploymentParam: z.string(),
|
||||
});
|
||||
|
||||
export const v3ScheduleParams = EnvironmentParamSchema.extend({
|
||||
scheduleParam: z.string(),
|
||||
});
|
||||
|
||||
export function rootPath() {
|
||||
return `/`;
|
||||
}
|
||||
|
||||
/** Given a path, it makes it an impersonation path */
|
||||
export function impersonate(path: string) {
|
||||
return `/@${path}`;
|
||||
}
|
||||
|
||||
export function accountPath() {
|
||||
return `/account`;
|
||||
}
|
||||
|
||||
export function personalAccessTokensPath() {
|
||||
return `/account/tokens`;
|
||||
}
|
||||
|
||||
export function accountSecurityPath() {
|
||||
return `/account/security`;
|
||||
}
|
||||
|
||||
export function invitesPath() {
|
||||
return `/invites`;
|
||||
}
|
||||
|
||||
export function confirmBasicDetailsPath() {
|
||||
return `/confirm-basic-details`;
|
||||
}
|
||||
|
||||
export function acceptInvitePath(token: string) {
|
||||
return `/invite-accept?token=${token}`;
|
||||
}
|
||||
|
||||
export function resendInvitePath() {
|
||||
return `/invite-resend`;
|
||||
}
|
||||
|
||||
export function logoutPath() {
|
||||
return `/logout`;
|
||||
}
|
||||
|
||||
export function revokeInvitePath() {
|
||||
return `/invite-revoke`;
|
||||
}
|
||||
|
||||
// Org
|
||||
export function organizationPath(organization: OrgForPath) {
|
||||
return `/orgs/${organizationParam(organization)}`;
|
||||
}
|
||||
|
||||
export function newOrganizationPath() {
|
||||
return `/orgs/new`;
|
||||
}
|
||||
|
||||
export function selectPlanPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/select-plan`;
|
||||
}
|
||||
|
||||
export function organizationTeamPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/team`;
|
||||
}
|
||||
|
||||
export function organizationRolesPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/roles`;
|
||||
}
|
||||
|
||||
export function organizationSsoPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/sso`;
|
||||
}
|
||||
|
||||
export function inviteTeamMemberPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/invite`;
|
||||
}
|
||||
|
||||
export function organizationBillingPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/billing`;
|
||||
}
|
||||
|
||||
export function organizationSettingsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings`;
|
||||
}
|
||||
|
||||
export function organizationIntegrationsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/integrations`;
|
||||
}
|
||||
|
||||
export function organizationVercelIntegrationPath(organization: OrgForPath) {
|
||||
return `${organizationIntegrationsPath(organization)}/vercel`;
|
||||
}
|
||||
|
||||
export function organizationSlackIntegrationPath(organization: OrgForPath) {
|
||||
return `${organizationIntegrationsPath(organization)}/slack`;
|
||||
}
|
||||
|
||||
function organizationParam(organization: OrgForPath) {
|
||||
return organization.slug;
|
||||
}
|
||||
|
||||
// Project
|
||||
export function newProjectPath(organization: OrgForPath, message?: string) {
|
||||
return `${organizationPath(organization)}/projects/new${
|
||||
message ? `?message=${encodeURIComponent(message)}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
function projectParam(project: ProjectForPath) {
|
||||
return project.slug;
|
||||
}
|
||||
|
||||
function environmentParam(environment: EnvironmentForPath) {
|
||||
return environment.slug;
|
||||
}
|
||||
|
||||
//v3 project
|
||||
export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`;
|
||||
}
|
||||
|
||||
export function githubAppInstallPath(organizationSlug: string, redirectTo: string) {
|
||||
return `/github/install?org_slug=${organizationSlug}&redirect_to=${encodeURIComponent(
|
||||
redirectTo
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function vercelAppInstallPath(organizationSlug: string, projectSlug: string) {
|
||||
return `/vercel/install?org_slug=${organizationSlug}&project_slug=${projectSlug}`;
|
||||
}
|
||||
|
||||
export function vercelCallbackPath() {
|
||||
return `/vercel/callback`;
|
||||
}
|
||||
|
||||
export function vercelResourcePath(
|
||||
organizationSlug: string,
|
||||
projectSlug: string,
|
||||
environmentSlug: string
|
||||
) {
|
||||
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`;
|
||||
}
|
||||
|
||||
export function v3EnvironmentPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `/orgs/${organizationParam(organization)}/projects/${projectParam(
|
||||
project
|
||||
)}/env/${environmentParam(environment)}`;
|
||||
}
|
||||
|
||||
export function v3TasksDashboardPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/tasks/dashboard`;
|
||||
}
|
||||
|
||||
export function v3TasksStreamingPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/tasks/stream`;
|
||||
}
|
||||
|
||||
export function v3ApiKeysPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/apikeys`;
|
||||
}
|
||||
|
||||
export function v3BulkActionsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/bulk-actions`;
|
||||
}
|
||||
|
||||
export function v3BulkActionPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
bulkAction: { friendlyId: string }
|
||||
) {
|
||||
return `${v3BulkActionsPath(organization, project, environment)}/${bulkAction.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3EnvironmentVariablesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/environment-variables`;
|
||||
}
|
||||
|
||||
export function v3NewEnvironmentVariablesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentVariablesPath(organization, project, environment)}/new`;
|
||||
}
|
||||
|
||||
export function v3ProjectAlertsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/alerts`;
|
||||
}
|
||||
|
||||
export function v3NewProjectAlertPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ProjectAlertsPath(organization, project, environment)}/new`;
|
||||
}
|
||||
|
||||
export function v3NewProjectAlertPathConnectToSlackPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ProjectAlertsPath(organization, project, environment)}/new/connect-to-slack`;
|
||||
}
|
||||
|
||||
export function v3TestPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/test`;
|
||||
}
|
||||
|
||||
export function queryPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/query`;
|
||||
}
|
||||
|
||||
export function v3CustomDashboardPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
dashboard: { friendlyId: string }
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/dashboards/custom/${
|
||||
dashboard.friendlyId
|
||||
}`;
|
||||
}
|
||||
|
||||
export function v3BuiltInDashboardPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
key: string
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/dashboards/${key}`;
|
||||
}
|
||||
|
||||
export function v3DashboardsLandingPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/dashboards`;
|
||||
}
|
||||
|
||||
export function v3TestTaskPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
task: TaskForPath
|
||||
) {
|
||||
return `${v3TestPath(organization, project, environment)}/tasks/${encodeURIComponent(
|
||||
task.taskIdentifier
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function v3PlaygroundPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/playground`;
|
||||
}
|
||||
|
||||
export function v3PlaygroundAgentPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
agentSlug: string
|
||||
) {
|
||||
return `${v3PlaygroundPath(organization, project, environment)}/${encodeURIComponent(agentSlug)}`;
|
||||
}
|
||||
|
||||
export function v3AgentTaskPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
agentSlug: string
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/agents/${encodeURIComponent(
|
||||
agentSlug
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function v3StandardTaskPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
taskSlug: string
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/tasks/standard/${encodeURIComponent(
|
||||
taskSlug
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function v3ScheduledTaskPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
taskSlug: string
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/tasks/scheduled/${encodeURIComponent(
|
||||
taskSlug
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function v3RunsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
filters?: TaskRunListSearchFilters
|
||||
) {
|
||||
const searchParams = objectToSearchParams(filters);
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`;
|
||||
}
|
||||
|
||||
export function v3CreateBulkActionPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
filters?: TaskRunListSearchFilters,
|
||||
mode?: "selected" | "filter",
|
||||
action?: "replay" | "cancel"
|
||||
) {
|
||||
const searchParams = objectToSearchParams(filters) ?? new URLSearchParams();
|
||||
searchParams.set("bulkInspector", RUNS_BULK_INSPECTOR_OPEN_VALUE);
|
||||
if (mode) {
|
||||
searchParams.set("mode", mode);
|
||||
}
|
||||
if (action) {
|
||||
searchParams.set("action", action);
|
||||
}
|
||||
const query = `?${searchParams.toString()}`;
|
||||
return `${v3RunsPath(organization, project, environment)}${query}`;
|
||||
}
|
||||
|
||||
export function v3RunPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath,
|
||||
searchParams?: URLSearchParams
|
||||
) {
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
|
||||
}
|
||||
|
||||
export function v3RunRedirectPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
run: v3RunForPath
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3RunPathFromFriendlyId(runId: string) {
|
||||
return `/runs/${runId}`;
|
||||
}
|
||||
|
||||
export function v3RunDownloadLogsPath(run: v3RunForPath) {
|
||||
return `/resources/runs/${run.friendlyId}/logs/download`;
|
||||
}
|
||||
|
||||
export function v3RunSpanPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath,
|
||||
span: v3SpanForPath,
|
||||
searchParams?: URLSearchParams
|
||||
) {
|
||||
searchParams = searchParams ?? new URLSearchParams();
|
||||
searchParams.set("span", span.spanId);
|
||||
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
|
||||
}
|
||||
|
||||
export function v3RunStreamingPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath
|
||||
) {
|
||||
return `${v3RunPath(organization, project, environment, run)}/stream`;
|
||||
}
|
||||
|
||||
export function v3RunIdempotencyKeyResetPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath
|
||||
) {
|
||||
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
|
||||
project
|
||||
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
|
||||
}
|
||||
|
||||
export function v3SchedulePath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
schedule: { friendlyId: string }
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/schedules/${
|
||||
schedule.friendlyId
|
||||
}`;
|
||||
}
|
||||
|
||||
export function v3EditSchedulePath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
schedule: { friendlyId: string }
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/schedules/edit/${
|
||||
schedule.friendlyId
|
||||
}`;
|
||||
}
|
||||
|
||||
export function v3NewSchedulePath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/schedules/new`;
|
||||
}
|
||||
|
||||
export function v3SchedulesAddOnPath(organization: OrgForPath) {
|
||||
return `/resources/orgs/${organizationParam(organization)}/schedules-addon`;
|
||||
}
|
||||
|
||||
export function v3QueuesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/queues`;
|
||||
}
|
||||
|
||||
export function v3WaitpointTokensPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
filters?: WaitpointSearchParams
|
||||
) {
|
||||
const searchParams = objectToSearchParams(filters);
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/waitpoints/tokens${query}`;
|
||||
}
|
||||
|
||||
export function v3WaitpointTokenPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
token: { id: string },
|
||||
filters?: WaitpointSearchParams
|
||||
) {
|
||||
const searchParams = objectToSearchParams(filters);
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3WaitpointTokensPath(organization, project, environment)}/${token.id}${query}`;
|
||||
}
|
||||
|
||||
export function v3BatchesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/batches`;
|
||||
}
|
||||
|
||||
export function v3SessionsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/sessions`;
|
||||
}
|
||||
|
||||
export function v3SessionPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
session: { friendlyId: string }
|
||||
) {
|
||||
return `${v3SessionsPath(organization, project, environment)}/${session.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3BatchPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
batch: { friendlyId: string }
|
||||
) {
|
||||
return `${v3BatchesPath(organization, project, environment)}/${batch.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3BatchRunsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
batch: { friendlyId: string }
|
||||
) {
|
||||
return `${v3RunsPath(organization, project, environment, { batchId: batch.friendlyId })}`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/settings`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsGeneralPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ProjectSettingsPath(organization, project, environment)}/general`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsIntegrationsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ProjectSettingsPath(organization, project, environment)}/integrations`;
|
||||
}
|
||||
|
||||
export function v3LogsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/logs`;
|
||||
}
|
||||
|
||||
export function v3PromptsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/prompts`;
|
||||
}
|
||||
|
||||
export function v3PromptPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
promptSlug: string,
|
||||
version?: string | number
|
||||
) {
|
||||
const base = `${v3PromptsPath(organization, project, environment)}/${promptSlug}`;
|
||||
return version != null ? `${base}?version=${version}` : base;
|
||||
}
|
||||
|
||||
export function v3ModelsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/models`;
|
||||
}
|
||||
|
||||
export function v3ModelDetailPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
modelId: string
|
||||
) {
|
||||
return `${v3ModelsPath(organization, project, environment)}/${modelId}`;
|
||||
}
|
||||
|
||||
export function v3ModelComparePath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ModelsPath(organization, project, environment)}/compare`;
|
||||
}
|
||||
|
||||
export function v3ErrorsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/errors`;
|
||||
}
|
||||
|
||||
export function v3ErrorsConnectToSlackPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ErrorsPath(organization, project, environment)}/connect-to-slack`;
|
||||
}
|
||||
|
||||
export function v3ErrorPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
error: { fingerprint: string }
|
||||
) {
|
||||
return `${v3ErrorsPath(organization, project, environment)}/${error.fingerprint}`;
|
||||
}
|
||||
|
||||
export function v3DeploymentsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/deployments`;
|
||||
}
|
||||
|
||||
export function v3DeploymentPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
deployment: DeploymentForPath,
|
||||
currentPage: number
|
||||
) {
|
||||
const query = currentPage ? `?page=${currentPage}` : "";
|
||||
return `${v3DeploymentsPath(organization, project, environment)}/${deployment.shortCode}${query}`;
|
||||
}
|
||||
|
||||
export function v3DeploymentVersionPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
version: string
|
||||
) {
|
||||
return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`;
|
||||
}
|
||||
|
||||
export function branchesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/branches`;
|
||||
}
|
||||
|
||||
export function branchesDevPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/dev-branches`;
|
||||
}
|
||||
|
||||
export function concurrencyPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/concurrency`;
|
||||
}
|
||||
|
||||
export function limitsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/limits`;
|
||||
}
|
||||
|
||||
export function regionsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/regions`;
|
||||
}
|
||||
|
||||
export function v3BillingPath(organization: OrgForPath, message?: string) {
|
||||
return `${organizationPath(organization)}/settings/billing${
|
||||
message ? `?message=${encodeURIComponent(message)}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function v3BillingLimitsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/billing-limits`;
|
||||
}
|
||||
|
||||
/** @deprecated Use v3BillingLimitsPath — redirects from billing-alerts are preserved */
|
||||
export function v3BillingAlertsPath(organization: OrgForPath) {
|
||||
return v3BillingLimitsPath(organization);
|
||||
}
|
||||
|
||||
export function v3PrivateConnectionsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/private-connections`;
|
||||
}
|
||||
|
||||
export function v3NewPrivateConnectionPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/private-connections/new`;
|
||||
}
|
||||
|
||||
export function v3StripePortalPath(organization: OrgForPath) {
|
||||
return `/resources/${organization.slug}/subscription/portal`;
|
||||
}
|
||||
|
||||
export function v3UsagePath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/usage`;
|
||||
}
|
||||
|
||||
// Docs
|
||||
export function docsRoot() {
|
||||
return "https://trigger.dev/docs";
|
||||
}
|
||||
|
||||
export function docsPath(path: string) {
|
||||
return `${docsRoot()}/${path}`;
|
||||
}
|
||||
|
||||
export function docsTroubleshootingPath(path: string) {
|
||||
return `${docsRoot()}/v3/troubleshooting`;
|
||||
}
|
||||
|
||||
export function adminPath() {
|
||||
return `/@`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Convert a period string like "7d", "24h", "30m" to milliseconds. Defaults to 7d. */
|
||||
export function parsePeriodToMs(period: string): number {
|
||||
const match = period.match(/^(\d+)([mhdw])$/);
|
||||
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
||||
const [, numStr, unit] = match;
|
||||
const num = parseInt(numStr, 10);
|
||||
switch (unit) {
|
||||
case "m":
|
||||
return num * 60 * 1000;
|
||||
case "h":
|
||||
return num * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return num * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return num * 7 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
|
||||
// Marker on the thrown 403 body so the error boundary can tell a
|
||||
// permission denial apart from any other route error.
|
||||
export const PERMISSION_DENIED_MARKER = "rbac-permission-denied";
|
||||
|
||||
const DEFAULT_PERMISSION_DENIED_MESSAGE = "You don't have permission to access this page.";
|
||||
|
||||
/** Build the 403 response thrown when the current role lacks access. */
|
||||
export function permissionDeniedResponse(message?: string): Response {
|
||||
return json(
|
||||
{ [PERMISSION_DENIED_MARKER]: true, message: message ?? DEFAULT_PERMISSION_DENIED_MESSAGE },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw from a loader/action when the current role lacks access. The thrown
|
||||
* 403 bubbles to the nearest route ErrorBoundary, where RouteErrorDisplay
|
||||
* renders the permission panel. `dashboardLoader`/`dashboardAction` do this
|
||||
* automatically when an `authorization` block fails; call this directly for
|
||||
* checks the block can't express (e.g. "any of these permissions").
|
||||
*/
|
||||
export function throwPermissionDenied(message?: string): never {
|
||||
throw permissionDeniedResponse(message);
|
||||
}
|
||||
|
||||
/** Returns the message when `data` is a permission-denied payload, else null. */
|
||||
export function permissionDeniedMessage(data: unknown): string | null {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
(data as Record<string, unknown>)[PERMISSION_DENIED_MARKER]
|
||||
) {
|
||||
const message = (data as Record<string, unknown>).message;
|
||||
return typeof message === "string" ? message : DEFAULT_PERMISSION_DENIED_MESSAGE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { uiComponent } from "@team-plain/typescript-sdk";
|
||||
import { PlainClient } from "@team-plain/typescript-sdk";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
type Input = {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
title: string;
|
||||
components: ReturnType<typeof uiComponent.text>[];
|
||||
labelTypeIds?: string[];
|
||||
};
|
||||
|
||||
export async function sendToPlain({ userId, email, name, title, components, labelTypeIds }: Input) {
|
||||
if (!env.PLAIN_API_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new PlainClient({
|
||||
apiKey: env.PLAIN_API_KEY,
|
||||
});
|
||||
|
||||
const upsertCustomerRes = await client.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: email,
|
||||
},
|
||||
onCreate: {
|
||||
externalId: userId,
|
||||
fullName: name,
|
||||
email: {
|
||||
email: email,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
onUpdate: {
|
||||
externalId: { value: userId },
|
||||
fullName: { value: name },
|
||||
email: {
|
||||
email: email,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (upsertCustomerRes.error) {
|
||||
console.error("Failed to upsert customer in Plain", upsertCustomerRes.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const createThreadRes = await client.createThread({
|
||||
customerIdentifier: {
|
||||
customerId: upsertCustomerRes.data.customer.id,
|
||||
},
|
||||
title: title,
|
||||
components: components,
|
||||
labelTypeIds,
|
||||
});
|
||||
|
||||
if (createThreadRes.error) {
|
||||
console.error("Failed to create thread in Plain", createThreadRes.error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Prisma, type PrismaClient, isPrismaKnownError } from "@trigger.dev/database";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
// Minimal structural logger so this stays decoupled from the concrete Logger
|
||||
// (and lets tests pass a capturing logger).
|
||||
type ErrorLogger = { error: (message: string, fields?: Record<string, unknown>) => void };
|
||||
|
||||
// Prisma connectivity / infrastructure error codes — engine- and
|
||||
// connection-level failures, not query- or validation-level ones. When the
|
||||
// database is unreachable, Prisma 6.x throws a PrismaClientKnownRequestError
|
||||
// carrying one of these codes (e.g. P1001 "Can't reach database server").
|
||||
const INFRASTRUCTURE_PRISMA_CODES = new Set([
|
||||
"P1001", // Can't reach database server
|
||||
"P1002", // Database server reached but timed out
|
||||
"P1008", // Operations timed out
|
||||
"P1017", // Server has closed the connection
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when `error` is a Prisma infrastructure/connectivity failure (DB
|
||||
* unreachable, timed out, connection dropped) rather than a query- or
|
||||
* validation-level error.
|
||||
*
|
||||
* These errors carry internal infrastructure detail (e.g. the database
|
||||
* hostname) in their `.message`, so they must never be surfaced to API
|
||||
* clients — callers should let them propagate to the generic 5xx handler
|
||||
* (which both scrubs the message and is retryable by the SDK) instead of
|
||||
* folding `.message` into a client-facing error.
|
||||
*/
|
||||
export function isInfrastructureError(error: unknown): boolean {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientInitializationError ||
|
||||
error instanceof Prisma.PrismaClientRustPanicError ||
|
||||
error instanceof Prisma.PrismaClientUnknownRequestError
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return INFRASTRUCTURE_PRISMA_CODES.has(error.code);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// One-shot marker so a single infra error is logged exactly once: the client
|
||||
// extension (statement level) tags it, and the $transaction-boundary loggers
|
||||
// skip a tagged error rather than logging the same failure a second time.
|
||||
const INFRA_ERROR_LOGGED: unique symbol = Symbol("prismaInfraErrorLogged");
|
||||
|
||||
function markInfraErrorLogged(error: unknown): void {
|
||||
if (typeof error !== "object" || error === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Non-enumerable so error-spreads/serializers can't copy the marker onto a
|
||||
// different error; try/catch so a frozen error object can't make this throw
|
||||
// and mask the original error as it propagates out of the catch.
|
||||
Object.defineProperty(error, INFRA_ERROR_LOGGED, {
|
||||
value: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
// best-effort: a sealed/frozen error simply won't be deduped.
|
||||
}
|
||||
}
|
||||
|
||||
export function infraErrorAlreadyLogged(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as Record<symbol, unknown>)[INFRA_ERROR_LOGGED] === true
|
||||
);
|
||||
}
|
||||
|
||||
// Logs infrastructure failures (P1xxx-class, see isInfrastructureError) and
|
||||
// rethrows the ORIGINAL error: callers branch on error.code, and this fires
|
||||
// per-statement inside transactions, so converting it would break that.
|
||||
export function captureInfrastructureErrors<T extends PrismaClient>(
|
||||
client: T,
|
||||
log: ErrorLogger = logger
|
||||
): T {
|
||||
return client.$extends({
|
||||
name: "infrastructure-error-capture",
|
||||
query: {
|
||||
$allOperations: async ({ model, operation, args, query }) => {
|
||||
try {
|
||||
return await query(args);
|
||||
} catch (error) {
|
||||
if (isInfrastructureError(error)) {
|
||||
log.error("prisma infrastructure error", {
|
||||
model,
|
||||
operation,
|
||||
code: error instanceof Prisma.PrismaClientKnownRequestError ? error.code : undefined,
|
||||
meta: error instanceof Prisma.PrismaClientKnownRequestError ? error.meta : undefined,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
markInfraErrorLogged(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}) as unknown as T;
|
||||
}
|
||||
|
||||
// Logs infrastructure errors that reach the $transaction boundary WITHOUT a
|
||||
// Prisma error code (e.g. PrismaClientInitializationError). Coded errors there
|
||||
// are already logged by transac()'s callback, and errors that bubbled up from a
|
||||
// statement were already logged (and tagged) by the client extension — both are
|
||||
// skipped here to avoid double-logging. Returns whether it logged.
|
||||
export function logTransactionInfrastructureError(
|
||||
error: unknown,
|
||||
log: ErrorLogger = logger
|
||||
): boolean {
|
||||
if (
|
||||
!isInfrastructureError(error) ||
|
||||
isPrismaKnownError(error) ||
|
||||
infraErrorAlreadyLogged(error)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log.error("prisma.$transaction infrastructure error", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Replaces a Prisma infrastructure error's message (which carries the DB
|
||||
// hostname) with a generic one before it reaches an API client. Any other
|
||||
// error's message is returned unchanged. Status codes/headers are unaffected.
|
||||
export function clientSafeErrorMessage(error: Error): string {
|
||||
return isInfrastructureError(error) ? "Internal Server Error" : error.message;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export interface QueryPerformanceConfig {
|
||||
verySlowQueryThreshold?: number; // ms
|
||||
maxQueryLogLength: number;
|
||||
}
|
||||
|
||||
export class QueryPerformanceMonitor {
|
||||
private config: QueryPerformanceConfig;
|
||||
|
||||
constructor(config: Partial<QueryPerformanceConfig> = {}) {
|
||||
this.config = {
|
||||
maxQueryLogLength: 1000,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
onQuery(
|
||||
clientType: "writer" | "replica",
|
||||
log: {
|
||||
duration: number;
|
||||
query: string;
|
||||
params: string;
|
||||
target: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
) {
|
||||
if (this.config.verySlowQueryThreshold === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { duration, query, params, target, timestamp } = log;
|
||||
|
||||
// Slow queries are an observability signal (DB load, missing indexes,
|
||||
// etc.), not an application error. Logged at warn so it lands in stdout
|
||||
// without flowing to Sentry — track via metrics/dashboards instead.
|
||||
if (duration > this.config.verySlowQueryThreshold) {
|
||||
const truncatedQuery =
|
||||
query.length > this.config.maxQueryLogLength
|
||||
? query.substring(0, this.config.maxQueryLogLength) + "..."
|
||||
: query;
|
||||
|
||||
logger.warn("Prisma: very slow database query", {
|
||||
clientType,
|
||||
durationMs: duration,
|
||||
query: truncatedQuery,
|
||||
target,
|
||||
timestamp,
|
||||
paramCount: this.countParams(query),
|
||||
hasParams: params !== "[]" && params !== "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private countParams(query: string): number {
|
||||
// Count the number of $1, $2, etc. parameters in the query
|
||||
const paramMatches = query.match(/\$\d+/g);
|
||||
return paramMatches ? paramMatches.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const queryPerformanceMonitor = new QueryPerformanceMonitor({
|
||||
verySlowQueryThreshold: env.VERY_SLOW_QUERY_THRESHOLD_MS,
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
// Redacts the given object based on the given paths
|
||||
// Example:
|
||||
// const redactor = new Redactor(["data.object.balance_transaction"]);
|
||||
// redactor.redact({
|
||||
// data: {
|
||||
// object: {
|
||||
// balance_transaction: "txn_1NYWgTI0XSgju2urW3aXpinM",
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// Returns:
|
||||
// {
|
||||
// data: {
|
||||
// object: {
|
||||
// balance_transaction: "[REDACTED]",
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// Does not currenly support arrays
|
||||
export class Redactor {
|
||||
constructor(private paths: string[]) {}
|
||||
|
||||
public redact(subject: unknown): unknown {
|
||||
if (!Array.isArray(this.paths)) {
|
||||
return subject;
|
||||
}
|
||||
|
||||
if (this.paths.length === 0) {
|
||||
return subject;
|
||||
}
|
||||
|
||||
const clonedSubject = JSON.parse(JSON.stringify(subject));
|
||||
|
||||
return this.redactPathsRecursive(clonedSubject, this.paths);
|
||||
}
|
||||
|
||||
private redactPathsRecursive(subject: any, paths: string[]): any {
|
||||
for (let path of paths) {
|
||||
let parts = path.split(".");
|
||||
|
||||
let curSubject = subject;
|
||||
|
||||
// Make sure curSubject is an object
|
||||
if (typeof curSubject !== "object") {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(curSubject, part) === false) {
|
||||
// Path is not found in object
|
||||
break;
|
||||
}
|
||||
|
||||
if (i === parts.length - 1) {
|
||||
// We're at the end of our path and have a string, redact it
|
||||
curSubject[part] = "[REDACTED]";
|
||||
} else if (part in curSubject && typeof curSubject[part] === "object") {
|
||||
// More paths to follow, continue down the path
|
||||
curSubject = curSubject[part];
|
||||
} else {
|
||||
// Path is not found in object or doesn't point to a string
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function isValidRegex(regex: string) {
|
||||
try {
|
||||
new RegExp(regex);
|
||||
return true;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import pRetry from "p-retry";
|
||||
import { Counter, Gauge } from "prom-client";
|
||||
import { metricsRegister } from "~/metrics.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { signalsEmitter } from "~/services/signals.server";
|
||||
|
||||
const loadFailures = new Counter({
|
||||
name: "reloading_registry_load_failures_total",
|
||||
help: "Failed loads of a reloading registry",
|
||||
labelNames: ["name"],
|
||||
registers: [metricsRegister],
|
||||
});
|
||||
|
||||
const lastSuccessfulLoadAt = new Gauge({
|
||||
name: "reloading_registry_last_successful_load_timestamp_seconds",
|
||||
help: "Unix time of the last successful registry load (staleness signal)",
|
||||
labelNames: ["name"],
|
||||
registers: [metricsRegister],
|
||||
});
|
||||
|
||||
// 0 until the first successful load, then 1. Starts at 0 (not absent) so a
|
||||
// never-loaded registry is an alertable series, distinct from "feature off".
|
||||
const registryLoaded = new Gauge({
|
||||
name: "reloading_registry_loaded",
|
||||
help: "1 once the registry has loaded at least once, else 0 (0 = serving cold fallback)",
|
||||
labelNames: ["name"],
|
||||
registers: [metricsRegister],
|
||||
});
|
||||
|
||||
export type ReloadingRegistry<T> = {
|
||||
isReady: Promise<void>;
|
||||
readonly isLoaded: boolean;
|
||||
current(): T | undefined;
|
||||
reload(): Promise<void>;
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
export type ReloadingRegistryOptions<T> = {
|
||||
/** Tag for metrics + logs. */
|
||||
name: string;
|
||||
/** Loads the full snapshot from the source of truth. */
|
||||
load: () => Promise<T>;
|
||||
/** How often to reload after the first successful load. */
|
||||
intervalMs: number;
|
||||
/** Startup retry config; defaults to forever with backoff. */
|
||||
retry?: { retries?: number };
|
||||
/** Start the background load + interval at construction. Default true; set false to keep inert (e.g. tests). */
|
||||
autoStart?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory snapshot loaded at startup and refreshed on an interval. Reads are
|
||||
* synchronous (`current()`) and return undefined until the first load completes;
|
||||
* callers must tolerate that (e.g. fall back to a safe default), the same cold-start
|
||||
* contract as the datastore / LLM-pricing registries. Interval-only: no pub/sub
|
||||
* (a follow-up if sub-second propagation is ever needed).
|
||||
*/
|
||||
export function createReloadingRegistry<T>(
|
||||
opts: ReloadingRegistryOptions<T>
|
||||
): ReloadingRegistry<T> {
|
||||
let snapshot: T | undefined;
|
||||
let loaded = false;
|
||||
let loadSeq = 0;
|
||||
let resolveReady!: () => void;
|
||||
const isReady = new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function startReloadInterval() {
|
||||
interval = setInterval(() => {
|
||||
doLoad().catch((err) => {
|
||||
loadFailures.inc({ name: opts.name });
|
||||
logger.warn("[ReloadingRegistry] reload failed", {
|
||||
name: opts.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}, opts.intervalMs);
|
||||
interval.unref(); // never keep the process alive; SIGTERM still clears it
|
||||
}
|
||||
|
||||
async function doLoad() {
|
||||
const seq = ++loadSeq;
|
||||
const next = await opts.load();
|
||||
if (seq < loadSeq) return; // a newer load started while we were awaiting; don't clobber
|
||||
snapshot = next;
|
||||
lastSuccessfulLoadAt.set({ name: opts.name }, Date.now() / 1000);
|
||||
if (!loaded) {
|
||||
loaded = true;
|
||||
registryLoaded.set({ name: opts.name }, 1);
|
||||
resolveReady();
|
||||
// Poll only after the first load lands, so the startup retry can't race it.
|
||||
if (opts.autoStart !== false) startReloadInterval();
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.autoStart !== false) {
|
||||
registryLoaded.set({ name: opts.name }, 0); // visible cold series until first load
|
||||
|
||||
pRetry(() => doLoad(), {
|
||||
forever: opts.retry?.retries === undefined,
|
||||
retries: opts.retry?.retries,
|
||||
minTimeout: 1_000,
|
||||
maxTimeout: 60_000,
|
||||
factor: 2,
|
||||
onFailedAttempt: (error) => {
|
||||
loadFailures.inc({ name: opts.name });
|
||||
logger.warn("[ReloadingRegistry] startup load failed, retrying", {
|
||||
name: opts.name,
|
||||
attemptNumber: error.attemptNumber,
|
||||
retriesLeft: error.retriesLeft,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}).catch((err) => {
|
||||
logger.error("[ReloadingRegistry] startup load gave up", {
|
||||
name: opts.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
resolveReady(); // inert: any direct `await isReady` resolves immediately
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
signalsEmitter.on("SIGTERM", stop);
|
||||
signalsEmitter.on("SIGINT", stop);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
get isLoaded() {
|
||||
return loaded;
|
||||
},
|
||||
current: () => snapshot,
|
||||
reload: doLoad,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requestIdempotency } from "~/services/requestIdempotencyInstance.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
type RequestIdempotencyType = "batch-trigger" | "trigger" | "create-batch";
|
||||
|
||||
export type IdempotencyConfig<T, R> = {
|
||||
requestType: RequestIdempotencyType;
|
||||
findCachedEntity: (cachedRequestId: string) => Promise<T | null>;
|
||||
buildResponse: (entity: T) => R;
|
||||
buildResponseHeaders: (response: R, entity: T) => Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export async function handleRequestIdempotency<T, R>(
|
||||
requestIdempotencyKey: string | null | undefined,
|
||||
config: IdempotencyConfig<T, R>
|
||||
): Promise<Response | null> {
|
||||
if (!requestIdempotencyKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug(`request-idempotency: checking for cached ${config.requestType} request`, {
|
||||
requestIdempotencyKey,
|
||||
});
|
||||
|
||||
return startActiveSpan("RequestIdempotency.handle()", async (span) => {
|
||||
span.setAttribute("request_idempotency_key", requestIdempotencyKey);
|
||||
|
||||
const cachedRequest = await requestIdempotency.checkRequest(
|
||||
config.requestType,
|
||||
requestIdempotencyKey
|
||||
);
|
||||
|
||||
if (!cachedRequest) {
|
||||
span.setAttribute("cached_request", false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
span.setAttribute("cached_request", true);
|
||||
span.setAttribute("cached_entity_id", cachedRequest.id);
|
||||
|
||||
logger.info(`request-idempotency: found cached ${config.requestType} request`, {
|
||||
requestIdempotencyKey,
|
||||
cachedRequest,
|
||||
});
|
||||
|
||||
const cachedEntity = await config.findCachedEntity(cachedRequest.id);
|
||||
|
||||
if (!cachedEntity) {
|
||||
span.setAttribute("cached_entity", false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
span.setAttribute("cached_entity", true);
|
||||
|
||||
logger.info(`request-idempotency: found cached ${config.requestType} entity`, {
|
||||
requestIdempotencyKey,
|
||||
cachedRequest,
|
||||
cachedEntity,
|
||||
});
|
||||
|
||||
const responseBody = config.buildResponse(cachedEntity);
|
||||
const responseHeaders = await config.buildResponseHeaders(responseBody, cachedEntity);
|
||||
|
||||
return json(responseBody, { status: 200, headers: responseHeaders });
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveRequestIdempotency(
|
||||
requestIdempotencyKey: string | null | undefined,
|
||||
requestType: RequestIdempotencyType,
|
||||
entityId: string
|
||||
): Promise<void> {
|
||||
if (!requestIdempotencyKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [error] = await tryCatch(
|
||||
requestIdempotency.saveRequest(requestType, requestIdempotencyKey, {
|
||||
id: entityId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error("request-idempotency: error saving request", {
|
||||
error,
|
||||
requestIdempotencyKey,
|
||||
requestType,
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Updates the protocol of the request url to match the request.headers x-forwarded-proto
|
||||
export function requestUrl(request: Request): URL {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.headers.get("x-forwarded-proto") === "https") {
|
||||
url.protocol = "https:";
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export type NormalizedRuntime = "node" | "bun";
|
||||
|
||||
export interface ParsedRuntime {
|
||||
/** The normalized runtime type */
|
||||
runtime: NormalizedRuntime;
|
||||
/** The original runtime string */
|
||||
originalRuntime: string;
|
||||
/** The display name for the runtime */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a runtime string and returns normalized runtime information
|
||||
*/
|
||||
export function parseRuntime(runtime: string | null | undefined): ParsedRuntime | null {
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize runtime strings
|
||||
let normalizedRuntime: NormalizedRuntime;
|
||||
let displayName: string;
|
||||
|
||||
if (runtime.startsWith("bun")) {
|
||||
normalizedRuntime = "bun";
|
||||
displayName = "Bun";
|
||||
} else if (runtime.startsWith("node")) {
|
||||
normalizedRuntime = "node";
|
||||
displayName = "Node.js";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
runtime: normalizedRuntime,
|
||||
originalRuntime: runtime,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats runtime with version for display
|
||||
*/
|
||||
export function formatRuntimeWithVersion(
|
||||
runtime: string | null | undefined,
|
||||
version: string | null | undefined
|
||||
): string {
|
||||
const parsed = parseRuntime(runtime);
|
||||
if (!parsed) {
|
||||
return "Unknown runtime";
|
||||
}
|
||||
|
||||
if (version) {
|
||||
return `${parsed.displayName} v${version}`;
|
||||
}
|
||||
|
||||
return parsed.displayName;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Whether `request` is an unambiguously same-origin navigation, used to
|
||||
* CSRF-gate state-changing GET routes. `allowedOrigin` is the dashboard origin
|
||||
* (caller passes `env.LOGIN_ORIGIN`, kept out so the rule stays testable).
|
||||
*
|
||||
* Deny-by-default: prefer `Sec-Fetch-Site: same-origin` when present, otherwise
|
||||
* require a `Referer` whose origin matches `allowedOrigin`. Anything
|
||||
* missing/cross-site/unparseable returns `false`.
|
||||
*/
|
||||
export function isSameOriginNavigation(request: Request, allowedOrigin: string): boolean {
|
||||
const fetchSite = request.headers.get("sec-fetch-site");
|
||||
if (fetchSite) return fetchSite === "same-origin";
|
||||
|
||||
const referer = request.headers.get("referer");
|
||||
if (!referer) return false;
|
||||
try {
|
||||
return new URL(referer).origin === new URL(allowedOrigin).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Return the URL only if it uses an http(s) scheme, else `undefined` so callers
|
||||
// can fall back to a default. Use for any URL rendered into an `<a href>`.
|
||||
|
||||
const SAFE_HTTP_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
export function sanitizeHttpUrl(url: string | undefined | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return SAFE_HTTP_PROTOCOLS.has(parsed.protocol) ? parsed.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
/**
|
||||
* Parses a comma-separated `runIds` query param into a trimmed, de-duplicated
|
||||
* list of run friendly IDs, capped at 100. Shared by the runs `/live` and
|
||||
* `/children-statuses` resource routes.
|
||||
*/
|
||||
export const runIdsQueryParam = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const ids =
|
||||
value
|
||||
?.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
return [...new Set(ids)].slice(0, 100);
|
||||
});
|
||||
|
||||
/**
|
||||
* `parseInt` accepts garbage-suffixed numbers (`parseInt("123abc", 10) === 123`)
|
||||
* and returns `NaN` for non-numeric input. Use this helper at loader boundaries
|
||||
* for URL-supplied integer params so a malformed URL silently falls back to
|
||||
* `undefined` rather than nudging downstream logic with a partial or NaN value.
|
||||
*/
|
||||
export function parseFiniteInt(value: string | null | undefined): number | undefined {
|
||||
if (value == null || value === "") return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
export function objectToSearchParams(
|
||||
obj:
|
||||
| undefined
|
||||
| Record<string, string | string[] | number | number[] | boolean | boolean[] | undefined>
|
||||
): URLSearchParams | undefined {
|
||||
if (!obj) return undefined;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
//for each item add to the search params, skip undefined and join arrays with commas
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
if (value === undefined) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
searchParams.append(key, v.toString());
|
||||
}
|
||||
} else {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
class SearchParams<TParams extends ParamType> {
|
||||
constructor(
|
||||
private params: TParams,
|
||||
readonly schema: ZodType<TParams>
|
||||
) {}
|
||||
|
||||
get(key: keyof TParams) {
|
||||
return this.params[key];
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
set(key: keyof TParams, value: TParams[keyof TParams]) {
|
||||
//check it matches the schema
|
||||
const newParams = { ...this.params, [key]: value };
|
||||
const result = parseSearchParams(newParams, this.schema);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
this.params = newParams;
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
type SearchParamsResult<TParams extends ParamType> =
|
||||
| { success: true; params: SearchParams<TParams> }
|
||||
| { success: false; error: string };
|
||||
|
||||
type ParamType = Record<string, any>;
|
||||
|
||||
export function createSearchParams<TParams extends ParamType>(
|
||||
url: string,
|
||||
schema: ZodType<TParams>
|
||||
): SearchParamsResult<TParams> {
|
||||
const searchParams = new URL(url).searchParams;
|
||||
const params = Object.fromEntries(searchParams.entries());
|
||||
|
||||
const parsed = parseSearchParams(params, schema);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error };
|
||||
}
|
||||
|
||||
return { success: true, params: new SearchParams<TParams>(parsed.params as TParams, schema) };
|
||||
}
|
||||
|
||||
function parseSearchParams<TParams extends ParamType>(params: TParams, schema: ZodType<TParams>) {
|
||||
const parsedParams = schema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
const friendlyError = fromZodError(parsedParams.error, {
|
||||
prefix: "There's an issue with your search params",
|
||||
}).message;
|
||||
return { success: false as const, error: friendlyError };
|
||||
}
|
||||
|
||||
return { success: true as const, params: parsedParams.data };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Parses a version string into comparable numeric parts.
|
||||
* Handles formats like "1.2.3", "20240115.1", "v1.0.0", plain timestamps, etc.
|
||||
* Non-numeric pre-release suffixes (e.g. "-beta.1") are stripped for ordering purposes.
|
||||
*/
|
||||
function parseVersionParts(version: string): number[] {
|
||||
const cleaned = version.replace(/^v/i, "").replace(/[-+].*$/, "");
|
||||
return cleaned.split(".").map((p) => {
|
||||
const n = parseInt(p, 10);
|
||||
return isNaN(n) ? 0 : n;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two version strings using numeric segment comparison (descending).
|
||||
* Falls back to lexicographic comparison when segments are equal.
|
||||
* Returns a negative number if `a` should come before `b` (i.e. `a` is newer).
|
||||
*/
|
||||
export function compareVersionsDescending(a: string, b: string): number {
|
||||
const partsA = parseVersionParts(a);
|
||||
const partsB = parseVersionParts(b);
|
||||
const maxLen = Math.max(partsA.length, partsB.length);
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const segA = partsA[i] ?? 0;
|
||||
const segB = partsB[i] ?? 0;
|
||||
if (segA !== segB) {
|
||||
return segB - segA;
|
||||
}
|
||||
}
|
||||
|
||||
return b.localeCompare(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts an array of version strings in descending order (newest first).
|
||||
* Non-destructive – returns a new array.
|
||||
*/
|
||||
export function sortVersionsDescending(versions: string[]): string[] {
|
||||
return [...versions].sort(compareVersionsDescending);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Event, EventHint } from "@sentry/remix";
|
||||
import { tenantContext } from "../services/tenantContext.server";
|
||||
|
||||
export function addTenantContextToEvent(event: Event, _hint: EventHint): Event {
|
||||
const ctx = tenantContext.get();
|
||||
if (!ctx) return event;
|
||||
return {
|
||||
...event,
|
||||
// Only stamp user.id when we have a real user — keeps "Users Impacted"
|
||||
// counting distinct humans rather than mixing in tenants. Events without
|
||||
// a known user (e.g. unauthenticated paths) skip user attribution.
|
||||
...(ctx.userId ? { user: { ...event.user, id: ctx.userId } } : {}),
|
||||
tags: {
|
||||
...event.tags,
|
||||
...(ctx.orgSlug ? { org_slug: ctx.orgSlug } : {}),
|
||||
...(ctx.projectSlug ? { project_slug: ctx.projectSlug } : {}),
|
||||
...(ctx.envSlug ? { env_slug: ctx.envSlug } : {}),
|
||||
...(ctx.orgId ? { org_id: ctx.orgId } : {}),
|
||||
...(ctx.projectId ? { project_id: ctx.projectId } : {}),
|
||||
...(ctx.projectRef ? { project_ref: ctx.projectRef } : {}),
|
||||
...(ctx.envId ? { environment_id: ctx.envId } : {}),
|
||||
...(ctx.envType ? { env_type: ctx.envType } : {}),
|
||||
...(ctx.impersonating ? { impersonating: "true" } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type Span, TraceFlags, trace } from "@opentelemetry/api";
|
||||
import type { Event, EventHint } from "@sentry/remix";
|
||||
|
||||
export type GetActiveSpan = () => Span | undefined;
|
||||
|
||||
const defaultGetActiveSpan: GetActiveSpan = () => trace.getActiveSpan();
|
||||
|
||||
export function getActiveTraceIds(
|
||||
getActiveSpan: GetActiveSpan = defaultGetActiveSpan
|
||||
): { traceId: string; spanId: string; sampled: boolean } | undefined {
|
||||
try {
|
||||
const span = getActiveSpan();
|
||||
if (!span) return undefined;
|
||||
const ctx = span.spanContext();
|
||||
return {
|
||||
traceId: ctx.traceId,
|
||||
spanId: ctx.spanId,
|
||||
sampled: (ctx.traceFlags & TraceFlags.SAMPLED) !== 0,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function addOtelTraceContextToEvent(
|
||||
event: Event,
|
||||
_hint: EventHint,
|
||||
getActiveSpan: GetActiveSpan = defaultGetActiveSpan
|
||||
): Event {
|
||||
const ids = getActiveTraceIds(getActiveSpan);
|
||||
if (!ids) return event;
|
||||
// We intentionally overwrite Sentry's own trace_id/span_id on contexts.trace.
|
||||
// With skipOpenTelemetrySetup: true, Sentry generates an internal trace_id
|
||||
// unrelated to OTel; replacing it with the active OTel ids is the whole
|
||||
// point of this processor — it makes Sentry issues navigable to the
|
||||
// corresponding OTel trace in any backend.
|
||||
return {
|
||||
...event,
|
||||
contexts: {
|
||||
...event.contexts,
|
||||
trace: {
|
||||
...event.contexts?.trace,
|
||||
trace_id: ids.traceId,
|
||||
span_id: ids.spanId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
...event.tags,
|
||||
otel_sampled: ids.sampled ? "true" : "false",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function singleton<T>(name: string, getValue: () => T): T {
|
||||
const thusly = globalThis as any;
|
||||
thusly.__trigger_singletons ??= {};
|
||||
thusly.__trigger_singletons[name] ??= getValue();
|
||||
return thusly.__trigger_singletons[name];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { eventStream } from "remix-utils/sse/server";
|
||||
import { env } from "~/env.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type SseProps = {
|
||||
request: Request;
|
||||
pingInterval?: number;
|
||||
updateInterval?: number;
|
||||
run: (send: (event: Event) => void, stop: () => void) => void;
|
||||
};
|
||||
|
||||
type Event = {
|
||||
/**
|
||||
* @default "update"
|
||||
*/
|
||||
event?: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: SseProps) {
|
||||
if (env.DISABLE_SSE === "1" || env.DISABLE_SSE === "true") {
|
||||
return new Response("SSE disabled", { status: 200 });
|
||||
}
|
||||
|
||||
const signal = getRequestAbortSignal();
|
||||
|
||||
let pinger: NodeJS.Timeout | undefined = undefined;
|
||||
let updater: NodeJS.Timeout | undefined = undefined;
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const abort = () => {
|
||||
clearInterval(pinger);
|
||||
clearInterval(updater);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
return eventStream(signal, (send, close) => {
|
||||
const safeSend = (args: { event?: string; data: string }) => {
|
||||
try {
|
||||
send(args);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE, aborting", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
args,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug("Unknown error sending SSE, aborting", {
|
||||
error,
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
pinger = setInterval(() => {
|
||||
if (signal.aborted) {
|
||||
return abort();
|
||||
}
|
||||
|
||||
safeSend({ event: "ping", data: new Date().toISOString() });
|
||||
}, pingInterval);
|
||||
|
||||
updater = setInterval(() => {
|
||||
if (signal.aborted) {
|
||||
return abort();
|
||||
}
|
||||
|
||||
run(safeSend, abort);
|
||||
}, updateInterval);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
close(); // close the connection after 1 minute of inactivity, which will refresh the connection (that's why we aren't using abort)
|
||||
}, 60 * 1000); // 1 minute
|
||||
|
||||
return abort;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { type Params } from "@remix-run/router";
|
||||
import { eventStream } from "remix-utils/sse/server";
|
||||
import { setInterval } from "timers/promises";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
|
||||
export type SendFunction = Parameters<Parameters<typeof eventStream>[1]>[0];
|
||||
|
||||
type HandlerParams = {
|
||||
send: SendFunction;
|
||||
};
|
||||
|
||||
type SSEHandlers = {
|
||||
/** Return false to stop */
|
||||
beforeStream?: () => Promise<boolean | void> | boolean | void;
|
||||
/** Return false to stop */
|
||||
initStream?: (params: HandlerParams) => Promise<boolean | void> | boolean | void;
|
||||
/** Return false to stop */
|
||||
iterator?: (params: HandlerParams & { date: Date }) => Promise<boolean | void> | boolean | void;
|
||||
cleanup?: (params: HandlerParams) => void;
|
||||
};
|
||||
|
||||
type SSEContext = {
|
||||
id: string;
|
||||
request: Request;
|
||||
params: Params<string>;
|
||||
controller: AbortController;
|
||||
debug: (message: string) => void;
|
||||
};
|
||||
|
||||
type SSEOptions = {
|
||||
timeout: number;
|
||||
interval?: number;
|
||||
debug?: boolean;
|
||||
handler: (context: SSEContext) => Promise<SSEHandlers>;
|
||||
};
|
||||
|
||||
// This is used to track the open connections, for debugging
|
||||
const connections: Set<string> = new Set();
|
||||
|
||||
// Stackless sentinel reasons passed to AbortController#abort. Calling .abort()
|
||||
// with no argument produces a DOMException that captures a ~500-byte stack
|
||||
// trace; a string reason is stored verbatim with no stack. The choice of
|
||||
// reason type does not cause the retention we saw in prod (that was the
|
||||
// AbortSignal.any composite — see comment near the timeoutTimer below for the
|
||||
// Node issue refs), but naming the sentinels keeps call sites readable and
|
||||
// lets future signal.reason consumers branch on the cause.
|
||||
export const ABORT_REASON_REQUEST = "request_aborted";
|
||||
export const ABORT_REASON_TIMEOUT = "timeout";
|
||||
export const ABORT_REASON_SEND_ERROR = "send_error";
|
||||
export const ABORT_REASON_INIT_STOP = "init_requested_stop";
|
||||
export const ABORT_REASON_ITERATOR_STOP = "iterator_requested_stop";
|
||||
export const ABORT_REASON_ITERATOR_ERROR = "iterator_error";
|
||||
|
||||
export function createSSELoader(options: SSEOptions) {
|
||||
const { timeout, interval = 500, debug = false, handler } = options;
|
||||
|
||||
return async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const internalController = new AbortController();
|
||||
|
||||
const log = (message: string) => {
|
||||
if (debug)
|
||||
console.log(
|
||||
`SSE: [${request.url} ${id}] ${message} (${connections.size} open connections)`
|
||||
);
|
||||
};
|
||||
|
||||
const createSafeSend = (originalSend: SendFunction): SendFunction => {
|
||||
return (event) => {
|
||||
try {
|
||||
if (!internalController.signal.aborted) {
|
||||
originalSend(event);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes("Controller is already closed")) {
|
||||
return;
|
||||
}
|
||||
log(`Error sending event: ${error.message}`);
|
||||
}
|
||||
// Abort before rethrowing so timer + request-abort listener are cleaned
|
||||
// up immediately. Otherwise a send-failure in initStream leaves them
|
||||
// alive until `timeout` fires.
|
||||
if (!internalController.signal.aborted) {
|
||||
internalController.abort(ABORT_REASON_SEND_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const context: SSEContext = {
|
||||
id,
|
||||
request,
|
||||
params,
|
||||
controller: internalController,
|
||||
debug: log,
|
||||
};
|
||||
|
||||
const handlers = await handler(context).catch((error) => {
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Response("Internal Server Error", { status: 500 });
|
||||
});
|
||||
|
||||
const requestAbortSignal = getRequestAbortSignal();
|
||||
|
||||
log("Start");
|
||||
|
||||
// Single-signal abort chain: everything rolls up into internalController.
|
||||
// Timeout is a plain setTimeout cleared on abort rather than an
|
||||
// AbortSignal.timeout() combined via AbortSignal.any() — AbortSignal.any
|
||||
// keeps its source signals in an internal Set<WeakRef> managed by a
|
||||
// FinalizationRegistry, and under sustained request traffic those entries
|
||||
// accumulate faster than they get cleaned up, pinning every source signal
|
||||
// (and its listeners, and anything those listeners close over) until the
|
||||
// parent signal is GC'd or aborts. Reproduced locally in isolation; shape
|
||||
// matches the ChainSafe Lodestar production case described in
|
||||
// nodejs/node#54614. See also nodejs/node#55351 (mechanism confirmed by
|
||||
// @jasnell, narrow fix in 22.12.0 via #55354) and nodejs/node#57584
|
||||
// (circular-dep variant, still open).
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_TIMEOUT);
|
||||
}, timeout);
|
||||
|
||||
const onRequestAbort = () => {
|
||||
log("request signal aborted");
|
||||
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_REQUEST);
|
||||
};
|
||||
|
||||
internalController.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeoutTimer);
|
||||
requestAbortSignal.removeEventListener("abort", onRequestAbort);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
// The request could have been aborted during `await handler(context)` above.
|
||||
// AbortSignal listeners added after the signal is already aborted never fire,
|
||||
// so invoke cleanup synchronously in that case instead of waiting for `timeout`.
|
||||
if (requestAbortSignal.aborted) {
|
||||
onRequestAbort();
|
||||
} else {
|
||||
requestAbortSignal.addEventListener("abort", onRequestAbort, { once: true });
|
||||
}
|
||||
|
||||
if (handlers.beforeStream) {
|
||||
const shouldContinue = await handlers.beforeStream();
|
||||
if (shouldContinue === false) {
|
||||
log("beforeStream returned false, so we'll exit before creating the stream");
|
||||
internalController.abort(ABORT_REASON_INIT_STOP);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return eventStream(internalController.signal, function setup(send) {
|
||||
connections.add(id);
|
||||
const safeSend = createSafeSend(send);
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
log("Initializing");
|
||||
if (handlers.initStream) {
|
||||
const shouldContinue = await handlers.initStream({ send: safeSend });
|
||||
if (shouldContinue === false) {
|
||||
log("initStream returned false, so we'll stop the stream");
|
||||
internalController.abort(ABORT_REASON_INIT_STOP);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log("Starting interval");
|
||||
for await (const _ of setInterval(interval, null, {
|
||||
signal: internalController.signal,
|
||||
})) {
|
||||
log("PING");
|
||||
|
||||
const date = new Date();
|
||||
|
||||
if (handlers.iterator) {
|
||||
try {
|
||||
const shouldContinue = await handlers.iterator({ date, send: safeSend });
|
||||
if (shouldContinue === false) {
|
||||
log("iterator return false, so we'll stop the stream");
|
||||
internalController.abort(ABORT_REASON_ITERATOR_STOP);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
log("iterator threw an error, aborting stream");
|
||||
// Immediately abort to trigger cleanup
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
log(`iterator error: ${error.message}`);
|
||||
}
|
||||
internalController.abort(ABORT_REASON_ITERATOR_ERROR);
|
||||
// No need to re-throw as we're handling it by aborting
|
||||
return; // Exit the run function immediately
|
||||
}
|
||||
}
|
||||
}
|
||||
log("iterator finished all iterations");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
log("iterator finished");
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
connections.delete(id);
|
||||
|
||||
log("Cleanup called");
|
||||
if (handlers.cleanup) {
|
||||
try {
|
||||
handlers.cleanup({ send: safeSend });
|
||||
} catch (error) {
|
||||
log(
|
||||
`Error in cleanup handler: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
console.error("SSE Cleanup Error:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Shared (server + client) constants for the SSO session-revalidation flow.
|
||||
|
||||
export const SSO_SESSION_EXPIRED_REASON = "session_expired";
|
||||
|
||||
// The reason rides as its own `?reason=` param, not `?redirectTo=/login...`,
|
||||
// because the redirect sanitizer rejects /login and would drop it.
|
||||
export function ssoSessionExpiredLogoutPath(): string {
|
||||
return `/logout?reason=${SSO_SESSION_EXPIRED_REASON}`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function capitalizeWord(word: string) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
type InputType = { id: string; parentId: string | null };
|
||||
export type OutputType<T> = T & { subtasks?: T[] };
|
||||
|
||||
export function taskListToTree<T extends InputType>(
|
||||
tasks: T[],
|
||||
addSubtasks = true
|
||||
): OutputType<T>[] {
|
||||
if (!addSubtasks) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const result: OutputType<T>[] = [];
|
||||
const map = new Map<string, T>(tasks.map((v) => [v.id, v]));
|
||||
|
||||
for (const node of tasks) {
|
||||
const parent: OutputType<T> | null = node.parentId
|
||||
? (map.get(node.parentId) as OutputType<T>)
|
||||
: null;
|
||||
if (parent) {
|
||||
if (!parent.subtasks) {
|
||||
parent.subtasks = [] as any;
|
||||
}
|
||||
parent.subtasks!.push(node as any);
|
||||
} else {
|
||||
result.push(node as any);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//From: https://kettanaito.com/blog/debounce-vs-throttle
|
||||
|
||||
/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */
|
||||
export function throttle<TArgs extends unknown[]>(
|
||||
func: (...args: TArgs) => void,
|
||||
durationMs: number
|
||||
): (...args: TArgs) => void {
|
||||
let isPrimedToFire = false;
|
||||
|
||||
return (...args: TArgs) => {
|
||||
if (!isPrimedToFire) {
|
||||
isPrimedToFire = true;
|
||||
|
||||
setTimeout(() => {
|
||||
func(...args);
|
||||
isPrimedToFire = false;
|
||||
}, durationMs);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
import parseDuration from "parse-duration";
|
||||
|
||||
const DurationString = z.string().refine(
|
||||
(val) => {
|
||||
const ms = parseDuration(val);
|
||||
return ms !== null && ms > 0;
|
||||
},
|
||||
(val) => ({ message: `Invalid or non-positive duration string: "${val}"` })
|
||||
);
|
||||
|
||||
const BracketSchema = z.object({
|
||||
max: z.union([z.literal("Infinity"), DurationString]),
|
||||
granularity: DurationString,
|
||||
});
|
||||
|
||||
const BracketsSchema = z
|
||||
.array(BracketSchema)
|
||||
.min(1, "TimeGranularity requires at least one bracket");
|
||||
|
||||
export type TimeGranularityBracket = z.input<typeof BracketSchema>;
|
||||
|
||||
type ParsedBracket = {
|
||||
maxMs: number;
|
||||
granularityMs: number;
|
||||
};
|
||||
|
||||
function requireParsedDuration(input: string): number {
|
||||
const ms = parseDuration(input);
|
||||
if (ms === null || ms <= 0) {
|
||||
throw new Error(`Duration must be strictly positive, got "${input}" (${ms}ms)`);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
export class TimeGranularity {
|
||||
private readonly parsed: ParsedBracket[];
|
||||
|
||||
constructor(brackets: TimeGranularityBracket[]) {
|
||||
const validated = BracketsSchema.parse(brackets);
|
||||
|
||||
this.parsed = validated.map((b) => ({
|
||||
maxMs: b.max === "Infinity" ? Infinity : requireParsedDuration(b.max),
|
||||
granularityMs: requireParsedDuration(b.granularity),
|
||||
}));
|
||||
}
|
||||
|
||||
getTimeGranularityMs(from: Date, to: Date): number {
|
||||
if (from.getTime() > to.getTime()) {
|
||||
return this.parsed[this.parsed.length - 1].granularityMs;
|
||||
}
|
||||
|
||||
const rangeMs = to.getTime() - from.getTime();
|
||||
for (const bracket of this.parsed) {
|
||||
if (rangeMs <= bracket.maxMs) {
|
||||
return bracket.granularityMs;
|
||||
}
|
||||
}
|
||||
return this.parsed[this.parsed.length - 1].granularityMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { SpanEvent } from "@trigger.dev/core/v3";
|
||||
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3/utils/durations";
|
||||
|
||||
export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed";
|
||||
|
||||
export type TimelineLineVariant = "light" | "normal";
|
||||
|
||||
export type TimelineEventVariant =
|
||||
| "start-cap"
|
||||
| "dot-hollow"
|
||||
| "dot-solid"
|
||||
| "start-cap-thick"
|
||||
| "end-cap-thick"
|
||||
| "end-cap";
|
||||
|
||||
export type TimelineSpanEvent = {
|
||||
name: string;
|
||||
offset: number;
|
||||
timestamp: Date;
|
||||
duration?: number;
|
||||
helpText?: string;
|
||||
markerVariant: TimelineEventVariant;
|
||||
lineVariant: TimelineLineVariant;
|
||||
};
|
||||
|
||||
export function createTimelineSpanEventsFromSpanEvents(
|
||||
spanEvents: SpanEvent[],
|
||||
isAdmin: boolean,
|
||||
relativeStartTime?: number
|
||||
): Array<TimelineSpanEvent> {
|
||||
if (!spanEvents) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matchingSpanEvents = spanEvents.filter((spanEvent) =>
|
||||
spanEvent.name.startsWith("trigger.dev/")
|
||||
);
|
||||
|
||||
if (matchingSpanEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if there's a fork event
|
||||
const hasForkEvent = matchingSpanEvents.some(
|
||||
(spanEvent) =>
|
||||
"event" in spanEvent.properties &&
|
||||
typeof spanEvent.properties.event === "string" &&
|
||||
spanEvent.properties.event === "fork"
|
||||
);
|
||||
|
||||
const sortedSpanEvents = [...matchingSpanEvents].sort((a, b) => {
|
||||
if (a.time === b.time) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
const aTime = typeof a.time === "string" ? new Date(a.time) : a.time;
|
||||
const bTime = typeof b.time === "string" ? new Date(b.time) : b.time;
|
||||
|
||||
return aTime.getTime() - bTime.getTime();
|
||||
});
|
||||
|
||||
const visibleSpanEvents = sortedSpanEvents.filter((spanEvent) => {
|
||||
const eventName =
|
||||
"event" in spanEvent.properties && typeof spanEvent.properties.event === "string"
|
||||
? spanEvent.properties.event
|
||||
: spanEvent.name;
|
||||
|
||||
// If we're admin, everything is visible
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If there's no fork event, import events are also visible to non-admins
|
||||
if (!hasForkEvent && eventName === "import") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use normal admin-only logic
|
||||
return !getAdminOnlyForEvent(eventName);
|
||||
});
|
||||
|
||||
if (visibleSpanEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstEventTime =
|
||||
typeof visibleSpanEvents[0].time === "string"
|
||||
? new Date(visibleSpanEvents[0].time)
|
||||
: visibleSpanEvents[0].time;
|
||||
|
||||
const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime();
|
||||
|
||||
const events = visibleSpanEvents.map((spanEvent, index) => {
|
||||
const timestamp =
|
||||
typeof spanEvent.time === "string" ? new Date(spanEvent.time) : spanEvent.time;
|
||||
|
||||
const offset = millisecondsToNanoseconds(timestamp.getTime() - $relativeStartTime);
|
||||
|
||||
const duration =
|
||||
"duration" in spanEvent.properties && typeof spanEvent.properties.duration === "number"
|
||||
? spanEvent.properties.duration
|
||||
: undefined;
|
||||
|
||||
const name =
|
||||
"event" in spanEvent.properties && typeof spanEvent.properties.event === "string"
|
||||
? spanEvent.properties.event
|
||||
: spanEvent.name;
|
||||
|
||||
let markerVariant: TimelineEventVariant = "dot-hollow";
|
||||
|
||||
if (index === 0) {
|
||||
markerVariant = "start-cap";
|
||||
}
|
||||
|
||||
return {
|
||||
name: getFriendlyNameForEvent(name, spanEvent.properties),
|
||||
offset,
|
||||
timestamp,
|
||||
duration,
|
||||
helpText: getHelpTextForEvent(name),
|
||||
markerVariant,
|
||||
lineVariant: "light" as const,
|
||||
};
|
||||
});
|
||||
|
||||
// Now sort by offset, ascending
|
||||
events.sort((a, b) => a.offset - b.offset);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function getFriendlyNameForEvent(event: string, properties?: Record<string, any>): string {
|
||||
switch (event) {
|
||||
case "dequeue": {
|
||||
return "Dequeued";
|
||||
}
|
||||
case "fork": {
|
||||
return "Launched";
|
||||
}
|
||||
case "create_attempt": {
|
||||
return "Attempt created";
|
||||
}
|
||||
case "import": {
|
||||
return "Importing task file";
|
||||
}
|
||||
case "lazy_payload": {
|
||||
return "Lazy attempt initialized";
|
||||
}
|
||||
case "pod_scheduled": {
|
||||
return "Pod scheduled";
|
||||
}
|
||||
default: {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAdminOnlyForEvent(event: string): boolean {
|
||||
switch (event) {
|
||||
case "dequeue": {
|
||||
return false;
|
||||
}
|
||||
case "fork": {
|
||||
return false;
|
||||
}
|
||||
case "create_attempt": {
|
||||
return true;
|
||||
}
|
||||
case "import": {
|
||||
return false;
|
||||
}
|
||||
case "lazy_payload": {
|
||||
return true;
|
||||
}
|
||||
case "pod_scheduled": {
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getHelpTextForEvent(event: string): string | undefined {
|
||||
switch (event) {
|
||||
case "dequeue": {
|
||||
return "The run was dequeued from the queue";
|
||||
}
|
||||
case "fork": {
|
||||
return "The process was created to run the task";
|
||||
}
|
||||
case "create_attempt": {
|
||||
return "An attempt was created for the run";
|
||||
}
|
||||
case "import": {
|
||||
return "A task file was imported";
|
||||
}
|
||||
case "lazy_payload": {
|
||||
return "The payload was initialized lazily";
|
||||
}
|
||||
case "pod_scheduled": {
|
||||
return "The Kubernetes pod was scheduled to run";
|
||||
}
|
||||
case "Triggered": {
|
||||
return "The run was triggered";
|
||||
}
|
||||
case "Dequeued": {
|
||||
return "The run was dequeued from the queue";
|
||||
}
|
||||
case "Started": {
|
||||
return "The run began executing";
|
||||
}
|
||||
case "Finished": {
|
||||
return "The run completed execution";
|
||||
}
|
||||
case "Expired": {
|
||||
return "The run expired before it could be started";
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function getTimezones(includeUtc = true) {
|
||||
const possibleTimezones = Intl.supportedValuesOf("timeZone").sort();
|
||||
if (includeUtc) {
|
||||
possibleTimezones.unshift("UTC");
|
||||
}
|
||||
return possibleTimezones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the runtime can resolve this IANA timezone. Prefer this over checking membership
|
||||
* in `Intl.supportedValuesOf("timeZone")`, which lists only canonical ids and omits zones
|
||||
* browsers legitimately report (e.g. "UTC", "Etc/UTC", "Asia/Kolkata") — rejecting those
|
||||
* would leave a client's stored timezone stale.
|
||||
*/
|
||||
export function isValidTimeZone(timeZone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import nodeCrypto from "node:crypto";
|
||||
|
||||
export function encryptToken(value: string, key: string) {
|
||||
const nonce = nodeCrypto.randomBytes(12);
|
||||
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
|
||||
|
||||
let encrypted = cipher.update(value, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return {
|
||||
nonce: nonce.toString("hex"),
|
||||
ciphertext: encrypted,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptToken(nonce: string, ciphertext: string, tag: string, key: string): string {
|
||||
const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "hex"));
|
||||
|
||||
decipher.setAuthTag(Buffer.from(tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(ciphertext, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
const hash = nodeCrypto.createHash("sha256");
|
||||
hash.update(token);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const ALLOWED_TRIGGER_SOURCES = new Set(["sdk", "cli", "mcp"]);
|
||||
|
||||
/** Validates a client-provided trigger source header against the allowlist. */
|
||||
export function sanitizeTriggerSource(value: string | null | undefined): string | undefined {
|
||||
if (value && ALLOWED_TRIGGER_SOURCES.has(value)) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { User as DBUser } from "~/models/user.server";
|
||||
|
||||
type User = Pick<DBUser, "name" | "displayName">;
|
||||
|
||||
// remove `null` from username
|
||||
export function getUsername(user?: User): string | undefined {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// user.displayName is of type `string | null`
|
||||
if (user.displayName) {
|
||||
return user.displayName;
|
||||
}
|
||||
|
||||
// user.name is of type `string | null`
|
||||
if (user.name) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CoercedDate = z.preprocess((arg) => {
|
||||
if (arg === undefined || arg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof arg === "number") {
|
||||
return new Date(arg);
|
||||
}
|
||||
|
||||
if (typeof arg === "string") {
|
||||
const num = Number(arg);
|
||||
if (!isNaN(num)) {
|
||||
return new Date(num);
|
||||
}
|
||||
|
||||
return new Date(arg);
|
||||
}
|
||||
|
||||
return arg;
|
||||
}, z.date().optional());
|
||||
Reference in New Issue
Block a user