@heroku/sdk 0.11.1 → 0.12.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.
Files changed (76) hide show
  1. package/dist/core/create-client.d.ts +7 -2
  2. package/dist/core/create-client.js +5 -2
  3. package/dist/core/extend-resource.d.ts +52 -2
  4. package/dist/core/extend-resource.js +27 -0
  5. package/dist/core/extensions-proxy.js +51 -1
  6. package/dist/core/heroku-sdk.js +44 -1
  7. package/dist/core/with-signal.d.ts +19 -0
  8. package/dist/core/with-signal.js +20 -0
  9. package/dist/resources/data/database.js +19 -6
  10. package/dist/resources/data/internal/resolve-pg-database.js +4 -7
  11. package/dist/resources/data/maintenance.js +4 -1
  12. package/dist/resources/data/postgres-database.js +4 -1
  13. package/dist/resources/data/redis/resolve-by-app.js +2 -1
  14. package/dist/resources/data/redis/wait-for-ready.d.ts +1 -1
  15. package/dist/resources/data/redis/wait-for-ready.js +3 -2
  16. package/dist/resources/data/transfer/capture-and-wait.js +4 -1
  17. package/dist/resources/data/transfer/restore-and-wait.js +4 -1
  18. package/dist/resources/data/transfer/wait-for-transfer.js +8 -1
  19. package/dist/resources/data/transfer-schedule.js +10 -3
  20. package/dist/resources/extensions/platform.d.ts +3 -0
  21. package/dist/resources/extensions/platform.js +3 -0
  22. package/dist/resources/platform/account.d.ts +22 -0
  23. package/dist/resources/platform/account.js +34 -0
  24. package/dist/resources/platform/add-on/create-and-wait.js +5 -1
  25. package/dist/resources/platform/add-on/describe.js +5 -2
  26. package/dist/resources/platform/add-on/destroy-and-wait.js +2 -3
  27. package/dist/resources/platform/add-on/list-plans.js +8 -2
  28. package/dist/resources/platform/add-on/resolve.js +5 -1
  29. package/dist/resources/platform/add-on/upgrade.js +5 -2
  30. package/dist/resources/platform/add-on/wait-for-provisioning.js +4 -1
  31. package/dist/resources/platform/add-on-attachment/resolve.js +2 -3
  32. package/dist/resources/platform/app/create-and-setup.d.ts +3 -4
  33. package/dist/resources/platform/app/create-and-setup.js +5 -5
  34. package/dist/resources/platform/app/diff.js +2 -3
  35. package/dist/resources/platform/app/index.js +9 -3
  36. package/dist/resources/platform/app/info.d.ts +4 -0
  37. package/dist/resources/platform/app/info.js +21 -7
  38. package/dist/resources/platform/app/transfer.js +2 -1
  39. package/dist/resources/platform/app/wait-for-acm-certificates.js +2 -1
  40. package/dist/resources/platform/container/ensure-container-stack.js +2 -1
  41. package/dist/resources/platform/container/release-images.js +2 -1
  42. package/dist/resources/platform/container/remove-process-types.js +2 -1
  43. package/dist/resources/platform/delinquency.d.ts +23 -0
  44. package/dist/resources/platform/delinquency.js +41 -0
  45. package/dist/resources/platform/domain/clear-custom-domains.js +2 -1
  46. package/dist/resources/platform/domain/create-and-wait.js +2 -1
  47. package/dist/resources/platform/domain/wait-for-ready.js +2 -1
  48. package/dist/resources/platform/dyno/exec.d.ts +12 -2
  49. package/dist/resources/platform/dyno/exec.js +27 -12
  50. package/dist/resources/platform/dyno/index.js +9 -6
  51. package/dist/resources/platform/dyno/list-extended.d.ts +7 -1
  52. package/dist/resources/platform/dyno/list-extended.js +8 -3
  53. package/dist/resources/platform/dyno/run.d.ts +1 -1
  54. package/dist/resources/platform/dyno/run.js +5 -3
  55. package/dist/resources/platform/dyno/wait-for-info.js +2 -1
  56. package/dist/resources/platform/dyno/wait-for-release.js +2 -1
  57. package/dist/resources/platform/log-session/index.d.ts +1 -1
  58. package/dist/resources/platform/log-session/index.js +12 -11
  59. package/dist/resources/platform/pipeline/coupling.js +5 -2
  60. package/dist/resources/platform/pipeline/index.d.ts +2 -2
  61. package/dist/resources/platform/pipeline/index.js +6 -4
  62. package/dist/resources/platform/pipeline/promotion.d.ts +1 -1
  63. package/dist/resources/platform/pipeline/promotion.js +22 -18
  64. package/dist/resources/platform/review-app-config/resolve-repo-name.js +4 -3
  65. package/dist/resources/platform/sni-endpoint/create-and-associate.js +2 -1
  66. package/dist/resources/platform/space/wait-for-allocated.js +2 -4
  67. package/dist/resources/platform/team.d.ts +22 -0
  68. package/dist/resources/platform/team.js +34 -0
  69. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +2 -1
  70. package/dist/resources/platform/test-run/wait-for-state.js +2 -1
  71. package/dist/resources/platform/vpn-connection/wait-for-active.js +2 -1
  72. package/dist/utils/is-abort-error.d.ts +11 -0
  73. package/dist/utils/is-abort-error.js +14 -0
  74. package/dist/version.d.ts +1 -0
  75. package/dist/version.js +2 -0
  76. package/package.json +9 -4
