@heroku/sdk 0.1.0-beta.0 → 0.2.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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/dist/compositions/add-on.d.ts +0 -7
  3. package/dist/compositions/add-on.js +0 -6
  4. package/dist/compositions/app.d.ts +0 -8
  5. package/dist/compositions/app.js +0 -11
  6. package/dist/compositions/dyno.d.ts +0 -17
  7. package/dist/compositions/dyno.js +0 -23
  8. package/dist/compositions/pg.d.ts +0 -15
  9. package/dist/compositions/pg.js +0 -56
  10. package/dist/compositions/pipeline.d.ts +0 -33
  11. package/dist/compositions/pipeline.js +0 -146
  12. package/dist/resources/data/backup.d.ts +0 -9
  13. package/dist/resources/data/backup.js +0 -10
  14. package/dist/resources/data/internal/resolve-addon-id.d.ts +0 -2
  15. package/dist/resources/data/internal/resolve-addon-id.js +0 -13
  16. package/dist/resources/data/postgres-credential.d.ts +0 -11
  17. package/dist/resources/data/postgres-credential.js +0 -16
  18. package/dist/resources/data/postgres.d.ts +0 -9
  19. package/dist/resources/data/postgres.js +0 -10
  20. package/dist/resources/data/transfer-schedule.d.ts +0 -9
  21. package/dist/resources/data/transfer-schedule.js +0 -10
  22. package/dist/resources/platform/add-on.d.ts +0 -211
  23. package/dist/resources/platform/add-on.js +0 -354
  24. package/dist/resources/platform/app.d.ts +0 -56
  25. package/dist/resources/platform/app.js +0 -50
  26. package/dist/resources/platform/dyno.d.ts +0 -45
  27. package/dist/resources/platform/dyno.js +0 -26
  28. package/dist/resources/platform/log-session.d.ts +0 -81
  29. package/dist/resources/platform/log-session.js +0 -167
  30. package/dist/resources/platform/pipeline-coupling.d.ts +0 -27
  31. package/dist/resources/platform/pipeline-coupling.js +0 -55
  32. package/dist/resources/platform/pipeline-promotion.d.ts +0 -21
  33. package/dist/resources/platform/pipeline-promotion.js +0 -128
  34. package/dist/resources/platform/pipeline.d.ts +0 -53
  35. package/dist/resources/platform/pipeline.js +0 -75
