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
2 changes: 2 additions & 0 deletions src/commands/_register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -49,6 +50,7 @@ export const apifyCommands = [
RequestQueuesIndexCommand,
RunsIndexCommand,
SecretsIndexCommand,
ServerIndexCommand,
TasksIndexCommand,
TelemetryIndexCommand,
TemplatesIndexCommand,
Expand Down
9 changes: 8 additions & 1 deletion src/commands/actors/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ActorsCallCommand> {
Expand Down Expand Up @@ -129,6 +130,12 @@ export class ActorsCallCommand extends ApifyCommand<typeof ActorsCallCommand> {
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
};
Expand Down
18 changes: 16 additions & 2 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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;
Expand Down Expand Up @@ -621,10 +621,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) {
Expand All @@ -651,6 +659,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') });
Expand Down
9 changes: 8 additions & 1 deletion src/commands/actors/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -88,6 +89,12 @@ export class ActorsStartCommand extends ApifyCommand<typeof ActorsStartCommand>
providedActorNameOrId: this.args.actorId,
});

const serverInterfaceHint = getServerInterfaceHint(actorData, userFriendlyId);

if (serverInterfaceHint) {
info({ message: serverInterfaceHint });
}

const runOpts: ActorStartOptions = {};

if (this.flags.build) {
Expand Down
107 changes: 10 additions & 97 deletions src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, unknown>)) {
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<string, string> {
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<string, string> = {};

for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
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, (text: string) => string> = {
GET: chalk.green,
POST: chalk.yellow,
Expand Down Expand Up @@ -325,25 +247,16 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
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<string, string> = {
const defaultHeaders: Record<string, string> = {
...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, {
Expand Down
44 changes: 43 additions & 1 deletion src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -184,11 +185,26 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
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.
Expand All @@ -215,6 +231,14 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {

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();

Expand Down Expand Up @@ -385,6 +409,16 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
[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;
Expand All @@ -406,6 +440,12 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
});
}

const readinessProbe = new AbortController();

if (this.flags.standby) {
void watchLocalServerReadiness(serverPort, readinessProbe.signal);
}

try {
switch (type) {
case ProjectLanguage.JavaScript: {
Expand Down Expand Up @@ -511,6 +551,8 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
// 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
Expand Down
27 changes: 27 additions & 0 deletions src/commands/server/_index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ServerIndexCommand> {
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();
}
}
Loading
Loading