@heroku/sdk 0.3.0-beta.0 → 0.4.0-beta.0

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.
@@ -1,6 +1,6 @@
1
1
  export { addOnExtensions } from '../platform/add-on/index.js';
2
2
  export { appExtensions, type AppInfo, type PipelineCouplingDetail } from '../platform/app/index.js';
3
- export { dynoExtensions } from '../platform/dyno/index.js';
3
+ export { dynoExtensions, DynoNotReadyError, type DynoState, waitForInfo, type WaitForInfoOptions, } from '../platform/dyno/index.js';
4
4
  export { type AttachmentAttachedEvent, type AttachmentDetachedEvent, type AttachmentUpdatedEvent, type HerokuLogEvent, logSessionExtensions, parseHerokuLogLine, type ProvisioningCompletedEvent, type ScaledToEntry, type ScaledToEvent, type StartingProcessEvent, type StateChangedEvent, } from '../platform/log-session/index.js';
5
5
  export { pipelineCouplingExtensions } from '../platform/pipeline/coupling.js';
6
6
  export { pipelineExtensions } from '../platform/pipeline/index.js';
@@ -1,6 +1,6 @@
1
1
  export { addOnExtensions } from '../platform/add-on/index.js';
2
2
  export { appExtensions } from '../platform/app/index.js';
3
- export { dynoExtensions } from '../platform/dyno/index.js';
3
+ export { dynoExtensions, DynoNotReadyError, waitForInfo, } from '../platform/dyno/index.js';
4
4
  export { logSessionExtensions, parseHerokuLogLine, } from '../platform/log-session/index.js';
5
5
  export { pipelineCouplingExtensions } from '../platform/pipeline/coupling.js';
6
6
  export { pipelineExtensions } from '../platform/pipeline/index.js';
@@ -1,6 +1,8 @@
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 WaitForInfoOptions } from './wait-for-info.js';
5
+ export { DynoNotReadyError, type DynoState, waitForInfo, type WaitForInfoOptions, } from './wait-for-info.js';
4
6
  export type DynoOptions = {
5
7
  signal?: AbortSignal;
6
8
  };
@@ -41,5 +43,5 @@ export declare const dynoExtensions: import("../../../core/extend-resource.js").
41
43
  (appIdentity: string, updates: ScaleDynosUpdate, options?: DynoOptions): Promise<Formation>;
42
44
  (appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
43
45
  };
46
+ waitForInfo: (appIdentity: string, dynoIdentity: string, options?: WaitForInfoOptions) => Promise<import("@heroku/types/3.sdk").Dyno>;
44
47
  }>;
45
- export {};
@@ -1,4 +1,6 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { waitForInfo } from './wait-for-info.js';
3
+ export { DynoNotReadyError, waitForInfo, } from './wait-for-info.js';
2
4
  export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
3
5
  options.signal?.throwIfAborted();
4
6
  const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
@@ -23,4 +25,5 @@ export async function restartDynos(ctx, appIdentity, target, options = {}) {
23
25
  export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
24
26
  restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
25
27
  scale: ((appIdentity, updates, options) => scaleDynos(ctx, appIdentity, updates, options)),
28
+ waitForInfo: (appIdentity, dynoIdentity, options) => (waitForInfo(ctx, appIdentity, dynoIdentity, options)),
26
29
  }));
