@heroku/sdk 0.5.1 → 0.5.3

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 +71 -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 +10 -2
@@ -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,10 +73,20 @@ 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 = '';
@@ -101,22 +111,46 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
101
111
  break;
102
112
  armTimeout();
103
113
  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) {
114
+ if (isSse) {
115
+ // SSE events are separated by a blank line (`\n\n`). Each
116
+ // event holds `data:` field lines whose values, joined by
117
+ // `\n` per the spec, are the log line to yield.
118
+ let boundary = pending.indexOf('\n\n');
119
+ while (boundary !== -1) {
120
+ const frame = pending.slice(0, boundary);
121
+ pending = pending.slice(boundary + 2);
122
+ // Any complete SSE frame — including heartbeats with no
123
+ // data — is proof the transport is healthy, so reset the
124
+ // retry counter here rather than only on yielded lines.
109
125
  consecutiveTransportErrors = 0;
110
- yield line;
126
+ const line = extractSseData(frame);
127
+ if (line.length > 0)
128
+ yield line;
129
+ boundary = pending.indexOf('\n\n');
130
+ }
131
+ }
132
+ else {
133
+ let newlineIndex = pending.indexOf('\n');
134
+ while (newlineIndex !== -1) {
135
+ const line = pending.slice(0, newlineIndex);
136
+ pending = pending.slice(newlineIndex + 1);
137
+ if (line.length > 0) {
138
+ consecutiveTransportErrors = 0;
139
+ yield line;
140
+ }
141
+ newlineIndex = pending.indexOf('\n');
111
142
  }
112
- newlineIndex = pending.indexOf('\n');
113
143
  }
114
144
  }
115
145
  // 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;
146
+ // Only meaningful for the plain-text path — an SSE stream that
147
+ // ends mid-frame has no complete event to yield.
148
+ if (!isSse) {
149
+ const trailing = pending + decoder.decode();
150
+ if (trailing.length > 0) {
151
+ consecutiveTransportErrors = 0;
152
+ yield trailing;
153
+ }
120
154
  }
121
155
  }
122
156
  finally {
@@ -162,6 +196,31 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
162
196
  }
163
197
  /* eslint-enable no-await-in-loop */
164
198
  }
