@heroku/sdk 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/core/create-client.d.ts +9 -1
  2. package/dist/core/create-client.js +4 -2
  3. package/dist/resources/data/redis/errors.d.ts +8 -0
  4. package/dist/resources/data/redis/errors.js +15 -0
  5. package/dist/resources/data/redis/index.d.ts +9 -0
  6. package/dist/resources/data/redis/index.js +10 -0
  7. package/dist/resources/data/redis/resolve-by-app.d.ts +35 -0
  8. package/dist/resources/data/redis/resolve-by-app.js +56 -0
  9. package/dist/resources/data/redis/wait-for-ready.d.ts +32 -0
  10. package/dist/resources/data/redis/wait-for-ready.js +46 -0
  11. package/dist/resources/extensions/data.d.ts +1 -0
  12. package/dist/resources/extensions/data.js +1 -0
  13. package/dist/resources/extensions/platform.d.ts +5 -1
  14. package/dist/resources/extensions/platform.js +5 -1
  15. package/dist/resources/platform/add-on/destroy-and-wait.d.ts +22 -0
  16. package/dist/resources/platform/add-on/destroy-and-wait.js +62 -0
  17. package/dist/resources/platform/add-on/index.d.ts +9 -5
  18. package/dist/resources/platform/add-on/index.js +6 -0
  19. package/dist/resources/platform/add-on/types.d.ts +35 -0
  20. package/dist/resources/platform/add-on/wait-for-provisioning.d.ts +19 -0
  21. package/dist/resources/platform/add-on/wait-for-provisioning.js +37 -0
  22. package/dist/resources/platform/app/index.d.ts +3 -0
  23. package/dist/resources/platform/app/index.js +3 -0
  24. package/dist/resources/platform/app/wait-for-acm-certificates.d.ts +34 -0
  25. package/dist/resources/platform/app/wait-for-acm-certificates.js +51 -0
  26. package/dist/resources/platform/container/ensure-container-stack.d.ts +18 -0
  27. package/dist/resources/platform/container/ensure-container-stack.js +32 -0
  28. package/dist/resources/platform/container/index.d.ts +13 -0
  29. package/dist/resources/platform/container/index.js +12 -0
  30. package/dist/resources/platform/container/release-images.d.ts +21 -0
  31. package/dist/resources/platform/container/release-images.js +23 -0
  32. package/dist/resources/platform/container/remove-process-types.d.ts +22 -0
  33. package/dist/resources/platform/container/remove-process-types.js +20 -0
  34. package/dist/resources/platform/dyno/index.d.ts +3 -0
  35. package/dist/resources/platform/dyno/index.js +3 -0
  36. package/dist/resources/platform/dyno/run.d.ts +76 -0
  37. package/dist/resources/platform/dyno/run.js +74 -0
  38. package/dist/resources/platform/log-session/index.js +109 -12
  39. package/dist/resources/platform/sni-endpoint/create-and-associate.d.ts +38 -0
  40. package/dist/resources/platform/sni-endpoint/create-and-associate.js +86 -0
  41. package/dist/resources/platform/sni-endpoint/index.d.ts +5 -0
  42. package/dist/resources/platform/sni-endpoint/index.js +6 -0
  43. package/dist/resources/platform/telemetry-drain/index.d.ts +6 -0
  44. package/dist/resources/platform/telemetry-drain/index.js +6 -0
  45. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.d.ts +13 -0
  46. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +25 -0
  47. package/dist/resources/platform/telemetry-drain/types.d.ts +13 -0
  48. package/dist/resources/platform/telemetry-drain/types.js +1 -0
  49. package/dist/resources/platform/test-run/index.d.ts +5 -0
  50. package/dist/resources/platform/test-run/index.js +9 -0
  51. package/dist/resources/platform/test-run/wait-for-state.d.ts +68 -0
  52. package/dist/resources/platform/test-run/wait-for-state.js +65 -0
  53. package/package.json +11 -3