@@ -1,45 +0,0 @@
1
- import type { Formation } from '@heroku/types/3.sdk';
2
- import type { ResourceCtx } from '../../core/extend-resource.js';
3
- import type { StickyRouteOptions } from '../../core/create-client.js';
4
- export type DynoOptions = {
5
- signal?: AbortSignal;
6
- };
7
- export type ScaleDynosUpdate = {
8
- dyno_size?: {
9
- id?: string;
10
- name?: string;
11
- };
12
- quantity?: number | string;
13
- size?: string;
14
- type: string;
15
- };
16
- export type RestartDynosTarget = {
17
- dyno: string;
18
- } | {
19
- type: string;
20
- };
21
- type FormationClient = {
22
- batchUpdate(appIdentity: string, body: {
23
- updates: ScaleDynosUpdate[];
24
- }): Promise<Formation[]>;
25
- update(appIdentity: string, type: string, body: Omit<ScaleDynosUpdate, 'type'>): Promise<Formation>;
26
- };
27
- type ScalePlatform = {
28
- formation: FormationClient;
29
- withOptions(opts: StickyRouteOptions): ScalePlatform;
30
- };
31
- export declare function scaleDynos(ctx: {
32
- platform: ScalePlatform;
33
- }, appIdentity: string, updates: ScaleDynosUpdate, options?: DynoOptions): Promise<Formation>;
34
- export declare function scaleDynos(ctx: {
35
- platform: ScalePlatform;
36
- }, appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
37
- export declare function restartDynos(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions): Promise<void>;
38
- export declare const dynoExtensions: import("../../index.js").ResourceExtension<"platform", "dyno", {
39
- restart: (appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions) => Promise<void>;
40
- scale: {
41
- (appIdentity: string, updates: ScaleDynosUpdate, options?: DynoOptions): Promise<Formation>;
42
- (appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
43
- };
44
- }>;
45
- export {};
@@ -1,26 +0,0 @@
1
- import { extendResource } from '../../core/extend-resource.js';
2
- export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
3
- options.signal?.throwIfAborted();
4
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
5
- if (Array.isArray(updates)) {
6
- return platform.formation.batchUpdate(appIdentity, { updates });
7
- }
8
- const { type, ...body } = updates;
9
- return platform.formation.update(appIdentity, type, body);
10
- }
11
- export async function restartDynos(ctx, appIdentity, target, options = {}) {
12
- options.signal?.throwIfAborted();
13
- if (!target) {
14
- await ctx.platform.dyno.restartAll(appIdentity);
15
- return;
16
- }
17
- if ('dyno' in target) {
18
- await ctx.platform.dyno.restart(appIdentity, target.dyno);
19
- return;
20
- }
21
- await ctx.platform.dyno.restartFormation(appIdentity, target.type);
22
- }
23
- export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
24
- restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
25
- scale: ((appIdentity, updates, options) => scaleDynos(ctx, appIdentity, updates, options)),
26
- }));
@@ -1,81 +0,0 @@
1
- import type { ResourceCtx } from '../../core/extend-resource.js';
2
- export type StreamLogsOptions = {
3
- /** Limit output to a single dyno (e.g. `web.1` on Cedar, `web-abc-123` on Fir). */
4
- dyno?: string;
5
- /**
6
- * Number of recent lines to fetch before tailing. Cedar-generation
7
- * apps only — silently ignored on Fir.
8
- */
9
- lines?: number;
10
- /**
11
- * Fires once per session creation, before the SDK opens the
12
- * `logplex_url` stream. Receives the resolved generation so the
13
- * caller can surface generation-specific UX (e.g. "Fetching
14
- * logs..." for Fir, where provisioning the stream takes a moment).
15
- *
16
- * Fires on every recreate when tailing.
17
- */
18
- onSessionCreated?: (info: {
19
- generation: 'cedar' | 'fir' | undefined;
20
- isRecreate: boolean;
21
- }) => Promise<void> | void;
22
- /**
23
- * When `tail` is true, watch for the platform's idle timeout and
24
- * recreate the log session to keep streaming. Defaults to true.
25
- * Set to false if you want a single bounded stream and surface the
26
- * disconnect to the caller.
27
- */
28
- recreateSession?: boolean;
29
- /**
30
- * If `recreateSession` is true, the SDK forces a recreate after
31
- * this many milliseconds without seeing data. Defaults to
32
- * 15 minutes (matches the Fir platform timeout).
33
- */
34
- sessionTimeoutMs?: number;
35
- signal?: AbortSignal;
36
- /** Limit output to a single source (e.g. `app`, `heroku`). */
37
- source?: string;
38
- /**
39
- * If true, the platform keeps the stream open and pushes lines as
40
- * they arrive. The platform may close the connection on its own
41
- * cadence (Fir is documented to time out around 15 minutes); when
42
- * `recreateSession` is also true, the SDK transparently opens a
43
- * new session and continues yielding lines.
44
- *
45
- * Defaults to false. Note: Fir-generation apps always tail (the
46
- * platform doesn't support a bounded log session there), so the
47
- * SDK forces `tail: true` for Fir regardless of what the caller
48
- * asked for.
49
- */
50
- tail?: boolean;
51
- /**
52
- * Limit output to a process type (e.g. `web`, `worker`). On Cedar
53
- * the underlying API combines `dyno` and `type` into a single
54
- * field; the SDK handles that translation for you.
55
- */
56
- type?: string;
57
- };
58
- /**
59
- * Stream logs for an app, yielding one line at a time.
60
- *
61
- * Creates a log session via `logSession.create`, opens the resulting
62
- * `logplex_url`, and parses the chunked plain-text response into
63
- * newline-delimited entries. Use `tail: true` to keep the stream
64
- * open; `recreateSession: true` (the default when tailing) makes the
65
- * SDK transparently re-open a new session after the platform's idle
66
- * timeout, so the iterator keeps yielding without the caller having
67
- * to handle the reconnect.
68
- *
69
- * The iterator terminates cleanly when:
70
- * - the platform closes a non-tailing stream (all lines yielded)
71
- * - `signal` is aborted (an `AbortError` propagates)
72
- * - `recreateSession` is false and the platform closes a tail
73
- *
74
- * The iterator throws on:
75
- * - non-2xx response from the logplex URL
76
- * - errors thrown by `logSession.create` (auth, app-not-found, etc.)
77
- */
78
- export declare function streamLogs(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: StreamLogsOptions): AsyncGenerator<string, void, unknown>;
79
- export declare const logSessionExtensions: import("../../index.js").ResourceExtension<"platform", "logSession", {
80
- streamLogs: (appIdentity: string, options?: StreamLogsOptions) => AsyncGenerator<string, void, unknown>;
81
- }>;
@@ -1,167 +0,0 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
- import createDebug from 'debug';
3
- import { extendResource } from '../../core/extend-resource.js';
4
- import { getGeneration } from './app.js';
5
- const debug = createDebug('heroku:sdk:resources:log-session');
6
- const DEFAULT_FIR_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
7
- const FIR_TIMEOUT_ERROR_MESSAGE = 'Fir log stream timeout';
8
- /**
9
- * Stream logs for an app, yielding one line at a time.
10
- *
11
- * Creates a log session via `logSession.create`, opens the resulting
12
- * `logplex_url`, and parses the chunked plain-text response into
13
- * newline-delimited entries. Use `tail: true` to keep the stream
14
- * open; `recreateSession: true` (the default when tailing) makes the
15
- * SDK transparently re-open a new session after the platform's idle
16
- * timeout, so the iterator keeps yielding without the caller having
17
- * to handle the reconnect.
18
- *
19
- * The iterator terminates cleanly when:
20
- * - the platform closes a non-tailing stream (all lines yielded)
21
- * - `signal` is aborted (an `AbortError` propagates)
22
- * - `recreateSession` is false and the platform closes a tail
23
- *
24
- * The iterator throws on:
25
- * - non-2xx response from the logplex URL
26
- * - errors thrown by `logSession.create` (auth, app-not-found, etc.)
27
- */
28
- export async function* streamLogs(ctx, appIdentity, options = {}) {
29
- const { dyno, lines, sessionTimeoutMs = DEFAULT_FIR_SESSION_TIMEOUT_MS, signal, source, tail = false, type, } = options;
30
- signal?.throwIfAborted();
31
- const generation = await getGeneration(ctx, appIdentity, { signal });
32
- const isFir = generation === 'fir';
33
- // Fir doesn't support a bounded session; the platform always streams.
34
- const effectiveTail = isFir ? true : tail;
35
- const recreateSession = options.recreateSession ?? effectiveTail;
36
- const createOpts = buildCreateOpts({
37
- dyno, isFir, lines, source, tail: effectiveTail, type,
38
- });
39
- // The recreate loop is inherently sequential: each session must
40
- // close before we ask for the next one.
41
- /* eslint-disable no-await-in-loop */
42
- let isRecreate = false;
43
- while (true) {
44
- debug('streamLogs creating session app=%s generation=%s tail=%s', appIdentity, generation ?? '<unknown>', effectiveTail);
45
- const session = await ctx.platform.logSession.create(appIdentity, createOpts);
46
- if (!session.logplex_url) {
47
- throw new Error('Log session response did not include a logplex_url.');
48
- }
49
- await options.onSessionCreated?.({ generation, isRecreate });
50
- isRecreate = true;
51
- debug('streamLogs session=%s opening stream', session.id);
52
- let timedOut = false;
53
- let timeoutHandle;
54
- try {
55
- // Logplex URLs hit a region-specific stream host (e.g.
56
- // virginia.sessions.logs.heroku.com). The token is embedded in
57
- // the URL, so we use a custom-service client with no auth and
58
- // a per-call baseUrl; this also picks up heroku-fetch's
59
- // env-var proxy support.
60
- const parsed = new URL(session.logplex_url);
61
- const logplexClient = new HerokuApiClient({
62
- baseUrl: parsed.origin,
63
- service: 'custom',
64
- token: '',
65
- });
66
- const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`);
67
- if (!response.body) {
68
- throw new Error('Logplex stream returned no body.');
69
- }
70
- const reader = response.body.getReader();
71
- const decoder = new TextDecoder();
72
- let pending = '';
73
- // Force a recreate after sessionTimeoutMs of silence; ignored
74
- // when not tailing.
75
- const armTimeout = () => {
76
- if (!recreateSession)
77
- return;
78
- if (timeoutHandle)
79
- clearTimeout(timeoutHandle);
80
- timeoutHandle = setTimeout(() => {
81
- timedOut = true;
82
- debug('streamLogs session=%s timed out after %dms', session.id, sessionTimeoutMs);
83
- reader.cancel().catch(() => { });
84
- }, sessionTimeoutMs);
85
- };
86
- armTimeout();
87
- try {
88
- while (true) {
89
- const { done, value } = await reader.read();
90
- if (done)
91
- break;
92
- armTimeout();
93
- pending += decoder.decode(value, { stream: true });
94
- let newlineIndex = pending.indexOf('\n');
95
- while (newlineIndex !== -1) {
96
- const line = pending.slice(0, newlineIndex);
97
- pending = pending.slice(newlineIndex + 1);
98
- if (line.length > 0)
99
- yield line;
100
- newlineIndex = pending.indexOf('\n');
101
- }
102
- }
103
- // Flush any trailing partial line that didn't end in '\n'.
104
- const trailing = pending + decoder.decode();
105
- if (trailing.length > 0)
106
- yield trailing;
107
- }
108
- finally {
109
- if (timeoutHandle)
110
- clearTimeout(timeoutHandle);
111
- reader.releaseLock();
112
- }
113
- if (timedOut) {
114
- debug('streamLogs session=%s recreating after timeout', session.id);
115
- continue;
116
- }
117
- if (!recreateSession) {
118
- return;
119
- }
120
- debug('streamLogs session=%s remote closed; recreating', session.id);
121
- }
122
- catch (error) {
123
- if (timeoutHandle)
124
- clearTimeout(timeoutHandle);
125
- if (signal?.aborted)
126
- throw error;
127
- if (timedOut)
128
- continue;
129
- // Translate Fir's documented timeout error message into a recreate.
130
- if (error instanceof Error && error.message === FIR_TIMEOUT_ERROR_MESSAGE && recreateSession) {
131
- continue;
132
- }
133
- throw error;
134
- }
135
- }
136
- /* eslint-enable no-await-in-loop */
137
- }
138
- /**
139
- * Translate the SDK's consumer-facing options into the shape the
140
- * platform's `log-sessions` endpoint expects per generation:
141
- * - Cedar's session takes a single `dyno` field that historically
142
- * accepted either a process type or a specific dyno; we collapse
143
- * `dyno || type` for the caller. `lines` and `tail` are honored.
144
- * - Fir's session takes `dyno` and `type` separately and ignores
145
- * `lines`; the stream always tails.
146
- */
147
- function buildCreateOpts(options) {
148
- const createOpts = { source: options.source };
149
- if (options.isFir) {
150
- if (options.dyno)
151
- createOpts.dyno = options.dyno;
152
- if (options.type)
153
- createOpts.type = options.type;
154
- }
155
- else {
156
- const cedarDyno = options.dyno ?? options.type;
157
- if (cedarDyno)
158
- createOpts.dyno = cedarDyno;
159
- if (options.lines !== undefined)
160
- createOpts.lines = options.lines;
161
- createOpts.tail = options.tail;
162
- }
163
- return createOpts;
164
- }
165
- export const logSessionExtensions = extendResource('platform', 'logSession', ctx => ({
166
- streamLogs: (appIdentity, options) => streamLogs(ctx, appIdentity, options),
167
- }));
@@ -1,27 +0,0 @@
1
- import type { PipelineCoupling, TeamApp } from '@heroku/types/3.sdk';
2
- import type { ResourceCtx } from '../../core/extend-resource.js';
3
- export type PipelineWarning = {
4
- limit: number;
5
- pipelineId: string;
6
- type: 'apps_truncated';
7
- };
8
- export type ListPipelineAppsOptions = {
9
- onWarning?: (warning: PipelineWarning) => void;
10
- signal?: AbortSignal;
11
- };
12
- export type AppWithPipelineCoupling = TeamApp & {
13
- pipelineCoupling: PipelineCoupling;
14
- };
15
- /**
16
- * List the apps coupled to a pipeline, each decorated with its
17
- * `PipelineCoupling`.
18
- *
19
- * Couplings missing an app id are dropped before issuing the bulk
20
- * filter call. If the pipeline has more than 1000 couplings, an
21
- * `onWarning` handler must be supplied to opt into a truncated
22
- * result; otherwise this throws.
23
- */
24
- export declare function listPipelineApps(ctx: Pick<ResourceCtx, 'platform'>, pipelineId: string, options?: ListPipelineAppsOptions): Promise<AppWithPipelineCoupling[]>;
25
- export declare const pipelineCouplingExtensions: import("../../index.js").ResourceExtension<"platform", "pipelineCoupling", {
26
- listApps: (pipelineId: string, options?: ListPipelineAppsOptions) => Promise<AppWithPipelineCoupling[]>;
27
- }>;
@@ -1,55 +0,0 @@
1
- import createDebug from 'debug';
2
- import { extendResource } from '../../core/extend-resource.js';
3
- const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
4
- const APPS_FILTER_LIMIT = 1000;
5
- /**
6
- * List the apps coupled to a pipeline, each decorated with its
7
- * `PipelineCoupling`.
8
- *
9
- * Couplings missing an app id are dropped before issuing the bulk
10
- * filter call. If the pipeline has more than 1000 couplings, an
11
- * `onWarning` handler must be supplied to opt into a truncated
12
- * result; otherwise this throws.
13
- */
14
- export async function listPipelineApps(ctx, pipelineId, options = {}) {
15
- options.signal?.throwIfAborted();
16
- const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
17
- // Drop malformed couplings (no app id) before issuing the bulk filter call.
18
- const couplings = allCouplings.filter(coupling => coupling.app?.id);
19
- const skipped = allCouplings.length - couplings.length;
20
- if (skipped > 0) {
21
- debug('listPipelineApps pipeline=%s skipped=%d couplings without app.id', pipelineId, skipped);
22
- }
23
- if (couplings.length === 0) {
24
- return [];
25
- }
26
- let couplingsToResolve = couplings;
27
- if (couplings.length > APPS_FILTER_LIMIT) {
28
- if (!options.onWarning) {
29
- throw new Error(`Pipeline ${pipelineId} has more than ${APPS_FILTER_LIMIT} apps. `
30
- + 'Pass an onWarning handler to opt into a truncated result.');
31
- }
32
- debug('listPipelineApps pipeline=%s truncating couplings=%d limit=%d', pipelineId, couplings.length, APPS_FILTER_LIMIT);
33
- options.onWarning({ limit: APPS_FILTER_LIMIT, pipelineId, type: 'apps_truncated' });
34
- couplingsToResolve = couplings.slice(0, APPS_FILTER_LIMIT);
35
- }
36
- options.signal?.throwIfAborted();
37
- const ids = couplingsToResolve.map(coupling => coupling.app.id);
38
- const apps = await ctx.platform
39
- .withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
40
- .filterApps.apps({ in: { id: ids } });
41
- if (apps.length !== ids.length) {
42
- debug('listPipelineApps pipeline=%s requested=%d returned=%d', pipelineId, ids.length, apps.length);
43
- }
44
- const couplingByAppId = new Map();
45
- for (const coupling of couplingsToResolve) {
46
- couplingByAppId.set(coupling.app.id, coupling);
47
- }
48
- return apps.map(app => ({
49
- ...app,
50
- pipelineCoupling: couplingByAppId.get(app.id),
51
- }));
52
- }
53
- export const pipelineCouplingExtensions = extendResource('platform', 'pipelineCoupling', ctx => ({
54
- listApps: (pipelineId, options) => listPipelineApps(ctx, pipelineId, options),
55
- }));
@@ -1,21 +0,0 @@
1
- import type { PipelinePromotion, PipelinePromotionCreateOpts, PipelinePromotionTarget } from '@heroku/types/3.sdk';
2
- import type { ResourceCtx } from '../../core/extend-resource.js';
3
- export type ReleaseStreamContext = {
4
- stream: ReadableStream<Uint8Array>;
5
- target: PipelinePromotionTarget;
6
- };
7
- export type PromotePipelineOptions = {
8
- intervalMs?: number;
9
- onReleaseStream?: (context: ReleaseStreamContext) => Promise<void> | void;
10
- releaseStreamMaxAttempts?: number;
11
- signal?: AbortSignal;
12
- timeoutMs?: number;
13
- };
14
- export type PromotePipelineResult = {
15
- promotion: PipelinePromotion;
16
- targets: PipelinePromotionTarget[];
17
- };
18
- export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
19
- export declare const pipelinePromotionExtensions: import("../../index.js").ResourceExtension<"platform", "pipelinePromotion", {
20
- promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
21
- }>;
@@ -1,128 +0,0 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
- import createDebug from 'debug';
3
- import { extendResource } from '../../core/extend-resource.js';
4
- const debug = createDebug('heroku:sdk:resources:pipeline-promotion');
5
- const DEFAULT_INTERVAL_MS = 1000;
6
- const DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS = 100;
7
- export async function promotePipeline(ctx, body, options = {}) {
8
- const { intervalMs = DEFAULT_INTERVAL_MS, onReleaseStream, releaseStreamMaxAttempts = DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS, signal, timeoutMs, } = options;
9
- debug('promote source=%s targets=%d', body.source?.app?.id ?? '<unknown>', body.targets?.length ?? 0);
10
- const promotion = await ctx.platform.pipelinePromotion.create(body);
11
- if (!promotion.id) {
12
- throw new Error('Pipeline promotion response did not include an id');
13
- }
14
- debug('promote created promotion=%s intervalMs=%d timeoutMs=%s', promotion.id, intervalMs, timeoutMs ?? '<none>');
15
- const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
16
- const streamRunner = onReleaseStream
17
- ? createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
18
- : undefined;
19
- let poll = 0;
20
- while (true) {
21
- signal?.throwIfAborted();
22
- poll++;
23
- // eslint-disable-next-line no-await-in-loop
24
- const targets = await ctx.platform.pipelinePromotionTarget.list(promotion.id);
25
- debug('promote poll=%d promotion=%s statuses=%o', poll, promotion.id, targets.map(target => target.status));
26
- if (targets.every(target => target.status !== 'pending')) {
27
- debug('promote terminal promotion=%s polls=%d', promotion.id, poll);
28
- return { promotion, targets };
29
- }
30
- // eslint-disable-next-line no-await-in-loop
31
- await streamRunner?.(targets);
32
- if (deadline !== undefined && Date.now() >= deadline) {
33
- throw new Error(`Pipeline promotion ${promotion.id} did not reach a terminal state within ${timeoutMs}ms`);
34
- }
35
- // eslint-disable-next-line no-await-in-loop
36
- await wait(intervalMs, signal);
37
- }
38
- }
39
- /**
40
- * Returns a function that, on first call where `targets` is a single
41
- * promotion target with `release.id` and `app.id`, fetches the release's
42
- * output stream and hands it to `onReleaseStream`. Subsequent calls are
43
- * no-ops; the stream is one-shot per promotion.
44
- */
45
- function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
46
- let handled = false;
47
- return async (targets) => {
48
- if (handled || targets.length !== 1)
49
- return;
50
- const target = targets[0];
51
- if (!target.release?.id || !target.app?.id)
52
- return;
53
- handled = true;
54
- debug('promote release-stream lookup app=%s release=%s', target.app.id, target.release.id);
55
- const release = await ctx.platform.release.info(target.app.id, target.release.id);
56
- if (!release.output_stream_url) {
57
- debug('promote release-stream skipped reason=no-output-stream-url');
58
- return;
59
- }
60
- debug('promote release-stream fetching url=%s maxAttempts=%d', release.output_stream_url, releaseStreamMaxAttempts);
61
- const stream = await fetchReleaseOutput(release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
62
- await onReleaseStream({ stream, target });
63
- };
64
- }
65
- export const pipelinePromotionExtensions = extendResource('platform', 'pipelinePromotion', ctx => ({
66
- promote: (body, options) => promotePipeline(ctx, body, options),
67
- }));
68
- async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
69
- const parsed = new URL(url);
70
- const path = `${parsed.pathname}${parsed.search}`;
71
- // The release output URL points at a third-party stream host (e.g. busl).
72
- // Use a custom-service client so the platform prefixUrl and bearer token
73
- // don't leak across origins.
74
- const buslClient = new HerokuApiClient({
75
- baseUrl: parsed.origin,
76
- service: 'custom',
77
- token: '',
78
- });
79
- let attempt = 0;
80
- while (true) {
81
- signal?.throwIfAborted();
82
- attempt++;
83
- let response;
84
- try {
85
- // eslint-disable-next-line no-await-in-loop
86
- response = await buslClient.stream(path);
87
- }
88
- catch {
89
- // Treat HTTP errors thrown by the api-client as a retryable miss.
90
- response = undefined;
91
- }
92
- if (response?.ok && response.body) {
93
- debug('release-stream attempt=%d status=%d ok stream=true', attempt, response.status);
94
- return response.body;
95
- }
96
- debug('release-stream attempt=%d status=%s ok=false', attempt, response?.status ?? '<error>');
97
- if (response?.body) {
98
- // Drain so the connection can be reused.
99
- // eslint-disable-next-line no-await-in-loop
100
- await response.body.cancel().catch(() => { });
101
- }
102
- if (attempt >= maxAttempts) {
103
- throw new Error('stream release output not available');
104
- }
105
- // eslint-disable-next-line no-await-in-loop
106
- await wait(intervalMs, signal);
107
- }
108
- }
109
- function wait(ms, signal) {
110
- return new Promise((resolve, reject) => {
111
- function onAbort() {
112
- clearTimeout(timer);
113
- reject(signal.reason ?? new Error('Aborted'));
114
- }
115
- const timer = setTimeout(() => {
116
- signal?.removeEventListener('abort', onAbort);
117
- resolve();
118
- }, ms);
119
- if (signal) {
120
- if (signal.aborted) {
121
- clearTimeout(timer);
122
- reject(signal.reason ?? new Error('Aborted'));
123
- return;
124
- }
125
- signal.addEventListener('abort', onAbort, { once: true });
126
- }
127
- });
128
- }
@@ -1,53 +0,0 @@
1
- import type { Pipeline } from '@heroku/types/3.sdk';
2
- import { type HerokuApiClientOptions } from '@heroku/heroku-fetch';
3
- import type { ResourceCtx } from '../../core/extend-resource.js';
4
- export type ResolvePipelineOptions = {
5
- clientOptions?: HerokuApiClientOptions;
6
- signal?: AbortSignal;
7
- };
8
- export declare class PipelineNotFoundError extends Error {
9
- readonly identity: string;
10
- readonly id = "not_found";
11
- readonly statusCode = 404;
12
- constructor(identity: string);
13
- get body(): {
14
- id: string;
15
- message: string;
16
- resource: string;
17
- };
18
- }
19
- export declare class PipelineAmbiguousError extends Error {
20
- readonly identity: string;
21
- readonly matches: Pipeline[];
22
- readonly id = "multiple_matches";
23
- readonly statusCode = 422;
24
- constructor(identity: string, matches: Pipeline[]);
25
- get body(): {
26
- id: string;
27
- message: string;
28
- };
29
- }
30
- /**
31
- * Resolve a Pipeline by identity.
32
- *
33
- * The identity may be:
34
- * - a UUID — fetched directly via `pipeline.info`.
35
- * - a name — looked up via `GET /pipelines?eq[name]=<name>`. Pipeline
36
- * names are not unique platform-wide, so a name lookup may return
37
- * zero, one, or many matches.
38
- *
39
- * The name path uses a raw `HerokuApiClient` because the route
40
- * registry's `pipeline.list` doesn't accept query params, and we don't
41
- * want to bake those params into the routes-generated client (where
42
- * they would leak across unrelated calls).
43
- *
44
- * Errors:
45
- * - throws `PipelineNotFoundError` when no pipeline matches the name.
46
- * - throws `PipelineAmbiguousError` when more than one does. Callers
47
- * that want to prompt the user should catch this and inspect
48
- * `error.matches`.
49
- */
50
- export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
51
- export declare const pipelineExtensions: import("../../index.js").ResourceExtension<"platform", "pipeline", {
52
- resolve: (identity: string, options?: ResolvePipelineOptions) => Promise<Pipeline>;
53
- }>;