Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions packages/core/src/compose/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,30 @@ export const fetchRemoteUserModel = async (connection: MachineConnection) => {

const serviceLinkEnvVars = (
expectedServiceUrls: { name: string; port: number; url: string }[],
) => Object.fromEntries(
expectedServiceUrls
.map(({ name, port, url }) => [`PREEVY_BASE_URI_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), url])
)
envId?: string,
): Record<string, string> => {
const baseUriVars = Object.fromEntries(
expectedServiceUrls
.map(({ name, port, url }) => [`PREEVY_BASE_URI_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), url])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the regex replace can be extracted to format service name for url function

)

const hostVars = Object.fromEntries(
expectedServiceUrls
.map(({ name, port, url }) => {
try {
const hostname = new URL(url).hostname
return [`PREEVY_HOST_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), hostname]
} catch (e) {
// If URL parsing fails, fallback to empty string
return [`PREEVY_HOST_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), '']
}
})
)

const envIdVars: Record<string, string> = envId ? { PREEVY_ENV_ID: envId } : {}

return { ...baseUriVars, ...hostVars, ...envIdVars }
}

export const defaultVolumeSkipList: string[] = [
'/var/log',
Expand Down Expand Up @@ -185,7 +205,7 @@ export const remoteComposeModel = async ({

log.debug(`Using compose files: ${composeFiles.files.join(', ')} and project directory "${composeFiles.projectDirectory}"`)

const linkEnvVars = serviceLinkEnvVars(expectedServiceUrls)
const linkEnvVars = serviceLinkEnvVars(expectedServiceUrls, agentSettings?.envId)

const composeClientWithInjectedArgs = localComposeClient({
composeFiles: composeFiles.files,
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/compose/service-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from '@jest/globals'
import { serviceLinkEnvVars } from './service-links.js'

describe('serviceLinkEnvVars', () => {
const mockServiceUrls = [
{ name: 'frontend', port: 3000, url: 'https://frontend-3000-env123-client456.livecycle.run/' },
{ name: 'api', port: 8080, url: 'https://api-8080-env123-client456.livecycle.run/' },
{ name: 'my-service', port: 9000, url: 'https://my-service-9000-env123-client456.livecycle.run/' },
]

describe('backward compatibility', () => {
it('should generate PREEVY_BASE_URI variables for each service and port', () => {
const result = serviceLinkEnvVars(mockServiceUrls)

expect(result).toMatchObject({
'PREEVY_BASE_URI_FRONTEND_3000': 'https://frontend-3000-env123-client456.livecycle.run/',
'PREEVY_BASE_URI_API_8080': 'https://api-8080-env123-client456.livecycle.run/',
'PREEVY_BASE_URI_MY_SERVICE_9000': 'https://my-service-9000-env123-client456.livecycle.run/',
})
})

it('should normalize service names with non-alphanumeric characters', () => {
const serviceUrls = [
{ name: 'my-service-name', port: 3000, url: 'https://example.com/' },
{ name: 'service.with.dots', port: 8080, url: 'https://example.com/' },
]

const result = serviceLinkEnvVars(serviceUrls)

expect(result).toMatchObject({
'PREEVY_BASE_URI_MY_SERVICE_NAME_3000': 'https://example.com/',
'PREEVY_BASE_URI_SERVICE_WITH_DOTS_8080': 'https://example.com/',
})
})
})

describe('new environment variables', () => {
it('should generate PREEVY_HOST variables with just the hostname', () => {
const result = serviceLinkEnvVars(mockServiceUrls)

expect(result).toMatchObject({
'PREEVY_HOST_FRONTEND_3000': 'frontend-3000-env123-client456.livecycle.run',
'PREEVY_HOST_API_8080': 'api-8080-env123-client456.livecycle.run',
'PREEVY_HOST_MY_SERVICE_9000': 'my-service-9000-env123-client456.livecycle.run',
})
})

it('should include PREEVY_ENV_ID when envId is provided', () => {
const result = serviceLinkEnvVars(mockServiceUrls, 'test-env-123')

expect(result).toMatchObject({
'PREEVY_ENV_ID': 'test-env-123',
})
})

it('should not include PREEVY_ENV_ID when envId is not provided', () => {
const result = serviceLinkEnvVars(mockServiceUrls)

expect(result).not.toHaveProperty('PREEVY_ENV_ID')
})

it('should handle invalid URLs gracefully for host extraction', () => {
const serviceUrls = [
{ name: 'valid', port: 3000, url: 'https://valid.example.com/' },
{ name: 'invalid', port: 8080, url: 'not-a-url' },
]

const result = serviceLinkEnvVars(serviceUrls)

expect(result).toMatchObject({
'PREEVY_BASE_URI_VALID_3000': 'https://valid.example.com/',
'PREEVY_BASE_URI_INVALID_8080': 'not-a-url',
'PREEVY_HOST_VALID_3000': 'valid.example.com',
'PREEVY_HOST_INVALID_8080': '', // Falls back to empty string for invalid URLs
})
})
})

describe('complete environment variable generation', () => {
it('should generate all expected environment variables when envId is provided', () => {
const result = serviceLinkEnvVars(mockServiceUrls, 'test-env-123')

// Verify all PREEVY_BASE_URI variables
expect(result['PREEVY_BASE_URI_FRONTEND_3000']).toBe('https://frontend-3000-env123-client456.livecycle.run/')
expect(result['PREEVY_BASE_URI_API_8080']).toBe('https://api-8080-env123-client456.livecycle.run/')
expect(result['PREEVY_BASE_URI_MY_SERVICE_9000']).toBe('https://my-service-9000-env123-client456.livecycle.run/')

// Verify all PREEVY_HOST variables
expect(result['PREEVY_HOST_FRONTEND_3000']).toBe('frontend-3000-env123-client456.livecycle.run')
expect(result['PREEVY_HOST_API_8080']).toBe('api-8080-env123-client456.livecycle.run')
expect(result['PREEVY_HOST_MY_SERVICE_9000']).toBe('my-service-9000-env123-client456.livecycle.run')

// Verify PREEVY_ENV_ID
expect(result['PREEVY_ENV_ID']).toBe('test-env-123')

// Verify the total number of variables
expect(Object.keys(result)).toHaveLength(7) // 3 BASE_URI + 3 HOST + 1 ENV_ID
})
})
})
28 changes: 24 additions & 4 deletions packages/core/src/compose/service-links.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
export const serviceLinkEnvVars = (
expectedServiceUrls: { name: string; port: number; url: string }[],
) => Object.fromEntries(
expectedServiceUrls
.map(({ name, port, url }) => [`PREEVY_BASE_URI_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), url])
)
envId?: string,
): Record<string, string> => {
const baseUriVars = Object.fromEntries(
expectedServiceUrls
.map(({ name, port, url }) => [`PREEVY_BASE_URI_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), url])
)

const hostVars = Object.fromEntries(
expectedServiceUrls
.map(({ name, port, url }) => {
try {
const hostname = new URL(url).hostname
return [`PREEVY_HOST_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), hostname]
} catch (e) {
// If URL parsing fails, fallback to empty string
return [`PREEVY_HOST_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_${port}`.toUpperCase(), '']
}
})
)

const envIdVars: Record<string, string> = envId ? { PREEVY_ENV_ID: envId } : {}

return { ...baseUriVars, ...hostVars, ...envIdVars }
}
26 changes: 19 additions & 7 deletions site/docs/recipes/service-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ services:
ports: 3000:80
```

Preevy will generate the following environment variable which will contain the generated preview environment URL:
Preevy will generate the following environment variables which will contain information about the generated preview environment:

```bash
PREEVY_BASE_URI_SERVICE_NAME_3000=https://service-name-3000-envid-clientid.livecycle.run/
PREEVY_HOST_SERVICE_NAME_3000=service-name-3000-envid-clientid.livecycle.run
PREEVY_ENV_ID=envid
```

## Problem
Expand All @@ -29,13 +31,19 @@ Service-to-service communication within containers can be handled using Docker C
However, this method does not apply to code executed in the browser, which creates difficulties for frontend applications when connecting to backend services through exposed ports. The tunneling URL needs to be substituted, but it cannot be determined at build time.
## Solution

Preevy offers a simple solution for this problem by exposing the tunneling URL as an environment variable at Compose *build time*. Environment variables can be [interpolated](https://docs.docker.com/compose/compose-file/12-interpolation/) in the Compose file.
Preevy offers a simple solution for this problem by exposing tunneling information as environment variables at Compose *build time*. Environment variables can be [interpolated](https://docs.docker.com/compose/compose-file/12-interpolation/) in the Compose file.

The environment variable is named after the service name + port, with the prefix `PREEVY_BASE_URI`. For example, if the service name is `frontend` and is exposed on port 4000, the environment variable will be `PREEVY_BASE_URI_FRONTEND_4000`.
Preevy provides the following environment variables:

If the service is exposed on multiple ports, the environment variable will be created for each port.
- **`PREEVY_BASE_URI_{SERVICE}_{PORT}`**: The complete URL including protocol and trailing slash (e.g., `https://service-3000-envid-clientid.livecycle.run/`)
- **`PREEVY_HOST_{SERVICE}_{PORT}`**: Just the hostname without protocol or trailing slash (e.g., `service-3000-envid-clientid.livecycle.run`)
- **`PREEVY_ENV_ID`**: The environment ID (e.g., `envid`)

*Note about service name normalization*: Non-alphanumeric characters in service names are replaced by `_` (underscore) in the `PREEVY_BASE_URI` environment variable. E.g, the environment variable for service `my-service` at port 80 will be `PREEVY_BASE_URI_MY_SERVICE_80`.
The service-specific environment variables are named after the service name + port. For example, if the service name is `frontend` and is exposed on port 4000, the environment variables will be `PREEVY_BASE_URI_FRONTEND_4000` and `PREEVY_HOST_FRONTEND_4000`.

If the service is exposed on multiple ports, environment variables will be created for each port.

*Note about service name normalization*: Non-alphanumeric characters in service names are replaced by `_` (underscore) in the environment variable names. E.g, the environment variables for service `my-service` at port 80 will be `PREEVY_BASE_URI_MY_SERVICE_80` and `PREEVY_HOST_MY_SERVICE_80`.

## Example

Expand All @@ -56,7 +64,7 @@ services:
- 9006:3000
```

In this example, the frontend service is configured to communicate with the API service on port 9005. This works well in development, but when using Preevy, the port is not known in advance. To solve this, we can use the `PREEVY_BASE_URI` environment variable:
In this example, the frontend service is configured to communicate with the API service on port 9005. This works well in development, but when using Preevy, the port is not known in advance. To solve this, we can use the Preevy environment variables:

```yaml
services:
Expand All @@ -67,10 +75,14 @@ services:
my-frontend:
environment:
- API_URL=${PREEVY_BASE_URI_MY_BACKEND_9006:-http://localhost:9006/}
# Or use just the hostname:
- API_HOST=${PREEVY_HOST_MY_BACKEND_9006:-localhost:9006}
# Or use the environment ID to build your own URL:
- ENV_ID=${PREEVY_ENV_ID:-local}
my-backend:
...
ports:
- 9006:3000
```

To keep things working normally in local development, where the `PREEVY_BASE_URI` variables are not defined, a [default value](https://docs.docker.com/compose/compose-file/12-interpolation/) of `http://localhost:9006/` is given.
To keep things working normally in local development, where the Preevy variables are not defined, [default values](https://docs.docker.com/compose/compose-file/12-interpolation/) are provided.