@@ -1,4 +1,6 @@
1
- import { HerokuApiClient, NotFoundError } from '@heroku/heroku-fetch';
1
+ import { NotFoundError } from '@heroku/heroku-fetch';
2
+ import { makeCustomHostClient } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
2
4
  import { wait } from '../../../utils/wait.js';
3
5
  import { waitForInfo } from './wait-for-info.js';
4
6
  /** The app feature that gates Heroku Exec. */
@@ -20,7 +22,7 @@ const DEFAULT_SETTLE_MS = 2000;
20
22
  */
21
23
  export async function execPrereqs(ctx, appIdentity, options = {}) {
22
24
  options.signal?.throwIfAborted();
23
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
25
+ const platform = withSignal(ctx.platform, options.signal);
24
26
  const [app, buildpacks, configVars, feature] = await Promise.all([
25
27
  platform.app.info(appIdentity),
26
28
  platform.buildpackInstallation.list(appIdentity),
@@ -51,7 +53,7 @@ export async function execPrereqs(ctx, appIdentity, options = {}) {
51
53
  */
52
54
  export async function enableExec(ctx, appIdentity, options = {}) {
53
55
  options.signal?.throwIfAborted();
54
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
56
+ const platform = withSignal(ctx.platform, options.signal);
55
57
  await platform.appFeature.update(appIdentity, EXEC_FEATURE, { enabled: true });
56
58
  }
57
59
  /**
@@ -82,7 +84,7 @@ export async function restartForExec(ctx, appIdentity, dynoIdentity, options = {
82
84
  const { settleMs = DEFAULT_SETTLE_MS, ...waitOptions } = options;
83
85
  const { signal } = waitOptions;
84
86
  signal?.throwIfAborted();
85
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
87
+ const platform = withSignal(ctx.platform, signal);
86
88
  await wait(settleMs, signal);
87
89
  await platform.dyno.restartAll(appIdentity);
88
90
  return waitForInfo(ctx, appIdentity, dynoIdentity, {
@@ -106,11 +108,16 @@ export async function restartForExec(ctx, appIdentity, dynoIdentity, options = {
106
108
  * `Authorization` header survive). The caller feeds the returned info,
107
109
  * plus the private key it holds, to its own SSH transport; the SDK does
108
110
  * not open the connection.
111
+ *
112
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
113
+ * resolved client identity and cancellation signal into the custom-host
114
+ * client. Standalone callers omit it, and the client falls back to the
115
+ * pre-threading bare construction.
109
116
  */
110
- export async function exchangeExecCredentials(appIdentity, options) {
117
+ export async function exchangeExecCredentials(appIdentity, options, ctx = {}) {
111
118
  options.signal?.throwIfAborted();
112
119
  const endpoint = resolveExecEndpoint(appIdentity, options.apiKey, options.configVars);
113
- const client = newExecClient(endpoint.baseUrl, options.clientOptions);
120
+ const client = newExecClient(endpoint.baseUrl, options.clientOptions, ctx);
114
121
  const path = `${endpoint.apiPath}/${encodeURIComponent(options.dyno)}`;
115
122
  const response = await client.put(path, { client_key: options.publicKey }, {
116
123
  headers: {
@@ -129,11 +136,16 @@ export async function exchangeExecCredentials(appIdentity, options) {
129
136
  * custom-host Basic-auth setup as {@link exchangeExecCredentials}. The
130
137
  * caller correlates each reservation's `dyno_name` with live dyno state
131
138
  * for display.
139
+ *
140
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
141
+ * resolved client identity and cancellation signal into the custom-host
142
+ * client. Standalone callers omit it, and the client falls back to the
143
+ * pre-threading bare construction.
132
144
  */
133
- export async function execStatus(appIdentity, options) {
145
+ export async function execStatus(appIdentity, options, ctx = {}) {
134
146
  options.signal?.throwIfAborted();
135
147
  const endpoint = resolveExecEndpoint(appIdentity, options.apiKey, options.configVars);
136
- const client = newExecClient(endpoint.baseUrl, options.clientOptions);
148
+ const client = newExecClient(endpoint.baseUrl, options.clientOptions, ctx);
137
149
  const response = await client.get(endpoint.apiPath, {
138
150
  headers: { ...options.headers, Authorization: endpoint.authorization },
139
151
  signal: options.signal,
@@ -168,10 +180,13 @@ function resolveExecEndpoint(appIdentity, apiKey, configVars) {
168
180
  baseUrl: `${url.protocol}//${url.host}`,
169
181
  };
170
182
  }
171
- function newExecClient(baseUrl, clientOptions) {
172
- return new HerokuApiClient({
173
- ...clientOptions, baseUrl, service: 'custom', token: '',
174
- });
183
+ function newExecClient(baseUrl, clientOptions, ctx = {}) {
184
+ // exec-manager is a custom host with Basic auth embedded per request. The
185
+ // custom-host client forces `token: ''` and inherits only the SDK's identity
186
+ // (User-Agent) and signal — never `reauthorize`/headers, so a bad-Basic 401
187
+ // can't drive a spurious `heroku login`. The caller's `clientOptions` still
188
+ // apply on top.
189
+ return makeCustomHostClient(ctx, baseUrl, clientOptions);
175
190
  }
176
191
  function toBasicAuth(username, password) {
177
192
  return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
@@ -1,4 +1,5 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { enableExec, exchangeExecCredentials, execPrereqs, execStatus, restartForExec, } from './exec.js';
3
4
  import { listExtended } from './list-extended.js';
4
5
  import { runDyno } from './run.js';
@@ -20,22 +21,24 @@ export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
20
21
  }
21
22
  export async function restartDynos(ctx, appIdentity, target, options = {}) {
22
23
  options.signal?.throwIfAborted();
24
+ // Bind the signal so the request aborts in flight.
25
+ const platform = withSignal(ctx.platform, options.signal);
23
26
  if (!target) {
24
- await ctx.platform.dyno.restartAll(appIdentity);
27
+ await platform.dyno.restartAll(appIdentity);
25
28
  return;
26
29
  }
27
30
  if ('dyno' in target) {
28
- await ctx.platform.dyno.restart(appIdentity, target.dyno);
31
+ await platform.dyno.restart(appIdentity, target.dyno);
29
32
  return;
30
33
  }
31
- await ctx.platform.dyno.restartFormation(appIdentity, target.type);
34
+ await platform.dyno.restartFormation(appIdentity, target.type);
32
35
  }
33
36
  export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
34
37
  enableExec: (appIdentity, options) => (enableExec(ctx, appIdentity, options)),
35
- exchangeExecCredentials: (appIdentity, options) => (exchangeExecCredentials(appIdentity, options)),
38
+ exchangeExecCredentials: (appIdentity, options) => (exchangeExecCredentials(appIdentity, options, ctx)),
36
39
  execPrereqs: (appIdentity, options) => (execPrereqs(ctx, appIdentity, options)),
37
- execStatus: (appIdentity, options) => (execStatus(appIdentity, options)),
38
- listExtended: (appIdentity, options) => (listExtended(appIdentity, options)),
40
+ execStatus: (appIdentity, options) => (execStatus(appIdentity, options, ctx)),
41
+ listExtended: (appIdentity, options) => (listExtended(appIdentity, options, ctx)),
39
42
  restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
40
43
  restartForExec: (appIdentity, dynoIdentity, options) => (restartForExec(ctx, appIdentity, dynoIdentity, options)),
41
44
  run: (appIdentity, command, options) => (runDyno(ctx, appIdentity, command, options)),
@@ -1,5 +1,6 @@
1
1
  import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
2
  import type { Dyno } from '@heroku/types/3.sdk';
3
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
4
  /**
4
5
  * Operational detail the platform attaches to each dyno only when the
5
6
  * `?extended=true` query is used with the `version=3.sdk` Accept
@@ -46,5 +47,10 @@ export type ListExtendedOptions = {
46
47
  *
47
48
  * Unlike the routes client, this does not auto-paginate — it issues a
48
49
  * single GET, matching the historical CLI behavior.
50
+ *
51
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
52
+ * resolved client identity and cancellation signal into the bare client.
53
+ * Standalone callers omit it, and the client falls back to the pre-threading
54
+ * bare construction.
49
55
  */
50
- export declare function listExtended(appIdentity: string, options?: ListExtendedOptions): Promise<DynoExtended[]>;
56
+ export declare function listExtended(appIdentity: string, options?: ListExtendedOptions, ctx?: Pick<ResourceCtx, 'makeClient'>): Promise<DynoExtended[]>;
@@ -1,4 +1,4 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
1
+ import { makeCtxClient } from '../../../core/extend-resource.js';
2
2
  const ACCEPT_EXTENDED = 'application/vnd.heroku+json; version=3.sdk';
3
3
  /**
4
4
  * List an app's dynos with the extended operational detail block.
@@ -12,10 +12,15 @@ const ACCEPT_EXTENDED = 'application/vnd.heroku+json; version=3.sdk';
12
12
  *
13
13
  * Unlike the routes client, this does not auto-paginate — it issues a
14
14
  * single GET, matching the historical CLI behavior.
15
+ *
16
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
17
+ * resolved client identity and cancellation signal into the bare client.
18
+ * Standalone callers omit it, and the client falls back to the pre-threading
19
+ * bare construction.
15
20
  */
16
- export async function listExtended(appIdentity, options = {}) {
21
+ export async function listExtended(appIdentity, options = {}, ctx = {}) {
17
22
  options.signal?.throwIfAborted();
18
- const apiClient = new HerokuApiClient({
23
+ const apiClient = makeCtxClient(ctx, 'platform', {
19
24
  ...options.clientOptions,
20
25
  service: 'platform',
21
26
  });
@@ -73,4 +73,4 @@ export type RunDynoOptions = {
73
73
  * open their own interactive session; the SDK does not establish the
74
74
  * session, spawn ssh, or manage TTY state.
75
75
  */
76
- export declare function runDyno(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, command: string, options?: RunDynoOptions): Promise<Dyno>;
76
+ export declare function runDyno(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, appIdentity: string, command: string, options?: RunDynoOptions): Promise<Dyno>;
@@ -1,4 +1,6 @@
1
- import { HerokuApiClient, HerokuApiError } from '@heroku/heroku-fetch';
1
+ import { HerokuApiError } from '@heroku/heroku-fetch';
2
+ import { makeCtxClient } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
2
4
  const DEFAULT_RUN_RETRIES = 2;
3
5
  const ACCEPT_RUN_INSIDE = 'application/vnd.heroku+json; version=3.sdk';
4
6
  /**
@@ -58,7 +60,7 @@ export async function runDyno(ctx, appIdentity, command, options = {}) {
58
60
  }
59
61
  async function postRun(ctx, appIdentity, body, options) {
60
62
  if (options.dyno) {
61
- const apiClient = new HerokuApiClient({
63
+ const apiClient = makeCtxClient(ctx, 'platform', {
62
64
  ...options.clientOptions,
63
65
  service: 'platform',
64
66
  });
@@ -69,6 +71,6 @@ async function postRun(ctx, appIdentity, body, options) {
69
71
  });
70
72
  return (await response.json());
71
73
  }
72
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
74
+ const platform = withSignal(ctx.platform, options.signal);
73
75
  return platform.dyno.create(appIdentity, body);
74
76
  }
@@ -1,4 +1,5 @@
1
1
  import { HerokuApiError, NotFoundError, RateLimitError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { wait } from '../../../utils/wait.js';
3
4
  const DEFAULT_ATTEMPTS = 20;
4
5
  const DEFAULT_DELAY_MS = 1000;
@@ -62,7 +63,7 @@ export async function waitForInfo(ctx, appIdentity, dynoIdentity, options = {})
62
63
  signal?.throwIfAborted();
63
64
  // Scope the in-flight info call to the caller's signal so an abort
64
65
  // takes effect during the API call, not just during the backoff.
65
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
66
+ const platform = withSignal(ctx.platform, signal);
66
67
  let lastError;
67
68
  let lastDyno;
68
69
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_DELAY_MS = 10_000;
3
4
  const LATEST_RELEASE_RANGE = 'version ..; max=1, order=desc';
@@ -45,7 +46,7 @@ export class ReleaseNotConvergedError extends Error {
45
46
  export async function waitForRelease(ctx, appIdentity, options = {}) {
46
47
  const { attempts, delayMs = DEFAULT_DELAY_MS, onPoll, signal, type, withRun, } = options;
47
48
  signal?.throwIfAborted();
48
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
49
+ const platform = withSignal(ctx.platform, signal);
49
50
  const releases = await platform
50
51
  .withHeaders({ Range: LATEST_RELEASE_RANGE })
51
52
  .release.list(appIdentity);
@@ -95,7 +95,7 @@ export type StreamLogsOptions = {
95
95
  * - non-2xx response from the logplex URL
96
96
  * - errors thrown by `logSession.create` (auth, app-not-found, etc.)
97
97
  */
98
- export declare function streamLogs(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: StreamLogsOptions): AsyncGenerator<string, void, unknown>;
98
+ export declare function streamLogs(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, appIdentity: string, options?: StreamLogsOptions): AsyncGenerator<string, void, unknown>;
99
99
  export declare const logSessionExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "logSession", {
100
100
  parseHerokuLogLine: typeof parseHerokuLogLine;
101
101
  streamLogs: (appIdentity: string, options?: StreamLogsOptions) => AsyncGenerator<string, void, unknown>;
@@ -1,6 +1,6 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
1
  import createDebug from 'debug';
3
- import { extendResource } from '../../../core/extend-resource.js';
2
+ import { extendResource, makeCustomHostClient } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
4
4
  import { getGeneration } from '../app/index.js';
5
5
  import { parseHerokuLogLine } from './parser.js';
6
6
  export { parseHerokuLogLine, } from './parser.js';
@@ -37,6 +37,9 @@ const TRANSPORT_RETRY_MAX_DELAY_MS = 5000;
37
37
  export async function* streamLogs(ctx, appIdentity, options = {}) {
38
38
  const { dyno, lines, maxConsecutiveTransportErrors = MAX_CONSECUTIVE_TRANSPORT_ERRORS, maxRetryDelayMs = TRANSPORT_RETRY_MAX_DELAY_MS, retryBaseDelayMs = TRANSPORT_RETRY_BASE_DELAY_MS, sessionTimeoutMs = DEFAULT_FIR_SESSION_TIMEOUT_MS, signal, source, tail = false, type, } = options;
39
39
  signal?.throwIfAborted();
40
+ // Bind the caller's signal to the platform client so the session-create
41
+ // request below aborts in flight, not merely between reads of the stream.
42
+ const platform = withSignal(ctx.platform, signal);
40
43
  const generation = await getGeneration(ctx, appIdentity, { signal });
41
44
  const isFir = generation === 'fir';
42
45
  // Fir doesn't support a bounded session; the platform always streams.
@@ -52,7 +55,7 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
52
55
  let consecutiveTransportErrors = 0;
53
56
  while (true) {
54
57
  debug('streamLogs creating session app=%s generation=%s tail=%s', appIdentity, generation ?? '<unknown>', effectiveTail);
55
- const session = await ctx.platform.logSession.create(appIdentity, createOpts);
58
+ const session = await platform.logSession.create(appIdentity, createOpts);
56
59
  if (!session.logplex_url) {
57
60
  throw new Error('Log session response did not include a logplex_url.');
58
61
  }
@@ -64,15 +67,12 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
64
67
  try {
65
68
  // Logplex URLs hit a region-specific stream host (e.g.
66
69
  // virginia.sessions.logs.heroku.com). The token is embedded in
67
- // the URL, so we use a custom-service client with no auth and
68
- // a per-call baseUrl; this also picks up heroku-fetch's
69
- // env-var proxy support.
70
+ // the URL, so we use a custom-host client with no auth and a
71
+ // per-call baseUrl, inheriting only the SDK's identity and
72
+ // cancellation; this also picks up heroku-fetch's env-var proxy
73
+ // support.
70
74
  const parsed = new URL(session.logplex_url);
71
- const logplexClient = new HerokuApiClient({
72
- baseUrl: parsed.origin,
73
- service: 'custom',
74
- token: '',
75
- });
75
+ const logplexClient = makeCustomHostClient(ctx, parsed.origin);
76
76
  // Fir's telemetry-proxy sends newline-delimited plain text by
77
77
  // default, but concatenates records with zero delimiter bytes —
78
78
  // splitting on '\n' yields nothing. Requesting SSE flips the
@@ -81,6 +81,7 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
81
81
  // header and keeps sending `text/plain`).
82
82
  const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`, {
83
83
  headers: { Accept: 'text/event-stream' },
84
+ signal,
84
85
  });
85
86
  if (!response.body) {
86
87
  throw new Error('Logplex stream returned no body.');
@@ -1,5 +1,6 @@
1
1
  import createDebug from 'debug';
2
2
  import { extendResource } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
3
4
  const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
4
5
  const APPS_FILTER_LIMIT = 1000;
5
6
  /**
@@ -13,7 +14,9 @@ const APPS_FILTER_LIMIT = 1000;
13
14
  */
14
15
  export async function listPipelineApps(ctx, pipelineId, options = {}) {
15
16
  options.signal?.throwIfAborted();
16
- const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
17
+ // Bind the signal so both requests abort in flight, not just at the checkpoints.
18
+ const platform = withSignal(ctx.platform, options.signal);
19
+ const allCouplings = await platform.pipelineCoupling.listByPipeline(pipelineId);
17
20
  // Drop malformed couplings (no app id) before issuing the bulk filter call.
18
21
  const couplings = allCouplings.filter(coupling => coupling.app?.id);
19
22
  const skipped = allCouplings.length - couplings.length;
@@ -35,7 +38,7 @@ export async function listPipelineApps(ctx, pipelineId, options = {}) {
35
38
  }
36
39
  options.signal?.throwIfAborted();
37
40
  const ids = couplingsToResolve.map(coupling => coupling.app.id);
38
- const apps = await ctx.platform
41
+ const apps = await platform
39
42
  .withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
40
43
  .filterApps.apps({ in: { id: ids } });
41
44
  if (apps.length !== ids.length) {
@@ -1,5 +1,5 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
1
2
  import type { Pipeline } from '@heroku/types/3.sdk';
2
- import { type HerokuApiClientOptions } from '@heroku/heroku-fetch';
3
3
  import type { ResourceCtx } from '../../../core/extend-resource.js';
4
4
  export type ResolvePipelineOptions = {
5
5
  clientOptions?: HerokuApiClientOptions;
@@ -47,7 +47,7 @@ export declare class PipelineAmbiguousError extends Error {
47
47
  * that want to prompt the user should catch this and inspect
48
48
  * `error.matches`.
49
49
  */
50
- export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
50
+ export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
51
51
  export declare const pipelineExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "pipeline", {
52
52
  resolve: (identity: string, options?: ResolvePipelineOptions) => Promise<Pipeline>;
53
53
  }>;
@@ -1,5 +1,5 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
- import { extendResource } from '../../../core/extend-resource.js';
1
+ import { extendResource, makeCtxClient } from '../../../core/extend-resource.js';
2
+ import { withSignal } from '../../../core/with-signal.js';
3
3
  export class PipelineNotFoundError extends Error {
4
4
  identity;
5
5
  id = 'not_found';
@@ -52,14 +52,16 @@ const UUID_RE = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
52
52
  export async function resolvePipeline(ctx, identity, options = {}) {
53
53
  options.signal?.throwIfAborted();
54
54
  if (UUID_RE.test(identity)) {
55
- return ctx.platform.pipeline.info(identity);
55
+ // Bind the signal so the lookup aborts in flight.
56
+ return withSignal(ctx.platform, options.signal).pipeline.info(identity);
56
57
  }
57
- const apiClient = new HerokuApiClient({
58
+ const apiClient = makeCtxClient(ctx, 'platform', {
58
59
  ...options.clientOptions,
59
60
  service: 'platform',
60
61
  });
61
62
  const response = await apiClient.get('/pipelines', {
62
63
  searchParams: { 'eq[name]': identity },
64
+ signal: options.signal,
63
65
  });
64
66
  const matches = (await response.json());
65
67
  if (matches.length === 0) {
@@ -15,7 +15,7 @@ export type PromotePipelineResult = {
15
15
  promotion: PipelinePromotion;
16
16
  targets: PipelinePromotionTarget[];
17
17
  };
18
- export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
18
+ export declare function promotePipeline(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
19
19
  export declare const pipelinePromotionExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "pipelinePromotion", {
20
20
  promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
21
21
  }>;
@@ -1,28 +1,32 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
1
  import createDebug from 'debug';
3
- import { extendResource } from '../../../core/extend-resource.js';
2
+ import { extendResource, makeCustomHostClient } from '../../../core/extend-resource.js';
3
+ import { withSignal } from '../../../core/with-signal.js';
4
+ import { isAbortError } from '../../../utils/is-abort-error.js';
4
5
  import { wait } from '../../../utils/wait.js';
5
6
  const debug = createDebug('heroku:sdk:resources:pipeline-promotion');
6
7
  const DEFAULT_INTERVAL_MS = 1000;
7
8
  const DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS = 100;
8
9
  export async function promotePipeline(ctx, body, options = {}) {
9
10
  const { intervalMs = DEFAULT_INTERVAL_MS, onReleaseStream, releaseStreamMaxAttempts = DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS, signal, timeoutMs, } = options;
11
+ // Bind the signal to the client so the create/poll/release-info requests
12
+ // abort in flight, not just the delay between polls.
13
+ const platform = withSignal(ctx.platform, signal);
10
14
  debug('promote source=%s targets=%d', body.source?.app?.id ?? '<unknown>', body.targets?.length ?? 0);
11
- const promotion = await ctx.platform.pipelinePromotion.create(body);
15
+ const promotion = await platform.pipelinePromotion.create(body);
12
16
  if (!promotion.id) {
13
17
  throw new Error('Pipeline promotion response did not include an id');
14
18
  }
15
19
  debug('promote created promotion=%s intervalMs=%d timeoutMs=%s', promotion.id, intervalMs, timeoutMs ?? '<none>');
16
20
  const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
17
21
  const streamRunner = onReleaseStream
18
- ? createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
22
+ ? createReleaseStreamRunner(ctx, platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
19
23
  : undefined;
20
24
  let poll = 0;
21
25
  while (true) {
22
26
  signal?.throwIfAborted();
23
27
  poll++;
24
28
  // eslint-disable-next-line no-await-in-loop
25
- const targets = await ctx.platform.pipelinePromotionTarget.list(promotion.id);
29
+ const targets = await platform.pipelinePromotionTarget.list(promotion.id);
26
30
  debug('promote poll=%d promotion=%s statuses=%o', poll, promotion.id, targets.map(target => target.status));
27
31
  if (targets.every(target => target.status !== 'pending')) {
28
32
  debug('promote terminal promotion=%s polls=%d', promotion.id, poll);
@@ -43,7 +47,7 @@ export async function promotePipeline(ctx, body, options = {}) {
43
47
  * output stream and hands it to `onReleaseStream`. Subsequent calls are
44
48
  * no-ops; the stream is one-shot per promotion.
45
49
  */
46
- function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
50
+ function createReleaseStreamRunner(ctx, platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
47
51
  let handled = false;
48
52
  return async (targets) => {
49
53
  if (handled || targets.length !== 1)
@@ -53,30 +57,27 @@ function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempt
53
57
  return;
54
58
  handled = true;
55
59
  debug('promote release-stream lookup app=%s release=%s', target.app.id, target.release.id);
56
- const release = await ctx.platform.release.info(target.app.id, target.release.id);
60
+ const release = await platform.release.info(target.app.id, target.release.id);
57
61
  if (!release.output_stream_url) {
58
62
  debug('promote release-stream skipped reason=no-output-stream-url');
59
63
  return;
60
64
  }
61
65
  debug('promote release-stream fetching url=%s maxAttempts=%d', release.output_stream_url, releaseStreamMaxAttempts);
62
- const stream = await fetchReleaseOutput(release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
66
+ const stream = await fetchReleaseOutput(ctx, release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
63
67
  await onReleaseStream({ stream, target });
64
68
  };
65
69
  }
66
70
  export const pipelinePromotionExtensions = extendResource('platform', 'pipelinePromotion', ctx => ({
67
71
  promote: (body, options) => promotePipeline(ctx, body, options),
68
72
  }));
69
- async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
73
+ async function fetchReleaseOutput(ctx, url, maxAttempts, intervalMs, signal) {
70
74
  const parsed = new URL(url);
71
75
  const path = `${parsed.pathname}${parsed.search}`;
72
76
  // The release output URL points at a third-party stream host (e.g. busl).
73
- // Use a custom-service client so the platform prefixUrl and bearer token
74
- // don't leak across origins.
75
- const buslClient = new HerokuApiClient({
76
- baseUrl: parsed.origin,
77
- service: 'custom',
78
- token: '',
79
- });
77
+ // Use a custom-host client so the platform prefixUrl, bearer token, and
78
+ // other auth/routing options don't leak across origins, while still
79
+ // inheriting the SDK's identity (User-Agent) and cancellation signal.
80
+ const buslClient = makeCustomHostClient(ctx, parsed.origin);
80
81
  let attempt = 0;
81
82
  while (true) {
82
83
  signal?.throwIfAborted();
@@ -84,9 +85,12 @@ async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
84
85
  let response;
85
86
  try {
86
87
  // eslint-disable-next-line no-await-in-loop
87
- response = await buslClient.stream(path);
88
+ response = await buslClient.stream(path, { signal });
88
89
  }
89
- catch {
90
+ catch (error) {
91
+ // An abort is terminal, not a retryable miss — let it propagate.
92
+ if (signal?.aborted || isAbortError(error))
93
+ throw error;
90
94
  // Treat HTTP errors thrown by the api-client as a retryable miss.
91
95
  response = undefined;
92
96
  }
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { debug } from './debug.js';
2
3
  /**
3
4
  * Resolves the GitHub repository name for a canonical pipeline ID.
@@ -9,7 +10,7 @@ import { debug } from './debug.js';
9
10
  export async function resolveRepoName(ctx, pipelineId, options = {}) {
10
11
  const { signal } = options;
11
12
  signal?.throwIfAborted();
12
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
13
+ const platform = withSignal(ctx.platform, signal);
13
14
  let useRepositoriesApi;
14
15
  try {
15
16
  const feature = await platform.accountFeature.info('dashboard-repositories-api');
@@ -22,7 +23,7 @@ export async function resolveRepoName(ctx, pipelineId, options = {}) {
22
23
  }
23
24
  if (useRepositoriesApi) {
24
25
  try {
25
- const repositoriesApi = signal ? ctx.repositoriesApi.withOptions({ signal }) : ctx.repositoriesApi;
26
+ const repositoriesApi = withSignal(ctx.repositoriesApi, signal);
26
27
  const repository = await repositoriesApi.githubRepository.info(pipelineId);
27
28
  signal?.throwIfAborted();
28
29
  const repositoryName = repository.full_name?.trim();
@@ -40,7 +41,7 @@ export async function resolveRepoName(ctx, pipelineId, options = {}) {
40
41
  signal?.throwIfAborted();
41
42
  debug('repositories API disabled; falling back');
42
43
  }
43
- const repositories = signal ? ctx.repositories.withOptions({ signal }) : ctx.repositories;
44
+ const repositories = withSignal(ctx.repositories, signal);
44
45
  const pipelineRepository = await repositories.pipelineRepository.info(pipelineId);
45
46
  signal?.throwIfAborted();
46
47
  const repositoryName = pipelineRepository.repository.name.trim();
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { waitForReady } from '../domain/wait-for-ready.js';
2
3
  const DEFAULT_WAIT_INTERVAL_MS = 1000;
3
4
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -35,7 +36,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
35
36
  export async function createAndAssociate(ctx, appIdentity, certificateChain, privateKey, options) {
36
37
  const { domainPoller, intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
37
38
  signal?.throwIfAborted();
38
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
39
+ const platform = withSignal(ctx.platform, signal);
39
40
  const sniEndpoint = await platform.sniEndpoint.create(appIdentity, {
40
41
  certificate_chain: certificateChain,
41
42
  private_key: privateKey,
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  import { SpaceNotAllocatedError, SpaceNotReadyError } from './errors.js';
3
4
  const DEFAULT_INTERVAL_MS = 5000;
@@ -22,10 +23,7 @@ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
22
23
  export async function waitForAllocated(ctx, name, options = {}) {
23
24
  const { includeNat, intervalMs = DEFAULT_INTERVAL_MS, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
24
25
  signal?.throwIfAborted();
25
- let platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'region' });
26
- if (signal) {
27
- platform = platform.withOptions({ signal });
28
- }
26
+ const platform = withSignal(ctx.platform.withHeaders({ 'Accept-Expansion': 'region' }), signal);
29
27
  const deadline = Date.now() + timeoutMs;
30
28
  let space = await platform.space.info(name);
31
29
  while (space.state === 'allocating') {
@@ -0,0 +1,22 @@
1
+ import type { Team, TeamDelinquency } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../core/extend-resource.js';
3
+ export type GetTeamWithDelinquencyOptions = {
4
+ signal?: AbortSignal;
5
+ };
6
+ export type TeamWithDelinquency = {
7
+ delinquency: null | TeamDelinquency;
8
+ team: Team;
9
+ };
10
+ /**
11
+ * Fetch a team together with its delinquency status.
12
+ *
13
+ * Runs the two reads concurrently. The team lookup hard-fails — there is no
14
+ * result without it. The delinquency lookup soft-fails to `null`: a hiccup on
15
+ * that secondary call must not sink an otherwise-successful team fetch. Callers
16
+ * decide whether and how to surface a warning from `delinquency` —
17
+ * `formatDelinquencyWarning` renders the sentence.
18
+ */
19
+ export declare function getTeamWithDelinquency(ctx: Pick<ResourceCtx, 'platform'>, teamIdentity: string, options?: GetTeamWithDelinquencyOptions): Promise<TeamWithDelinquency>;
20
+ export declare const teamExtensions: import("../../core/extend-resource.js").ResourceExtension<"platform", "team", {
21
+ getWithDelinquency: (teamIdentity: string, options?: GetTeamWithDelinquencyOptions) => Promise<TeamWithDelinquency>;
22
+ }>;
@@ -0,0 +1,34 @@
1
+ import createDebug from 'debug';
2
+ import { extendResource } from '../../core/extend-resource.js';
3
+ const debug = createDebug('heroku:sdk:resources:team');
4
+ /**
5
+ * Fetch a team together with its delinquency status.
6
+ *
7
+ * Runs the two reads concurrently. The team lookup hard-fails — there is no
8
+ * result without it. The delinquency lookup soft-fails to `null`: a hiccup on
9
+ * that secondary call must not sink an otherwise-successful team fetch. Callers
10
+ * decide whether and how to surface a warning from `delinquency` —
11
+ * `formatDelinquencyWarning` renders the sentence.
12
+ */
13
+ export async function getTeamWithDelinquency(ctx, teamIdentity, options = {}) {
14
+ options.signal?.throwIfAborted();
15
+ const platform = options.signal
16
+ ? ctx.platform.withOptions({ signal: options.signal })
17
+ : ctx.platform;
18
+ const [team, delinquency] = await Promise.all([
19
+ platform.team.info(teamIdentity),
20
+ platform.teamDelinquency.info(teamIdentity).catch((error) => {
21
+ // Soft-fail a secondary-call hiccup to null, but rethrow a cancellation:
22
+ // if the caller aborted, returning null would misreport a delinquent team
23
+ // as current. Anything else is logged and swallowed.
24
+ if (options.signal?.aborted)
25
+ throw error;
26
+ debug('team delinquency lookup failed; treating as not delinquent: %O', error);
27
+ return null;
28
+ }),
29
+ ]);
30
+ return { delinquency, team };
31
+ }
32
+ export const teamExtensions = extendResource('platform', 'team', ctx => ({
33
+ getWithDelinquency: (teamIdentity, options) => getTeamWithDelinquency(ctx, teamIdentity, options),
34
+ }));
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  /**
2
3
  * Remove all telemetry drains for a given app or space.
3
4
  *
@@ -11,7 +12,7 @@ export async function removeDrainsForTarget(ctx, target, options = {}) {
11
12
  const { signal } = options;
12
13
  const { app, space } = target;
13
14
  signal?.throwIfAborted();
14
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
+ const platform = withSignal(ctx.platform, signal);
15
16
  let drains = [];
16
17
  if (app) {
17
18
  drains = await platform.telemetryDrain.listByApp(app);