From 4a2ff9aa6c63f6673ce2ff352947780971fbee12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 26 Oct 2026 17:29:25 +0100 Subject: [PATCH] feat: Add apify server commands for Server Actors Draft POC for #1299. Co-Authored-By: Claude Opus 5.5 --- src/commands/_register.ts | 2 + src/commands/actors/call.ts | 9 +- src/commands/actors/push.ts | 18 +- src/commands/actors/start.ts | 9 +- src/commands/api.ts | 107 +--------- src/commands/run.ts | 44 +++- src/commands/server/_index.ts | 27 +++ src/commands/server/endpoints.ts | 77 +++++++ src/commands/server/request.ts | 228 ++++++++++++++++++++ src/commands/server/status.ts | 112 ++++++++++ src/commands/server/url.ts | 39 ++++ src/lib/commands/http-request.ts | 104 ++++++++++ src/lib/server-actor.ts | 311 ++++++++++++++++++++++++++++ test/local/lib/server-actor.test.ts | 238 +++++++++++++++++++++ 14 files changed, 1223 insertions(+), 102 deletions(-) create mode 100644 src/commands/server/_index.ts create mode 100644 src/commands/server/endpoints.ts create mode 100644 src/commands/server/request.ts create mode 100644 src/commands/server/status.ts create mode 100644 src/commands/server/url.ts create mode 100644 src/lib/commands/http-request.ts create mode 100644 src/lib/server-actor.ts create mode 100644 test/local/lib/server-actor.test.ts diff --git a/src/commands/_register.ts b/src/commands/_register.ts index b1cce10ce..3b5de8a4a 100644 --- a/src/commands/_register.ts +++ b/src/commands/_register.ts @@ -32,6 +32,7 @@ import { RequestQueuesIndexCommand } from './request-queues/_index.js'; import { RunCommand } from './run.js'; import { RunsIndexCommand } from './runs/_index.js'; import { SecretsIndexCommand } from './secrets/_index.js'; +import { ServerIndexCommand } from './server/_index.js'; import { TasksIndexCommand } from './task/_index.js'; import { TelemetryIndexCommand } from './telemetry/_index.js'; import { TemplatesIndexCommand } from './templates/_index.js'; @@ -49,6 +50,7 @@ export const apifyCommands = [ RequestQueuesIndexCommand, RunsIndexCommand, SecretsIndexCommand, + ServerIndexCommand, TasksIndexCommand, TelemetryIndexCommand, TemplatesIndexCommand, diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index e70ba2ecf..278553a9a 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -20,7 +20,8 @@ import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { finalizeRun } from '../../lib/commands/run-result.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; -import { error, simpleLog } from '../../lib/outputs.js'; +import { error, info, simpleLog } from '../../lib/outputs.js'; +import { getServerInterfaceHint } from '../../lib/server-actor.js'; import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; export class ActorsCallCommand extends ApifyCommand { @@ -129,6 +130,12 @@ export class ActorsCallCommand extends ApifyCommand { providedActorNameOrId: this.args.actorId, }); + const serverInterfaceHint = getServerInterfaceHint(actorData, userFriendlyId); + + if (!this.flags.silent && serverInterfaceHint) { + info({ message: serverInterfaceHint }); + } + const runOpts: ActorStartOptions = { waitForFinish: 2, // NOTE: We need to wait some time to Apify open stream and we can create connection }; diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 8c690bb53..d34dae10b 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -55,7 +55,7 @@ const BUILD_LOG_TAIL_LINES = 10; interface PushResult { ok: boolean; operation: 'push'; - actor: { id: string; url: string }; + actor: { id: string; url: string; standbyUrl?: string }; build: { id: string; number: string; status: string; url: string }; error?: { phase: 'build'; message: string; logTail: string[] }; exitCode?: number; @@ -587,10 +587,18 @@ Skipping push. Use --force to override.`, process.exitCode = outcome.exitCode; } + // Read the URL from the API: the hostname format differs between Actors and environments. + let standbyUrl: string | undefined; + + if (outcome.ok && actorConfig!.usesStandbyMode) { + const pushedActor = (await actorClient.get()) as (Actor & { standbyUrl?: string | null }) | undefined; + standbyUrl = pushedActor?.standbyUrl ?? undefined; + } + const result: PushResult = { ok: outcome.ok, operation: 'push', - actor: { id: build.actId, url: actorUrl }, + actor: { id: build.actId, url: actorUrl, ...(standbyUrl && { standbyUrl }) }, build: { id: build.id, number: build.buildNumber, status: buildStatus, url: buildUrl }, }; if (outcome.exitCode !== undefined) { @@ -617,6 +625,12 @@ Skipping push. Use --force to override.`, '', `Actor URL: ${actorUrl}`, `Build URL: ${buildUrl}`, + ...(standbyUrl + ? [ + `Server interface URL: ${standbyUrl}`, + `Send a request: apify server request ${actor.username}/${actor.name} /`, + ] + : []), ...(outcome.errorMessage && logTail.length ? ['', 'Reason:', ...logTail] : []), ]; simpleLog({ stdout: true, message: lines.join('\n') }); diff --git a/src/commands/actors/start.ts b/src/commands/actors/start.ts index 77c299f35..f19a136ce 100644 --- a/src/commands/actors/start.ts +++ b/src/commands/actors/start.ts @@ -10,7 +10,8 @@ import { consoleActorUrl, consoleRunUrl } from '../../lib/commands/agent-output. import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { LOCAL_CONFIG_PATH } from '../../lib/consts.js'; -import { simpleLog } from '../../lib/outputs.js'; +import { info, simpleLog } from '../../lib/outputs.js'; +import { getServerInterfaceHint } from '../../lib/server-actor.js'; import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; import { ActorsCallCommand } from './call.js'; @@ -88,6 +89,12 @@ export class ActorsStartCommand extends ApifyCommand providedActorNameOrId: this.args.actorId, }); + const serverInterfaceHint = getServerInterfaceHint(actorData, userFriendlyId); + + if (serverInterfaceHint) { + info({ message: serverInterfaceHint }); + } + const runOpts: ActorStartOptions = {}; if (this.flags.build) { diff --git a/src/commands/api.ts b/src/commands/api.ts index b35a2afe9..0816008ce 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -5,12 +5,17 @@ import chalk from 'chalk'; import { ApifyCommand, StdinMode } from '../lib/command-framework/apify-command.js'; import { Args } from '../lib/command-framework/args.js'; import { Flags } from '../lib/command-framework/flags.js'; +import { + HTTP_METHODS, + mergeHeaders, + METHODS_WITHOUT_BODY, + parseHeaders, + parseParams, +} from '../lib/commands/http-request.js'; import { APIFY_CLIENT_DEFAULT_HEADERS, CommandExitCodes } from '../lib/consts.js'; import { error, simpleLog } from '../lib/outputs.js'; import { getLoggedClientOrThrow } from '../lib/utils.js'; -const HTTP_METHODS: string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; -const METHODS_WITHOUT_BODY = new Set(['GET', 'HEAD']); const OPENAPI_SPEC_URL = 'https://docs.apify.com/api/openapi.json'; interface OpenAPISpec { @@ -23,89 +28,6 @@ interface Endpoint { summary: string; } -function parseParams(raw: string | undefined): string { - if (!raw) { - return ''; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(raw); - } catch { - throw new Error('Invalid JSON in --params flag. Please provide a valid JSON object, e.g. \'{"limit": 1}\'.'); - } - - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('--params must be a JSON object (e.g. \'{"limit": 1}\').'); - } - - const searchParams = new URLSearchParams(); - - for (const [key, value] of Object.entries(parsed as Record)) { - if (value === undefined || value === null) { - continue; - } - - if (typeof value === 'object') { - throw new Error( - `--params value for "${key}" must be a scalar (string, number, or boolean), got ${Array.isArray(value) ? 'array' : 'object'}. ` + - 'Query parameters cannot contain nested objects or arrays.', - ); - } - - searchParams.append(key, String(value)); - } - - return searchParams.toString(); -} - -function parseHeaders(raw: string | undefined): Record { - if (!raw) { - return {}; - } - - const trimmed = raw.trim(); - - // JSON object form: --header '{"X-Foo": "bar", "X-Baz": "qux"}' - if (trimmed.startsWith('{')) { - let parsed: unknown; - - try { - parsed = JSON.parse(trimmed); - } catch { - throw new Error('Invalid JSON in --header flag. Provide a JSON object like \'{"X-Foo": "bar"}\'.'); - } - - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('--header JSON must be an object mapping header names to string values.'); - } - - const result: Record = {}; - - for (const [key, value] of Object.entries(parsed as Record)) { - if (typeof value !== 'string') { - throw new Error(`--header value for "${key}" must be a string, got ${typeof value}.`); - } - - result[key.trim()] = value.trim(); - } - - return result; - } - - // "key:value" form - const colonIndex = trimmed.indexOf(':'); - - if (colonIndex === -1) { - throw new Error('Header must be in "key:value" format, or a JSON object for multiple headers.'); - } - - return { - [trimmed.slice(0, colonIndex).trim()]: trimmed.slice(colonIndex + 1).trim(), - }; -} - const METHOD_COLORS: Record string> = { GET: chalk.green, POST: chalk.yellow, @@ -325,25 +247,16 @@ export class ApiCommand extends ApifyCommand { url = `${url}${separator}${queryString}`; } - // Build headers. Custom headers overwrite defaults case-insensitively so - // callers can override e.g. Content-Type without creating duplicate entries. - const headers: Record = { + const defaultHeaders: Record = { ...APIFY_CLIENT_DEFAULT_HEADERS, Authorization: `Bearer ${token}`, }; if (this.flags.body) { - headers['Content-Type'] = 'application/json'; + defaultHeaders['Content-Type'] = 'application/json'; } - for (const [key, value] of Object.entries(customHeaders)) { - for (const existingKey of Object.keys(headers)) { - if (existingKey.toLowerCase() === key.toLowerCase()) { - delete headers[existingKey]; - } - } - headers[key] = value; - } + const headers = mergeHeaders(defaultHeaders, customHeaders); // Make the request const response = await fetch(url, { diff --git a/src/commands/run.ts b/src/commands/run.ts index 46ec9d607..be4de0dee 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -7,7 +7,7 @@ import type { ExecaError } from 'execa'; import mime from 'mime'; import { minVersion } from 'semver'; -import { ACTOR_ENV_VARS, APIFY_ENV_VARS } from '@apify/consts'; +import { ACTOR_ENV_VARS, APIFY_ENV_VARS, META_ORIGINS } from '@apify/consts'; import { validateInputSchema, validateInputUsingValidator } from '@apify/input_schema'; import { lookUpProfile } from '../lib/auth-file.js'; @@ -33,6 +33,7 @@ import { deleteKvsRecord, writeKvsRecord } from '../lib/kvs-metadata.js'; import { getAccessToken, OAuthSessionError } from '../lib/oauth/session.js'; import { error, info, warning } from '../lib/outputs.js'; import { replaceSecretsValue } from '../lib/secrets.js'; +import { DEFAULT_LOCAL_SERVER_PORT, watchLocalServerReadiness } from '../lib/server-actor.js'; import type { AuthJSON } from '../lib/types.js'; import { Ajv2019, @@ -184,11 +185,26 @@ export class RunCommand extends ApifyCommand { required: false, default: false, }), + standby: Flags.boolean({ + description: + 'Run the Actor the way the platform starts it for its server interface: sets APIFY_META_ORIGIN=STANDBY ' + + 'and the web server port, then sends the readiness probe and prints the local URL.', + required: false, + default: false, + }), + port: Flags.integer({ + description: `The port the Actor's web server listens on. Use with --standby. Defaults to ${DEFAULT_LOCAL_SERVER_PORT}.`, + required: false, + }), }; async run() { const cwd = process.cwd(); + if (this.flags.port !== undefined && !this.flags.standby) { + throw new Error('The --port flag can only be used together with --standby.'); + } + let authFailure: string | undefined; const auth = await resolveAuth().catch((err): undefined => { // A dead login session must not stop a local run; the Actor just runs without Apify API access. @@ -215,6 +231,14 @@ export class RunCommand extends ApifyCommand { const { config: localConfig } = localConfigResult.unwrap(); + if (localConfig!.usesStandbyMode && !this.flags.standby) { + info({ + message: + 'This Actor has a server interface ("usesStandbyMode" in actor.json). ' + + 'Pass --standby to run it the way the platform does for HTTP requests.', + }); + } + const actualStoragePath = getLocalStorageDir(); const resolvedInputKey = resolveInputKey(); @@ -385,6 +409,16 @@ export class RunCommand extends ApifyCommand { [CRAWLEE_INPUT_KEY_ENV]: effectiveInputKey, }; + const serverPort = this.flags.port ?? DEFAULT_LOCAL_SERVER_PORT; + + if (this.flags.standby) { + localEnvVars[APIFY_ENV_VARS.META_ORIGIN] = META_ORIGINS.STANDBY; + localEnvVars[ACTOR_ENV_VARS.WEB_SERVER_PORT] = String(serverPort); + // The platform still injects the legacy name, and older templates read it. + localEnvVars[ACTOR_ENV_VARS.STANDBY_PORT] = String(serverPort); + localEnvVars[ACTOR_ENV_VARS.STANDBY_URL] = `http://localhost:${serverPort}`; + } + if (proxy && proxy.password) localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD] = proxy.password; if (userId) localEnvVars[APIFY_ENV_VARS.USER_ID] = userId; if (token) localEnvVars[APIFY_ENV_VARS.TOKEN] = token; @@ -406,6 +440,12 @@ export class RunCommand extends ApifyCommand { }); } + const readinessProbe = new AbortController(); + + if (this.flags.standby) { + void watchLocalServerReadiness(serverPort, readinessProbe.signal); + } + try { switch (type) { case ProjectLanguage.JavaScript: { @@ -511,6 +551,8 @@ export class RunCommand extends ApifyCommand { // The command framework only defaults exitCode to 1; preserve the Actor's own code when present. process.exitCode = (err as ExecaError).exitCode ?? 1; } finally { + readinessProbe.abort(); + if (storedInputResults) { if ('tempInputKey' in storedInputResults) { // Temp input record: just delete it, user's INPUT.json was never touched diff --git a/src/commands/server/_index.ts b/src/commands/server/_index.ts new file mode 100644 index 000000000..587e0c8a6 --- /dev/null +++ b/src/commands/server/_index.ts @@ -0,0 +1,27 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { ServerEndpointsCommand } from './endpoints.js'; +import { ServerRequestCommand } from './request.js'; +import { ServerStatusCommand } from './status.js'; +import { ServerUrlCommand } from './url.js'; + +export class ServerIndexCommand extends ApifyCommand { + static override name = 'server' as const; + + static override description = 'Send requests to a Server Actor and inspect its server interface.'; + + static override group = 'Apify Console'; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-server'; + + static override subcommands = [ + // + ServerRequestCommand, + ServerStatusCommand, + ServerEndpointsCommand, + ServerUrlCommand, + ]; + + async run() { + this.printHelp(); + } +} diff --git a/src/commands/server/endpoints.ts b/src/commands/server/endpoints.ts new file mode 100644 index 000000000..8e64c1a5c --- /dev/null +++ b/src/commands/server/endpoints.ts @@ -0,0 +1,77 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { info, simpleLog } from '../../lib/outputs.js'; +import { + getServerDefinition, + listServerOperations, + resolveServerActor, + resolveServerBuild, + shortenSummary, +} from '../../lib/server-actor.js'; +import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; + +export class ServerEndpointsCommand extends ApifyCommand { + static override name = 'endpoints' as const; + + static override enableProfileFlag = true; + + static override description = + 'Lists the HTTP endpoints of a Server Actor, read from the web server schema of the build that serves its server interface.'; + + static override examples = [ + { + description: 'List the endpoints of an Actor.', + command: 'apify server endpoints apify/rag-web-browser', + }, + { + description: 'Print the endpoints as JSON.', + command: 'apify server endpoints apify/rag-web-browser --json', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-server-endpoints'; + + static override args = { + target: Args.string({ + description: 'The Actor ID or "username/name". Defaults to the Actor in the current directory.', + }), + }; + + static override enableJsonFlag = true; + + async run() { + const client = await getLoggedClientOrThrow(); + const actor = await resolveServerActor(client, this.args.target); + const build = await resolveServerBuild(client, actor); + const { webServerSchema, webServerMcpPath } = getServerDefinition(build); + const operations = listServerOperations(webServerSchema); + + if (this.flags.json) { + printJsonToStdout({ + url: actor.standbyUrl, + tenancy: actor.tenancy ?? null, + build: { ref: actor.buildRef, id: build.id, buildNumber: build.buildNumber }, + operations, + mcpPath: webServerMcpPath ?? null, + }); + return; + } + + for (const operation of operations) { + const summary = operation.summary ? ` ${shortenSummary(operation.summary)}` : ''; + simpleLog({ message: `${operation.method.padEnd(7)} ${operation.path}${summary}`, stdout: true }); + } + + if (webServerMcpPath) { + simpleLog({ message: `${'MCP'.padEnd(7)} ${webServerMcpPath}`, stdout: true }); + } + + if (operations.length === 0 && !webServerMcpPath) { + info({ + message: + `Actor "${actor.userFriendlyId}" does not document its endpoints. ` + + `See its README: apify actors info ${actor.userFriendlyId} --readme`, + }); + } + } +} diff --git a/src/commands/server/request.ts b/src/commands/server/request.ts new file mode 100644 index 000000000..9fbe4ea93 --- /dev/null +++ b/src/commands/server/request.ts @@ -0,0 +1,228 @@ +import { once } from 'node:events'; +import process from 'node:process'; + +import { ApifyCommand, StdinMode } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { + HTTP_METHODS, + mergeHeaders, + METHODS_WITHOUT_BODY, + parseHeaders, + parseParams, +} from '../../lib/commands/http-request.js'; +import { APIFY_CLIENT_DEFAULT_HEADERS, CommandExitCodes } from '../../lib/consts.js'; +import { error, info, simpleLog, warning } from '../../lib/outputs.js'; +import { + describeServerError, + isMultiTenant, + parseServerRequestArgs, + resolveServerActor, + resolveServerRequestUrl, + SERVER_REQUEST_TIMEOUT_MILLIS, +} from '../../lib/server-actor.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; + +const SLOW_RESPONSE_NOTICE_MILLIS = 2_000; + +export class ServerRequestCommand extends ApifyCommand { + static override name = 'request' as const; + + static override enableProfileFlag = true; + + static override description = + "Sends an authenticated HTTP request to a Server Actor's server interface and prints the response.\n" + + 'The host always comes from the Actor; pass a path, never a full URL. ' + + 'The API token is sent in the Authorization header and never reaches the Actor.'; + + static override examples = [ + { + description: 'Send a GET request (the default method).', + command: 'apify server request apify/rag-web-browser /search -p \'{"query":"apify"}\'', + }, + { + description: 'Send a POST request with a JSON body.', + command: `apify server request POST apify/web-fetch / -d '{"url":"https://example.com"}'`, + }, + { + description: 'Call the Actor in the current directory.', + command: 'apify server request /health', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-server-request'; + + static override args = { + methodOrTarget: Args.string({ + description: 'The HTTP method (defaults to GET), or the Actor when the method is omitted.', + }), + targetOrPath: Args.string({ + description: 'The Actor ID or "username/name" (defaults to the Actor in the current directory), or the path.', + }), + path: Args.string({ + description: 'The path on the Actor\'s server, starting with "/". Defaults to "/".', + }), + }; + + static override flags = { + method: Flags.string({ + char: 'X', + description: 'The HTTP method to use. Defaults to GET.', + choices: HTTP_METHODS, + }), + body: Flags.string({ + char: 'd', + description: 'The request body. Use "-" to read from stdin.', + required: false, + stdin: StdinMode.Stringified, + }), + header: Flags.string({ + char: 'H', + description: + 'Additional HTTP header(s). Pass a single "key:value" string, or a JSON object ' + + 'like \'{"X-Foo": "bar", "X-Baz": "qux"}\' to send multiple headers.', + required: false, + }), + params: Flags.string({ + char: 'p', + description: 'Query parameters as a JSON object, e.g. \'{"query": "apify"}\'.', + required: false, + }), + }; + + async run() { + const parsed = parseServerRequestArgs([this.args.methodOrTarget, this.args.targetOrPath, this.args.path]); + const explicitMethodFlag = this.flags.method?.toUpperCase(); + + if (parsed.method && explicitMethodFlag && parsed.method !== explicitMethodFlag) { + throw new Error( + `Conflicting HTTP methods: positional "${parsed.method}" vs --method "${explicitMethodFlag}". ` + + 'Please specify the method only once.', + ); + } + + const method = parsed.method ?? explicitMethodFlag ?? 'GET'; + const queryString = parseParams(this.flags.params); + const customHeaders = parseHeaders(this.flags.header); + const { body } = this.flags; + + if (body && METHODS_WITHOUT_BODY.has(method)) { + throw new Error(`HTTP ${method} requests cannot have a request body. Use POST, PUT or PATCH, or omit --body.`); + } + + const client = await getLoggedClientOrThrow(); + const actor = await resolveServerActor(client, parsed.target); + const url = resolveServerRequestUrl(actor.standbyUrl, parsed.path, queryString); + + // Single-tenant is the platform default; only multi-tenant Actors run in the developer's account. + if (!isMultiTenant(actor)) { + const { username } = await getCurrentUserInfo(); + info({ + message: `${actor.userFriendlyId} is single-tenant: it runs in account "${username}", which pays the platform usage.`, + }); + } + + const defaultHeaders: Record = { + ...APIFY_CLIENT_DEFAULT_HEADERS, + Authorization: `Bearer ${client.token!}`, + }; + + if (body) { + defaultHeaders['Content-Type'] = isJson(body) ? 'application/json' : 'text/plain; charset=utf-8'; + } + + const headers = mergeHeaders(defaultHeaders, customHeaders); + + // The timeout covers the wait for the response head only; a streamed body may legitimately take longer. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SERVER_REQUEST_TIMEOUT_MILLIS); + const slowNotice = setTimeout(() => { + info({ + message: 'Waiting for the Actor. If no run is ready, the platform starts one, which can take up to 2 minutes.', + }); + }, SLOW_RESPONSE_NOTICE_MILLIS); + + let response: Response; + + try { + response = await fetch(url, { + method, + headers, + body: body || undefined, + // The Actor controls redirects; following one could send the token to another host. + redirect: 'manual', + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) { + throw new Error('The Actor did not respond within 5 minutes.'); + } + + throw err; + } finally { + clearTimeout(timeout); + clearTimeout(slowNotice); + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + warning({ + message: `${response.status} ${response.statusText}. Not following the redirect${location ? ` to ${location}` : ''}.`, + }); + } + + if (response.status >= 400) { + process.exitCode = CommandExitCodes.RunFailed; + + const responseText = await response.text(); + error({ message: `${response.status} ${response.statusText}` }); + + const hint = describeServerError(response.status, responseText); + + if (hint) { + simpleLog({ message: hint }); + } + + if (responseText) { + process.stdout.write(responseText); + endLineOnTerminal(responseText.endsWith('\n')); + } + + return; + } + + if (!response.body) { + return; + } + + let lastByte: number | undefined; + + for await (const chunk of response.body) { + if (chunk.length > 0) { + lastByte = chunk[chunk.length - 1]; + } + + if (!process.stdout.write(chunk)) { + await once(process.stdout, 'drain'); + } + } + + endLineOnTerminal(lastByte === undefined || lastByte === 0x0a); + } +} + +// Without a final newline the shell prompt lands on the body's last line; pipes still get the exact bytes. +function endLineOnTerminal(endsWithNewline: boolean) { + if (process.stdout.isTTY && !endsWithNewline) { + process.stdout.write('\n'); + } +} + +function isJson(text: string) { + try { + JSON.parse(text); + return true; + } catch { + return false; + } +} diff --git a/src/commands/server/status.ts b/src/commands/server/status.ts new file mode 100644 index 000000000..0f1cc9468 --- /dev/null +++ b/src/commands/server/status.ts @@ -0,0 +1,112 @@ +import { META_ORIGINS } from '@apify/consts'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { simpleLog } from '../../lib/outputs.js'; +import { + getServerDefinition, + isMultiTenant, + listServerOperations, + resolveServerActor, + resolveServerBuild, +} from '../../lib/server-actor.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; + +export class ServerStatusCommand extends ApifyCommand { + static override name = 'status' as const; + + static override enableProfileFlag = true; + + static override description = + "Shows how a Server Actor's server interface is configured and, for single-tenant Actors, the runs serving you. " + + 'Never sends a request to the Actor, so it cannot start a run.'; + + static override examples = [ + { + description: 'Show the server interface of an Actor.', + command: 'apify server status apify/rag-web-browser', + }, + { + description: 'Print the status as JSON.', + command: 'apify server status apify/web-fetch --json', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-server-status'; + + static override args = { + target: Args.string({ + description: 'The Actor ID or "username/name". Defaults to the Actor in the current directory.', + }), + }; + + static override enableJsonFlag = true; + + async run() { + const client = await getLoggedClientOrThrow(); + const actor = await resolveServerActor(client, this.args.target); + const build = await resolveServerBuild(client, actor); + const { webServerSchema, webServerMcpPath } = getServerDefinition(build); + const operations = listServerOperations(webServerSchema); + const multiTenant = isMultiTenant(actor); + const { standby } = actor; + + // A multi-tenant Actor runs in the developer's account, so the caller has no runs to list. + let runs: { id: string; startedAt: Date }[] | undefined; + let username: string | undefined; + + if (!multiTenant) { + const { items } = await client.runs().list({ status: 'RUNNING', desc: true, limit: 1000 }); + runs = items + .filter((run) => run.actId === actor.id && run.meta?.origin === META_ORIGINS.STANDBY) + .map(({ id, startedAt }) => ({ id, startedAt })); + ({ username } = await getCurrentUserInfo()); + } + + if (this.flags.json) { + printJsonToStdout({ + url: actor.standbyUrl, + tenancy: actor.tenancy ?? null, + build: { ref: actor.buildRef, id: build.id, buildNumber: build.buildNumber }, + memoryMbytes: standby.memoryMbytes ?? null, + idleTimeoutSecs: standby.idleTimeoutSecs ?? null, + desiredRequestsPerActorRun: standby.desiredRequestsPerActorRun ?? null, + maxRequestsPerActorRun: standby.maxRequestsPerActorRun ?? null, + operationCount: operations.length, + mcpPath: webServerMcpPath ?? null, + runs: runs ?? null, + }); + return; + } + + const tenancyLine = multiTenant + ? "multi-tenant (runs in the developer's account; you pay per event)" + : `single-tenant (runs in your account "${username}", which pays the platform usage)`; + + const endpointsLine = + operations.length > 0 + ? `${operations.length} documented (apify server endpoints ${actor.userFriendlyId})` + : 'not documented (see the Actor README)'; + + const lines = [ + `URL: ${actor.standbyUrl}`, + `Tenancy: ${tenancyLine}`, + `Build: ${actor.buildRef} (${build.buildNumber})`, + `Memory: ${standby.memoryMbytes ?? '-'} MB`, + `Idle timeout: ${standby.idleTimeoutSecs ?? '-'} s`, + `Requests/run: ${standby.desiredRequestsPerActorRun ?? '-'} desired, ${standby.maxRequestsPerActorRun ?? '-'} max`, + `Endpoints: ${endpointsLine}`, + ...(webServerMcpPath ? [`MCP path: ${webServerMcpPath}`] : []), + ]; + + if (runs) { + lines.push( + runs.length > 0 + ? `Runs: ${runs.length} running (${runs.map((run) => run.id).join(', ')}); the next request is served warm` + : 'Runs: none running; the next request starts one (cold start)', + ); + } + + simpleLog({ message: lines.join('\n'), stdout: true }); + } +} diff --git a/src/commands/server/url.ts b/src/commands/server/url.ts new file mode 100644 index 000000000..510a0ec21 --- /dev/null +++ b/src/commands/server/url.ts @@ -0,0 +1,39 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { simpleLog } from '../../lib/outputs.js'; +import { resolveServerActor } from '../../lib/server-actor.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; + +export class ServerUrlCommand extends ApifyCommand { + static override name = 'url' as const; + + static override enableProfileFlag = true; + + static override description = "Prints the URL of a Server Actor's server interface."; + + static override examples = [ + { + description: 'Print the server interface URL of an Actor.', + command: 'apify server url apify/rag-web-browser', + }, + { + description: 'Print the URL of the Actor in the current directory.', + command: 'apify server url', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-server-url'; + + static override args = { + target: Args.string({ + description: 'The Actor ID or "username/name". Defaults to the Actor in the current directory.', + }), + }; + + async run() { + const client = await getLoggedClientOrThrow(); + const actor = await resolveServerActor(client, this.args.target); + + simpleLog({ message: actor.standbyUrl, stdout: true }); + } +} diff --git a/src/lib/commands/http-request.ts b/src/lib/commands/http-request.ts new file mode 100644 index 000000000..73be64b5b --- /dev/null +++ b/src/lib/commands/http-request.ts @@ -0,0 +1,104 @@ +export const HTTP_METHODS: string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; +export const METHODS_WITHOUT_BODY = new Set(['GET', 'HEAD']); + +export function parseParams(raw: string | undefined): string { + if (!raw) { + return ''; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('Invalid JSON in --params flag. Please provide a valid JSON object, e.g. \'{"limit": 1}\'.'); + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('--params must be a JSON object (e.g. \'{"limit": 1}\').'); + } + + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(parsed as Record)) { + if (value === undefined || value === null) { + continue; + } + + if (typeof value === 'object') { + throw new Error( + `--params value for "${key}" must be a scalar (string, number, or boolean), got ${Array.isArray(value) ? 'array' : 'object'}. ` + + 'Query parameters cannot contain nested objects or arrays.', + ); + } + + searchParams.append(key, String(value)); + } + + return searchParams.toString(); +} + +export function parseHeaders(raw: string | undefined): Record { + if (!raw) { + return {}; + } + + const trimmed = raw.trim(); + + // JSON object form: --header '{"X-Foo": "bar", "X-Baz": "qux"}' + if (trimmed.startsWith('{')) { + let parsed: unknown; + + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error('Invalid JSON in --header flag. Provide a JSON object like \'{"X-Foo": "bar"}\'.'); + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('--header JSON must be an object mapping header names to string values.'); + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value !== 'string') { + throw new Error(`--header value for "${key}" must be a string, got ${typeof value}.`); + } + + result[key.trim()] = value.trim(); + } + + return result; + } + + // "key:value" form + const colonIndex = trimmed.indexOf(':'); + + if (colonIndex === -1) { + throw new Error('Header must be in "key:value" format, or a JSON object for multiple headers.'); + } + + return { + [trimmed.slice(0, colonIndex).trim()]: trimmed.slice(colonIndex + 1).trim(), + }; +} + +/** + * Custom headers overwrite defaults case-insensitively so callers can override e.g. Content-Type + * without creating duplicate entries. + */ +export function mergeHeaders(defaults: Record, custom: Record): Record { + const headers = { ...defaults }; + + for (const [key, value] of Object.entries(custom)) { + for (const existingKey of Object.keys(headers)) { + if (existingKey.toLowerCase() === key.toLowerCase()) { + delete headers[existingKey]; + } + } + headers[key] = value; + } + + return headers; +} diff --git a/src/lib/server-actor.ts b/src/lib/server-actor.ts new file mode 100644 index 000000000..3d9a38553 --- /dev/null +++ b/src/lib/server-actor.ts @@ -0,0 +1,311 @@ +import { setTimeout as delay } from 'node:timers/promises'; + +import type { Actor, ApifyClient, Build } from 'apify-client'; + +import { HTTP_METHODS } from './commands/http-request.js'; +import { resolveActorContext } from './commands/resolve-actor-context.js'; +import { success, warning } from './outputs.js'; + +/** The router's total budget from request to the first response byte. */ +export const SERVER_REQUEST_TIMEOUT_MILLIS = 5 * 60 * 1_000; + +export const DEFAULT_LOCAL_SERVER_PORT = 4321; + +const READINESS_PROBE_HEADER = 'x-apify-container-server-readiness-probe'; +const READINESS_PROBE_INTERVAL_MILLIS = 500; +const READINESS_PROBE_WARNING_MILLIS = 30_000; + +const BUILD_NUMBER_REGEX = /^\d+\.\d+\.\d+$/; +const OPENAPI_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + +// apify-client 2.25.0 types omit `standbyUrl`, `actorStandby.tenancy`, the build's web server fields and +// `buildNumber` on build list items; the API returns all of them. +type ActorWithServerFields = Actor & { + standbyUrl?: string | null; + actorStandby?: Actor['actorStandby'] & { tenancy?: string }; +}; + +type ActorDefinitionWithServerFields = NonNullable & { + webServerSchema?: unknown; + webServerMcpPath?: string; +}; + +export interface ServerActor { + id: string; + userFriendlyId: string; + standbyUrl: string; + tenancy?: string; + buildRef?: string; + taggedBuilds: Actor['taggedBuilds']; + standby: NonNullable; +} + +export function isMultiTenant(actor: Pick) { + return actor.tenancy === 'MULTI_TENANT'; +} + +/** Points callers of the input interface to the server interface; dual-mode Actors are legitimate, so it only informs. */ +export function getServerInterfaceHint(actor: Actor, userFriendlyId: string): string | undefined { + if (!actor.actorStandby?.isEnabled) { + return undefined; + } + + return ( + `${userFriendlyId} also has a server interface. ` + + `To send it HTTP requests instead of starting a run, use "apify server request ${userFriendlyId} ".` + ); +} + +export interface ServerOperation { + method: string; + path: string; + summary?: string; +} + +export async function resolveServerActor(client: ApifyClient, target: string | undefined): Promise { + const ctx = await resolveActorContext({ providedActorNameOrId: target, client }); + + if (!ctx.valid) { + throw new Error( + target + ? `${ctx.reason}.` + : 'No Actor specified and the current directory is not an Actor project. Pass an Actor ID or "username/name".', + ); + } + + const actor = (await client.actor(ctx.id).get()) as ActorWithServerFields | undefined; + + if (!actor) { + throw new Error(`Actor "${ctx.userFriendlyId}" was not found.`); + } + + if (!actor.actorStandby?.isEnabled || !actor.standbyUrl) { + throw new Error( + `Actor "${ctx.userFriendlyId}" has no server interface. ` + + `Run it with "apify call ${ctx.userFriendlyId}" instead.`, + ); + } + + return { + id: actor.id, + userFriendlyId: ctx.userFriendlyId, + standbyUrl: actor.standbyUrl, + tenancy: actor.actorStandby.tenancy, + buildRef: actor.actorStandby.build, + taggedBuilds: actor.taggedBuilds, + standby: actor.actorStandby, + }; +} + +/** Shortens an OpenAPI summary to its first line, cut at a word boundary. */ +export function shortenSummary(summary: string, maxLength = 100): string { + const firstLine = summary.split('\n')[0].trim(); + + if (firstLine.length <= maxLength) { + return firstLine; + } + + const cut = firstLine.slice(0, maxLength - 1); + const lastSpace = cut.lastIndexOf(' '); + + return `${(lastSpace > maxLength / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`; +} + +/** + * Resolves the build that serves the server interface. This is `actorStandby.build`, which can differ from the + * default build, so there is deliberately no fallback to it. + */ +export async function resolveServerBuild(client: ApifyClient, actor: ServerActor): Promise { + const ref = actor.buildRef; + + if (!ref) { + throw new Error(`Actor "${actor.userFriendlyId}" has no build set for its server interface.`); + } + + let buildId: string | undefined; + + if (BUILD_NUMBER_REGEX.test(ref)) { + const { items } = await client.actor(actor.id).builds().list({ desc: true, limit: 1000 }); + buildId = (items as { id: string; buildNumber?: string }[]).find((build) => build.buildNumber === ref)?.id; + } else { + buildId = actor.taggedBuilds?.[ref]?.buildId; + } + + const build = buildId ? await client.build(buildId).get() : undefined; + + if (!build) { + throw new Error( + `The server interface of Actor "${actor.userFriendlyId}" uses build "${ref}", which was not found.`, + ); + } + + return build; +} + +export function getServerDefinition(build: Build): { webServerSchema?: unknown; webServerMcpPath?: string } { + const definition = build.actorDefinition as ActorDefinitionWithServerFields | undefined; + return { webServerSchema: definition?.webServerSchema, webServerMcpPath: definition?.webServerMcpPath }; +} + +/** Lists the operations in an OpenAPI document. An empty schema passes the build check, so it yields no operations. */ +export function listServerOperations(schema: unknown): ServerOperation[] { + if (!schema || typeof schema !== 'object') { + return []; + } + + const { paths } = schema as { paths?: unknown }; + + if (!paths || typeof paths !== 'object') { + return []; + } + + const operations: ServerOperation[] = []; + + for (const [path, item] of Object.entries(paths as Record)) { + if (!item || typeof item !== 'object') { + continue; + } + + for (const [method, operation] of Object.entries(item as Record)) { + if (!OPENAPI_METHODS.has(method.toLowerCase())) { + continue; + } + + const summary = (operation as { summary?: unknown } | null)?.summary; + operations.push({ + method: method.toUpperCase(), + path, + summary: typeof summary === 'string' ? summary : undefined, + }); + } + } + + return operations; +} + +/** + * Resolves `path` against the server interface URL. The host always comes from `standbyUrl`, never from user input: + * a leading slash alone is not enough, because `//other-host/x` also starts with one. + */ +export function resolveServerRequestUrl(standbyUrl: string, path: string, queryString = ''): URL { + if (!path.startsWith('/')) { + throw new Error(`The path must start with "/", got "${path}". Pass a path, not a full URL.`); + } + + const base = new URL(standbyUrl); + const url = new URL(path, base); + + if (url.origin !== base.origin || url.username || url.password) { + throw new Error(`The path "${path}" points outside ${base.origin}. Pass a path on the Actor's server.`); + } + + if (queryString) { + for (const [key, value] of new URLSearchParams(queryString)) { + url.searchParams.append(key, value); + } + } + + if (url.searchParams.has('token')) { + throw new Error('Do not put the API token in the URL. The CLI sends it in the Authorization header.'); + } + + return url; +} + +/** + * Splits `[METHOD] [target] [path]`. A first argument starting with "/" is a path, and the target then comes + * from the current directory. + */ +export function parseServerRequestArgs(args: (string | undefined)[]): { + method?: string; + target?: string; + path: string; +} { + const tokens = args.filter((arg): arg is string => arg !== undefined && arg !== ''); + let method: string | undefined; + + if (tokens.length > 0 && HTTP_METHODS.includes(tokens[0].toUpperCase())) { + method = tokens.shift()!.toUpperCase(); + } + + let target: string | undefined; + + if (tokens.length > 0 && !tokens[0].startsWith('/')) { + target = tokens.shift(); + } + + const path = tokens.shift() ?? '/'; + + if (tokens.length > 0) { + throw new Error(`Unexpected argument "${tokens[0]}". Usage: apify server request [METHOD] [target] [path]`); + } + + return { method, target, path }; +} + +/** Maps router errors (`{ error: { type, message } }`) to a hint; Actor errors pass through without one. */ +export function describeServerError(status: number, body: string): string | undefined { + let type: string | undefined; + + try { + type = (JSON.parse(body) as { error?: { type?: unknown } }).error?.type as string | undefined; + } catch { + // Not a router error body; the Actor answered. + } + + switch (type) { + case 'failed-to-choose-actor-run': + return 'The platform could not start or find a run of this Actor within 2 minutes. Check the Actor runs for failures, then retry.'; + case 'too-many-concurrent-requests': + return 'Too many requests to this Actor are waiting for a run. Send fewer requests at once.'; + case 'rate-limit-exceeded': + return 'Your account sent too many requests to Server Actors. Wait, then retry.'; + default: + break; + } + + if (status === 504) { + return 'The Actor did not respond within 5 minutes.'; + } + + return undefined; +} + +/** + * Sends the platform's readiness probe to a locally running Actor until it answers. On the platform, a run that + * never answers the probe never receives requests, and nothing says why. + */ +export async function watchLocalServerReadiness(port: number, signal: AbortSignal) { + // 127.0.0.1 reaches both IPv4-only and dual-stack servers; "localhost" may resolve to ::1 first. + const probeUrl = `http://127.0.0.1:${port}/`; + const startedAt = Date.now(); + let hasWarned = false; + + while (!signal.aborted) { + try { + await fetch(probeUrl, { + headers: { [READINESS_PROBE_HEADER]: '1' }, + signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]), + }); + success({ message: `The Actor server is ready at http://localhost:${port}` }); + return; + } catch { + // Not listening yet. + } + + if (!hasWarned && Date.now() - startedAt > READINESS_PROBE_WARNING_MILLIS) { + hasWarned = true; + warning({ + message: + `No response from http://localhost:${port} after 30 seconds. On the platform, a run receives requests ` + + `only after it answers GET / with the "${READINESS_PROBE_HEADER}" header.`, + }); + } + + try { + await delay(READINESS_PROBE_INTERVAL_MILLIS, undefined, { signal }); + } catch { + return; + } + } +} diff --git a/test/local/lib/server-actor.test.ts b/test/local/lib/server-actor.test.ts new file mode 100644 index 000000000..c830782ed --- /dev/null +++ b/test/local/lib/server-actor.test.ts @@ -0,0 +1,238 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import type { Actor, ApifyClient, Build } from 'apify-client'; + +import type { ServerActor } from '../../../src/lib/server-actor.js'; +import { + describeServerError, + getServerInterfaceHint, + listServerOperations, + parseServerRequestArgs, + resolveServerBuild, + resolveServerRequestUrl, + shortenSummary, + watchLocalServerReadiness, +} from '../../../src/lib/server-actor.js'; + +const STANDBY_URL = 'https://rag-web-browser.apify.actor'; + +function fakeClient({ + builds = [], + buildsById = {}, +}: { + builds?: { id: string; buildNumber: string }[]; + buildsById?: Record>; +}) { + return { + actor: () => ({ builds: () => ({ list: async () => ({ items: builds }) }) }), + build: (id: string) => ({ get: async () => buildsById[id] }), + } as unknown as ApifyClient; +} + +function serverActor(overrides: Partial = {}): ServerActor { + return { + id: 'actorId', + userFriendlyId: 'apify/rag-web-browser', + standbyUrl: STANDBY_URL, + taggedBuilds: {}, + standby: { isEnabled: true }, + ...overrides, + }; +} + +describe('server-actor', () => { + describe('resolveServerRequestUrl()', () => { + it('resolves a path against the server interface URL', () => { + expect(resolveServerRequestUrl(STANDBY_URL, '/search?query=x').href).toBe(`${STANDBY_URL}/search?query=x`); + }); + + it('appends query parameters after a query already in the path', () => { + expect(resolveServerRequestUrl(STANDBY_URL, '/search?a=1', 'b=2').href).toBe(`${STANDBY_URL}/search?a=1&b=2`); + }); + + it('rejects a path without a leading slash', () => { + expect(() => resolveServerRequestUrl(STANDBY_URL, 'https://evil.example/x')).toThrow('must start with "/"'); + }); + + it('rejects a protocol-relative path that changes the host', () => { + expect(() => resolveServerRequestUrl(STANDBY_URL, '//evil.example/x')).toThrow('points outside'); + }); + + it('rejects a backslash path that some parsers treat as protocol-relative', () => { + expect(() => resolveServerRequestUrl(STANDBY_URL, '/\\evil.example/x')).toThrow('points outside'); + }); + + it('rejects the token in the query string', () => { + expect(() => resolveServerRequestUrl(STANDBY_URL, '/search?token=abc')).toThrow('Authorization header'); + expect(() => resolveServerRequestUrl(STANDBY_URL, '/search', 'token=abc')).toThrow('Authorization header'); + }); + }); + + describe('parseServerRequestArgs()', () => { + it('reads method, target and path', () => { + expect(parseServerRequestArgs(['post', 'apify/web-fetch', '/x'])).toEqual({ + method: 'POST', + target: 'apify/web-fetch', + path: '/x', + }); + }); + + it('leaves the method unset when omitted', () => { + expect(parseServerRequestArgs(['apify/web-fetch', '/x', undefined])).toEqual({ + method: undefined, + target: 'apify/web-fetch', + path: '/x', + }); + }); + + it('treats a leading path as a request to the Actor in the current directory', () => { + expect(parseServerRequestArgs(['/health', undefined, undefined])).toEqual({ + method: undefined, + target: undefined, + path: '/health', + }); + }); + + it('defaults the path to "/"', () => { + expect(parseServerRequestArgs(['GET', 'apify/web-fetch', undefined]).path).toBe('/'); + }); + + it('rejects extra arguments', () => { + expect(() => parseServerRequestArgs(['apify/web-fetch', '/x', '/y'])).toThrow('Unexpected argument "/y"'); + }); + }); + + describe('listServerOperations()', () => { + it('lists operations with their summaries and skips non-method keys', () => { + const schema = { + paths: { + '/search': { + get: { summary: 'Search' }, + parameters: [], + }, + '/': { post: {} }, + }, + }; + + expect(listServerOperations(schema)).toEqual([ + { method: 'GET', path: '/search', summary: 'Search' }, + { method: 'POST', path: '/', summary: undefined }, + ]); + }); + + it('returns no operations for an empty or missing schema', () => { + expect(listServerOperations({})).toEqual([]); + expect(listServerOperations(undefined)).toEqual([]); + }); + }); + + describe('resolveServerBuild()', () => { + it('resolves a build tag through taggedBuilds', async () => { + const client = fakeClient({ buildsById: { tagged: { id: 'tagged' } } }); + const actor = serverActor({ + buildRef: 'version-1', + taggedBuilds: { 'version-1': { buildId: 'tagged' } as never }, + }); + + expect((await resolveServerBuild(client, actor)).id).toBe('tagged'); + }); + + it('resolves a full build number through the build list', async () => { + const client = fakeClient({ + builds: [ + { id: 'newer', buildNumber: '1.0.34' }, + { id: 'pinned', buildNumber: '1.0.33' }, + ], + buildsById: { pinned: { id: 'pinned' } }, + }); + + expect((await resolveServerBuild(client, serverActor({ buildRef: '1.0.33' }))).id).toBe('pinned'); + }); + + it('does not fall back to another build when the reference is unknown', async () => { + const client = fakeClient({ buildsById: { latest: { id: 'latest' } } }); + const actor = serverActor({ buildRef: 'beta', taggedBuilds: { latest: { buildId: 'latest' } as never } }); + + await expect(resolveServerBuild(client, actor)).rejects.toThrow('uses build "beta", which was not found'); + }); + }); + + describe('describeServerError()', () => { + const routerError = (type: string) => JSON.stringify({ error: { type, message: 'x' } }); + + it('separates a failed cold start from rate limiting', () => { + expect(describeServerError(429, routerError('failed-to-choose-actor-run'))).toContain('within 2 minutes'); + expect(describeServerError(429, routerError('too-many-concurrent-requests'))).toContain('fewer requests'); + expect(describeServerError(429, routerError('rate-limit-exceeded'))).toContain('Your account'); + }); + + it('explains a router timeout', () => { + expect(describeServerError(504, routerError('proxy-timeout'))).toContain('5 minutes'); + }); + + it('adds no hint for an error the Actor returned', () => { + expect(describeServerError(400, 'Missing query parameter')).toBeUndefined(); + expect(describeServerError(429, '{"message":"slow down"}')).toBeUndefined(); + }); + }); + describe('shortenSummary()', () => { + it('keeps a short summary unchanged', () => { + expect(shortenSummary('Fetch a URL')).toBe('Fetch a URL'); + }); + + it('keeps only the first line', () => { + expect(shortenSummary('Search\nLonger explanation')).toBe('Search'); + }); + + it('cuts a long summary at a word boundary', () => { + const summary = `${'word '.repeat(30)}end`; + const short = shortenSummary(summary, 40); + + expect(short.length).toBeLessThanOrEqual(40); + expect(short).toMatch(/word…$/); + }); + }); + + describe('getServerInterfaceHint()', () => { + it('points to apify server request for an Actor with a server interface', () => { + const actor = { actorStandby: { isEnabled: true } } as Actor; + + expect(getServerInterfaceHint(actor, 'apify/web-fetch')).toContain('apify server request apify/web-fetch'); + }); + + it('gives no hint for an Actor without one', () => { + expect(getServerInterfaceHint({} as Actor, 'apify/hello-world')).toBeUndefined(); + }); + }); + + describe('watchLocalServerReadiness()', () => { + it('sends the readiness probe header and stops once the server answers', async () => { + const probeHeaders: (string | string[] | undefined)[] = []; + const server = createServer((req, res) => { + probeHeaders.push(req.headers['x-apify-container-server-readiness-probe']); + res.end('ok'); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + try { + const { port } = server.address() as AddressInfo; + await watchLocalServerReadiness(port, new AbortController().signal); + + expect(probeHeaders).toEqual(['1']); + } finally { + server.close(); + } + }); + + it('stops when aborted before the server answers', async () => { + const controller = new AbortController(); + const watching = watchLocalServerReadiness(1, controller.signal); + controller.abort(); + + await expect(watching).resolves.toBeUndefined(); + }); + }); +});