feat: add faas-utils, logger, exit hooks

pull/715/head
Travis Fischer 2025-04-25 04:54:33 +07:00
rodzic 1e90451b0c
commit 3efbed9d23
39 zmienionych plików z 3224 dodań i 149 usunięć

Wyświetl plik

@ -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'

Wyświetl plik

@ -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',

Wyświetl plik

@ -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:",

Wyświetl plik

@ -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({

Wyświetl plik

@ -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,

Wyświetl plik

@ -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,

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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()]
})

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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
)
}
}
})
}

Wyświetl plik

@ -0,0 +1,2 @@
export * from './logger'
export * from './utils'

Wyświetl plik

@ -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()
})
})

Wyświetl plik

@ -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'

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -0,0 +1,2 @@
export * from './mock-sentry-node'
export * from './setup-mock-logger'

Wyświetl plik

@ -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
})
}
})
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -6,6 +6,6 @@
"@/*": ["src/*"]
}
},
"include": ["src", "drizzle.config.ts"],
"include": ["src", "*.config.ts"],
"exclude": ["node_modules", "dist"]
}

Wyświetl plik

@ -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
}
])

Wyświetl plik

@ -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
}
})

Wyświetl plik

@ -43,6 +43,7 @@
"tsx": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "catalog:",
"zod": "catalog:"
},

Wyświetl plik

@ -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"
}
}

Wyświetl plik

@ -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",
}
`;

Wyświetl plik

@ -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",
}
`;

Wyświetl plik

@ -0,0 +1,4 @@
export * from './parse-faas-identifier'
export * from './parse-faas-uri'
export type * from './types'
export * as validators from './validators'

Wyświetl plik

@ -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')
})

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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')
})

Wyświetl plik

@ -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
}

Wyświetl plik

@ -0,0 +1,15 @@
export type ParsedFaasIdentifier = {
projectId: string
servicePath: string
deploymentHash?: string
deploymentId?: string
version?: string
} & (
| {
deploymentHash: string
deploymentId: string
}
| {
version: string
}
)

Wyświetl plik

@ -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)
})

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -0,0 +1,5 @@
{
"extends": "@fisch0920/config/tsconfig-node",
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]
}

Plik diff jest za duży Load Diff

Wyświetl plik

@ -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