@@ -0,0 +1,95 @@
1
+ import type { Dyno } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ /**
4
+ * Documented dyno states from the Platform API:
5
+ * https://devcenter.heroku.com/articles/platform-api-reference#dyno
6
+ *
7
+ * Recommended for autocomplete in {@link WaitForInfoOptions.states},
8
+ * which accepts any string at runtime — the platform does not commit
9
+ * to a closed set, so the comparison is plain string equality.
10
+ */
11
+ export type DynoState = 'crashed' | 'down' | 'idle' | 'starting' | 'up';
12
+ /**
13
+ * Thrown by {@link waitForInfo} when `states` was set, the dyno was
14
+ * reachable, but its `state` never matched within the configured
15
+ * number of attempts. Carries the last observed dyno so callers can
16
+ * branch on its state (e.g. distinguish a `crashed` dyno from one
17
+ * stuck in `starting`).
18
+ */
19
+ export declare class DynoNotReadyError extends Error {
20
+ readonly dyno: Dyno;
21
+ readonly expectedStates: ReadonlyArray<string>;
22
+ readonly attempts: number;
23
+ readonly id = "dyno_not_ready";
24
+ constructor(dyno: Dyno, expectedStates: ReadonlyArray<string>, attempts: number);
25
+ }
26
+ export type WaitForInfoOptions = {
27
+ /**
28
+ * Maximum total number of `dyno.info` calls before giving up.
29
+ * Defaults to 20. Each attempt is one info call; the delay
30
+ * between attempts is {@link WaitForInfoOptions.delayMs}.
31
+ */
32
+ attempts?: number;
33
+ /**
34
+ * Delay between attempts in milliseconds. Defaults to 1000.
35
+ * On a 429 response, the platform's `Retry-After` header takes
36
+ * precedence over this value for the next wait.
37
+ */
38
+ delayMs?: number;
39
+ /**
40
+ * Fires after every successful `dyno.info` call, including ones
41
+ * whose `state` does not yet match {@link WaitForInfoOptions.states}
42
+ * and trigger another retry, AND for the final matching dyno
43
+ * before it is returned. Use this to surface live state
44
+ * transitions in a UI (e.g. a CLI spinner that updates
45
+ * `starting → up`). Not called when an attempt errors (404, 429,
46
+ * 503). Exceptions thrown from this callback propagate and end
47
+ * the wait — there is no implicit catch.
48
+ */
49
+ onPoll?: (dyno: Dyno) => void;
50
+ signal?: AbortSignal;
51
+ /**
52
+ * If set, only return when the dyno's `state` is one of these.
53
+ * If omitted, the call returns on the first 2xx response
54
+ * regardless of state — useful for racing past the post-creation
55
+ * 404 window without waiting for the dyno to be runnable. The
56
+ * recommended values are documented as {@link DynoState}; any
57
+ * string is accepted.
58
+ */
59
+ states?: ReadonlyArray<DynoState | string>;
60
+ };
61
+ /**
62
+ * Poll `dyno.info` until the dyno exists and (optionally) reaches a
63
+ * caller-specified state. Absorbs two distinct races:
64
+ *
65
+ * - **Post-creation 404.** The platform briefly returns 404 for a
66
+ * newly-provisioning dyno even after `dyno.create` resolves
67
+ * (also observable from log events emitting "Starting process"
68
+ * before the dyno row is queryable). Callers that just need the
69
+ * row to exist should omit `states` and accept the first 2xx —
70
+ * the dyno's `state` value does not gate the return.
71
+ * - **Wait for runnable.** Callers that need to act on the dyno
72
+ * (open an SSH connection, exec a one-off command) need it in
73
+ * `starting` or `up` first. Pass `{states: ['starting', 'up']}`
74
+ * and the helper polls until the dyno reports one of those
75
+ * states.
76
+ *
77
+ * Retry policy: 404, 429, and 503 are treated as transient and
78
+ * trigger another attempt. 429 responses honor `Retry-After` for
79
+ * the next wait. Any other error throws immediately.
80
+ *
81
+ * After {@link WaitForInfoOptions.attempts} attempts:
82
+ * - If state-filtered, throws {@link DynoNotReadyError} carrying
83
+ * the last observed dyno.
84
+ * - Otherwise rethrows the last transient error.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * // Race past the post-creation 404; return on first 2xx regardless of state.
89
+ * await platform.dyno.waitForInfo(app, name)
90
+ *
91
+ * // Wait for the dyno to be ready to accept connections.
92
+ * await platform.dyno.waitForInfo(app, name, {states: ['starting', 'up']})
93
+ * ```
94
+ */
95
+ export declare function waitForInfo(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, dynoIdentity: string, options?: WaitForInfoOptions): Promise<Dyno>;
@@ -0,0 +1,108 @@
1
+ import { HerokuApiError, NotFoundError, RateLimitError } from '@heroku/heroku-fetch';
2
+ import { wait } from '../../../utils/wait.js';
3
+ const DEFAULT_ATTEMPTS = 20;
4
+ const DEFAULT_DELAY_MS = 1000;
5
+ /**
6
+ * Thrown by {@link waitForInfo} when `states` was set, the dyno was
7
+ * reachable, but its `state` never matched within the configured
8
+ * number of attempts. Carries the last observed dyno so callers can
9
+ * branch on its state (e.g. distinguish a `crashed` dyno from one
10
+ * stuck in `starting`).
11
+ */
12
+ export class DynoNotReadyError extends Error {
13
+ dyno;
14
+ expectedStates;
15
+ attempts;
16
+ id = 'dyno_not_ready';
17
+ constructor(dyno, expectedStates, attempts) {
18
+ super(`Dyno '${dyno.name}' did not reach state ${expectedStates.join('/')} `
19
+ + `within ${attempts} attempts (last state: ${dyno.state}).`);
20
+ this.dyno = dyno;
21
+ this.expectedStates = expectedStates;
22
+ this.attempts = attempts;
23
+ this.name = 'DynoNotReadyError';
24
+ }
25
+ }
26
+ /**
27
+ * Poll `dyno.info` until the dyno exists and (optionally) reaches a
28
+ * caller-specified state. Absorbs two distinct races:
29
+ *
30
+ * - **Post-creation 404.** The platform briefly returns 404 for a
31
+ * newly-provisioning dyno even after `dyno.create` resolves
32
+ * (also observable from log events emitting "Starting process"
33
+ * before the dyno row is queryable). Callers that just need the
34
+ * row to exist should omit `states` and accept the first 2xx —
35
+ * the dyno's `state` value does not gate the return.
36
+ * - **Wait for runnable.** Callers that need to act on the dyno
37
+ * (open an SSH connection, exec a one-off command) need it in
38
+ * `starting` or `up` first. Pass `{states: ['starting', 'up']}`
39
+ * and the helper polls until the dyno reports one of those
40
+ * states.
41
+ *
42
+ * Retry policy: 404, 429, and 503 are treated as transient and
43
+ * trigger another attempt. 429 responses honor `Retry-After` for
44
+ * the next wait. Any other error throws immediately.
45
+ *
46
+ * After {@link WaitForInfoOptions.attempts} attempts:
47
+ * - If state-filtered, throws {@link DynoNotReadyError} carrying
48
+ * the last observed dyno.
49
+ * - Otherwise rethrows the last transient error.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * // Race past the post-creation 404; return on first 2xx regardless of state.
54
+ * await platform.dyno.waitForInfo(app, name)
55
+ *
56
+ * // Wait for the dyno to be ready to accept connections.
57
+ * await platform.dyno.waitForInfo(app, name, {states: ['starting', 'up']})
58
+ * ```
59
+ */
60
+ export async function waitForInfo(ctx, appIdentity, dynoIdentity, options = {}) {
61
+ const { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, onPoll, signal, states, } = options;
62
+ signal?.throwIfAborted();
63
+ // Scope the in-flight info call to the caller's signal so an abort
64
+ // takes effect during the API call, not just during the backoff.
65
+ const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
66
+ let lastError;
67
+ let lastDyno;
68
+ for (let attempt = 0; attempt < attempts; attempt++) {
69
+ let nextDelayMs = delayMs;
70
+ try {
71
+ const dyno = await platform.dyno.info(appIdentity, dynoIdentity);
72
+ onPoll?.(dyno);
73
+ if (!states || states.includes(dyno.state)) {
74
+ return dyno;
75
+ }
76
+ lastDyno = dyno;
77
+ }
78
+ catch (error) {
79
+ if (!isTransient(error))
80
+ throw error;
81
+ lastError = error;
82
+ if (error instanceof RateLimitError && typeof error.retryAfter === 'number') {
83
+ nextDelayMs = Math.max(delayMs, error.retryAfter * 1000);
84
+ }
85
+ }
86
+ if (attempt < attempts - 1) {
87
+ await wait(nextDelayMs, signal);
88
+ }
89
+ }
90
+ if (states && lastDyno) {
91
+ throw new DynoNotReadyError(lastDyno, states, attempts);
92
+ }
93
+ throw lastError ?? new Error(`Dyno '${dynoIdentity}' on app '${appIdentity}' was not available within ${attempts} attempts.`);
94
+ }
95
+ /**
96
+ * Errors the platform commonly returns transiently during a dyno's
97
+ * provisioning window. Anything else propagates so callers see real
98
+ * failures (auth, app-not-found-permanently, 5xx that aren't 503).
99
+ */
100
+ function isTransient(error) {
101
+ if (error instanceof NotFoundError)
102
+ return true;
103
+ if (error instanceof RateLimitError)
104
+ return true;
105
+ if (error instanceof HerokuApiError && error.statusCode === 503)
106
+ return true;
107
+ return false;
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.3.0-beta.0",
3
+ "version": "0.4.0-beta.0",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -26,6 +26,30 @@
26
26
  "types": "./dist/resources/extensions/data.d.ts",
27
27
  "default": "./dist/resources/extensions/data.js"
28
28
  },
