kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: add faas-utils, logger, exit hooks
rodzic
1e90451b0c
commit
3efbed9d23
|
@ -0,0 +1,15 @@
|
|||
# ------------------------------------------------------------------------------
|
||||
# This is an example .env file.
|
||||
#
|
||||
# All of these environment vars must be defined either in your environment or in
|
||||
# a local .env file in order to run this project.
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
DATABASE_URL=
|
||||
JWT_SECRET=
|
||||
|
||||
SENTRY_DSN=
|
||||
|
||||
GCP_PROJECT_ID=
|
||||
GCP_LOG_NAME='local-dev'
|
||||
METADATA_SERVER_DETECTION='none'
|
|
@ -2,7 +2,7 @@ import 'dotenv/config'
|
|||
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
import { env } from './src/lib/env'
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
|
|
|
@ -11,14 +11,16 @@
|
|||
"directory": "apps/api"
|
||||
},
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"source": "./src/server.ts",
|
||||
"types": "./dist/server.d.ts",
|
||||
"bin": {
|
||||
"agentic-platform-api": "./dist/server.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
"types": "./dist/server.d.ts",
|
||||
"import": "./dist/server.js",
|
||||
"default": "./dist/server.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
@ -29,20 +31,27 @@
|
|||
"dev": "tsup --watch",
|
||||
"clean": "del dist",
|
||||
"test": "run-s test:*",
|
||||
"test:lint": "eslint .",
|
||||
"test:lint": "eslint src",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentic/faas-utils": "workspace:*",
|
||||
"@google-cloud/logging": "^11.2.0",
|
||||
"@hono/node-server": "^1.14.1",
|
||||
"@hono/sentry": "^1.2.1",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sentry/node": "^9.14.0",
|
||||
"@workos-inc/node": "^7.47.0",
|
||||
"drizzle-orm": "^0.42.0",
|
||||
"drizzle-orm": "^0.43.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"eventid": "^2.0.1",
|
||||
"exit-hook": "catalog:",
|
||||
"hono": "^4.7.7",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pino": "^9.6.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"restore-cursor": "catalog:",
|
||||
"type-fest": "catalog:",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { validators } from '@agentic/faas-utils'
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { boolean, index, jsonb, pgTable, text } from 'drizzle-orm/pg-core'
|
||||
|
||||
|
@ -10,6 +11,8 @@ import {
|
|||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
cuid,
|
||||
deploymentId,
|
||||
projectId,
|
||||
timestamps
|
||||
} from './utils'
|
||||
|
||||
|
@ -17,7 +20,7 @@ export const deployments = pgTable(
|
|||
'deployments',
|
||||
{
|
||||
// namespace/projectName@hash
|
||||
id: text().primaryKey(),
|
||||
id: deploymentId().primaryKey(),
|
||||
...timestamps,
|
||||
|
||||
hash: text().notNull(),
|
||||
|
@ -33,7 +36,7 @@ export const deployments = pgTable(
|
|||
.notNull()
|
||||
.references(() => users.id),
|
||||
teamId: cuid().references(() => teams.id),
|
||||
projectId: cuid()
|
||||
projectId: projectId()
|
||||
.notNull()
|
||||
.references(() => projects.id, {
|
||||
onDelete: 'cascade'
|
||||
|
@ -92,9 +95,17 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||
|
||||
// TODO: narrow
|
||||
export const deploymentInsertSchema = createInsertSchema(deployments, {
|
||||
// TODO: validate deployment id
|
||||
// id: (schema) =>
|
||||
// schema.refine((id) => validators.deployment(id), 'Invalid deployment id')
|
||||
id: (schema) =>
|
||||
schema.refine((id) => validators.project(id), {
|
||||
message: 'Invalid deployment id'
|
||||
}),
|
||||
|
||||
hash: (schema) =>
|
||||
schema.refine((hash) => validators.deploymentHash(hash), {
|
||||
message: 'Invalid deployment hash'
|
||||
}),
|
||||
|
||||
_url: (schema) => schema.url()
|
||||
})
|
||||
|
||||
export const deploymentSelectSchema = createSelectSchema(deployments).omit({
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { validators } from '@agentic/faas-utils'
|
||||
import { relations } from 'drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
|
@ -19,6 +20,8 @@ import {
|
|||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
cuid,
|
||||
deploymentId,
|
||||
projectId,
|
||||
stripeId,
|
||||
timestamps
|
||||
} from './utils'
|
||||
|
@ -27,7 +30,7 @@ export const projects = pgTable(
|
|||
'projects',
|
||||
{
|
||||
// namespace/projectName
|
||||
id: text().primaryKey(),
|
||||
id: projectId().primaryKey(),
|
||||
...timestamps,
|
||||
|
||||
name: text().notNull(),
|
||||
|
@ -39,10 +42,10 @@ export const projects = pgTable(
|
|||
teamId: cuid().notNull(),
|
||||
|
||||
// Most recently published Deployment if one exists
|
||||
lastPublishedDeploymentId: cuid(),
|
||||
lastPublishedDeploymentId: deploymentId(),
|
||||
|
||||
// Most recent Deployment if one exists
|
||||
lastDeploymentId: cuid(),
|
||||
lastDeploymentId: deploymentId(),
|
||||
|
||||
applicationFeePercent: integer().notNull().default(20),
|
||||
|
||||
|
@ -124,12 +127,15 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
|
|||
}))
|
||||
|
||||
export const projectInsertSchema = createInsertSchema(projects, {
|
||||
// TODO: validate project id
|
||||
// id: (schema) =>
|
||||
// schema.refine((id) => validators.project(id), 'Invalid project id')
|
||||
// TODO: validate project name
|
||||
// name: (schema) =>
|
||||
// schema.refine((name) => validators.projectName(name), 'Invalid project name')
|
||||
id: (schema) =>
|
||||
schema.refine((id) => validators.project(id), {
|
||||
message: 'Invalid project id'
|
||||
}),
|
||||
|
||||
name: (schema) =>
|
||||
schema.refine((name) => validators.projectName(name), {
|
||||
message: 'Invalid project name'
|
||||
})
|
||||
})
|
||||
.pick({
|
||||
id: true,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { validators } from '@agentic/faas-utils'
|
||||
import { relations } from 'drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
|
@ -64,13 +65,27 @@ export const users = pgTable(
|
|||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
teamsOwned: many(teams)
|
||||
|
||||
// TODO: team memberships
|
||||
}))
|
||||
|
||||
export const userInsertSchema = createInsertSchema(users, {
|
||||
// TODO: username validation
|
||||
// username: (schema) => schema.min(3).max(20)
|
||||
username: (schema) =>
|
||||
schema.nonempty().refine((username) => validators.username(username), {
|
||||
message: 'Invalid username'
|
||||
}),
|
||||
|
||||
email: (schema) =>
|
||||
schema.refine(
|
||||
(email) => {
|
||||
if (email) {
|
||||
return validators.email(email)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Invalid email'
|
||||
}
|
||||
)
|
||||
}).pick({
|
||||
username: true,
|
||||
email: true,
|
||||
|
|
|
@ -21,6 +21,24 @@ export function stripeId<U extends string, T extends Readonly<[U, ...U[]]>>(
|
|||
return varchar({ length: 255, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* `namespace/projectName`
|
||||
*/
|
||||
export function projectId<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, 130> {
|
||||
return varchar({ length: 130, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* `namespace/projectName@hash`
|
||||
*/
|
||||
export function deploymentId<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, 160> {
|
||||
return varchar({ length: 160, ...config })
|
||||
}
|
||||
|
||||
export const id = varchar('id', { length: 24 })
|
||||
.primaryKey()
|
||||
.$defaultFn(createId)
|
||||
|
|
|
@ -10,7 +10,8 @@ export const envSchema = z.object({
|
|||
.default('development'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
JWT_SECRET: z.string(),
|
||||
PORT: z.number().default(3000)
|
||||
PORT: z.number().default(3000),
|
||||
SENTRY_DSN: z.string().url()
|
||||
})
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
|
@ -19,4 +20,6 @@ export const env = parseZodSchema(envSchema, process.env, {
|
|||
error: 'Invalid environment variables'
|
||||
})
|
||||
|
||||
export const isDev = env.NODE_ENV === 'development'
|
||||
export const isProd = env.NODE_ENV === 'production'
|
||||
export const isBrowser = (globalThis as any).window !== undefined
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { promisify } from 'node:util'
|
||||
|
||||
import type { ServerType } from '@hono/node-server'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { asyncExitHook } from 'exit-hook'
|
||||
import restoreCursor from 'restore-cursor'
|
||||
|
||||
|
@ -38,5 +39,15 @@ export function initExitHooks({
|
|||
}
|
||||
)
|
||||
|
||||
// Gracefully flush Sentry events
|
||||
asyncExitHook(
|
||||
async function flushSentryExitHook() {
|
||||
await Sentry.flush(timeoutMs)
|
||||
},
|
||||
{
|
||||
wait: timeoutMs
|
||||
}
|
||||
)
|
||||
|
||||
// TODO: On Node.js, log unhandledRejection, uncaughtException, and warning events
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import 'dotenv/config'
|
||||
|
||||
import * as Sentry from '@sentry/node'
|
||||
|
||||
// This MUST be run before anything else (imported first in the root file).
|
||||
// No other imports (like env) should be imported in this file.
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN, // eslint-disable-line no-process-env
|
||||
environment: process.env.NODE_ENV || 'development', // eslint-disable-line no-process-env
|
||||
release: process.env.COMMIT_SHA, // eslint-disable-line no-process-env
|
||||
tracesSampleRate: 1.0,
|
||||
integrations: [Sentry.extraErrorDataIntegration()]
|
||||
})
|
|
@ -0,0 +1,125 @@
|
|||
import type { pino } from 'pino'
|
||||
import { EventId } from 'eventid'
|
||||
|
||||
/** ==========================================================================
|
||||
* GCP logging helpers taken from their official repo.
|
||||
* @see https://github.com/GoogleCloudPlatform/cloud-solutions/blob/main/projects/pino-logging-gcp-config/src/pino_gcp_config.ts
|
||||
* ======================================================================== */
|
||||
|
||||
/** Monotonically increasing ID for insertId. */
|
||||
const eventId = new EventId()
|
||||
|
||||
const PINO_TO_GCP_LOG_LEVELS = Object.freeze(
|
||||
Object.fromEntries([
|
||||
['trace', 'DEBUG'],
|
||||
['debug', 'DEBUG'],
|
||||
['info', 'INFO'],
|
||||
['warn', 'WARNING'],
|
||||
['error', 'ERROR'],
|
||||
['fatal', 'CRITICAL']
|
||||
])
|
||||
) as Record<pino.Level, string>
|
||||
|
||||
/**
|
||||
* Converts pino log level to Google severity level.
|
||||
*
|
||||
* @see pino.LoggerOptions.formatters.level
|
||||
*/
|
||||
export function pinoLevelToGcpSeverity(
|
||||
pinoSeverityLabel: string,
|
||||
pinoSeverityLevel: number
|
||||
): Record<string, unknown> {
|
||||
const pinoLevel = pinoSeverityLabel as pino.Level
|
||||
const severity = PINO_TO_GCP_LOG_LEVELS[pinoLevel] ?? 'INFO'
|
||||
return {
|
||||
severity,
|
||||
level: pinoSeverityLevel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON fragment string containing the timestamp in GCP logging
|
||||
* format.
|
||||
*
|
||||
* @example ', "timestamp": { "seconds": 123456789, "nanos": 123000000 }'
|
||||
*
|
||||
* Creating a string with seconds/nanos is ~10x faster than formatting the
|
||||
* timestamp as an ISO string.
|
||||
*
|
||||
* @see https://cloud.google.com/logging/docs/agent/logging/configuration#timestamp-processing
|
||||
*
|
||||
* As Javascript Date uses millisecond precision, in
|
||||
* {@link formatLogObject} the logger adds a monotonically increasing insertId
|
||||
* into the log object to preserve log order inside GCP logging.
|
||||
*
|
||||
* @see https://github.com/googleapis/nodejs-logging/blob/main/src/entry.ts#L189
|
||||
*/
|
||||
export function getGcpLoggingTimestamp() {
|
||||
const seconds = Date.now() / 1000
|
||||
const secondsRounded = Math.floor(seconds)
|
||||
// The following line is 2x as fast as seconds % 1000
|
||||
// Uses Math.round, not Math.floor due to JS floating point...
|
||||
// eg for a Date.now()=1713024754120
|
||||
// (seconds-secondsRounded)*1000 => 119.99988555908203
|
||||
const millis = Math.round((seconds - secondsRounded) * 1000)
|
||||
return `,"timestamp":{"seconds":${secondsRounded},"nanos":${millis}000000}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformats log entry record for GCP.
|
||||
*
|
||||
* * Adds OpenTelemetry properties with correct key.
|
||||
* * Adds stack_trace if an Error is given in the err property.
|
||||
* * Adds serviceContext
|
||||
* * Adds sequential insertId to preserve logging order.
|
||||
*/
|
||||
export function formatGcpLogObject(
|
||||
entry: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// OpenTelemetry adds properties trace_id, span_id, trace_flags. If these
|
||||
// are present, not null and not blank, convert them to the property keys
|
||||
// specified by GCP logging.
|
||||
//
|
||||
// @see https://cloud.google.com/logging/docs/structured-logging#special-payload-fields
|
||||
// @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#trace-context-fields
|
||||
if ((entry.trace_id as string | undefined)?.length) {
|
||||
entry['logging.googleapis.com/trace'] = entry.trace_id
|
||||
delete entry.trace_id
|
||||
}
|
||||
if ((entry.span_id as string | undefined)?.length) {
|
||||
entry['logging.googleapis.com/spanId'] = entry.span_id
|
||||
delete entry.span_id
|
||||
}
|
||||
// Trace flags is a bit field even though there is one on defined bit,
|
||||
// so lets convert it to an int and test against a bitmask.
|
||||
// @see https://www.w3.org/TR/trace-context/#trace-flags
|
||||
const traceFlagsBits = Number.parseInt(entry.trace_flags as string)
|
||||
if (!!traceFlagsBits && (traceFlagsBits & 0x1) === 1) {
|
||||
entry['logging.googleapis.com/trace_sampled'] = true
|
||||
}
|
||||
delete entry.trace_flags
|
||||
|
||||
// If there is an Error, add the stack trace for Event Reporting.
|
||||
if (entry.err instanceof Error && entry.err.stack) {
|
||||
entry.stack_trace = entry.err.stack
|
||||
}
|
||||
|
||||
// Add a sequential EventID.
|
||||
//
|
||||
// This is required because Javascript Date has a very coarse granularity
|
||||
// (millisecond), which makes it quite likely that multiple log entries
|
||||
// would have the same timestamp.
|
||||
//
|
||||
// The GCP Logging API doesn't guarantee to preserve insertion order for
|
||||
// entries with the same timestamp. The service does use `insertId` as a
|
||||
// secondary ordering for entries with the same timestamp. `insertId` needs
|
||||
// to be globally unique (within the project) however.
|
||||
//
|
||||
// We use a globally unique monotonically increasing EventId as the
|
||||
// insertId.
|
||||
//
|
||||
// @see https://github.com/googleapis/nodejs-logging/blob/main/src/entry.ts#L189
|
||||
entry['logging.googleapis.com/insertId'] = eventId.new()
|
||||
|
||||
return entry
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/* eslint-disable no-process-env */
|
||||
|
||||
import build from 'pino-abstract-transport'
|
||||
|
||||
/**
|
||||
* Pino transport that send logs to GCP cloud logging.
|
||||
*
|
||||
* Google Cloud setup and auth instructions are in the root readme.
|
||||
*
|
||||
* For information about Pino transports:
|
||||
* @see https://getpino.io/#/docs/transports?id=writing-a-transport
|
||||
*/
|
||||
export default async function gcpTransport() {
|
||||
// Dynamically import @google-cloud/logging only if/when this function is called
|
||||
// This prevent the GCP bloatware from being loaded in prod, where this is not used.
|
||||
const { Logging } = await import('@google-cloud/logging')
|
||||
|
||||
const projectId = process.env.GCP_PROJECT_ID || 'agentic-426105'
|
||||
const logName = process.env.GCP_LOG_NAME || 'local-dev'
|
||||
|
||||
if (!process.env.METADATA_SERVER_DETECTION) {
|
||||
console.error(
|
||||
'Metadata server detection is not set. Set `METADATA_SERVER_DETECTION=none` in the repo root `.env`.'
|
||||
)
|
||||
}
|
||||
|
||||
const logging = new Logging({ projectId })
|
||||
const log = logging.log(logName)
|
||||
|
||||
return build(async function (source: AsyncIterable<Record<string, any>>) {
|
||||
for await (const obj of source) {
|
||||
try {
|
||||
const { severity, ...rest } = obj
|
||||
const entry = log.entry(
|
||||
{
|
||||
severity,
|
||||
resource: { type: 'global' }
|
||||
},
|
||||
rest
|
||||
)
|
||||
await log.write(entry)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Error sending log to GCP. Consult `readme.md` for setup instructions.',
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './logger'
|
||||
export * from './utils'
|
|
@ -0,0 +1,36 @@
|
|||
/* eslint-disable simple-import-sort/imports */
|
||||
/* eslint-disable import/first */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockSentryNode } from '../test'
|
||||
|
||||
// Mock Sentry before importing logger
|
||||
mockSentryNode()
|
||||
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { logger } from './logger'
|
||||
|
||||
describe('logger', () => {
|
||||
afterEach(() => {
|
||||
// We only clear the usage data so it remains a spy.
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should call Sentry.captureException when calling logger.error() with an Error', () => {
|
||||
const error = new Error('test error')
|
||||
logger.error(error)
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('should call Sentry.captureException when calling logger.error() with an {err: Error}', () => {
|
||||
const error = new Error('test error')
|
||||
logger.error({ err: error }, 'With some message')
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('should not call Sentry.captureException for logger.warn()', () => {
|
||||
logger.warn('some warning message')
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,116 @@
|
|||
import fs from 'node:fs'
|
||||
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { type Logger, pino } from 'pino'
|
||||
|
||||
import { isBrowser, isDev, isProd } from '@/lib/env'
|
||||
|
||||
import {
|
||||
formatGcpLogObject,
|
||||
getGcpLoggingTimestamp,
|
||||
pinoLevelToGcpSeverity
|
||||
} from './gcp-formatters'
|
||||
import { getTraceId } from './utils'
|
||||
|
||||
const gcpTransportPath = `${import.meta.dirname}/gcp-transport.js`
|
||||
|
||||
// TODO: Transport imports are hacky; find a better workaround
|
||||
const transportExists = fs.existsSync(gcpTransportPath)
|
||||
|
||||
export const logger = pino({
|
||||
messageKey: 'message',
|
||||
level: isProd ? 'info' : 'trace',
|
||||
timestamp: () => getGcpLoggingTimestamp(),
|
||||
// Add the Sentry trace ID to the log context
|
||||
mixin(_obj, _level, mixinLogger) {
|
||||
try {
|
||||
// Check if the logger already has a traceId in its bindings
|
||||
const currentBindings = mixinLogger.bindings()
|
||||
if (
|
||||
currentBindings &&
|
||||
typeof currentBindings === 'object' &&
|
||||
'traceId' in currentBindings &&
|
||||
currentBindings.traceId
|
||||
) {
|
||||
// If traceId already exists in bindings, use that
|
||||
const traceId = currentBindings.traceId
|
||||
return { traceId, meta: { traceId } }
|
||||
}
|
||||
|
||||
// Otherwise, get the trace ID from Sentry
|
||||
const traceId = getTraceId()
|
||||
|
||||
// Duplicate in the `meta` field
|
||||
return traceId ? { traceId, meta: { traceId } } : {}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return {}
|
||||
}
|
||||
},
|
||||
formatters: {
|
||||
level: pinoLevelToGcpSeverity,
|
||||
log: (entry: Record<string, unknown>) => formatGcpLogObject(entry)
|
||||
},
|
||||
transport:
|
||||
isDev && !isBrowser && transportExists
|
||||
? { target: gcpTransportPath }
|
||||
: undefined,
|
||||
hooks: {
|
||||
logMethod(args, method, level) {
|
||||
// Only capture errors if the log level is at least 50 (error)
|
||||
if (level >= 50) {
|
||||
let foundError: Error | undefined
|
||||
const arg0 = args[0] as unknown
|
||||
const arg1 = args[1] as unknown
|
||||
|
||||
for (const arg of [arg0, arg1]) {
|
||||
if (arg instanceof Error) {
|
||||
foundError = arg
|
||||
} else if (arg && typeof arg === 'object') {
|
||||
if ('err' in arg && arg.err instanceof Error) {
|
||||
foundError = arg.err
|
||||
}
|
||||
|
||||
if ('error' in arg && arg.error instanceof Error) {
|
||||
foundError = arg.error
|
||||
}
|
||||
}
|
||||
|
||||
if (foundError) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundError) {
|
||||
Sentry.captureException(foundError)
|
||||
}
|
||||
}
|
||||
|
||||
return method.apply(this, args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Add more groups
|
||||
export type LogGroup = 'api'
|
||||
|
||||
/** Standardized way to extend the logger with helpful info */
|
||||
export function extendedLogger({
|
||||
logger: baseLogger = logger,
|
||||
...args
|
||||
}: {
|
||||
group: LogGroup
|
||||
name: string
|
||||
/** A more specific subtype of the name */
|
||||
nameSubtype?: string
|
||||
/** The eventId to add to the logger */
|
||||
eventId?: string
|
||||
logger?: Logger
|
||||
}): Logger {
|
||||
const { group, name, nameSubtype } = args
|
||||
return baseLogger.child(args, {
|
||||
msgPrefix: `[${group}:${name}${nameSubtype ? `:${nameSubtype}` : ''}] `
|
||||
})
|
||||
}
|
||||
|
||||
export type { Logger } from 'pino'
|
|
@ -0,0 +1,54 @@
|
|||
import * as Sentry from '@sentry/node'
|
||||
|
||||
/** Get the URL for the logs in the GCP console. */
|
||||
function getGCPLogsUrl(): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
const queryParts = [
|
||||
'resource.type = "cloud_run_revision"',
|
||||
'resource.labels.service_name = "agentic"'
|
||||
]
|
||||
const traceId = getTraceId()
|
||||
|
||||
if (traceId) {
|
||||
queryParts.push(`jsonPayload.meta.traceId = "${traceId}"`)
|
||||
}
|
||||
|
||||
const query = queryParts.join(' AND ')
|
||||
const url = `https://console.cloud.google.com/logs/query;query=${encodeURIComponent(
|
||||
query
|
||||
)};summaryFields=jsonPayload%252Fmeta%252FtraceId:false:32:beginning;aroundTime=${timestamp};duration=PT1H?project=agentic-internal-tools`
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/** Get the ID of the trace from the root span of the current span. */
|
||||
export function getTraceId(): string | undefined {
|
||||
try {
|
||||
const activeSpan = Sentry.getActiveSpan()
|
||||
const rootSpan = activeSpan ? Sentry.getRootSpan(activeSpan) : undefined
|
||||
if (rootSpan) {
|
||||
const { traceId } = rootSpan.spanContext()
|
||||
return traceId
|
||||
}
|
||||
return undefined
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the Sentry trace link for the current span. */
|
||||
function getSentryTraceURL(): string {
|
||||
const traceId = getTraceId()
|
||||
return `https://agentic-platform.sentry.io/performance/trace/${traceId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logs and trace URLs for the current event.
|
||||
*/
|
||||
export function getDebugURLs(): { logs: string; trace: string } {
|
||||
return {
|
||||
logs: getGCPLogsUrl(),
|
||||
trace: getSentryTraceURL()
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
|
||||
|
@ -28,6 +29,7 @@ export const errorHandler = createMiddleware<AuthenticatedEnv>(
|
|||
|
||||
if (status >= 500) {
|
||||
console.error('http error', status, message)
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
|
||||
ctx.json({ error: message }, status)
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export * from './mock-sentry-node'
|
||||
export * from './setup-mock-logger'
|
|
@ -0,0 +1,25 @@
|
|||
import { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Mocks the entire @sentry/node module so that captureException is a spy.
|
||||
*/
|
||||
export function mockSentryNode(): void {
|
||||
vi.mock('@sentry/node', async () => {
|
||||
const actual = await vi.importActual('@sentry/node')
|
||||
return {
|
||||
...actual,
|
||||
captureException: vi.fn(),
|
||||
setTags: vi.fn(),
|
||||
setTag: vi.fn(),
|
||||
withIsolationScope: vi.fn((fn) => fn()),
|
||||
startSpan: vi.fn((_, fn) => {
|
||||
const fakeSpan = {
|
||||
setAttributes: vi.fn(),
|
||||
end: vi.fn()
|
||||
}
|
||||
const callbackResult = fn(fakeSpan)
|
||||
return callbackResult
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import type { Logger } from 'pino'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export function setupMockLogger() {
|
||||
return {
|
||||
child: () =>
|
||||
({
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
}) as unknown as Logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
} as unknown as Logger
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
import '@/lib/instrument'
|
||||
|
||||
import { serve } from '@hono/node-server'
|
||||
import { sentry } from '@hono/sentry'
|
||||
import { Hono } from 'hono'
|
||||
import { compress } from 'hono/compress'
|
||||
import { cors } from 'hono/cors'
|
||||
|
@ -11,6 +14,7 @@ import { initExitHooks } from './lib/exit-hooks'
|
|||
|
||||
export const app = new Hono()
|
||||
|
||||
app.use(sentry())
|
||||
app.use(compress())
|
||||
app.use(middleware.responseTime)
|
||||
app.use(middleware.errorHandler)
|
||||
|
|
|
@ -6,6 +6,6 @@
|
|||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "drizzle.config.ts"],
|
||||
"include": ["src", "*.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['src/server.ts'],
|
||||
outDir: 'dist',
|
||||
target: 'node18',
|
||||
platform: 'node',
|
||||
format: ['esm'],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
shims: true,
|
||||
dts: true
|
||||
}
|
||||
])
|
|
@ -0,0 +1,12 @@
|
|||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
watch: false,
|
||||
restoreMocks: true
|
||||
}
|
||||
})
|
|
@ -43,6 +43,7 @@
|
|||
"tsx": "catalog:",
|
||||
"turbo": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@agentic/faas-utils",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Agentic platform FaaS utils.",
|
||||
"author": "Travis Fischer <travis@transitivebullsh.it>",
|
||||
"license": "UNLICENSED",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/transitive-bullshit/agentic-platform.git",
|
||||
"directory": "packages/faas-utils"
|
||||
},
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "run-s test:*",
|
||||
"test:lint": "eslint .",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"email-validator": "^2.0.4",
|
||||
"is-relative-url": "^4.0.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`URL prefix and suffix success 1`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix and suffix success 2`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/123",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 1`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 2`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 3`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/456/123",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 4`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/456/123",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 5`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/456/123",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 6`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/456/123",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 7`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/456/123",
|
||||
"version": "dev",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL prefix success 8`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/456/123",
|
||||
"version": "2.1.0",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL suffix success 1`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL suffix success 2`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL suffix success 3`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "dev",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`URL suffix success 4`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "2.1.0",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 1`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 2`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 3`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 4`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 5`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/123",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 6`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/123",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 7`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/123",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 8`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/123",
|
||||
"version": "dev",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`namespace success 9`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo/bar/123",
|
||||
"version": "1.2.3",
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,240 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`username/projectName success 1`] = `
|
||||
{
|
||||
"projectId": "abc/hello-world",
|
||||
"servicePath": "/",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName success 2`] = `
|
||||
{
|
||||
"projectId": "a16z/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName success 3`] = `
|
||||
{
|
||||
"projectId": "foodoo/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName success 4`] = `
|
||||
{
|
||||
"projectId": "u/foobar123-yo",
|
||||
"servicePath": "/",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName success 5`] = `
|
||||
{
|
||||
"projectId": "abc/hello-world",
|
||||
"servicePath": "/",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName/servicePath success 1`] = `
|
||||
{
|
||||
"projectId": "u/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName/servicePath success 2`] = `
|
||||
{
|
||||
"projectId": "a/foo-bar",
|
||||
"servicePath": "/foo_123",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName/servicePath success 3`] = `
|
||||
{
|
||||
"projectId": "foo/foobar123-yo",
|
||||
"servicePath": "/foo_bar_BAR_901",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName/servicePath success 4`] = `
|
||||
{
|
||||
"projectId": "foo/foobar123-yo",
|
||||
"servicePath": "/foo/bar/123/456",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment success 1`] = `
|
||||
{
|
||||
"deploymentHash": "3d2e0fd5",
|
||||
"deploymentId": "abc/hello-world@3d2e0fd5",
|
||||
"projectId": "abc/hello-world",
|
||||
"servicePath": "/",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment success 2`] = `
|
||||
{
|
||||
"projectId": "a16z/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "f673db32c",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment success 3`] = `
|
||||
{
|
||||
"projectId": "foodoo/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "f673db32c",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment success 4`] = `
|
||||
{
|
||||
"deploymentHash": "673db32c",
|
||||
"deploymentId": "u/foobar123-yo@673db32c",
|
||||
"projectId": "u/foobar123-yo",
|
||||
"servicePath": "/",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment success 5`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment/servicePath success 1`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foo-bar@01234567",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment/servicePath success 2`] = `
|
||||
{
|
||||
"deploymentHash": "abc123lz",
|
||||
"deploymentId": "username/foo-bar@abc123lz",
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment/servicePath success 3`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foobar123-yo@01234567",
|
||||
"projectId": "username/foobar123-yo",
|
||||
"servicePath": "/foo_bar_BAR_901",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@deployment/servicePath success 4`] = `
|
||||
{
|
||||
"deploymentHash": "01234567",
|
||||
"deploymentId": "username/foobar@01234567",
|
||||
"projectId": "username/foobar",
|
||||
"servicePath": "/foo/bar/123/456",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version success 1`] = `
|
||||
{
|
||||
"projectId": "abc/hello-world",
|
||||
"servicePath": "/",
|
||||
"version": "1.0.3",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version success 2`] = `
|
||||
{
|
||||
"projectId": "a16z/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version success 3`] = `
|
||||
{
|
||||
"projectId": "a16z/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "dev",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version success 4`] = `
|
||||
{
|
||||
"projectId": "foodoo/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "1.0.1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version success 5`] = `
|
||||
{
|
||||
"projectId": "u/foobar123-yo",
|
||||
"servicePath": "/",
|
||||
"version": "3.2.2234",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version success 6`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/",
|
||||
"version": "1.0.3",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version/servicePath success 1`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "latest",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version/servicePath success 2`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "dev",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version/servicePath success 3`] = `
|
||||
{
|
||||
"projectId": "username/foo-bar",
|
||||
"servicePath": "/foo",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version/servicePath success 4`] = `
|
||||
{
|
||||
"projectId": "username/foobar123-yo",
|
||||
"servicePath": "/foo_bar_BAR_901",
|
||||
"version": "0.0.1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`username/projectName@version/servicePath success 5`] = `
|
||||
{
|
||||
"projectId": "username/foobar123-yo",
|
||||
"servicePath": "/foo/bar/123-456",
|
||||
"version": "0.0.1",
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,4 @@
|
|||
export * from './parse-faas-identifier'
|
||||
export * from './parse-faas-uri'
|
||||
export type * from './types'
|
||||
export * as validators from './validators'
|
|
@ -0,0 +1,86 @@
|
|||
import { expect, test } from 'vitest'
|
||||
|
||||
import { parseFaasIdentifier } from './parse-faas-identifier'
|
||||
import * as validators from './validators'
|
||||
|
||||
function success(...args: Parameters<typeof parseFaasIdentifier>) {
|
||||
const result = parseFaasIdentifier(...args)
|
||||
expect(result).toBeTruthy()
|
||||
expect(result!.projectId).toBeTruthy()
|
||||
expect(result!.version || result!.deploymentHash).toBeTruthy()
|
||||
expect(validators.project(result!.projectId)).toBe(true)
|
||||
expect(validators.servicePath(result!.servicePath)).toBe(true)
|
||||
|
||||
if (result!.deploymentHash) {
|
||||
expect(validators.deploymentHash(result!.deploymentHash)).toBe(true)
|
||||
expect(validators.deployment(result!.deploymentId!)).toBe(true)
|
||||
}
|
||||
|
||||
expect(result).toMatchSnapshot()
|
||||
}
|
||||
|
||||
function error(...args: Parameters<typeof parseFaasIdentifier>) {
|
||||
const result = parseFaasIdentifier(...args)
|
||||
expect(result).toBeUndefined()
|
||||
}
|
||||
|
||||
test('URL prefix success', () => {
|
||||
success('https://api.saasify.sh/username/foo-bar@01234567/foo')
|
||||
success('/username/foo-bar@01234567/foo')
|
||||
success('https://api.saasify.sh/username/foo-bar@01234567/foo/bar/456/123')
|
||||
success('/username/foo-bar@01234567/foo/bar/456/123')
|
||||
success('/username/foo-bar@01234567/foo/bar/456/123')
|
||||
success('/username/foo-bar@latest/foo/bar/456/123')
|
||||
success('/username/foo-bar@dev/foo/bar/456/123')
|
||||
success('/username/foo-bar@2.1.0/foo/bar/456/123')
|
||||
})
|
||||
|
||||
test('URL prefix error', () => {
|
||||
error('https://api.saasify.sh/2/proxy/username/foo-bar@01234567/foo')
|
||||
error('/call/username/foo-bar@01234567/foo')
|
||||
error('//username/foo-bar@01234567/foo')
|
||||
})
|
||||
|
||||
test('URL suffix success', () => {
|
||||
success('username/foo-bar@01234567/foo/')
|
||||
success('username/foo-bar@latest/foo/')
|
||||
success('username/foo-bar@dev/foo/')
|
||||
success('username/foo-bar@2.1.0/foo/')
|
||||
})
|
||||
|
||||
test('URL suffix error', () => {
|
||||
error('username/foo-bar@01234567/foo😀')
|
||||
error('username/Foo-Bar@dev/foo/')
|
||||
})
|
||||
|
||||
test('URL prefix and suffix success', () => {
|
||||
success('https://api.saasify.sh/username/foo-bar@01234567/foo/')
|
||||
success('https://api.saasify.sh/username/foo-bar@01234567/foo/bar/123')
|
||||
})
|
||||
|
||||
test('namespace success', () => {
|
||||
success('https://api.saasify.sh/foo-bar@01234567/foo', {
|
||||
namespace: 'username'
|
||||
})
|
||||
success('/foo-bar@01234567/foo', { namespace: 'username' })
|
||||
success('/foo-bar@01234567/foo', { namespace: 'username' })
|
||||
success('/foo-bar@01234567/foo/', { namespace: 'username' })
|
||||
success('https://api.saasify.sh/foo-bar@01234567/foo/bar/123', {
|
||||
namespace: 'username'
|
||||
})
|
||||
success('/foo-bar@01234567/foo/bar/123', { namespace: 'username' })
|
||||
success('/foo-bar@latest/foo/bar/123', { namespace: 'username' })
|
||||
success('/foo-bar@dev/foo/bar/123', { namespace: 'username' })
|
||||
success('/foo-bar@1.2.3/foo/bar/123', { namespace: 'username' })
|
||||
})
|
||||
|
||||
test('namespace error', () => {
|
||||
error('https://api.saasify.sh/foo-bar@01234567/foo')
|
||||
error('https://api.saasify.sh/foo-bar@latest/foo')
|
||||
error('/foo-bar@01234567/foo')
|
||||
error('/foo-bar@dev/foo')
|
||||
error('/foo-bar@01234567/foo')
|
||||
error('/foo-bar@01234567/foo/')
|
||||
error('/foo-bar@01234567/foo/bar/123')
|
||||
error('/foo-bar@0latest/foo/bar/123')
|
||||
})
|
|
@ -0,0 +1,43 @@
|
|||
import type { ParsedFaasIdentifier } from './types'
|
||||
import { parseFaasUri } from './parse-faas-uri'
|
||||
|
||||
export function parseFaasIdentifier(
|
||||
identifier: string,
|
||||
{ namespace }: { namespace?: string } = {}
|
||||
): ParsedFaasIdentifier | undefined {
|
||||
if (!identifier) {
|
||||
return
|
||||
}
|
||||
|
||||
let uri = identifier
|
||||
try {
|
||||
const { pathname } = new URL(identifier)
|
||||
uri = pathname
|
||||
} catch {}
|
||||
|
||||
if (uri.startsWith('/')) {
|
||||
uri = uri.slice(1)
|
||||
}
|
||||
|
||||
if (uri.endsWith('/')) {
|
||||
uri = uri.slice(0, -1)
|
||||
}
|
||||
|
||||
if (!uri.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasNamespacePrefix = /^([a-zA-Z0-9-]{1,64}\/)/.test(uri)
|
||||
|
||||
if (!hasNamespacePrefix) {
|
||||
if (namespace) {
|
||||
// add inferred namespace prefix (defaults to authenticated user's username)
|
||||
uri = `${namespace}/${uri}`
|
||||
} else {
|
||||
// throw new Error(`FaaS identifier is missing namespace prefix or you must be authenticated [${uri}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return parseFaasUri(uri)
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { expect, test } from 'vitest'
|
||||
|
||||
import { parseFaasUri } from './parse-faas-uri'
|
||||
|
||||
function success(value: string) {
|
||||
const result = parseFaasUri(value)
|
||||
expect(result).toBeTruthy()
|
||||
expect(result?.projectId).toBeTruthy()
|
||||
expect(result?.version || result?.deploymentHash).toBeTruthy()
|
||||
expect(result).toMatchSnapshot()
|
||||
}
|
||||
|
||||
function error(value: string) {
|
||||
const result = parseFaasUri(value)
|
||||
expect(result).toBeUndefined()
|
||||
}
|
||||
|
||||
test('username/projectName@deployment/servicePath success', () => {
|
||||
success('username/foo-bar@01234567/foo')
|
||||
success('username/foo-bar@abc123lz/foo')
|
||||
success('username/foobar123-yo@01234567/foo_bar_BAR_901')
|
||||
success('username/foobar@01234567/foo/bar/123/456')
|
||||
})
|
||||
|
||||
test('username/projectName@deployment/servicePath error', () => {
|
||||
error('foo-bar@01234567/foo')
|
||||
error('%/foo-bar@01234567/foo')
|
||||
error('user/foo^bar@01234567/foo')
|
||||
error('user@foo^bar@01234567/foo')
|
||||
error('username/Foo-Bar@01234567/foo')
|
||||
})
|
||||
|
||||
test('username/projectName@version/servicePath success', () => {
|
||||
success('username/foo-bar@latest/foo')
|
||||
success('username/foo-bar@dev/foo')
|
||||
success('username/foo-bar@1.0.0/foo')
|
||||
success('username/foobar123-yo@0.0.1/foo_bar_BAR_901')
|
||||
success('username/foobar123-yo@0.0.1/foo/bar/123-456')
|
||||
})
|
||||
|
||||
test('username/projectName@version/servicePath error', () => {
|
||||
error('foo_bar@latest/foo')
|
||||
error('username/foo-bar@1.0.0/foo@')
|
||||
error('username/foo-bar@/foo')
|
||||
error('username/foo-bar@/foo/')
|
||||
error('username/fooBar123-yo@0.0.1/foo/bar/123-456')
|
||||
})
|
||||
|
||||
test('username/projectName/servicePath success', () => {
|
||||
success('u/foo-bar/foo')
|
||||
success('a/foo-bar/foo_123')
|
||||
success('foo/foobar123-yo/foo_bar_BAR_901')
|
||||
success('foo/foobar123-yo/foo/bar/123/456')
|
||||
})
|
||||
|
||||
test('username/projectName/servicePath error', () => {
|
||||
error('@/foo_bar/foo')
|
||||
error('foo-bar/foo\\/')
|
||||
error('user/_/foo')
|
||||
error('user/a 1/foo')
|
||||
error('u/FOO-bar/foo')
|
||||
})
|
||||
|
||||
test('username/projectName@deployment success', () => {
|
||||
success('abc/hello-world@3d2e0fd5')
|
||||
success('a16z/foo-bar@f673db32c')
|
||||
success('foodoo/foo-bar@f673db32c')
|
||||
success('u/foobar123-yo@673db32c')
|
||||
success('username/foo-bar@01234567/')
|
||||
})
|
||||
|
||||
test('username/projectName@deployment error', () => {
|
||||
error('/hello-world@3d2e0fd5')
|
||||
error('foo-bar@f673db32c')
|
||||
error('foodoo/foo@bar@f673db32c')
|
||||
error('u/fooBar123-yo@/673db32c')
|
||||
error('abc/Hello-World@3d2e0fd5')
|
||||
})
|
||||
|
||||
test('username/projectName@version success', () => {
|
||||
success('abc/hello-world@1.0.3')
|
||||
success('a16z/foo-bar@latest')
|
||||
success('a16z/foo-bar@dev')
|
||||
success('foodoo/foo-bar@1.0.1')
|
||||
success('u/foobar123-yo@3.2.2234')
|
||||
success('username/foo-bar@1.0.3/')
|
||||
})
|
||||
|
||||
test('username/projectName@version error', () => {
|
||||
error('/hello-world@3d2e0fd5')
|
||||
error('foo-bar@f673db32c')
|
||||
error('foodoo/foo@bar@f673db32c@')
|
||||
error('u/fooBar123-yo@/673db32c/')
|
||||
error('abc/hello-World@1.0.3')
|
||||
})
|
||||
|
||||
test('username/projectName success', () => {
|
||||
success('abc/hello-world')
|
||||
success('a16z/foo-bar')
|
||||
success('foodoo/foo-bar')
|
||||
success('u/foobar123-yo')
|
||||
success('abc/hello-world/')
|
||||
})
|
||||
|
||||
test('username/projectName error', () => {
|
||||
error('/hello-world')
|
||||
error('foo-barc')
|
||||
error('foodoo/foo@bar@')
|
||||
error('u/fooBar123-yo@/')
|
||||
error('abc/HELLO-WORLD')
|
||||
})
|
|
@ -0,0 +1,59 @@
|
|||
// TODO: investigate this
|
||||
/* eslint-disable security/detect-unsafe-regex */
|
||||
|
||||
import type { ParsedFaasIdentifier } from './types'
|
||||
|
||||
// namespace/projectName@deploymentHash/servicePath
|
||||
// project@deploymentHash/servicePath
|
||||
const projectDeploymentServiceRe =
|
||||
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{3,64})@([a-z0-9]{8})(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
|
||||
|
||||
// namespace/projectName@version/servicePath
|
||||
// project@version/servicePath
|
||||
const projectVersionServiceRe =
|
||||
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{3,64})@([^/?@]+)(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
|
||||
|
||||
// namespace/projectName/servicePath
|
||||
// project/servicePath (latest version)
|
||||
const projectServiceRe =
|
||||
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{3,64})(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
|
||||
|
||||
export function parseFaasUri(uri: string): ParsedFaasIdentifier | undefined {
|
||||
const pdsMatch = uri.match(projectDeploymentServiceRe)
|
||||
|
||||
if (pdsMatch) {
|
||||
const projectId = pdsMatch[1]!
|
||||
const deploymentHash = pdsMatch[2]!
|
||||
const servicePath = pdsMatch[3] || '/'
|
||||
|
||||
return {
|
||||
projectId,
|
||||
deploymentHash,
|
||||
servicePath,
|
||||
deploymentId: `${projectId}@${deploymentHash}`
|
||||
}
|
||||
}
|
||||
|
||||
const pvsMatch = uri.match(projectVersionServiceRe)
|
||||
|
||||
if (pvsMatch) {
|
||||
return {
|
||||
projectId: pvsMatch[1]!,
|
||||
version: pvsMatch[2]!,
|
||||
servicePath: pvsMatch[3] || '/'
|
||||
}
|
||||
}
|
||||
|
||||
const psMatch = uri.match(projectServiceRe)
|
||||
|
||||
if (psMatch) {
|
||||
return {
|
||||
projectId: psMatch[1]!,
|
||||
servicePath: psMatch[2] || '/',
|
||||
version: 'latest'
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid FaaS uri, so return undefined
|
||||
return
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
export type ParsedFaasIdentifier = {
|
||||
projectId: string
|
||||
servicePath: string
|
||||
deploymentHash?: string
|
||||
deploymentId?: string
|
||||
version?: string
|
||||
} & (
|
||||
| {
|
||||
deploymentHash: string
|
||||
deploymentId: string
|
||||
}
|
||||
| {
|
||||
version: string
|
||||
}
|
||||
)
|
|
@ -0,0 +1,137 @@
|
|||
import { expect, test } from 'vitest'
|
||||
|
||||
import * as validators from './validators'
|
||||
|
||||
test('email success', () => {
|
||||
expect(validators.email('t@t.com')).toBe(true)
|
||||
expect(validators.email('abc@gmail.com')).toBe(true)
|
||||
expect(validators.email('abc@foo.io')).toBe(true)
|
||||
})
|
||||
|
||||
test('email failure', () => {
|
||||
expect(validators.email('t@t')).toBe(false)
|
||||
expect(validators.email('abc')).toBe(false)
|
||||
expect(validators.email('@')).toBe(false)
|
||||
})
|
||||
|
||||
test('username success', () => {
|
||||
expect(validators.username('z')).toBe(true)
|
||||
expect(validators.username('abc')).toBe(true)
|
||||
expect(validators.username('abc-123')).toBe(true)
|
||||
expect(validators.username('Foo123')).toBe(true)
|
||||
expect(validators.username('asldfkjasldkfjlaksdfjlkas')).toBe(true)
|
||||
})
|
||||
|
||||
test('username failure (invalid)', () => {
|
||||
expect(validators.username('ab%')).toBe(false)
|
||||
expect(validators.username('.'))
|
||||
expect(validators.username('$'))
|
||||
expect(validators.username('abc_123'))
|
||||
expect(validators.username('a'.repeat(65))).toBe(false)
|
||||
})
|
||||
|
||||
test('password success', () => {
|
||||
expect(validators.password('abc')).toBe(true)
|
||||
expect(validators.password('password')).toBe(true)
|
||||
expect(validators.password('asldfkjasldkfjlaksdfjlkas')).toBe(true)
|
||||
})
|
||||
|
||||
test('password failure', () => {
|
||||
expect(validators.password('aa')).toBe(false)
|
||||
expect(validators.password('.'))
|
||||
expect(validators.password('a'.repeat(1025))).toBe(false)
|
||||
})
|
||||
|
||||
test('projectName success', () => {
|
||||
expect(validators.projectName('aaa')).toBe(true)
|
||||
expect(validators.projectName('hello-world')).toBe(true)
|
||||
expect(validators.projectName('123-abc')).toBe(true)
|
||||
})
|
||||
|
||||
test('projectName failure', () => {
|
||||
expect(validators.projectName('aa')).toBe(false)
|
||||
expect(validators.projectName('hello_world')).toBe(false)
|
||||
expect(validators.projectName('a_bc')).toBe(false)
|
||||
expect(validators.projectName('abc.'))
|
||||
expect(validators.projectName('abc_123'))
|
||||
expect(validators.projectName('ah^23'))
|
||||
expect(validators.projectName('Hello-World')).toBe(false)
|
||||
expect(validators.projectName('f'.repeat(100))).toBe(false)
|
||||
})
|
||||
|
||||
test('deploymentHash success', () => {
|
||||
expect(validators.deploymentHash('abcdefgh')).toBe(true)
|
||||
expect(validators.deploymentHash('01234567')).toBe(true)
|
||||
expect(validators.deploymentHash('k2l3n6l2')).toBe(true)
|
||||
})
|
||||
|
||||
test('deploymentHash failure', () => {
|
||||
expect(validators.deploymentHash('aa')).toBe(false)
|
||||
expect(validators.deploymentHash('')).toBe(false)
|
||||
expect(validators.deploymentHash('Abcdefgh')).toBe(false)
|
||||
expect(validators.deploymentHash('012345678')).toBe(false)
|
||||
})
|
||||
|
||||
test('project success', () => {
|
||||
expect(validators.project('username/project-name')).toBe(true)
|
||||
expect(validators.project('a/123')).toBe(true)
|
||||
})
|
||||
|
||||
test('project failure', () => {
|
||||
expect(validators.project('aaa//0123')).toBe(false)
|
||||
expect(validators.project('foo@bar')).toBe(false)
|
||||
expect(validators.project('abc/1.23')).toBe(false)
|
||||
expect(validators.project('012345678/123@latest')).toBe(false)
|
||||
expect(validators.project('foo@dev')).toBe(false)
|
||||
expect(validators.project('username/Project-Name')).toBe(false)
|
||||
expect(validators.project('_/___')).toBe(false)
|
||||
})
|
||||
|
||||
test('deployment success', () => {
|
||||
expect(validators.deployment('username/project-name@01234567')).toBe(true)
|
||||
expect(validators.deployment('a/123@01234567')).toBe(true)
|
||||
})
|
||||
|
||||
test('deployment failure', () => {
|
||||
expect(validators.deployment('username/project-name@012345678')).toBe(false)
|
||||
expect(validators.deployment('username/project-name@latest')).toBe(false)
|
||||
expect(validators.deployment('username/project-name@dev')).toBe(false)
|
||||
expect(validators.deployment('username/Project-Name@01234567')).toBe(false)
|
||||
expect(validators.deployment('a/123@0123A567')).toBe(false)
|
||||
expect(validators.deployment('_/___@012.4567')).toBe(false)
|
||||
expect(validators.deployment('_/___@01234567')).toBe(false)
|
||||
expect(validators.deployment('aaa//0123@01234567')).toBe(false)
|
||||
expect(validators.deployment('foo@bar@01234567')).toBe(false)
|
||||
expect(validators.deployment('abc/1.23@01234567')).toBe(false)
|
||||
expect(validators.deployment('012345678/123@latest')).toBe(false)
|
||||
expect(validators.deployment('012345678/123@dev')).toBe(false)
|
||||
expect(validators.deployment('012345678/123@1.0.1')).toBe(false)
|
||||
})
|
||||
|
||||
test('serviceName success', () => {
|
||||
expect(validators.serviceName('serviceName')).toBe(true)
|
||||
expect(validators.serviceName('_identIFIER0123')).toBe(true)
|
||||
expect(validators.serviceName('abc_123_foo')).toBe(true)
|
||||
})
|
||||
|
||||
test('serviceName failure', () => {
|
||||
expect(validators.serviceName('ab1.2')).toBe(false)
|
||||
expect(validators.serviceName('foo-bar')).toBe(false)
|
||||
expect(validators.serviceName('abc/123')).toBe(false)
|
||||
})
|
||||
|
||||
test('servicePath success', () => {
|
||||
expect(validators.servicePath('/foo')).toBe(true)
|
||||
expect(validators.servicePath('/')).toBe(true)
|
||||
expect(validators.servicePath('/foo/bar/123%20-_abc')).toBe(true)
|
||||
expect(validators.servicePath('/foo/BAR/..')).toBe(true)
|
||||
expect(validators.servicePath('/api/iconsets/v3/categories')).toBe(true)
|
||||
})
|
||||
|
||||
test('servicePath failure', () => {
|
||||
expect(validators.servicePath('')).toBe(false)
|
||||
expect(validators.servicePath('foo/bar')).toBe(false)
|
||||
expect(validators.servicePath('/foo/bar\\')).toBe(false)
|
||||
expect(validators.servicePath('/foo/bar@')).toBe(false)
|
||||
expect(validators.servicePath('/foo/bar@abc')).toBe(false)
|
||||
})
|
|
@ -0,0 +1,52 @@
|
|||
import emailValidator from 'email-validator'
|
||||
import isRelativeUrl from 'is-relative-url'
|
||||
|
||||
export const usernameRe = /^[a-zA-Z0-9-]{1,64}$/
|
||||
export const passwordRe = /^.{3,1024}$/
|
||||
|
||||
export const projectNameRe = /^[a-z0-9-]{3,64}$/
|
||||
export const deploymentHashRe = /^[a-z0-9]{8}$/
|
||||
|
||||
export const projectRe = /^[a-zA-Z0-9-]{1,64}\/[a-z0-9-]{3,64}$/
|
||||
export const deploymentRe = /^[a-zA-Z0-9-]{1,64}\/[a-z0-9-]{3,64}@[a-z0-9]{8}$/
|
||||
|
||||
// service names may be any valid JavaScript identifier
|
||||
// TODO: should service names be any label?
|
||||
export const serviceNameRe = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
||||
export const servicePathRe = /^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*$/
|
||||
|
||||
export function email(value: string): boolean {
|
||||
return emailValidator.validate(value)
|
||||
}
|
||||
|
||||
export function username(value: string): boolean {
|
||||
return !!value && usernameRe.test(value)
|
||||
}
|
||||
|
||||
export function password(value: string): boolean {
|
||||
return !!value && passwordRe.test(value)
|
||||
}
|
||||
|
||||
export function projectName(value: string): boolean {
|
||||
return !!value && projectNameRe.test(value)
|
||||
}
|
||||
|
||||
export function deploymentHash(value: string): boolean {
|
||||
return !!value && deploymentHashRe.test(value)
|
||||
}
|
||||
|
||||
export function project(value: string): boolean {
|
||||
return !!value && projectRe.test(value)
|
||||
}
|
||||
|
||||
export function deployment(value: string): boolean {
|
||||
return !!value && deploymentRe.test(value)
|
||||
}
|
||||
|
||||
export function serviceName(value: string): boolean {
|
||||
return !!value && serviceNameRe.test(value)
|
||||
}
|
||||
|
||||
export function servicePath(value: string): boolean {
|
||||
return !!value && servicePathRe.test(value) && isRelativeUrl(value)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@fisch0920/config/tsconfig-node",
|
||||
"include": ["src", "*.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
1757
pnpm-lock.yaml
1757
pnpm-lock.yaml
Plik diff jest za duży
Load Diff
|
@ -1,11 +1,12 @@
|
|||
packages:
|
||||
- packages/*
|
||||
- apps/*
|
||||
|
||||
catalog:
|
||||
'@ai-sdk/openai': ^1.3.6
|
||||
'@fisch0920/config': ^1.0.4
|
||||
'@fisch0920/config': ^1.1.0
|
||||
'@modelcontextprotocol/sdk': ^1.8.0
|
||||
'@types/node': ^22.14.1
|
||||
'@types/node': ^22.15.0
|
||||
ai: ^4.2.11
|
||||
cleye: ^1.3.4
|
||||
del-cli: ^6.0.0
|
||||
|
@ -17,13 +18,13 @@ catalog:
|
|||
npm-run-all2: ^7.0.2
|
||||
only-allow: ^1.2.1
|
||||
p-map: ^7.0.3
|
||||
p-throttle: 6.2.0 # pinned for now
|
||||
p-throttle: 6.2.0
|
||||
prettier: ^3.5.3
|
||||
restore-cursor: ^5.1.0
|
||||
simple-git-hooks: ^2.12.1
|
||||
simple-git-hooks: ^2.13.0
|
||||
tsup: ^8.4.0
|
||||
tsx: ^4.19.3
|
||||
turbo: ^2.5.0
|
||||
turbo: ^2.5.1
|
||||
type-fest: ^4.40.0
|
||||
typescript: ^5.8.3
|
||||
vitest: ^3.1.2
|
||||
|
|
Ładowanie…
Reference in New Issue