Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
10 changes: 9 additions & 1 deletion packages/cli/src/lib/run/dyno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ export default class Dyno extends Duplex {
r.end()

r.on('error', this.reject)

r.on('response', response => {
const statusCode = response.statusCode
if (statusCode === 403) {
r.destroy()
this.reject?.(new Error("You can't access this space from your IP address. Contact your team admin."))
}
})
r.on('upgrade', (_, remote) => {
const s = net.createServer(client => {
client.on('end', () => {
Expand Down Expand Up @@ -317,7 +325,7 @@ export default class Dyno extends Duplex {

// suppress host key and permission denied messages
const messages = [
"Warning: Permanently added '[127.0.0.1]"
"Warning: Permanently added '[127.0.0.1]",
]

const killMessages = [
Expand Down
110 changes: 110 additions & 0 deletions packages/cli/src/lib/run/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import {ux} from '@oclif/core'
import type {APIClient} from '@heroku-cli/command'
import type {App} from '../types/fir'
import * as https from 'https'
import {URL} from 'url'

// this function exists because oclif sorts argv
// and to capture all non-flag command inputs
Expand Down Expand Up @@ -89,3 +91,111 @@ export async function buildCommandWithLauncher(
const prependLauncher = await shouldPrependLauncher(heroku, appName, disableLauncher)
return buildCommand(args, prependLauncher)
}

/**
* Fetches the response body from an HTTP request when the response body isn't available
* from the error object (e.g., EventSource doesn't expose response bodies).
*
* Uses native fetch API with a custom https.Agent to handle staging SSL certificates
* (rejectUnauthorized: false).
*
* Note: Node.js native fetch doesn't support custom agents directly, so we use
* https.request when rejectUnauthorized is needed, but structure the code with
*
* @param url - The URL to fetch the response body from
* @param expectedStatusCode - Only return body if status code matches (default: 403)
* @returns The response body as a string, or empty string if unavailable or status doesn't match
*/
export async function fetchHttpResponseBody(url: string, expectedStatusCode: number = 403): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout

try {
const parsedUrl = new URL(url)
const userAgent = process.env.HEROKU_DEBUG_USER_AGENT || 'heroku-run'

// Note: Native fetch in Node.js doesn't support custom https.Agent for SSL certificate handling.
// We use https.request when rejectUnauthorized: false is needed (staging environments).
// This maintains compatibility while using modern async/await and AbortController patterns.
if (parsedUrl.protocol !== 'https:') {
// For non-HTTPS URLs, use native fetch
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': userAgent,
Accept: 'text/plain',
},
signal: controller.signal,
})

clearTimeout(timeoutId)

if (response.status === expectedStatusCode) {
return await response.text()
}

return ''
}

// For HTTPS with rejectUnauthorized: false (staging), use https.request
// This is the same pattern as dyno.ts - necessary for staging SSL certs
return await new Promise<string>(resolve => {
const cleanup = (): void => {
clearTimeout(timeoutId)
controller.abort()
}

const options: https.RequestOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || 443,
path: parsedUrl.pathname + parsedUrl.search,
method: 'GET',
headers: {
'User-Agent': userAgent,
Accept: 'text/plain',
},
rejectUnauthorized: false, // Allow staging self-signed certificates
}

const req = https.request(options, res => {
let body = ''
res.setEncoding('utf8')
res.on('data', chunk => {
body += chunk
})
res.on('end', () => {
cleanup()
if (res.statusCode === expectedStatusCode) {
resolve(body)
} else {
resolve('')
}
})
})

req.on('error', () => {
cleanup()
resolve('')
})

// Abort on timeout
controller.signal.addEventListener('abort', () => {
req.destroy()
resolve('')
}, {once: true})

req.end()
})
} catch (error: unknown) {
clearTimeout(timeoutId)

// AbortError is expected on timeout - return empty string
if (error instanceof Error && error.name === 'AbortError') {
return ''
}

// For other errors, return empty string for graceful degradation
// This matches the previous behavior where errors returned empty string
return ''
}
}
46 changes: 35 additions & 11 deletions packages/cli/src/lib/run/log-displayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,53 @@ function readLogs(logplexURL: string, isTail: boolean, recreateSessionTimeout?:
},
})

es.addEventListener('error', function (err: { status?: number; message?: string | null }) {
if (err && (err.status || err.message)) {
const msg = (isTail && (err.status === 404 || err.status === 403)) ?
'Log stream timed out. Please try again.' :
`Logs eventsource failed with: ${err.status}${err.message ? ` ${err.message}` : ''}`
reject(new Error(msg))
let isResolved = false
let hasReceivedMessages = false

const safeReject = (error: Error) => {
if (!isResolved) {
isResolved = true
es.close()
reject(error)
}
}

if (!isTail) {
resolve()
const safeResolve = () => {
if (!isResolved) {
isResolved = true
es.close()
resolve()
}

// should only land here if --tail and no error status or message
})
}

es.addEventListener('message', function (e: { data: string }) {
hasReceivedMessages = true
e.data.trim().split(/\n+/).forEach(line => {
ux.log(colorize(line))
})
})

es.addEventListener('error', function (err: { status?: number; message?: string | null }) {
if (err && (err.status || err.message)) {
let msg: string
if (err.status === 403) {
msg = hasReceivedMessages ?
'Log stream access expired. Please try again.' :
"You can't access this space from your IP address. Contact your team admin."
} else if (err.status === 404 && isTail) {
msg = 'Log stream access expired. Please try again.'
} else {
msg = `Logs eventsource failed with: ${err.status}${err.message ? ` ${err.message}` : ''}`
}

safeReject(new Error(msg))
} else if (!isTail) {
safeResolve()
}

// should only land here if --tail and no error status or message
})

if (isTail && recreateSessionTimeout) {
setTimeout(() => {
reject(new Error('Fir log stream timeout'))
Expand Down
Loading
Loading