29
+ "./resources/platform/add-on": {
30
+ "types": "./dist/resources/platform/add-on/index.d.ts",
31
+ "default": "./dist/resources/platform/add-on/index.js"
32
+ },
33
+ "./resources/platform/app": {
34
+ "types": "./dist/resources/platform/app/index.d.ts",
35
+ "default": "./dist/resources/platform/app/index.js"
36
+ },
37
+ "./resources/platform/dyno": {
38
+ "types": "./dist/resources/platform/dyno/index.d.ts",
39
+ "default": "./dist/resources/platform/dyno/index.js"
40
+ },
41
+ "./resources/platform/log-session": {
42
+ "types": "./dist/resources/platform/log-session/index.d.ts",
43
+ "default": "./dist/resources/platform/log-session/index.js"
44
+ },
45
+ "./resources/platform/pipeline": {
46
+ "types": "./dist/resources/platform/pipeline/index.d.ts",
47
+ "default": "./dist/resources/platform/pipeline/index.js"
48
+ },
49
+ "./resources/platform/pipeline/*": {
50
+ "types": "./dist/resources/platform/pipeline/*.d.ts",
51
+ "default": "./dist/resources/platform/pipeline/*.js"
52
+ },
29
53
  "./resources/*": {
30
54
  "types": "./dist/resources/*.d.ts",
31
55
  "default": "./dist/resources/*.js"
@@ -42,6 +66,7 @@
42
66
  "example": "tsx",
43
67
  "lint": "eslint",
44
68
  "lint:fix": "eslint --fix",
69
+ "prebuild": "rimraf dist",
45
70
  "prepare": "npm run build",
46
71
  "test": "vitest --run"
47
72
  },
@@ -60,6 +85,7 @@
60
85
  "eslint": "^9.39.4",
61
86
  "eslint-config-oclif": "^6.0.167",
62
87
  "eslint-plugin-vitest": "0.5.4",
88
+ "rimraf": "^6.1.3",
63
89
  "tsx": "^4.22.4",
64
90
  "typescript": "^6.0.2",
65
91
  "vitest": "^4.1.4"