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 { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
import { env } from '@/lib/env'
|
import { env } from './src/lib/env'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
|
|
|
@ -11,14 +11,16 @@
|
||||||
"directory": "apps/api"
|
"directory": "apps/api"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/server.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/server.d.ts",
|
||||||
"sideEffects": false,
|
"bin": {
|
||||||
|
"agentic-platform-api": "./dist/server.js"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/server.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/server.js",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/server.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -29,20 +31,27 @@
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"clean": "del dist",
|
"clean": "del dist",
|
||||||
"test": "run-s test:*",
|
"test": "run-s test:*",
|
||||||
"test:lint": "eslint .",
|
"test:lint": "eslint src",
|
||||||
"test:typecheck": "tsc --noEmit",
|
"test:typecheck": "tsc --noEmit",
|
||||||
"test:unit": "vitest run"
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentic/faas-utils": "workspace:*",
|
||||||
|
"@google-cloud/logging": "^11.2.0",
|
||||||
"@hono/node-server": "^1.14.1",
|
"@hono/node-server": "^1.14.1",
|
||||||
|
"@hono/sentry": "^1.2.1",
|
||||||
"@hono/zod-validator": "^0.4.3",
|
"@hono/zod-validator": "^0.4.3",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
|
"@sentry/node": "^9.14.0",
|
||||||
"@workos-inc/node": "^7.47.0",
|
"@workos-inc/node": "^7.47.0",
|
||||||
"drizzle-orm": "^0.42.0",
|
"drizzle-orm": "^0.43.0",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
|
"eventid": "^2.0.1",
|
||||||
"exit-hook": "catalog:",
|
"exit-hook": "catalog:",
|
||||||
"hono": "^4.7.7",
|
"hono": "^4.7.7",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"pino-abstract-transport": "^2.0.0",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"restore-cursor": "catalog:",
|
"restore-cursor": "catalog:",
|
||||||
"type-fest": "catalog:",
|
"type-fest": "catalog:",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { validators } from '@agentic/faas-utils'
|
||||||
import { relations } from 'drizzle-orm'
|
import { relations } from 'drizzle-orm'
|
||||||
import { boolean, index, jsonb, pgTable, text } from 'drizzle-orm/pg-core'
|
import { boolean, index, jsonb, pgTable, text } from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
|
@ -10,6 +11,8 @@ import {
|
||||||
createSelectSchema,
|
createSelectSchema,
|
||||||
createUpdateSchema,
|
createUpdateSchema,
|
||||||
cuid,
|
cuid,
|
||||||
|
deploymentId,
|
||||||
|
projectId,
|
||||||
timestamps
|
timestamps
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
|
@ -17,7 +20,7 @@ export const deployments = pgTable(
|
||||||
'deployments',
|
'deployments',
|
||||||
{
|
{
|
||||||
// namespace/projectName@hash
|
// namespace/projectName@hash
|
||||||
id: text().primaryKey(),
|
id: deploymentId().primaryKey(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
|
|
||||||
hash: text().notNull(),
|
hash: text().notNull(),
|
||||||
|
@ -33,7 +36,7 @@ export const deployments = pgTable(
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
teamId: cuid().references(() => teams.id),
|
teamId: cuid().references(() => teams.id),
|
||||||
projectId: cuid()
|
projectId: projectId()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.id, {
|
.references(() => projects.id, {
|
||||||
onDelete: 'cascade'
|
onDelete: 'cascade'
|
||||||
|
@ -92,9 +95,17 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||||
|
|
||||||
// TODO: narrow
|
// TODO: narrow
|
||||||
export const deploymentInsertSchema = createInsertSchema(deployments, {
|
export const deploymentInsertSchema = createInsertSchema(deployments, {
|
||||||
// TODO: validate deployment id
|
id: (schema) =>
|
||||||
// id: (schema) =>
|
schema.refine((id) => validators.project(id), {
|
||||||
// schema.refine((id) => validators.deployment(id), 'Invalid deployment 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({
|
export const deploymentSelectSchema = createSelectSchema(deployments).omit({
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { validators } from '@agentic/faas-utils'
|
||||||
import { relations } from 'drizzle-orm'
|
import { relations } from 'drizzle-orm'
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
@ -19,6 +20,8 @@ import {
|
||||||
createSelectSchema,
|
createSelectSchema,
|
||||||
createUpdateSchema,
|
createUpdateSchema,
|
||||||
cuid,
|
cuid,
|
||||||
|
deploymentId,
|
||||||
|
projectId,
|
||||||
stripeId,
|
stripeId,
|
||||||
timestamps
|
timestamps
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
@ -27,7 +30,7 @@ export const projects = pgTable(
|
||||||
'projects',
|
'projects',
|
||||||
{
|
{
|
||||||
// namespace/projectName
|
// namespace/projectName
|
||||||
id: text().primaryKey(),
|
id: projectId().primaryKey(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
|
|
||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
|
@ -39,10 +42,10 @@ export const projects = pgTable(
|
||||||
teamId: cuid().notNull(),
|
teamId: cuid().notNull(),
|
||||||
|
|
||||||
// Most recently published Deployment if one exists
|
// Most recently published Deployment if one exists
|
||||||
lastPublishedDeploymentId: cuid(),
|
lastPublishedDeploymentId: deploymentId(),
|
||||||
|
|
||||||
// Most recent Deployment if one exists
|
// Most recent Deployment if one exists
|
||||||
lastDeploymentId: cuid(),
|
lastDeploymentId: deploymentId(),
|
||||||
|
|
||||||
applicationFeePercent: integer().notNull().default(20),
|
applicationFeePercent: integer().notNull().default(20),
|
||||||
|
|
||||||
|
@ -124,12 +127,15 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const projectInsertSchema = createInsertSchema(projects, {
|
export const projectInsertSchema = createInsertSchema(projects, {
|
||||||
// TODO: validate project id
|
id: (schema) =>
|
||||||
// id: (schema) =>
|
schema.refine((id) => validators.project(id), {
|
||||||
// schema.refine((id) => validators.project(id), 'Invalid project id')
|
message: 'Invalid project id'
|
||||||
// TODO: validate project name
|
}),
|
||||||
// name: (schema) =>
|
|
||||||
// schema.refine((name) => validators.projectName(name), 'Invalid project name')
|
name: (schema) =>
|
||||||
|
schema.refine((name) => validators.projectName(name), {
|
||||||
|
message: 'Invalid project name'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.pick({
|
.pick({
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { validators } from '@agentic/faas-utils'
|
||||||
import { relations } from 'drizzle-orm'
|
import { relations } from 'drizzle-orm'
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
@ -64,13 +65,27 @@ export const users = pgTable(
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
teamsOwned: many(teams)
|
teamsOwned: many(teams)
|
||||||
|
|
||||||
// TODO: team memberships
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const userInsertSchema = createInsertSchema(users, {
|
export const userInsertSchema = createInsertSchema(users, {
|
||||||
// TODO: username validation
|
username: (schema) =>
|
||||||
// username: (schema) => schema.min(3).max(20)
|
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({
|
}).pick({
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
|
|
@ -21,6 +21,24 @@ export function stripeId<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||||
return varchar({ length: 255, ...config })
|
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 })
|
export const id = varchar('id', { length: 24 })
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(createId)
|
.$defaultFn(createId)
|
||||||
|
|
|
@ -10,7 +10,8 @@ export const envSchema = z.object({
|
||||||
.default('development'),
|
.default('development'),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
JWT_SECRET: z.string(),
|
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>
|
export type Env = z.infer<typeof envSchema>
|
||||||
|
|
||||||
|
@ -19,4 +20,6 @@ export const env = parseZodSchema(envSchema, process.env, {
|
||||||
error: 'Invalid environment variables'
|
error: 'Invalid environment variables'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const isDev = env.NODE_ENV === 'development'
|
||||||
export const isProd = env.NODE_ENV === 'production'
|
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 { promisify } from 'node:util'
|
||||||
|
|
||||||
import type { ServerType } from '@hono/node-server'
|
import type { ServerType } from '@hono/node-server'
|
||||||
|
import * as Sentry from '@sentry/node'
|
||||||
import { asyncExitHook } from 'exit-hook'
|
import { asyncExitHook } from 'exit-hook'
|
||||||
import restoreCursor from 'restore-cursor'
|
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
|
// 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 type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||||
|
import * as Sentry from '@sentry/node'
|
||||||
import { createMiddleware } from 'hono/factory'
|
import { createMiddleware } from 'hono/factory'
|
||||||
import { HTTPException } from 'hono/http-exception'
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
|
||||||
|
@ -28,6 +29,7 @@ export const errorHandler = createMiddleware<AuthenticatedEnv>(
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
console.error('http error', status, message)
|
console.error('http error', status, message)
|
||||||
|
Sentry.captureException(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.json({ error: message }, status)
|
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 { serve } from '@hono/node-server'
|
||||||
|
import { sentry } from '@hono/sentry'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { compress } from 'hono/compress'
|
import { compress } from 'hono/compress'
|
||||||
import { cors } from 'hono/cors'
|
import { cors } from 'hono/cors'
|
||||||
|
@ -11,6 +14,7 @@ import { initExitHooks } from './lib/exit-hooks'
|
||||||
|
|
||||||
export const app = new Hono()
|
export const app = new Hono()
|
||||||
|
|
||||||
|
app.use(sentry())
|
||||||
app.use(compress())
|
app.use(compress())
|
||||||
app.use(middleware.responseTime)
|
app.use(middleware.responseTime)
|
||||||
app.use(middleware.errorHandler)
|
app.use(middleware.errorHandler)
|
||||||
|
|
|
@ -6,6 +6,6 @@
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "drizzle.config.ts"],
|
"include": ["src", "*.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"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:",
|
"tsx": "catalog:",
|
||||||
"turbo": "catalog:",
|
"turbo": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "catalog:",
|
"vitest": "catalog:",
|
||||||
"zod": "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:
|
||||||
- packages/*
|
- packages/*
|
||||||
- apps/*
|
- apps/*
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@ai-sdk/openai': ^1.3.6
|
'@ai-sdk/openai': ^1.3.6
|
||||||
'@fisch0920/config': ^1.0.4
|
'@fisch0920/config': ^1.1.0
|
||||||
'@modelcontextprotocol/sdk': ^1.8.0
|
'@modelcontextprotocol/sdk': ^1.8.0
|
||||||
'@types/node': ^22.14.1
|
'@types/node': ^22.15.0
|
||||||
ai: ^4.2.11
|
ai: ^4.2.11
|
||||||
cleye: ^1.3.4
|
cleye: ^1.3.4
|
||||||
del-cli: ^6.0.0
|
del-cli: ^6.0.0
|
||||||
|
@ -17,13 +18,13 @@ catalog:
|
||||||
npm-run-all2: ^7.0.2
|
npm-run-all2: ^7.0.2
|
||||||
only-allow: ^1.2.1
|
only-allow: ^1.2.1
|
||||||
p-map: ^7.0.3
|
p-map: ^7.0.3
|
||||||
p-throttle: 6.2.0 # pinned for now
|
p-throttle: 6.2.0
|
||||||
prettier: ^3.5.3
|
prettier: ^3.5.3
|
||||||
restore-cursor: ^5.1.0
|
restore-cursor: ^5.1.0
|
||||||
simple-git-hooks: ^2.12.1
|
simple-git-hooks: ^2.13.0
|
||||||
tsup: ^8.4.0
|
tsup: ^8.4.0
|
||||||
tsx: ^4.19.3
|
tsx: ^4.19.3
|
||||||
turbo: ^2.5.0
|
turbo: ^2.5.1
|
||||||
type-fest: ^4.40.0
|
type-fest: ^4.40.0
|
||||||
typescript: ^5.8.3
|
typescript: ^5.8.3
|
||||||
vitest: ^3.1.2
|
vitest: ^3.1.2
|
||||||
|
|
Ładowanie…
Reference in New Issue