Skip to content

Commit

Permalink
Refactor endpoint records with defineEndpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
trpfrog committed Oct 18, 2024
1 parent ecd9076 commit 9ab0b25
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 79 deletions.
4 changes: 2 additions & 2 deletions apps/dev-blog-server/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { devEndpoints } from '@trpfrog.net/constants'
import { services } from '@trpfrog.net/constants'
import { io, Socket } from 'socket.io-client'

export function createClient(): Socket | null {
Expand All @@ -7,7 +7,7 @@ export function createClient(): Socket | null {
return null
}

const endpoint = devEndpoints.mdServer
const endpoint = services.mdServer.development
if (endpoint) {
return io(endpoint)
} else {
Expand Down
8 changes: 4 additions & 4 deletions apps/dev-blog-server/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createServer } from 'http'
import path from 'path'

import { ports } from '@trpfrog.net/constants'
import { devEndpoints } from '@trpfrog.net/constants'
import { services } from '@trpfrog.net/constants'
import chokidar from 'chokidar'
import { Server } from 'socket.io'

Expand Down Expand Up @@ -32,6 +31,7 @@ watcher.on('change', markdownPath => {
io.emit('update', slug)
})

httpServer.listen(ports.mdServer, () => {
console.log(`Markdown Watcher is now listening on ${devEndpoints.mdServer}`)
const { port, development: devEndpoint } = services.mdServer
httpServer.listen(port, () => {
console.log(`Markdown Watcher is now listening on ${devEndpoint}`)
})
8 changes: 6 additions & 2 deletions apps/image-generation/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { endpoints } from '@trpfrog.net/constants'
import { services } from '@trpfrog.net/constants'
import { hc } from 'hono/client'

export {
Expand All @@ -9,5 +9,9 @@ export {
import type { AppType } from './app'

export function createTrpFrogImageGenerationClient(env: 'development' | 'production' | 'test') {
return hc<AppType>(endpoints(env).imageGeneration)
const endpoint = services.imageGeneration.endpoint(env)
if (endpoint == null) {
throw new Error('Image generation service is not available in this environment')
}
return hc<AppType>(endpoint)
}
5 changes: 3 additions & 2 deletions apps/trpfrog.net/src/app/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { endpoints } from '@trpfrog.net/constants'
import { services } from '@trpfrog.net/constants'
import { hc } from 'hono/client'

import { NODE_ENV } from '@/env/client.ts'

import type { AppType } from './[[...route]]/route.ts'

const baseUrl = typeof window === 'undefined' ? endpoints(NODE_ENV).website : window.location.origin
const baseUrl =
typeof window === 'undefined' ? services.website.endpoint(NODE_ENV) : window.location.origin

export const bffClient = hc<AppType>(baseUrl).api
168 changes: 168 additions & 0 deletions packages/constants/defineEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, test, expect, expectTypeOf } from 'vitest'

import { defineEndpoints } from './defineEndpoints'

describe('defineEndpoints', () => {
describe('should correctly parse and return endpoints with all fields provided', () => {
const endpoints = defineEndpoints({
api: {
port: 3000,
development: 'http://dev.api.local',
production: 'https://api.example.com',
},
auth: {
port: 4000,
development: 'http://dev.auth.local',
production: 'https://auth.example.com',
},
})

test('endpoints.api', () => {
expect(endpoints.api.port).toBe(3000)
expect(endpoints.api.development).toBe('http://dev.api.local')
expect(endpoints.api.production).toBe('https://api.example.com')
expect(endpoints.api.endpoint('development')).toBe('http://dev.api.local')
expect(endpoints.api.endpoint('production')).toBe('https://api.example.com')
expect(endpoints.api.endpoint('test')).toBe('http://dev.api.local')
})

test('endpoints.api (type)', () => {
expectTypeOf(endpoints.api).toEqualTypeOf<{
port: 3000
development: 'http://dev.api.local'
production: 'https://api.example.com'
endpoint: (env: 'development' | 'production' | 'test') => string | null
}>()
})

test('endpoints.auth', () => {
expect(endpoints.auth.port).toBe(4000)
expect(endpoints.auth.development).toBe('http://dev.auth.local')
expect(endpoints.auth.production).toBe('https://auth.example.com')
expect(endpoints.auth.endpoint('development')).toBe('http://dev.auth.local')
expect(endpoints.auth.endpoint('production')).toBe('https://auth.example.com')
expect(endpoints.auth.endpoint('test')).toBe('http://dev.auth.local')
})

test('endpoints.auth (type)', () => {
expectTypeOf(endpoints.auth).toEqualTypeOf<{
port: 4000
development: 'http://dev.auth.local'
production: 'https://auth.example.com'
endpoint: (env: 'development' | 'production' | 'test') => string | null
}>()
})
})

describe('should default development to localhost URL when development is missing but port is provided', () => {
const endpoints = defineEndpoints({
api: {
port: 3000,
production: 'https://api.example.com',
},
})

test('endpoints.api', () => {
expect(endpoints.api.development).toBe('http://localhost:3000')
expect(endpoints.api.endpoint('development')).toBe('http://localhost:3000')
expect(endpoints.api.endpoint('production')).toBe('https://api.example.com')
expect(endpoints.api.endpoint('test')).toBe('http://localhost:3000')
})

test('endpoints.api (type)', () => {
expectTypeOf(endpoints.api).toEqualTypeOf<{
port: 3000
development: 'http://localhost:3000'
production: 'https://api.example.com'
endpoint: (env: 'development' | 'production' | 'test') => string | null
}>()
})
})

describe('should default development to production when both development and port are missing', () => {
const endpoints = defineEndpoints({
metrics: {
production: 'https://metrics.example.com',
},
})

test('endpoints.metrics', () => {
expect(endpoints.metrics.development).toBe('https://metrics.example.com')
expect(endpoints.metrics.production).toBe('https://metrics.example.com')
expect(endpoints.metrics.endpoint('development')).toBe('https://metrics.example.com')
expect(endpoints.metrics.endpoint('production')).toBe('https://metrics.example.com')
expect(endpoints.metrics.endpoint('test')).toBe('https://metrics.example.com')
})

test('endpoints.metrics (type)', () => {
expectTypeOf(endpoints.metrics).toEqualTypeOf<{
port: undefined
development: 'https://metrics.example.com'
production: 'https://metrics.example.com'
endpoint: (env: 'development' | 'production' | 'test') => string | null
}>()
})
})

describe('should handle production being null gracefully', () => {
const endpoints = defineEndpoints({
api: {
port: 3000,
production: null,
},
})

test('endpoints.api', () => {
expect(endpoints.api.development).toBe('http://localhost:3000')
expect(endpoints.api.production).toBe(null)
expect(endpoints.api.endpoint('development')).toBe('http://localhost:3000')
expect(endpoints.api.endpoint('production')).toBe(null)
expect(endpoints.api.endpoint('test')).toBe('http://localhost:3000')
})

test('endpoints.api (type)', () => {
expectTypeOf(endpoints.api).toEqualTypeOf<{
port: 3000
development: 'http://localhost:3000'
production: null
endpoint: (env: 'development' | 'production' | 'test') => string | null
}>()
})
})

describe('should throw an error when invalid data is provided', () => {
test('should throw when port is not a number', () => {
expect(() =>
defineEndpoints({
api: {
port: 'not-a-number' as unknown as number,
production: 'https://api.example.com',
},
}),
).toThrow()
})

test('should throw when development is not a valid URL', () => {
expect(() =>
defineEndpoints({
api: {
port: 3000,
development: 'invalid-url',
production: 'https://api.example.com',
},
}),
).toThrow()
})

test('should throw when production is missing', () => {
expect(() =>
defineEndpoints({
api: {
port: 3000,
development: 'http://dev.api.local',
},
}),
).toThrow()
})
})
})
43 changes: 43 additions & 0 deletions packages/constants/defineEndpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { z } from 'zod'

const EndpointRecordSchema = z.record(
z.string(),
z.object({
port: z.number().nullish(),
development: z.string().url().nullish(),
production: z.string().url().nullish(),
}),
)

export type EndpointRecord = z.infer<typeof EndpointRecordSchema>

export function defineEndpoints<const T extends EndpointRecord>(endpoints: T) {
const parsedEndpoints = EndpointRecordSchema.parse(endpoints)

for (const name in parsedEndpoints) {
const currentEndpoint = parsedEndpoints[name]
parsedEndpoints[name].development ??= currentEndpoint.port
? `http://localhost:${currentEndpoint.port}`
: currentEndpoint.production

// @ts-expect-error - endpoint is not typed
parsedEndpoints[name].endpoint = (env: 'development' | 'production' | 'test') => {
return env === 'production'
? parsedEndpoints[name].production
: parsedEndpoints[name].development
}
}

return parsedEndpoints as {
[K in keyof T]: {
port: T[K]['port'] extends number ? T[K]['port'] : undefined
development: T[K]['development'] extends string
? T[K]['development']
: T[K]['port'] extends number
? `http://localhost:${T[K]['port']}`
: T[K]['production']
production: T[K]['production']
endpoint: (env: 'development' | 'production' | 'test') => string | null
}
}
}
77 changes: 9 additions & 68 deletions packages/constants/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,16 @@
import { z } from 'zod'
import { defineEndpoints } from './defineEndpoints'

const EndpointRecordSchema = z.object({
name: z.string(),
port: z.number().nullable(),
development: z.string().url().nullable(),
production: z.string().url().nullable(),
})

type EndpointRecord = z.infer<typeof EndpointRecordSchema>

function createEndpointRecord<const T extends Omit<EndpointRecord, 'development'>>(record: T) {
const ret = {
...record,
development: record.port ? `http://localhost:${record.port}` : null,
} as const
return EndpointRecordSchema.parse(ret) as typeof ret
}

// ============================== ENDPOINTS ============================== //

const internalEndpoints = [
createEndpointRecord({
name: 'website',
export const services = defineEndpoints({
website: {
port: 3000,
production: 'https://trpfrog.net',
}),
createEndpointRecord({
name: 'imageGeneration',
},
imageGeneration: {
port: 8001,
production: 'https://production.trpfrog-diffusion.trpfrog.workers.dev',
}),
createEndpointRecord({
name: 'mdServer',
},
mdServer: {
port: 8002,
production: null,
}),
] as const satisfies EndpointRecord[]

// ======================================================================= //

/**
* The endpoints of the application.
*/
export const devEndpoints = Object.fromEntries(
internalEndpoints
.filter(endpoint => endpoint.production == null)
.map(endpoint => [
endpoint.name,
// eslint-disable-next-line n/no-process-env
process?.env.NODE_ENV === 'development' && endpoint.development
? endpoint.development
: endpoint.production,
]),
)

export function endpoints(env: 'development' | 'production' | 'test') {
return Object.fromEntries(
internalEndpoints
.filter(endpoint => endpoint.production != null)
.map(endpoint => [
endpoint.name,
env === 'production' ? endpoint.production : `http://localhost:${endpoint.port}`,
]),
)
}

/**
* The ports of the backend services.
*/
export const ports = Object.fromEntries(
internalEndpoints
.filter(endpoint => typeof endpoint.port === 'number')
.map(endpoint => [endpoint.name, endpoint.port]),
)
},
})
2 changes: 1 addition & 1 deletion packages/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { devEndpoints, endpoints, ports } from './endpoints'
export { services } from './endpoints'

0 comments on commit 9ab0b25

Please sign in to comment.