199
+ /**
200
+ * Extract the joined `data:` field values from a single SSE event
201
+ * frame, per the HTML Living Standard's event-stream parsing rules.
202
+ * Lines beginning with `:` are comments and skipped; other field
203
+ * names (`id`, `event`, `retry`) are ignored — we only care about
204
+ * the log payload. Multiple `data:` lines within one event are
205
+ * joined with `\n`.
206
+ */
207
+ function extractSseData(frame) {
208
+ const dataLines = [];
209
+ for (const rawLine of frame.split('\n')) {
210
+ if (!rawLine || rawLine.startsWith(':'))
211
+ continue;
212
+ const colon = rawLine.indexOf(':');
213
+ const field = colon === -1 ? rawLine : rawLine.slice(0, colon);
214
+ if (field !== 'data')
215
+ continue;
216
+ // Per spec: strip a single leading space from the value.
217
+ let value = colon === -1 ? '' : rawLine.slice(colon + 1);
218
+ if (value.startsWith(' '))
219
+ value = value.slice(1);
220
+ dataLines.push(value);
221
+ }
222
+ return dataLines.join('\n');
223
+ }
165
224
  /**
166
225
  * Resolves after `ms` milliseconds, or rejects if the signal aborts
167
226
  * 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>;
@@ -0,0 +1,86 @@
1
+ import { waitForReady } from '../domain/wait-for-ready.js';
2
+ const DEFAULT_WAIT_INTERVAL_MS = 1000;
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ /**
5
+ * Create an SNI endpoint (SSL certificate) on an app and associate it with the
6
+ * app's matching domains.
7
+ *
8
+ * Ports the CLI `certs:add` business logic into the SDK.
9
+ *
10
+ * Steps:
11
+ * 1. Create the SNI endpoint from the given certificate chain and private key.
12
+ * 2. Wait for the app's domains to reach ready status (replaces the old CNAME poll).
13
+ * The SDK waits on domain STATUS (`succeeded`/`none`) via `domain.waitForReady`
14
+ * rather than the CLI's old `cname`-defined readiness check, polling at a default
15
+ * 1000ms interval to mirror the CLI's `waitForDomains` cadence. `timeoutMs`
16
+ * defaults to 30s and is applied per pending domain (not a single global budget),
17
+ * so an app with several pending domains can wait longer than 30s overall.
18
+ * 3. List the (now stable) app domains and wildcard-match them against the
19
+ * certificate's `cert_domains`.
20
+ * 4. If any domains matched, invoke `resolveDomains` — the interactive selection
21
+ * hook the consumer supplies — to choose which matched hostnames to associate.
22
+ * 5. Associate the selected hostnames with the endpoint in parallel via
23
+ * `PATCH /apps/{app}/domains/{hostname}` (`domain.update`).
24
+ *
25
+ * If no domains match the certificate, neither `resolveDomains` nor any
26
+ * association call is made.
27
+ *
28
+ * @throws if the domain readiness wait times out or a domain fails, or if any
29
+ * association call rejects.
30
+ * @returns the created SNI endpoint.
31
+ */
32
+ // Signature mirrors the CLI `certs:add` / plan-locked public API:
33
+ // createAndAssociate(app, certChain, privateKey, {resolveDomains}).
34
+ // eslint-disable-next-line max-params
35
+ export async function createAndAssociate(ctx, appIdentity, certificateChain, privateKey, options) {
36
+ const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
37
+ signal?.throwIfAborted();
38
+ const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
39
+ const sniEndpoint = await platform.sniEndpoint.create(appIdentity, {
40
+ certificate_chain: certificateChain,
41
+ private_key: privateKey,
42
+ });
43
+ await waitForReady(ctx, appIdentity, { signal, timeoutMs, waitIntervalMs: intervalMs });
44
+ const apiDomains = await platform.domain.list(appIdentity);
45
+ const appDomains = apiDomains.map(d => d.hostname);
46
+ const certDomains = (sniEndpoint.ssl_cert.cert_domains ?? []);
47
+ const matched = matchDomains(certDomains, appDomains);
48
+ if (matched.length > 0) {
49
+ const selected = await resolveDomains(matched);
50
+ await Promise.all(selected.map(hostname => platform.domain.update(appIdentity, hostname, { sni_endpoint: sniEndpoint.name })));
51
+ }
52
+ return sniEndpoint;
53
+ }
54
+ function splitDomains(domains) {
55
+ return domains.map(domain => [domain.slice(0, 1), domain.slice(1)]);
56
+ }
57
+ // Adapted from the CLI `certs/add.ts` matcher, but ADDS `^`/`$` anchors to fix a
58
+ // prefix-match bug: without them a wildcard cert like `*.example.com` would match
59
+ // hostnames that merely share a prefix (e.g. `www.example.com.evil.org`). Wildcard
60
+ // certs must not match such hostnames, so both ends of the pattern are anchored.
61
+ function createMatcherFromSplitDomain([firstChar, rest]) {
62
+ const matcherContents = [];
63
+ if (firstChar === '*') {
64
+ matcherContents.push(String.raw `^[\w\-]+`);
65
+ }
66
+ else {
67
+ matcherContents.push(`^${firstChar}`);
68
+ }
69
+ const escapedRest = rest.replaceAll('.', String.raw `\.`);
70
+ matcherContents.push(escapedRest);
71
+ return new RegExp(`${matcherContents.join('')}$`);
72
+ }
73
+ function matchDomains(certDomains, appDomains) {
74
+ const splitCertDomains = splitDomains(certDomains);
75
+ const matchers = splitCertDomains.map(splitDomain => createMatcherFromSplitDomain(splitDomain));
76
+ if (splitCertDomains.some(domain => (domain[0] === '*'))) {
77
+ const matchedDomains = [];
78
+ for (const appDomain of appDomains) {
79
+ if (matchers.some(matcher => matcher.test(appDomain))) {
80
+ matchedDomains.push(appDomain);
81
+ }
82
+ }
83
+ return matchedDomains;
84
+ }
85
+ return certDomains.filter(domain => appDomains.includes(domain));
86
+ }
@@ -0,0 +1,5 @@
1
+ import type { CreateAndAssociateOptions } from './create-and-associate.js';
2
+ export { createAndAssociate, type CreateAndAssociateOptions } from './create-and-associate.js';
3
+ export declare const sniEndpointExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "sniEndpoint", {
4
+ createAndAssociate: (appIdentity: string, certificateChain: string, privateKey: string, options: CreateAndAssociateOptions) => Promise<import("@heroku/types/3.sdk").SniEndpoint>;
5
+ }>;