@@ -0,0 +1,51 @@
1
+ import { wait } from '../../../utils/wait.js';
2
+ const DEFAULT_WAIT_INTERVAL_MS = 15_000;
3
+ /**
4
+ * Waits for ACM (Automated Certificate Management) certificates to be
5
+ * issued on all custom domains of an app.
6
+ *
7
+ * Polls the app's domains until every `custom` domain reaches a
8
+ * terminal `acm_status` of `'cert issued'` or `'failed'`, then returns
9
+ * the custom domains. `heroku` domains are ignored and never block
10
+ * termination.
11
+ *
12
+ * @throws if any custom domain finishes with `acm_status: 'failed'`
13
+ * (`'ACM not enabled for some domains'`), or if `timeoutMs` elapses
14
+ * before all custom domains reach a terminal status.
15
+ */
16
+ export async function waitForACMCertificates(ctx, appIdentity, options = {}) {
17
+ const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, signal, timeoutMs } = options;
18
+ signal?.throwIfAborted();
19
+ const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
20
+ function terminal(domains) {
21
+ return domains
22
+ .filter(domain => domain.kind === 'custom')
23
+ .every(domain => domain.acm_status === 'cert issued' || domain.acm_status === 'failed');
24
+ }
25
+ function someFailed(domains) {
26
+ return domains
27
+ .filter(domain => domain.kind === 'custom')
28
+ .some(domain => domain.acm_status === 'failed');
29
+ }
30
+ function backoff(attempts) {
31
+ const multiplier = attempts < 60 ? Math.floor(attempts / 20) : 3;
32
+ return intervalMs * (1 + multiplier);
33
+ }
34
+ let domains = await platform.domain.list(appIdentity);
35
+ if (!terminal(domains)) {
36
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
37
+ let retries = 0;
38
+ while (!terminal(domains)) {
39
+ await wait(backoff(retries), signal);
40
+ if (deadline !== undefined && Date.now() >= deadline) {
41
+ throw new Error(`Timed out waiting for ACM certificates on app ${appIdentity}`);
42
+ }
43
+ domains = await platform.domain.list(appIdentity);
44
+ retries++;
45
+ }
46
+ }
47
+ if (someFailed(domains)) {
48
+ throw new Error('ACM not enabled for some domains');
49
+ }
50
+ return domains.filter(domain => domain.kind === 'custom');
51
+ }
@@ -0,0 +1,18 @@
1
+ import type { App } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { ContainerOptions } from './index.js';
4
+ export declare class NotAContainerAppError extends Error {
5
+ readonly app: Pick<App, 'build_stack' | 'id' | 'name' | 'stack'>;
6
+ readonly id = "not_a_container_app";
7
+ constructor(app: Pick<App, 'build_stack' | 'id' | 'name' | 'stack'>);
8
+ get body(): {
9
+ app: Pick<App, "stack" | "id" | "name" | "build_stack">;
10
+ id: string;
11
+ message: string;
12
+ };
13
+ }
14
+ /**
15
+ * Ensure that the given app is a container app.
16
+ * @throws {NotAContainerAppError} if the app is not a container app
17
+ */
18
+ export declare function ensureContainerStack(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: ContainerOptions): Promise<void>;
@@ -0,0 +1,32 @@
1
+ export class NotAContainerAppError extends Error {
2
+ app;
3
+ id = 'not_a_container_app';
4
+ constructor(app) {
5
+ super('This operation is for Docker apps only.');
6
+ this.app = app;
7
+ this.name = 'NotAContainerAppError';
8
+ }
9
+ get body() {
10
+ return { app: this.app, id: this.id, message: this.message };
11
+ }
12
+ }
13
+ /**
14
+ * Ensure that the given app is a container app.
15
+ * @throws {NotAContainerAppError} if the app is not a container app
16
+ */
17
+ export async function ensureContainerStack(ctx, appIdentity, options = {}) {
18
+ const { signal } = options;
19
+ signal?.throwIfAborted();
20
+ const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
21
+ const app = await platform.app.info(appIdentity);
22
+ const allowedStack = 'container';
23
+ if (app.build_stack.name !== allowedStack && app.stack.name !== allowedStack) {
24
+ throw new NotAContainerAppError({
25
+ // eslint-disable-next-line camelcase
26
+ build_stack: app.build_stack,
27
+ id: app.id,
28
+ name: app.name,
29
+ stack: app.stack,
30
+ });
31
+ }
32
+ }
@@ -0,0 +1,13 @@
1
+ import { type DockerReleasesBatchUpdateOpts } from './release-images.js';
2
+ import { type RemoveProcessTypesOpts } from './remove-process-types.js';
3
+ export { ensureContainerStack, NotAContainerAppError } from './ensure-container-stack.js';
4
+ export { type DockerReleasesBatchUpdateOpts, releaseDockerImages, type ReleaseDockerImagesResult } from './release-images.js';
5
+ export { removeProcessTypes, type RemoveProcessTypesOpts } from './remove-process-types.js';
6
+ export type ContainerOptions = {
7
+ signal?: AbortSignal;
8
+ };
9
+ export declare const containerExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "container", {
10
+ ensureContainerStack: (appIdentity: string, options?: ContainerOptions) => Promise<void>;
11
+ releaseImages: (appIdentity: string, imagesByProcessType: DockerReleasesBatchUpdateOpts[], options?: ContainerOptions) => Promise<import("./release-images.js").ReleaseDockerImagesResult>;
12
+ removeProcessTypes: (appIdentity: string, processTypes: string[], options?: RemoveProcessTypesOpts) => Promise<import("@heroku/types/3.sdk").Formation[]>;
13
+ }>;
@@ -0,0 +1,12 @@
1
+ import { extendResource } from '../../../core/extend-resource.js';
2
+ import { ensureContainerStack } from './ensure-container-stack.js';
3
+ import { releaseDockerImages } from './release-images.js';
4
+ import { removeProcessTypes } from './remove-process-types.js';
5
+ export { ensureContainerStack, NotAContainerAppError } from './ensure-container-stack.js';
6
+ export { releaseDockerImages } from './release-images.js';
7
+ export { removeProcessTypes } from './remove-process-types.js';
8
+ export const containerExtensions = extendResource('platform', 'container', ctx => ({
9
+ ensureContainerStack: (appIdentity, options) => ensureContainerStack(ctx, appIdentity, options),
10
+ releaseImages: (appIdentity, imagesByProcessType, options) => releaseDockerImages(ctx, appIdentity, imagesByProcessType, options),
11
+ removeProcessTypes: (appIdentity, processTypes, options) => removeProcessTypes(ctx, appIdentity, processTypes, options),
12
+ }));
@@ -0,0 +1,21 @@
1
+ import type { Release } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { ContainerOptions } from './index.js';
4
+ /**
5
+ * An update item for the `3.docker-releases` API variant.
6
+ *
7
+ * The `docker_image` field is only available with
8
+ * `Accept: application/vnd.heroku+json; version=3.docker-releases`
9
+ */
10
+ export type DockerReleasesBatchUpdateOpts = {
11
+ docker_image: string;
12
+ type: string;
13
+ };
14
+ export type ReleaseDockerImagesResult = {
15
+ newRelease: Release | undefined;
16
+ oldRelease: Release | undefined;
17
+ };
18
+ /**
19
+ * Release Docker images to a container app.
20
+ */
21
+ export declare function releaseDockerImages(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, imagesByProcessType: DockerReleasesBatchUpdateOpts[], options?: ContainerOptions): Promise<ReleaseDockerImagesResult>;
@@ -0,0 +1,23 @@
1
+ import { ensureContainerStack } from './ensure-container-stack.js';
2
+ /**
3
+ * Release Docker images to a container app.
4
+ */
5
+ export async function releaseDockerImages(ctx, appIdentity, imagesByProcessType, options = {}) {
6
+ await ensureContainerStack(ctx, appIdentity, options);
7
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
8
+ const oldReleases = await platform
9
+ .withHeaders({ Range: 'version ..; max=1, order=desc' })
10
+ .release.list(appIdentity);
11
+ const oldRelease = oldReleases[0];
12
+ await platform
13
+ .withHeaders({ Accept: 'application/vnd.heroku+json; version=3.docker-releases' })
14
+ .formation.batchUpdate(appIdentity, { updates: imagesByProcessType });
15
+ const newReleases = await platform
16
+ .withHeaders({ Range: 'version ..; max=1, order=desc' })
17
+ .release.list(appIdentity);
18
+ const newRelease = newReleases[0];
19
+ return {
20
+ newRelease,
21
+ oldRelease,
22
+ };
23
+ }
@@ -0,0 +1,22 @@
1
+ import type { Formation } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { ContainerOptions } from './index.js';
4
+ export type RemoveProcessTypesOpts = {
5
+ /**
6
+ * Fires `onStart` immediately before, and `onStop` immediately after,
7
+ * each process type's `formation.update` call. Process types are
8
+ * removed one at a time (not concurrently) so a caller can drive a
9
+ * single shared UI element (e.g. a CLI spinner) across the whole
10
+ * batch. If an update rejects, its `onStart` has already fired but
11
+ * `onStop` never will — the rejection propagates immediately and any
12
+ * remaining process types are not attempted.
13
+ */
14
+ onProgress?: {
15
+ onStart?: (processType: string) => void;
16
+ onStop?: (processType: string) => void;
17
+ };
18
+ } & ContainerOptions;
19
+ /**
20
+ * Remove one or more process types from a container app
21
+ */
22
+ export declare function removeProcessTypes(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, processTypes: string[], options?: RemoveProcessTypesOpts): Promise<Formation[]>;
@@ -0,0 +1,20 @@
1
+ import { ensureContainerStack } from './ensure-container-stack.js';
2
+ /**
3
+ * Remove one or more process types from a container app
4
+ */
5
+ export async function removeProcessTypes(ctx, appIdentity, processTypes, options = {}) {
6
+ await ensureContainerStack(ctx, appIdentity, options);
7
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
8
+ // eslint-disable-next-line camelcase
9
+ const requestBody = { docker_image: null };
10
+ const updatedFormations = [];
11
+ for (const processType of processTypes) {
12
+ options.onProgress?.onStart?.(processType);
13
+ const formation = await platform
14
+ .withHeaders({ Accept: 'application/vnd.heroku+json; version=3.docker-releases' })
15
+ .formation.update(appIdentity, processType, requestBody);
16
+ options.onProgress?.onStop?.(processType);
17
+ updatedFormations.push(formation);
18
+ }
19
+ return updatedFormations;
20
+ }
@@ -1,7 +1,9 @@
1
1
  import type { Formation } from '@heroku/types/3.sdk';
