@heroku/sdk 0.5.4 → 0.5.5
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.
- package/dist/resources/platform/add-on/index.d.ts +4 -3
- package/dist/resources/platform/add-on/index.js +3 -2
- package/dist/resources/platform/add-on/pricing.d.ts +39 -10
- package/dist/resources/platform/add-on/pricing.js +56 -29
- package/dist/resources/platform/add-on/resolve.d.ts +22 -6
- package/dist/resources/platform/add-on/resolve.js +33 -13
- package/dist/resources/platform/add-on/types.d.ts +18 -0
- package/dist/resources/platform/container/remove-process-types.d.ts +6 -8
- package/dist/resources/platform/container/remove-process-types.js +2 -2
- package/dist/resources/platform/domain/create-and-wait.js +3 -2
- package/dist/resources/platform/domain/types.d.ts +13 -0
- package/dist/resources/platform/domain/wait-for-ready.js +9 -5
- package/dist/resources/platform/sni-endpoint/create-and-associate.d.ts +7 -0
- package/dist/resources/platform/sni-endpoint/create-and-associate.js +3 -1
- package/dist/utils/poller.d.ts +12 -0
- package/dist/utils/poller.js +1 -0
- package/package.json +1 -1
|
@@ -7,14 +7,15 @@ export { describeAddon } from './describe.js';
|
|
|
7
7
|
export { destroyAndWait } from './destroy-and-wait.js';
|
|
8
8
|
export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
|
|
9
9
|
export { listPlans, listPlansForAddon, type ListPlansForAddonOptions } from './list-plans.js';
|
|
10
|
-
export { formatPlanPriceLabel, type FormatPlanPriceLabelOptions, type PlanPriceBreakdown, priceForPlan, } from './pricing.js';
|
|
11
|
-
export { resolveAddon, resolveAddonByAttachment } from './resolve.js';
|
|
12
|
-
export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
|
|
10
|
+
export { formatPlanPriceLabel, type FormatPlanPriceLabelOptions, type PlanPriceBreakdown, type PlanPriceKind, priceForPlan, } from './pricing.js';
|
|
11
|
+
export { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
|
|
12
|
+
export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAttachment, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
|
|
13
13
|
export { upgrade } from './upgrade.js';
|
|
14
14
|
export { waitForProvisioning } from './wait-for-provisioning.js';
|
|
15
15
|
export declare const addOnExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "addOn", {
|
|
16
16
|
createAndWait: (appIdentity: string, body: AddOnCreateOpts, options?: CreateAndWaitOptions) => Promise<AddOn>;
|
|
17
17
|
describe: (addonIdentity: string, options?: ResolveAddonOptions) => Promise<import("./types.js").DescribedAddOn>;
|
|
18
|
+
describeAttachment: (appIdentity: string, attachmentName: string, options?: AddOnOptions) => Promise<import("./types.js").ResolvedAddOnAttachment>;
|
|
18
19
|
destroyAndWait: (appIdentity: string, addonIdentity: string, options?: DestroyAndWaitOptions) => Promise<AddOn>;
|
|
19
20
|
formatPlanPriceLabel: typeof formatPlanPriceLabel;
|
|
20
21
|
listPlans: (serviceIdentity: string, options?: AddOnOptions) => Promise<import("@heroku/types/3.sdk").Plan[]>;
|
|
@@ -4,7 +4,7 @@ import { describeAddon } from './describe.js';
|
|
|
4
4
|
import { destroyAndWait } from './destroy-and-wait.js';
|
|
5
5
|
import { listPlans, listPlansForAddon } from './list-plans.js';
|
|
6
6
|
import { formatPlanPriceLabel, priceForPlan } from './pricing.js';
|
|
7
|
-
import { resolveAddon, resolveAddonByAttachment } from './resolve.js';
|
|
7
|
+
import { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
|
|
8
8
|
import { upgrade } from './upgrade.js';
|
|
9
9
|
import { waitForProvisioning } from './wait-for-provisioning.js';
|
|
10
10
|
export { createAndWait } from './create-and-wait.js';
|
|
@@ -13,12 +13,13 @@ export { destroyAndWait } from './destroy-and-wait.js';
|
|
|
13
13
|
export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
|
|
14
14
|
export { listPlans, listPlansForAddon } from './list-plans.js';
|
|
15
15
|
export { formatPlanPriceLabel, priceForPlan, } from './pricing.js';
|
|
16
|
-
export { resolveAddon, resolveAddonByAttachment } from './resolve.js';
|
|
16
|
+
export { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
|
|
17
17
|
export { upgrade } from './upgrade.js';
|
|
18
18
|
export { waitForProvisioning } from './wait-for-provisioning.js';
|
|
19
19
|
export const addOnExtensions = extendResource('platform', 'addOn', ctx => ({
|
|
20
20
|
createAndWait: (appIdentity, body, options) => createAndWait(ctx, appIdentity, body, options),
|
|
21
21
|
describe: (addonIdentity, options) => describeAddon(ctx, addonIdentity, options),
|
|
22
|
+
describeAttachment: (appIdentity, attachmentName, options) => describeAttachment(ctx, appIdentity, attachmentName, options),
|
|
22
23
|
destroyAndWait: (appIdentity, addonIdentity, options) => destroyAndWait(ctx, appIdentity, addonIdentity, options),
|
|
23
24
|
formatPlanPriceLabel,
|
|
24
25
|
listPlans: (serviceIdentity, options) => listPlans(ctx, serviceIdentity, options),
|
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
import type { Plan } from '@heroku/types/3.sdk';
|
|
2
|
+
/**
|
|
3
|
+
* How a plan is priced, classified once so every consumer agrees. The
|
|
4
|
+
* classification — not the raw `cents`/`contract`/`metered` fields — is what
|
|
5
|
+
* a caller should branch on to decide how to present a plan:
|
|
6
|
+
*
|
|
7
|
+
* - `'contract'` — negotiated outside monthly add-on billing. `cents` is not a
|
|
8
|
+
* meaningful price and may be absent.
|
|
9
|
+
* - `'metered'` — billed per use. A flat `cents` (often `0`) does NOT reflect
|
|
10
|
+
* real cost; per-use rates live on the plan's meters (fetched separately).
|
|
11
|
+
* - `'free'` — a flat plan that genuinely costs nothing (`cents === 0`).
|
|
12
|
+
* - `'flat'` — a fixed recurring price of `cents` per `unit`.
|
|
13
|
+
*
|
|
14
|
+
* The precedence (contract, then metered, then free, then flat) matches the
|
|
15
|
+
* Heroku CLI's long-standing production behavior: a contract- or metered-billed
|
|
16
|
+
* plan is reported as such even when its flat `cents` reads `0`, so a `cents: 0`
|
|
17
|
+
* metered/contract plan is never mistaken for free.
|
|
18
|
+
*/
|
|
19
|
+
export type PlanPriceKind = 'contract' | 'flat' | 'free' | 'metered';
|
|
2
20
|
export type PlanPriceBreakdown = {
|
|
3
|
-
/**
|
|
4
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Raw price as reported by the API, in cents. `undefined` for a contract
|
|
23
|
+
* plan that reports no `cents` (the price is negotiated elsewhere).
|
|
24
|
+
*/
|
|
25
|
+
cents: number | undefined;
|
|
5
26
|
/** True when the price is negotiated outside monthly add-on billing */
|
|
6
27
|
contract: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The price classification. Branch on this rather than re-deriving it from
|
|
30
|
+
* `cents`/`contract`/`metered` at each call site — that re-derivation is the
|
|
31
|
+
* source of the "0 cents ⇒ free" bug this field exists to prevent.
|
|
32
|
+
*/
|
|
33
|
+
kind: PlanPriceKind;
|
|
7
34
|
/** True when the price is billed per use rather than at a fixed cadence */
|
|
8
35
|
metered: boolean;
|
|
9
36
|
/**
|
|
@@ -31,15 +58,17 @@ export type PlanPriceBreakdown = {
|
|
|
31
58
|
* keeps the 30-day-month assumption (Heroku's documented convention)
|
|
32
59
|
* in one place.
|
|
33
60
|
*
|
|
34
|
-
* Returns `undefined`
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
61
|
+
* Returns `undefined` only when the plan carries no pricing signal at
|
|
62
|
+
* all — no `price`, and no `cents`/`contract`/`metered` to classify
|
|
63
|
+
* from. A contract- or metered-billed plan still classifies (its flag
|
|
64
|
+
* is the signal) even when `cents` is absent. `Plan.price` is typed as
|
|
65
|
+
* optional in `@heroku/types`, and consumers occasionally receive
|
|
66
|
+
* partial responses depending on the Accept variant.
|
|
38
67
|
*
|
|
39
|
-
* `perMonthCents` and `perHourCents` are
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* numbers.
|
|
68
|
+
* `perMonthCents` and `perHourCents` are populated only for a flat or
|
|
69
|
+
* free plan on a `'month'`/`'hour'` cadence; they are `undefined` for
|
|
70
|
+
* metered/contract plans and for unknown units, so callers omit those
|
|
71
|
+
* parts of their label rather than show fabricated numbers.
|
|
43
72
|
*
|
|
44
73
|
* @param plan The plan to break down. Pass the whole `Plan` rather
|
|
45
74
|
* than just `plan.price` so the helper can pick up future fields
|
|
@@ -13,15 +13,17 @@ const HOURS_PER_MONTH = 24 * 30;
|
|
|
13
13
|
* keeps the 30-day-month assumption (Heroku's documented convention)
|
|
14
14
|
* in one place.
|
|
15
15
|
*
|
|
16
|
-
* Returns `undefined`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Returns `undefined` only when the plan carries no pricing signal at
|
|
17
|
+
* all — no `price`, and no `cents`/`contract`/`metered` to classify
|
|
18
|
+
* from. A contract- or metered-billed plan still classifies (its flag
|
|
19
|
+
* is the signal) even when `cents` is absent. `Plan.price` is typed as
|
|
20
|
+
* optional in `@heroku/types`, and consumers occasionally receive
|
|
21
|
+
* partial responses depending on the Accept variant.
|
|
20
22
|
*
|
|
21
|
-
* `perMonthCents` and `perHourCents` are
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* numbers.
|
|
23
|
+
* `perMonthCents` and `perHourCents` are populated only for a flat or
|
|
24
|
+
* free plan on a `'month'`/`'hour'` cadence; they are `undefined` for
|
|
25
|
+
* metered/contract plans and for unknown units, so callers omit those
|
|
26
|
+
* parts of their label rather than show fabricated numbers.
|
|
25
27
|
*
|
|
26
28
|
* @param plan The plan to break down. Pass the whole `Plan` rather
|
|
27
29
|
* than just `plan.price` so the helper can pick up future fields
|
|
@@ -29,35 +31,60 @@ const HOURS_PER_MONTH = 24 * 30;
|
|
|
29
31
|
*/
|
|
30
32
|
export function priceForPlan(plan) {
|
|
31
33
|
const { price } = plan;
|
|
32
|
-
if (!price
|
|
34
|
+
if (!price)
|
|
33
35
|
return undefined;
|
|
34
|
-
const
|
|
36
|
+
const contract = Boolean(price.contract);
|
|
37
|
+
const metered = Boolean(price.metered);
|
|
38
|
+
const hasCents = typeof price.cents === 'number';
|
|
39
|
+
// A plan with no `cents` and no contract/metered flag carries no usable
|
|
40
|
+
// pricing signal at all — treat it as unknown (undefined) rather than
|
|
41
|
+
// guessing. Contract/metered plans, by contrast, are fully classifiable
|
|
42
|
+
// without a `cents` value: the flag IS the price signal.
|
|
43
|
+
if (!hasCents && !contract && !metered)
|
|
44
|
+
return undefined;
|
|
45
|
+
const cents = hasCents ? price.cents : undefined;
|
|
35
46
|
const unit = price.unit ?? '';
|
|
47
|
+
// Precedence matches the CLI's production formatter: contract and metered
|
|
48
|
+
// win over the raw cents, so a `cents: 0` contract/metered plan is never
|
|
49
|
+
// classified as free. `free` is a flat plan that genuinely costs nothing.
|
|
50
|
+
let kind;
|
|
51
|
+
if (contract)
|
|
52
|
+
kind = 'contract';
|
|
53
|
+
else if (metered)
|
|
54
|
+
kind = 'metered';
|
|
55
|
+
else if (cents === 0)
|
|
56
|
+
kind = 'free';
|
|
57
|
+
else
|
|
58
|
+
kind = 'flat';
|
|
59
|
+
// Per-month / per-hour equivalents are only meaningful for a flat recurring
|
|
60
|
+
// price on a known cadence. Metered/contract plans (and unknown units) leave
|
|
61
|
+
// them undefined so callers omit those labels instead of rendering
|
|
62
|
+
// fabricated numbers.
|
|
36
63
|
let perMonthCents;
|
|
37
64
|
let perHourCents;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
perHourCents = undefined;
|
|
65
|
+
if ((kind === 'flat' || kind === 'free') && cents !== undefined) {
|
|
66
|
+
switch (unit) {
|
|
67
|
+
case 'hour': {
|
|
68
|
+
perHourCents = cents;
|
|
69
|
+
perMonthCents = cents * HOURS_PER_MONTH;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'month': {
|
|
73
|
+
perMonthCents = cents;
|
|
74
|
+
perHourCents = cents / HOURS_PER_MONTH;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default: {
|
|
78
|
+
perMonthCents = undefined;
|
|
79
|
+
perHourCents = undefined;
|
|
80
|
+
}
|
|
55
81
|
}
|
|
56
82
|
}
|
|
57
83
|
return {
|
|
58
84
|
cents,
|
|
59
|
-
contract
|
|
60
|
-
|
|
85
|
+
contract,
|
|
86
|
+
kind,
|
|
87
|
+
metered,
|
|
61
88
|
perHourCents,
|
|
62
89
|
perMonthCents,
|
|
63
90
|
unit,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
2
2
|
import type { PlatformClient } from '../../../services/platform.js';
|
|
3
|
-
import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn } from './types.js';
|
|
3
|
+
import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAttachment } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* Resolve a Platform add-on by identity.
|
|
6
6
|
*
|
|
@@ -27,14 +27,30 @@ import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn } from './types.j
|
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveAddon(ctx: Pick<ResourceCtx, 'platform'>, addonIdentity: string, options?: ResolveAddonOptions): Promise<ResolvedAddOn>;
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* Describe an add-on *attachment* via its name on a given app.
|
|
31
31
|
*
|
|
32
32
|
* Use this when you have an attachment name (e.g. `DATABASE_URL`,
|
|
33
|
-
* `HEROKU_POSTGRESQL_GREEN`) on a known app
|
|
34
|
-
*
|
|
35
|
-
* the
|
|
33
|
+
* `HEROKU_POSTGRESQL_GREEN`) on a known app and need the attachment
|
|
34
|
+
* itself — including its context-scoped `web_url` (the dashboard URL for
|
|
35
|
+
* opening the add-on *from this app*, which differs from the add-on's own
|
|
36
|
+
* billing-app `web_url`). Calls `addOnAttachment.resolution` and returns
|
|
37
|
+
* the matched attachment with its resolved add-on.
|
|
36
38
|
*
|
|
37
|
-
*
|
|
39
|
+
* `web_url` is `string | null` by design: it is `null` for add-ons that
|
|
40
|
+
* expose no web dashboard. A `null` here is a valid result, not a
|
|
41
|
+
* not-found — callers who need a URL must handle it (e.g. fall back to
|
|
42
|
+
* the add-on's own `web_url` via `resolveAddon`).
|
|
43
|
+
*
|
|
44
|
+
* To resolve just the add-on the attachment points to, use
|
|
45
|
+
* `resolveAddonByAttachment`. For resolving by add-on identity, use
|
|
46
|
+
* `resolveAddon`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function describeAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, attachmentName: string, options?: AddOnOptions): Promise<ResolvedAddOnAttachment>;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the add-on that an attachment points to, by attachment name on a
|
|
51
|
+
* given app. A thin wrapper over `describeAttachment` that returns only the
|
|
52
|
+
* resolved add-on (not the attachment). Use `describeAttachment` when you
|
|
53
|
+
* need the attachment's context-scoped `web_url`.
|
|
38
54
|
*/
|
|
39
55
|
export declare function resolveAddonByAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, attachmentName: string, options?: AddOnOptions): Promise<ResolvedAddOn>;
|
|
40
56
|
export declare function resolveAddonInternal(platform: PlatformClient, addonIdentity: string, options?: {
|
|
@@ -30,31 +30,51 @@ export async function resolveAddon(ctx, addonIdentity, options = {}) {
|
|
|
30
30
|
return resolveAddonInternal(ctx.platform, addonIdentity, options);
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* Describe an add-on *attachment* via its name on a given app.
|
|
34
34
|
*
|
|
35
35
|
* Use this when you have an attachment name (e.g. `DATABASE_URL`,
|
|
36
|
-
* `HEROKU_POSTGRESQL_GREEN`) on a known app
|
|
37
|
-
*
|
|
38
|
-
* the
|
|
36
|
+
* `HEROKU_POSTGRESQL_GREEN`) on a known app and need the attachment
|
|
37
|
+
* itself — including its context-scoped `web_url` (the dashboard URL for
|
|
38
|
+
* opening the add-on *from this app*, which differs from the add-on's own
|
|
39
|
+
* billing-app `web_url`). Calls `addOnAttachment.resolution` and returns
|
|
40
|
+
* the matched attachment with its resolved add-on.
|
|
39
41
|
*
|
|
40
|
-
*
|
|
42
|
+
* `web_url` is `string | null` by design: it is `null` for add-ons that
|
|
43
|
+
* expose no web dashboard. A `null` here is a valid result, not a
|
|
44
|
+
* not-found — callers who need a URL must handle it (e.g. fall back to
|
|
45
|
+
* the add-on's own `web_url` via `resolveAddon`).
|
|
46
|
+
*
|
|
47
|
+
* To resolve just the add-on the attachment points to, use
|
|
48
|
+
* `resolveAddonByAttachment`. For resolving by add-on identity, use
|
|
49
|
+
* `resolveAddon`.
|
|
41
50
|
*/
|
|
42
|
-
export async function
|
|
51
|
+
export async function describeAttachment(ctx, appIdentity, attachmentName, options = {}) {
|
|
43
52
|
options.signal?.throwIfAborted();
|
|
44
|
-
debug('
|
|
45
|
-
|
|
53
|
+
debug('describeAttachment app=%s attachment=%s', appIdentity, attachmentName);
|
|
54
|
+
// Scope the in-flight resolution call to the caller's signal so an abort
|
|
55
|
+
// mid-request is honored, not just before it starts.
|
|
56
|
+
const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
|
|
57
|
+
const matches = await platform.addOnAttachment.resolution({
|
|
46
58
|
// eslint-disable-next-line camelcase
|
|
47
59
|
addon_attachment: attachmentName,
|
|
48
60
|
app: appIdentity,
|
|
49
61
|
});
|
|
50
62
|
const attachment = matches[0];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
debug('resolveByAttachment matches=%d (no usable add-on returned)', matches.length);
|
|
63
|
+
if (!attachment?.addon?.id || !attachment.addon.app?.id) {
|
|
64
|
+
debug('describeAttachment matches=%d (no usable add-on returned)', matches.length);
|
|
54
65
|
throw new AddonNotFoundError();
|
|
55
66
|
}
|
|
56
|
-
debug('
|
|
57
|
-
return
|
|
67
|
+
debug('describeAttachment resolved addon=%s app=%s', attachment.addon.id, attachment.addon.app.id);
|
|
68
|
+
return attachment;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the add-on that an attachment points to, by attachment name on a
|
|
72
|
+
* given app. A thin wrapper over `describeAttachment` that returns only the
|
|
73
|
+
* resolved add-on (not the attachment). Use `describeAttachment` when you
|
|
74
|
+
* need the attachment's context-scoped `web_url`.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveAddonByAttachment(ctx, appIdentity, attachmentName, options = {}) {
|
|
77
|
+
return (await describeAttachment(ctx, appIdentity, attachmentName, options)).addon;
|
|
58
78
|
}
|
|
59
79
|
export async function resolveAddonInternal(platform, addonIdentity, options = {}) {
|
|
60
80
|
const { addonService, appIdentity } = options;
|
|
@@ -18,6 +18,24 @@ export type ResolvedAddOn = AddOn & {
|
|
|
18
18
|
};
|
|
19
19
|
id: string;
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* An attachment whose add-on `id`/`app.id` are guaranteed non-null (as the
|
|
23
|
+
* resolver enforces at runtime). `web_url` is scoped to the attached app's
|
|
24
|
+
* context — the dashboard URL for opening this add-on *from this app*, which
|
|
25
|
+
* differs from the add-on's own (billing-app) `web_url`.
|
|
26
|
+
*
|
|
27
|
+
* `web_url` remains `string | null` by design (inherited from
|
|
28
|
+
* `AddOnAttachment`): it is `null` for add-ons that expose no web dashboard.
|
|
29
|
+
* The resolver does not guarantee a URL — callers must handle the `null` case.
|
|
30
|
+
*/
|
|
31
|
+
export type ResolvedAddOnAttachment = AddOnAttachment & {
|
|
32
|
+
addon: NonNullable<AddOnAttachment['addon']> & {
|
|
33
|
+
app: {
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
id: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
21
39
|
export type DescribedAddOn = ResolvedAddOn & {
|
|
22
40
|
attachments: AddOnAttachment[];
|
|
23
41
|
};
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import type { Formation } from '@heroku/types/3.sdk';
|
|
2
2
|
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
import type { Poller } from '../../../utils/poller.js';
|
|
3
4
|
import type { ContainerOptions } from './index.js';
|
|
4
5
|
export type RemoveProcessTypesOpts = {
|
|
5
6
|
/**
|
|
6
|
-
* Fires `onStart` immediately before, and `onStop`
|
|
7
|
-
* each process type's `formation.update` call. Process
|
|
8
|
-
* removed one at a time (not concurrently) so a caller can
|
|
9
|
-
* single shared UI element (e.g. a CLI spinner) across the whole
|
|
7
|
+
* Fires `poller.onStart` immediately before, and `poller.onStop`
|
|
8
|
+
* immediately after, each process type's `formation.update` call. Process
|
|
9
|
+
* types are removed one at a time (not concurrently) so a caller can
|
|
10
|
+
* drive a single shared UI element (e.g. a CLI spinner) across the whole
|
|
10
11
|
* batch. If an update rejects, its `onStart` has already fired but
|
|
11
12
|
* `onStop` never will — the rejection propagates immediately and any
|
|
12
13
|
* remaining process types are not attempted.
|
|
13
14
|
*/
|
|
14
|
-
|
|
15
|
-
onStart?: (processType: string) => void;
|
|
16
|
-
onStop?: (processType: string) => void;
|
|
17
|
-
};
|
|
15
|
+
poller?: Poller<string>;
|
|
18
16
|
} & ContainerOptions;
|
|
19
17
|
/**
|
|
20
18
|
* Remove one or more process types from a container app
|
|
@@ -9,11 +9,11 @@ export async function removeProcessTypes(ctx, appIdentity, processTypes, options
|
|
|
9
9
|
const requestBody = { docker_image: null };
|
|
10
10
|
const updatedFormations = [];
|
|
11
11
|
for (const processType of processTypes) {
|
|
12
|
-
options.
|
|
12
|
+
options.poller?.onStart?.(processType);
|
|
13
13
|
const formation = await platform
|
|
14
14
|
.withHeaders({ Accept: 'application/vnd.heroku+json; version=3.docker-releases' })
|
|
15
15
|
.formation.update(appIdentity, processType, requestBody);
|
|
16
|
-
options.
|
|
16
|
+
options.poller?.onStop?.(processType);
|
|
17
17
|
updatedFormations.push(formation);
|
|
18
18
|
}
|
|
19
19
|
return updatedFormations;
|
|
@@ -9,7 +9,7 @@ import { waitForReady } from './wait-for-ready.js';
|
|
|
9
9
|
* 4. If wait: true, poll until domain reaches ready status
|
|
10
10
|
*/
|
|
11
11
|
export async function createAndWait(ctx, appIdentity, hostname, options = {}) {
|
|
12
|
-
const { resolveSniEndpoint, signal, sniEndpoint, timeoutMs, wait: shouldWait, waitIntervalMs, } = options;
|
|
12
|
+
const { poller, resolveSniEndpoint, signal, sniEndpoint, timeoutMs, wait: shouldWait, waitIntervalMs, } = options;
|
|
13
13
|
signal?.throwIfAborted();
|
|
14
14
|
const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
|
|
15
15
|
let resolvedSniEndpoint = null;
|
|
@@ -33,9 +33,10 @@ export async function createAndWait(ctx, appIdentity, hostname, options = {}) {
|
|
|
33
33
|
// eslint-disable-next-line camelcase
|
|
34
34
|
sni_endpoint: resolvedSniEndpoint,
|
|
35
35
|
});
|
|
36
|
-
if (shouldWait && domain.status !== '
|
|
36
|
+
if (shouldWait && domain.status !== 'none') {
|
|
37
37
|
const [readyDomain] = await waitForReady(ctx, appIdentity, {
|
|
38
38
|
domain,
|
|
39
|
+
poller,
|
|
39
40
|
signal,
|
|
40
41
|
timeoutMs,
|
|
41
42
|
waitIntervalMs,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Domain, SniEndpoint } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { Poller } from '../../../utils/poller.js';
|
|
2
3
|
export type DomainOptions = {
|
|
3
4
|
/**
|
|
4
5
|
* Abort signal to cancel the operation.
|
|
@@ -14,6 +15,12 @@ export type WaitForReadyOptions = DomainOptions & {
|
|
|
14
15
|
* Hostname of the domain to wait for.
|
|
15
16
|
*/
|
|
16
17
|
hostname?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Progress hooks fired once per domain being waited for:
|
|
20
|
+
* `poller.onStart(domain)` before polling begins, `poller.onStop(domain)`
|
|
21
|
+
* after the domain becomes ready.
|
|
22
|
+
*/
|
|
23
|
+
poller?: Poller<Domain>;
|
|
17
24
|
/**
|
|
18
25
|
* Maximum time in milliseconds to wait before giving up.
|
|
19
26
|
* If undefined, waits indefinitely (until signal aborted).
|
|
@@ -25,6 +32,12 @@ export type WaitForReadyOptions = DomainOptions & {
|
|
|
25
32
|
waitIntervalMs?: number;
|
|
26
33
|
};
|
|
27
34
|
export type CreateAndWaitOptions = DomainOptions & {
|
|
35
|
+
/**
|
|
36
|
+
* Progress hooks fired once per domain being waited for:
|
|
37
|
+
* `poller.onStart(domain)` before polling begins, `poller.onStop(domain)`
|
|
38
|
+
* after the domain becomes ready.
|
|
39
|
+
*/
|
|
40
|
+
poller?: Poller<Domain>;
|
|
28
41
|
/**
|
|
29
42
|
* Callback to resolve which SNI endpoint to use
|
|
30
43
|
* when one is not provided.
|
|
@@ -9,7 +9,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 5000;
|
|
|
9
9
|
* @throws if the domain reaches a non-ready status (e.g., 'failed').
|
|
10
10
|
*/
|
|
11
11
|
export async function waitForReady(ctx, appIdentity, options = {}) {
|
|
12
|
-
const { domain, hostname, signal, timeoutMs, waitIntervalMs = DEFAULT_WAIT_INTERVAL_MS } = options;
|
|
12
|
+
const { domain, hostname, poller, signal, timeoutMs, waitIntervalMs = DEFAULT_WAIT_INTERVAL_MS, } = options;
|
|
13
13
|
signal?.throwIfAborted();
|
|
14
14
|
const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
|
|
15
15
|
let domains;
|
|
@@ -24,10 +24,14 @@ export async function waitForReady(ctx, appIdentity, options = {}) {
|
|
|
24
24
|
const apiDomains = await platform.domain.list(appIdentity);
|
|
25
25
|
domains = apiDomains.filter(domain => domain.status === 'pending');
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const readyDomains = [];
|
|
28
|
+
for (const domain of domains) {
|
|
29
|
+
poller?.onStart?.(domain);
|
|
30
|
+
const readyDomain = await waitForDomain(platform, appIdentity, domain, waitIntervalMs, timeoutMs, signal);
|
|
31
|
+
poller?.onStop?.(domain);
|
|
32
|
+
readyDomains.push(readyDomain);
|
|
29
33
|
}
|
|
30
|
-
return
|
|
34
|
+
return readyDomains;
|
|
31
35
|
}
|
|
32
36
|
async function waitForDomain(platform, appIdentity, domain, waitIntervalMs, timeoutMs, signal) {
|
|
33
37
|
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
|
|
@@ -35,7 +39,7 @@ async function waitForDomain(platform, appIdentity, domain, waitIntervalMs, time
|
|
|
35
39
|
await wait(waitIntervalMs, signal);
|
|
36
40
|
const updatedDomain = await platform.domain.info(appIdentity, domain.id);
|
|
37
41
|
domain = updatedDomain;
|
|
38
|
-
if (deadline !== undefined && Date.now() >= deadline) {
|
|
42
|
+
if (domain.status === 'pending' && deadline !== undefined && Date.now() >= deadline) {
|
|
39
43
|
throw new Error(`Timed out waiting for domain ${domain.hostname}`);
|
|
40
44
|
}
|
|
41
45
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { SniEndpoint } from '@heroku/types/3.sdk';
|
|
2
2
|
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
import type { Poller } from '../../../utils/poller.js';
|
|
3
4
|
export type CreateAndAssociateOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* Progress hooks for the domain readiness wait:
|
|
7
|
+
* `domainPoller.onStart()` before polling begins, `domainPoller.onStop()`
|
|
8
|
+
* once the app's domains reach ready status.
|
|
9
|
+
*/
|
|
10
|
+
domainPoller?: Poller;
|
|
4
11
|
intervalMs?: number;
|
|
5
12
|
/** Required. Given the wildcard-matched candidate hostnames, returns the subset to associate. */
|
|
6
13
|
resolveDomains: (candidates: string[]) => Promise<string[]>;
|
|
@@ -33,14 +33,16 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
33
33
|
// createAndAssociate(app, certChain, privateKey, {resolveDomains}).
|
|
34
34
|
// eslint-disable-next-line max-params
|
|
35
35
|
export async function createAndAssociate(ctx, appIdentity, certificateChain, privateKey, options) {
|
|
36
|
-
const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
|
|
36
|
+
const { domainPoller, intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
|
|
37
37
|
signal?.throwIfAborted();
|
|
38
38
|
const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
|
|
39
39
|
const sniEndpoint = await platform.sniEndpoint.create(appIdentity, {
|
|
40
40
|
certificate_chain: certificateChain,
|
|
41
41
|
private_key: privateKey,
|
|
42
42
|
});
|
|
43
|
+
domainPoller?.onStart?.();
|
|
43
44
|
await waitForReady(ctx, appIdentity, { signal, timeoutMs, waitIntervalMs: intervalMs });
|
|
45
|
+
domainPoller?.onStop?.();
|
|
44
46
|
const apiDomains = await platform.domain.list(appIdentity);
|
|
45
47
|
const appDomains = apiDomains.map(d => d.hostname);
|
|
46
48
|
const certDomains = (sniEndpoint.ssl_cert.cert_domains ?? []);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress hooks for a polling loop, so a caller can drive UI (e.g. a CLI
|
|
3
|
+
* spinner) around each unit of work without reaching into the loop itself.
|
|
4
|
+
*
|
|
5
|
+
* `onStart` fires immediately before polling a unit begins; `onStop` fires
|
|
6
|
+
* once it settles into a terminal state. If polling that unit throws,
|
|
7
|
+
* `onStart` has already fired but `onStop` never will.
|
|
8
|
+
*/
|
|
9
|
+
export type Poller<T = void> = {
|
|
10
|
+
onStart?: (arg: T) => void;
|
|
11
|
+
onStop?: (arg: T) => void;
|
|
12
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|