Skip to content
Merged
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const sandbox = await Sandbox.create({
name: 'my-sandbox',
type: 'cpu:default',
maxLifetime: 3600,
ports: [3000, 8080],
envs: { API_KEY: 'your-api-key' }
})
```
Expand Down Expand Up @@ -149,11 +150,20 @@ await sandbox.destroy()

### Preview URLs

Use preview URLs to get access to servers or web services running in a sandbox on a particular port:
Ports that should be publicly accessible must be declared at creation time via the `ports` array.

```js
const url = await sandbox.getUrl({port: 3000})
console.log("preview:", url)
const sandbox = await Sandbox.create({
name: 'web-sandbox',
ports: [3000, 8080]
})

// Start a server inside the sandbox on the declared port
await sandbox.exec('node server.js &', { timeout: 50_000 })

// Retrieve the pre-provisioned preview URL — synchronous, no network call
const url = sandbox.getUrl(3000)
console.log('preview:', url)
// https://sb-abc123-va6-0-xK3mPq2nAeB-3000.sandbox-adobeioruntime.net
```

Expand Down
72 changes: 51 additions & 21 deletions src/Sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const crypto = require('node:crypto')
const {
SandboxClientError,
SandboxTimeoutError,
SandboxWebSocketError
SandboxWebSocketError,
SandboxPortNotProvisionedError,
SandboxInvalidPortError
} = require('./errors')
const {
buildWebSocketEndpoint,
Expand Down Expand Up @@ -46,7 +48,8 @@ class Sandbox {
this.apiHost = options.apiHost
this.apiKey = options.apiKey
this.token = options.token
this.publicUrlTemplate = options.publicUrlTemplate || null
// previewUrls is a Map<number, string> of (port → URL) returned by the server.
this.previewUrls = options.previewUrls || new Map()
this.managementEndpoint = options.managementEndpoint || null
this.ws = null
}
Expand All @@ -68,6 +71,7 @@ class Sandbox {
* @param {string} [options.type] sandbox type (default: `'cpu:default'`)
* @param {string|object} [options.size] sandbox size tier (name or spec object)
* @param {number} [options.maxLifetime] maximum lifetime in seconds
* @param {number[]} [options.ports] TCP ports to expose via preview URLs (default: `[]`)
* @param {object} [options.envs] environment variables to inject into the sandbox
* @param {object} [options.policy] network policy (e.g. egress allowlist)
* @returns {Promise<Sandbox>} connected sandbox instance
Expand All @@ -87,6 +91,7 @@ class Sandbox {
if (options.region !== undefined) body.region = options.region
if (options.envs !== undefined) body.envs = options.envs
if (options.policy !== undefined) body.policy = options.policy
if (options.ports !== undefined) body.ports = options.ports

const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox`
const payload = await apiRequest('POST', url, creds.apiKey, body)
Expand All @@ -101,7 +106,7 @@ class Sandbox {
cluster: payload.cluster,
region: payload.region,
maxLifetime: payload.maxLifetime,
publicUrlTemplate: payload.publicUrlTemplate || null,
previewUrls: parsePreviewUrls(payload.previewUrls),
managementEndpoint: payload.managementEndpoint || null,
namespace: creds.namespace,
apiHost: creds.apiHost,
Expand Down Expand Up @@ -139,6 +144,7 @@ class Sandbox {
cluster: payload.cluster,
region: payload.region,
maxLifetime: payload.maxLifetime,
previewUrls: parsePreviewUrls(payload.previewUrls),
namespace: creds.namespace,
apiHost: creds.apiHost,
apiKey: creds.apiKey,
Expand Down Expand Up @@ -404,30 +410,29 @@ class Sandbox {
/**
* Returns the public preview URL for a given port on this sandbox.
*
* @param {object} options URL options
* @param {number} options.port port number (1–65535)
* @param {string} [options.protocol] override the URL scheme (e.g. `'wss'`)
* @returns {Promise<string>} public preview URL
* This is a synchronous local lookup against the `previewUrls` map returned
* by the server at create time. The URL is opaque — do not parse or reconstruct it.
*
* @param {number} port port number (1–65535)
* @returns {string} public preview URL
* @throws {SandboxInvalidPortError} when `port` is not an integer in the
* range 1–65535
* @throws {SandboxPortNotProvisionedError} when `port` is valid but was not
* declared in `create({ ports })`
*/
async getUrl ({ port, protocol } = {}) {
if (!this.publicUrlTemplate) {
throw new SandboxClientError(
`Cannot get URL for sandbox '${this.id}': publicUrlTemplate is not available`
)
}

getUrl (port) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new SandboxClientError(
throw new SandboxInvalidPortError(
`Invalid port '${port}': must be an integer between 1 and 65535`
)
}

let url = this.publicUrlTemplate
.replace('{sandboxId}', this.id)
.replace('{port}', String(port))

if (protocol) {
url = url.replace(/^https?:\/\//, `${protocol}://`)
const url = this.previewUrls.get(port)
if (url === undefined) {
throw new SandboxPortNotProvisionedError(
`Port ${port} was not provisioned for sandbox '${this.id}'. ` +
"Declare it in create({ ports: [...] }) to get a preview URL."
)
}

return url
Expand Down Expand Up @@ -464,4 +469,29 @@ class Sandbox {
}
}

/**
* Parses the `previewUrls` JSON object returned by the server into a
* `Map<number, string>`. String keys (port numbers) are converted to integers.
* The URL values are treated as opaque — not parsed or reconstructed.
*
* Returns an empty Map when the server response omits `previewUrls` (fail-closed:
* every `getUrl()` call will throw `SandboxPortNotProvisionedError`).
*
* @param {object|null|undefined} raw the `previewUrls` field from the API response
* @returns {Map<number, string>}
*/
function parsePreviewUrls (raw) {
if (!raw || typeof raw !== 'object') {
return new Map()
}
const map = new Map()
for (const [key, value] of Object.entries(raw)) {
const port = Number(key)
if (Number.isInteger(port) && port >= 1 && port <= 65535 && typeof value === 'string') {
map.set(port, value)
}
}
return map
}

module.exports = Sandbox
6 changes: 5 additions & 1 deletion src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class SandboxNotFoundError extends SandboxSDKError {}
class SandboxUnauthorizedError extends SandboxSDKError {}
class SandboxTimeoutError extends SandboxSDKError {}
class SandboxWebSocketError extends SandboxSDKError {}
class SandboxPortNotProvisionedError extends SandboxSDKError {}
class SandboxInvalidPortError extends SandboxClientError {}

module.exports = {
SandboxSDKError,
Expand All @@ -33,5 +35,7 @@ module.exports = {
SandboxNotFoundError,
SandboxUnauthorizedError,
SandboxTimeoutError,
SandboxWebSocketError
SandboxWebSocketError,
SandboxPortNotProvisionedError,
SandboxInvalidPortError
}
8 changes: 6 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ const {
SandboxNotFoundError,
SandboxUnauthorizedError,
SandboxTimeoutError,
SandboxWebSocketError
SandboxWebSocketError,
SandboxPortNotProvisionedError,
SandboxInvalidPortError
} = require('./errors')

module.exports = {
Expand All @@ -28,5 +30,7 @@ module.exports = {
SandboxNotFoundError,
SandboxUnauthorizedError,
SandboxTimeoutError,
SandboxWebSocketError
SandboxWebSocketError,
SandboxPortNotProvisionedError,
SandboxInvalidPortError
}
74 changes: 58 additions & 16 deletions test/Sandbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const {
SandboxClientError,
SandboxInitializationError,
SandboxNotFoundError,
SandboxPortNotProvisionedError,
SandboxInvalidPortError,
SandboxTimeoutError,
SandboxUnauthorizedError,
SandboxWebSocketError
Expand Down Expand Up @@ -258,6 +260,42 @@ describe('Sandbox', () => {
expect(body.policy).toEqual(policy)
})

test('forwards ports and populates previewUrls from the response', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
sandboxId: 'sb-ports',
wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-ports/exec',
status: 'ready',
token: 'tok-ports',
maxLifetime: 3600,
previewUrls: {
3000: 'https://sb-ports-3000.preview.example.net',
8080: 'https://sb-ports-8080.preview.example.net'
}
})
})
global.fetch = mockFetch

const createPromise = Sandbox.create({
name: 'ports-sandbox',
apiHost: 'https://runtime.example.net',
namespace: 'ns',
auth: 'uuid:key',
ports: [3000, 8080]
})

await new Promise(resolve => setImmediate(resolve))
sockets[0].open()
sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-ports' })
const sandbox = await createPromise

const body = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(body.ports).toEqual([3000, 8080])
expect(sandbox.getUrl(3000)).toBe('https://sb-ports-3000.preview.example.net')
expect(sandbox.getUrl(8080)).toBe('https://sb-ports-8080.preview.example.net')
})

test('reads credentials from env vars', async () => {
process.env.__OW_API_HOST = 'https://runtime.example.net'
process.env.__OW_NAMESPACE = 'ns'
Expand Down Expand Up @@ -654,36 +692,40 @@ describe('Sandbox', () => {
// -------------------------------------------------------------------------

describe('getUrl()', () => {
test('resolves preview URL from template', async () => {
test('resolves preview URL from previewUrls map', () => {
const sandbox = new Sandbox({
...BASE_OPTIONS,
publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net'
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
})

const url = await sandbox.getUrl({ port: 3000 })
const url = sandbox.getUrl(3000)
expect(url).toBe('https://sb-test-3000.preview.example.net')
})

test('replaces scheme when protocol option provided', async () => {
test('throws SandboxPortNotProvisionedError when port was not provisioned', () => {
const sandbox = new Sandbox({
...BASE_OPTIONS,
publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net'
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
})

const url = await sandbox.getUrl({ port: 3000, protocol: 'wss' })
expect(url).toBe('wss://sb-test-3000.preview.example.net')
expect(() => sandbox.getUrl(9999)).toThrow(SandboxPortNotProvisionedError)
})

test('throws SandboxClientError when publicUrlTemplate is absent', async () => {
const sandbox = new Sandbox(BASE_OPTIONS)
await expect(sandbox.getUrl({ port: 3000 })).rejects.toThrow(SandboxClientError)
test('throws SandboxInvalidPortError for out-of-range port', () => {
const sandbox = new Sandbox({
...BASE_OPTIONS,
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
})
expect(() => sandbox.getUrl(0)).toThrow(SandboxInvalidPortError)
expect(() => sandbox.getUrl(65536)).toThrow(SandboxInvalidPortError)
})

test('throws SandboxClientError for invalid port', async () => {
const sandbox = new Sandbox({ ...BASE_OPTIONS, publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net' })
await expect(sandbox.getUrl({ port: 0 })).rejects.toThrow(SandboxClientError)
await expect(sandbox.getUrl({ port: 70000 })).rejects.toThrow(SandboxClientError)
await expect(sandbox.getUrl({ port: 'abc' })).rejects.toThrow(SandboxClientError)
test('throws SandboxInvalidPortError for non-integer port', () => {
const sandbox = new Sandbox({
...BASE_OPTIONS,
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
})
expect(() => sandbox.getUrl('abc')).toThrow(SandboxInvalidPortError)
expect(() => sandbox.getUrl(3000.5)).toThrow(SandboxInvalidPortError)
})
})

Expand Down
Loading