2
2
  import type { StickyRouteOptions } from '../../../core/create-client.js';
3
3
  import type { ResourceCtx } from '../../../core/extend-resource.js';
4
+ import { type RunDynoOptions } from './run.js';
4
5
  import { type WaitForInfoOptions } from './wait-for-info.js';
6
+ export { runDyno, type RunDynoOptions } from './run.js';
5
7
  export { DynoNotReadyError, type DynoState, waitForInfo, type WaitForInfoOptions, } from './wait-for-info.js';
6
8
  export type DynoOptions = {
7
9
  signal?: AbortSignal;
@@ -39,6 +41,7 @@ export declare function scaleDynos(ctx: {
39
41
  export declare function restartDynos(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions): Promise<void>;
40
42
  export declare const dynoExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "dyno", {
41
43
  restart: (appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions) => Promise<void>;
44
+ run: (appIdentity: string, command: string, options?: RunDynoOptions) => Promise<import("@heroku/types/3.sdk").Dyno>;
42
45
  scale: {
43
46
  (appIdentity: string, updates: ScaleDynosUpdate, options?: DynoOptions): Promise<Formation>;
44
47
  (appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
@@ -1,5 +1,7 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { runDyno } from './run.js';
2
3
  import { waitForInfo } from './wait-for-info.js';
4
+ export { runDyno } from './run.js';
3
5
  export { DynoNotReadyError, waitForInfo, } from './wait-for-info.js';
4
6
  export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
5
7
  options.signal?.throwIfAborted();
@@ -24,6 +26,7 @@ export async function restartDynos(ctx, appIdentity, target, options = {}) {
24
26
  }
25
27
  export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
26
28
  restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
29
+ run: (appIdentity, command, options) => (runDyno(ctx, appIdentity, command, options)),
27
30
  scale: ((appIdentity, updates, options) => scaleDynos(ctx, appIdentity, updates, options)),
28
31
  waitForInfo: (appIdentity, dynoIdentity, options) => (waitForInfo(ctx, appIdentity, dynoIdentity, options)),
29
32
  }));
@@ -0,0 +1,76 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
+ import type { Dyno } from '@heroku/types/3.sdk';
3
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
4
+ export type RunDynoOptions = {
5
+ /**
6
+ * When `true`, the platform returns a dyno with a non-null
7
+ * `attach_url` the caller can use to open its own interactive
8
+ * session (STDIN/STDOUT rendezvous). The SDK itself does not open
9
+ * the connection.
10
+ */
11
+ attach?: boolean;
12
+ /**
13
+ * Options forwarded to the raw `HerokuApiClient` created for the
14
+ * exec-inside path (`dyno` is set). The route registry has no entry
15
+ * for `POST /apps/{app}/dynos/{dyno}`, so that call is issued via a
16
+ * bare client. Ignored when `dyno` is omitted — the standard
17
+ * create-one-off path uses the SDK's own routes client.
18
+ */
19
+ clientOptions?: HerokuApiClientOptions;
20
+ /**
21
+ * If set, exec `command` inside the named dyno via
22
+ * `POST /apps/{app}/dynos/{dyno}` with the `version=3.run-inside`
23
+ * Accept variant. Omit to create a new one-off dyno via
24
+ * `POST /apps/{app}/dynos`.
25
+ */
26
+ dyno?: string;
27
+ /** Additional environment variables layered onto the dyno. */
28
+ env?: Record<string, string>;
29
+ /**
30
+ * When `true`, `command` is appended with `; echo "￿
31
+ * heroku-command-exit-status: $?"` to surface the process exit
32
+ * code over the attach stream. Consumers may parse this line to
33
+ * recover the exit code.
34
+ *
35
+ * Because the wrap uses a bare `;`, `command` must be a single
36
+ * shell statement that terminates cleanly with `;`. Trailing `&`
37
+ * (backgrounding), line continuations (`\`), unclosed quotes, or
38
+ * unterminated heredocs will break the wrap and produce a shell
39
+ * syntax error or garbled output. Callers who need those
40
+ * constructs should wrap them in a subshell or `{ ...; }` group
41
+ * themselves before passing `command`.
42
+ */
43
+ exitCode?: boolean;
44
+ /**
45
+ * Serialized to the platform as `force_no_tty`. Forces an attached
46
+ * one-off dyno to not run in a TTY even when the caller's
47
+ * environment looks like one.
48
+ */
49
+ forceNoTTY?: boolean;
50
+ signal?: AbortSignal;
51
+ size?: string;
52
+ type?: string;
53
+ };
54
+ /**
55
+ * Create a one-off dyno or exec a command inside an existing one.
56
+ *
57
+ * Route selection:
58
+ * - `POST /apps/{app}/dynos` (default) creates a new one-off dyno.
59
+ * Uses the SDK's routes client, which sends the platform
60
+ * service's default `Accept: version=3` header.
61
+ * - `POST /apps/{app}/dynos/{dyno}` (when `options.dyno` is set)
62
+ * execs the command inside the named dyno. Uses a raw
63
+ * `HerokuApiClient` with `Accept: version=3.sdk` because the
64
+ * routes registry has no entry for this endpoint.
65
+ *
66
+ * Retries: the runtime API occasionally returns 409 while a newly
67
+ * pushed app's release is still propagating. This is transient and
68
+ * short-lived, so 409 is retried up to two times back-to-back with no
69
+ * delay. Any other error propagates. Matches the retry semantics of
70
+ * the historical CLI implementation.
71
+ *
72
+ * The returned `Dyno` includes `attach_url` for callers who want to
73
+ * open their own interactive session; the SDK does not establish the
74
+ * session, spawn ssh, or manage TTY state.
75
+ */
76
+ export declare function runDyno(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, command: string, options?: RunDynoOptions): Promise<Dyno>;
@@ -0,0 +1,74 @@
1
+ import { HerokuApiClient, HerokuApiError } from '@heroku/heroku-fetch';
2
+ const DEFAULT_RUN_RETRIES = 2;
3
+ const ACCEPT_RUN_INSIDE = 'application/vnd.heroku+json; version=3.sdk';
4
+ /**
5
+ * Create a one-off dyno or exec a command inside an existing one.
6
+ *
7
+ * Route selection:
8
+ * - `POST /apps/{app}/dynos` (default) creates a new one-off dyno.
9
+ * Uses the SDK's routes client, which sends the platform
10
+ * service's default `Accept: version=3` header.
11
+ * - `POST /apps/{app}/dynos/{dyno}` (when `options.dyno` is set)
12
+ * execs the command inside the named dyno. Uses a raw
13
+ * `HerokuApiClient` with `Accept: version=3.sdk` because the
14
+ * routes registry has no entry for this endpoint.
15
+ *
16
+ * Retries: the runtime API occasionally returns 409 while a newly
17
+ * pushed app's release is still propagating. This is transient and
18
+ * short-lived, so 409 is retried up to two times back-to-back with no
19
+ * delay. Any other error propagates. Matches the retry semantics of
20
+ * the historical CLI implementation.
21
+ *
22
+ * The returned `Dyno` includes `attach_url` for callers who want to
23
+ * open their own interactive session; the SDK does not establish the
24
+ * session, spawn ssh, or manage TTY state.
25
+ */
26
+ export async function runDyno(ctx, appIdentity, command, options = {}) {
27
+ options.signal?.throwIfAborted();
28
+ const body = {
29
+ command: options.exitCode
30
+ ? `${command}; echo "￿ heroku-command-exit-status: $?"`
31
+ : command,
32
+ };
33
+ if (options.attach !== undefined)
34
+ body.attach = options.attach;
35
+ if (options.env !== undefined)
36
+ body.env = options.env;
37
+ if (options.forceNoTTY !== undefined)
38
+ body.force_no_tty = options.forceNoTTY;
39
+ if (options.size !== undefined)
40
+ body.size = options.size;
41
+ if (options.type !== undefined)
42
+ body.type = options.type;
43
+ /* eslint-disable no-await-in-loop -- retry is inherently sequential on 409 */
44
+ let attemptsLeft = DEFAULT_RUN_RETRIES + 1;
45
+ while (true) {
46
+ attemptsLeft--;
47
+ try {
48
+ return await postRun(ctx, appIdentity, body, options);
49
+ }
50
+ catch (error) {
51
+ if (attemptsLeft > 0 && error instanceof HerokuApiError && error.statusCode === 409) {
52
+ continue;
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+ /* eslint-enable no-await-in-loop */
58
+ }
59
+ async function postRun(ctx, appIdentity, body, options) {
60
+ if (options.dyno) {
61
+ const apiClient = new HerokuApiClient({
62
+ ...options.clientOptions,
63
+ service: 'platform',
64
+ });
65
+ const path = `/apps/${encodeURIComponent(appIdentity)}/dynos/${encodeURIComponent(options.dyno)}`;
66
+ const response = await apiClient.post(path, body, {
67
+ headers: { Accept: ACCEPT_RUN_INSIDE },
68
+ signal: options.signal,
69
+ });
70
+ return (await response.json());
71
+ }
72
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
73
+ return platform.dyno.create(appIdentity, body);
74
+ }
@@ -73,13 +73,38 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
73
73
  service: 'custom',
74
74
  token: '',
75
75
  });
76
- const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`);
76
+ // Fir's telemetry-proxy sends newline-delimited plain text by
77
+ // default, but concatenates records with zero delimiter bytes —
78
+ // splitting on '\n' yields nothing. Requesting SSE flips the
79
+ // proxy into `text/event-stream`, where each record is framed
80
+ // as `id: N\ndata: <line>\n\n` (Cedar's logplex ignores the
81
+ // header and keeps sending `text/plain`).
82
+ const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`, {
83
+ headers: { Accept: 'text/event-stream' },
84
+ });
77
85
  if (!response.body) {
78
86
  throw new Error('Logplex stream returned no body.');
79
87
  }
88
+ const contentType = response.headers.get('content-type') ?? '';
89
+ const isSse = contentType.toLowerCase().includes('text/event-stream');
80
90
  const reader = response.body.getReader();
81
91
  const decoder = new TextDecoder();
82
92
  let pending = '';
93
+ // Wire the caller's abort signal into the reader. `await reader.read()`
94
+ // parks until the next chunk arrives; a bare `signal.aborted` check
95
+ // between reads never runs while it's parked, so on a quiet tail the
96
+ // read would block indefinitely past an abort. Cancelling the reader
97
+ // settles the pending `read()` promise, letting the loop below observe
98
+ // the abort and terminate — which also closes the underlying socket.
99
+ const onAbort = () => {
100
+ reader.cancel().catch(() => { });
101
+ };
102
+ if (signal) {
103
+ if (signal.aborted)
104
+ reader.cancel().catch(() => { });
105
+ else
106
+ signal.addEventListener('abort', onAbort, { once: true });
107
+ }
83
108
  // Force a recreate after sessionTimeoutMs of silence; ignored
84
109
  // when not tailing.
85
110
  const armTimeout = () => {
@@ -99,31 +124,78 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
99
124
  const { done, value } = await reader.read();
100
125
  if (done)
101
126
  break;
127
+ // A cancel triggered by the caller's signal also resolves the read
128
+ // as `done`; stop rather than treating it as a remote close and
129
+ // recreating the session.
130
+ if (signal?.aborted)
131
+ break;
102
132
  armTimeout();
103
133
  pending += decoder.decode(value, { stream: true });
104
- let newlineIndex = pending.indexOf('\n');
105
- while (newlineIndex !== -1) {
106
- const line = pending.slice(0, newlineIndex);
107
- pending = pending.slice(newlineIndex + 1);
108
- if (line.length > 0) {
134
+ if (isSse) {
135
+ // SSE events are separated by a blank line (`\n\n`). Each
136
+ // event holds `data:` field lines whose values, joined by
137
+ // `\n` per the spec, are the log line to yield.
138
+ let boundary = pending.indexOf('\n\n');
139
+ while (boundary !== -1) {
140
+ const frame = pending.slice(0, boundary);
141
+ pending = pending.slice(boundary + 2);
142
+ // Any complete SSE frame — including heartbeats with no
143
+ // data — is proof the transport is healthy, so reset the
144
+ // retry counter here rather than only on yielded lines.
109
145
  consecutiveTransportErrors = 0;
110
- yield line;
146
+ const line = extractSseData(frame);
147
+ if (line.length > 0)
148
+ yield line;
149
+ boundary = pending.indexOf('\n\n');
150
+ }
151
+ }
152
+ else {
153
+ let newlineIndex = pending.indexOf('\n');
154
+ while (newlineIndex !== -1) {
155
+ const line = pending.slice(0, newlineIndex);
156
+ pending = pending.slice(newlineIndex + 1);
157
+ if (line.length > 0) {
158
+ consecutiveTransportErrors = 0;
159
+ yield line;
160
+ }
161
+ newlineIndex = pending.indexOf('\n');
111
162
  }
112
- newlineIndex = pending.indexOf('\n');
113
163
  }
114
164
  }
115
165
  // Flush any trailing partial line that didn't end in '\n'.
116
- const trailing = pending + decoder.decode();
117
- if (trailing.length > 0) {
118
- consecutiveTransportErrors = 0;
119
- yield trailing;
166
+ // Only meaningful for the plain-text path — an SSE stream that
167
+ // ends mid-frame has no complete event to yield.
168
+ if (!isSse) {
169
+ const trailing = pending + decoder.decode();
170
+ if (trailing.length > 0) {
171
+ consecutiveTransportErrors = 0;
172
+ yield trailing;
173
+ }
120
174
  }
121
175
  }
122
176
  finally {
123
177
  if (timeoutHandle)
124
178
  clearTimeout(timeoutHandle);
179
+ signal?.removeEventListener('abort', onAbort);
180
+ // Tear the reader down. If a consumer stops early — `break`/`return()`
181
+ // out of a `for await`, or an explicit `.return()` — this finally runs
182
+ // while the stream is still open, and `releaseLock()` alone would leak
183
+ // the underlying socket (plus, when tailing, the session-recreate
184
+ // loop). `cancel()` closes the body; fire-and-forget because on an
185
+ // errored/already-closed stream it rejects with the stored error,
186
+ // which we don't act on. It does not release the lock, so we always
187
+ // `releaseLock()` afterward — otherwise the next recreate's
188
+ // `getReader()` throws "ReadableStream is locked". No pending read
189
+ // exists once the loop above has exited, so `releaseLock()` is safe.
190
+ reader.cancel().catch(() => { });
125
191
  reader.releaseLock();
126
192
  }
193
+ // The caller aborted (or their signal fired mid-read): terminate the
194
+ // whole stream, throwing the abort reason so `for await` consumers see
195
+ // an AbortError rather than a silent end.
196
+ if (signal?.aborted) {
197
+ throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
198
+ }
127
199
  if (timedOut) {
128
200
  debug('streamLogs session=%s recreating after timeout', session.id);
129
201
  continue;
@@ -162,6 +234,31 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
162
234
  }
163
235
  /* eslint-enable no-await-in-loop */
164
236
  }
237
+ /**
238
+ * Extract the joined `data:` field values from a single SSE event
239
+ * frame, per the HTML Living Standard's event-stream parsing rules.
240
+ * Lines beginning with `:` are comments and skipped; other field
241
+ * names (`id`, `event`, `retry`) are ignored — we only care about
242
+ * the log payload. Multiple `data:` lines within one event are
243
+ * joined with `\n`.
244
+ */
245
+ function extractSseData(frame) {
246
+ const dataLines = [];
247
+ for (const rawLine of frame.split('\n')) {
248
+ if (!rawLine || rawLine.startsWith(':'))
249
+ continue;
250
+ const colon = rawLine.indexOf(':');
251
+ const field = colon === -1 ? rawLine : rawLine.slice(0, colon);
252
+ if (field !== 'data')
253
+ continue;
254
+ // Per spec: strip a single leading space from the value.
255
+ let value = colon === -1 ? '' : rawLine.slice(colon + 1);
256
+ if (value.startsWith(' '))
257
+ value = value.slice(1);
258
+ dataLines.push(value);
259
+ }
260
+ return dataLines.join('\n');
261
+ }
165
262
  /**
166
263
  * Resolves after `ms` milliseconds, or rejects if the signal aborts
167
264
  * first. Used between recreate attempts so a persistent transport
@@ -0,0 +1,38 @@
1
+ import type { SniEndpoint } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type CreateAndAssociateOptions = {
4
+ intervalMs?: number;
5
+ /** Required. Given the wildcard-matched candidate hostnames, returns the subset to associate. */
6
+ resolveDomains: (candidates: string[]) => Promise<string[]>;
7
+ signal?: AbortSignal;
8
+ timeoutMs?: number;
9
+ };
10
+ /**
11
+ * Create an SNI endpoint (SSL certificate) on an app and associate it with the
12
+ * app's matching domains.
13
+ *
14
+ * Ports the CLI `certs:add` business logic into the SDK.
15
+ *
16
+ * Steps:
17
+ * 1. Create the SNI endpoint from the given certificate chain and private key.
18
+ * 2. Wait for the app's domains to reach ready status (replaces the old CNAME poll).
19
+ * The SDK waits on domain STATUS (`succeeded`/`none`) via `domain.waitForReady`
20
+ * rather than the CLI's old `cname`-defined readiness check, polling at a default
21
+ * 1000ms interval to mirror the CLI's `waitForDomains` cadence. `timeoutMs`
22
+ * defaults to 30s and is applied per pending domain (not a single global budget),
23
+ * so an app with several pending domains can wait longer than 30s overall.
24
+ * 3. List the (now stable) app domains and wildcard-match them against the
25
+ * certificate's `cert_domains`.
26
+ * 4. If any domains matched, invoke `resolveDomains` — the interactive selection
27
+ * hook the consumer supplies — to choose which matched hostnames to associate.
28
+ * 5. Associate the selected hostnames with the endpoint in parallel via
29
+ * `PATCH /apps/{app}/domains/{hostname}` (`domain.update`).
30
+ *
31
+ * If no domains match the certificate, neither `resolveDomains` nor any
32
+ * association call is made.
33
+ *
34
+ * @throws if the domain readiness wait times out or a domain fails, or if any
35
+ * association call rejects.
36
+ * @returns the created SNI endpoint.
37
+ */
38
+ export declare function createAndAssociate(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, certificateChain: string, privateKey: string, options: CreateAndAssociateOptions): Promise<SniEndpoint>;