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
54 changes: 54 additions & 0 deletions sdk/openapi/builds.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
paths:
logs:
get:
operationId: streamBuildLogs
description: |
The output of one build as server-sent events, for a member who may observe the
build's project. Same contract as a service's logs.

Each line is a `log` event: its data is `{ at, level, message, instance }` and its id
is the cursor to resume from. Without `follow` the read is a tail: the newest lines,
oldest first, then an `end` event. A read from the start opens with one line naming
how the build runs (`Build recipe: ...`), which has no cursor and does not count
towards `limit`. An `error` event carrying a `code` ends a stream the platform could
not serve.
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: buildId
required: true
schema: { type: string }
- in: query
name: follow
description: Keeps the stream open and delivers the lines the build writes next.
schema: { type: boolean }
- in: query
name: limit
description: The most lines the read returns before it ends. A tail holds 500 at most.
schema: { type: integer, minimum: 1 }
- in: header
name: Last-Event-ID
description: The id of the last event received. The read resumes after it, oldest first.
schema: { type: string }
responses:
'200':
description: Server-sent log events
content:
text/event-stream:
schema: { type: string }
'400':
description: A parameter the API cannot read, such as a `limit` that is not a positive integer.
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' }
'404':
description: '`cloud.errors.builds.not_found`: no such build, a build of another workspace or of a project the member may not observe, or one whose service was removed.'
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' }
'429':
description: The member or the workspace already holds every live follow it may open. `Retry-After` says how long to wait. A read without `follow` is never refused this way.
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' }
default: { $ref: './cloud.openapi.yaml#/components/responses/Error' }
1 change: 1 addition & 0 deletions sdk/openapi/cloud.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ paths:
text/event-stream:
schema: { type: string }
default: { $ref: '#/components/responses/Error' }
/api/cloud/builds/{buildId}/logs: { $ref: 'builds.yaml#/paths/logs' }
/api/cloud/deployments/{deploymentId}:
get:
operationId: readDeployment
Expand Down
39 changes: 24 additions & 15 deletions sdk/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ProjectDeploymentsApi } from "./deployments.js";
import { EnvironmentsApi, ProjectRepositoryApi } from "./environments.js";
import type { components, paths } from "./generated/cloud-api.js";
import { isRecord, isString } from "./guards.js";
import { type BuildLogRequest, type LogRequest, logReadOf } from "./logs.js";
import { acceptedOf, dataOf, isApiErrorBody, refusal } from "./result.js";
import { parseSse, type SseEvent } from "./sse.js";

Expand Down Expand Up @@ -60,14 +61,6 @@ export interface PublishedValue {
publishedKey: string;
}

/** How a log read is shaped: one bounded page, or a tail from a cursor. */
export interface LogRequest {
follow: boolean;
lastEventId?: string;
/** Called once the API accepted the read, before its first frame. */
onOpen?: () => void;
}

const SOURCE_MIME_TYPE = "application/x-tar";
const SOURCE_FILENAME = "source.tar";

Expand Down Expand Up @@ -383,14 +376,30 @@ export class CloudApi {
}

logs(serviceId: string, request: LogRequest): AsyncGenerator<SseEvent> {
const accept = { Accept: "text/event-stream" };
const headers = request.lastEventId
? { ...accept, "Last-Event-ID": request.lastEventId }
: accept;
const query = request.follow ? "?follow=true" : "";
const { follow, lastEventId } = request;
const read = logReadOf({ follow, lastEventId });
return this.stream(
`/api/cloud/services/${encodeURIComponent(serviceId)}/logs${query}`,
headers,
`/api/cloud/services/${encodeURIComponent(serviceId)}/logs${read.query}`,
read.headers,
undefined,
request.onOpen,
);
}

/**
* The output of one build, as the `log` events of its stream, then `end`.
* Without `follow` it is the newest lines, oldest first. A build that is
* unknown, of another workspace, or whose service was removed answers 404
* `cloud.errors.builds.not_found`.
*/
buildLogs(
buildId: string,
request: BuildLogRequest,
): AsyncGenerator<SseEvent> {
const read = logReadOf(request);
return this.stream(
`/api/cloud/builds/${encodeURIComponent(buildId)}/logs${read.query}`,
read.headers,
undefined,
request.onOpen,
);
Expand Down
85 changes: 85 additions & 0 deletions sdk/src/generated/cloud-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,33 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cloud/builds/{buildId}/logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* @description The output of one build as server-sent events, for a member who may observe the
* build's project. Same contract as a service's logs.
*
* Each line is a `log` event: its data is `{ at, level, message, instance }` and its id
* is the cursor to resume from. Without `follow` the read is a tail: the newest lines,
* oldest first, then an `end` event. A read from the start opens with one line naming
* how the build runs (`Build recipe: ...`), which has no cursor and does not count
* towards `limit`. An `error` event carrying a `code` ends a stream the platform could
* not serve.
*/
get: operations["streamBuildLogs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cloud/deployments/{deploymentId}": {
parameters: {
query?: never;
Expand Down Expand Up @@ -1932,6 +1959,64 @@ export interface operations {
default: components["responses"]["Error"];
};
};
streamBuildLogs: {
parameters: {
query?: {
/** @description Keeps the stream open and delivers the lines the build writes next. */
follow?: boolean;
/** @description The most lines the read returns before it ends. A tail holds 500 at most. */
limit?: number;
};
header?: {
/** @description The id of the last event received. The read resumes after it, oldest first. */
"Last-Event-ID"?: string;
};
path: {
buildId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server-sent log events */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": string;
};
};
/** @description A parameter the API cannot read, such as a `limit` that is not a positive integer. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description `cloud.errors.builds.not_found`: no such build, a build of another workspace or of a project the member may not observe, or one whose service was removed. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The member or the workspace already holds every live follow it may open. `Retry-After` says how long to wait. A read without `follow` is never refused this way. */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
default: components["responses"]["Error"];
};
};
readDeployment: {
parameters: {
query?: never;
Expand Down
1 change: 1 addition & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from "./api.js";
export * from "./deployments.js";
export * from "./environments.js";
export type { components, operations, paths } from "./generated/cloud-api.js";
export type { BuildLogRequest, LogRequest } from "./logs.js";
export type * from "./resources.js";
export * from "./sse.js";
39 changes: 39 additions & 0 deletions sdk/src/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** How a log read is shaped: one bounded page, or a tail from a cursor. */
export interface LogRequest {
follow: boolean;
lastEventId?: string;
/** Called once the API accepted the read, before its first frame. */
onOpen?: () => void;
}

/** How one build's log is read. */
export interface BuildLogRequest extends LogRequest {
/**
* The most lines the read returns before it ends. Without `follow` and
* without `lastEventId` the read is a tail, the newest lines oldest first,
* and the API returns 500 at most.
*/
limit?: number;
}

/** A log read as it goes on the wire. */
interface LogRead {
/** The query string, with its leading `?`, or empty. */
query: string;
headers: Record<string, string>;
}

/** The query string and headers a log read is sent with. */
export function logReadOf(request: BuildLogRequest): LogRead {
const query = new URLSearchParams();
if (request.follow) query.set("follow", "true");
if (request.limit !== undefined) query.set("limit", String(request.limit));
const text = query.toString();
const accept = { Accept: "text/event-stream" };
return {
query: text === "" ? "" : `?${text}`,
headers: request.lastEventId
? { ...accept, "Last-Event-ID": request.lastEventId }
: accept,
};
}
Loading
Loading