@heroku/sdk 0.1.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.
- package/LICENSE.txt +207 -0
- package/README.md +94 -0
- package/dist/compositions/add-on.d.ts +7 -0
- package/dist/compositions/add-on.js +6 -0
- package/dist/compositions/app.d.ts +8 -0
- package/dist/compositions/app.js +11 -0
- package/dist/compositions/dyno.d.ts +17 -0
- package/dist/compositions/dyno.js +23 -0
- package/dist/compositions/pg.d.ts +15 -0
- package/dist/compositions/pg.js +56 -0
- package/dist/compositions/pipeline.d.ts +33 -0
- package/dist/compositions/pipeline.js +146 -0
- package/dist/core/create-client.d.ts +32 -0
- package/dist/core/create-client.js +48 -0
- package/dist/core/dispatcher.d.ts +3 -0
- package/dist/core/dispatcher.js +80 -0
- package/dist/core/extend-resource.d.ts +24 -0
- package/dist/core/extend-resource.js +3 -0
- package/dist/core/extensions-proxy.d.ts +2 -0
- package/dist/core/extensions-proxy.js +56 -0
- package/dist/core/heroku-sdk.d.ts +14 -0
- package/dist/core/heroku-sdk.js +54 -0
- package/dist/core/interpolate-path.d.ts +3 -0
- package/dist/core/interpolate-path.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/resources/data/backup.d.ts +9 -0
- package/dist/resources/data/backup.js +10 -0
- package/dist/resources/data/database.d.ts +54 -0
- package/dist/resources/data/database.js +44 -0
- package/dist/resources/data/internal/resolve-addon-id.d.ts +2 -0
- package/dist/resources/data/internal/resolve-addon-id.js +13 -0
- package/dist/resources/data/internal/resolve-pg-database.d.ts +23 -0
- package/dist/resources/data/internal/resolve-pg-database.js +52 -0
- package/dist/resources/data/maintenance.d.ts +9 -0
- package/dist/resources/data/maintenance.js +10 -0
- package/dist/resources/data/postgres-credential.d.ts +11 -0
- package/dist/resources/data/postgres-credential.js +16 -0
- package/dist/resources/data/postgres-database.d.ts +32 -0
- package/dist/resources/data/postgres-database.js +14 -0
- package/dist/resources/data/postgres.d.ts +9 -0
- package/dist/resources/data/postgres.js +10 -0
- package/dist/resources/data/transfer-schedule.d.ts +9 -0
- package/dist/resources/data/transfer-schedule.js +10 -0
- package/dist/resources/data/transfer.d.ts +49 -0
- package/dist/resources/data/transfer.js +14 -0
- package/dist/resources/extensions/data.d.ts +4 -0
- package/dist/resources/extensions/data.js +4 -0
- package/dist/resources/extensions/platform.d.ts +8 -0
- package/dist/resources/extensions/platform.js +8 -0
- package/dist/resources/platform/add-on/create-and-wait.d.ts +28 -0
- package/dist/resources/platform/add-on/create-and-wait.js +63 -0
- package/dist/resources/platform/add-on/debug.d.ts +2 -0
- package/dist/resources/platform/add-on/debug.js +2 -0
- package/dist/resources/platform/add-on/describe.d.ts +20 -0
- package/dist/resources/platform/add-on/describe.js +51 -0
- package/dist/resources/platform/add-on/errors.d.ts +51 -0
- package/dist/resources/platform/add-on/errors.js +61 -0
- package/dist/resources/platform/add-on/index.d.ts +23 -0
- package/dist/resources/platform/add-on/index.js +25 -0
- package/dist/resources/platform/add-on/list-plans.d.ts +34 -0
- package/dist/resources/platform/add-on/list-plans.js +53 -0
- package/dist/resources/platform/add-on/pricing.d.ts +72 -0
- package/dist/resources/platform/add-on/pricing.js +100 -0
- package/dist/resources/platform/add-on/resolve.d.ts +43 -0
- package/dist/resources/platform/add-on/resolve.js +103 -0
- package/dist/resources/platform/add-on/types.d.ts +49 -0
- package/dist/resources/platform/add-on/types.js +1 -0
- package/dist/resources/platform/add-on/upgrade.d.ts +21 -0
- package/dist/resources/platform/add-on/upgrade.js +36 -0
- package/dist/resources/platform/add-on.d.ts +211 -0
- package/dist/resources/platform/add-on.js +354 -0
- package/dist/resources/platform/app/index.d.ts +58 -0
- package/dist/resources/platform/app/index.js +53 -0
- package/dist/resources/platform/app/info.d.ts +30 -0
- package/dist/resources/platform/app/info.js +27 -0
- package/dist/resources/platform/app.d.ts +56 -0
- package/dist/resources/platform/app.js +50 -0
- package/dist/resources/platform/dyno/index.d.ts +45 -0
- package/dist/resources/platform/dyno/index.js +26 -0
- package/dist/resources/platform/dyno.d.ts +45 -0
- package/dist/resources/platform/dyno.js +26 -0
- package/dist/resources/platform/log-session/index.d.ts +102 -0
- package/dist/resources/platform/log-session/index.js +223 -0
- package/dist/resources/platform/log-session/parser.d.ts +74 -0
- package/dist/resources/platform/log-session/parser.js +71 -0
- package/dist/resources/platform/log-session.d.ts +81 -0
- package/dist/resources/platform/log-session.js +167 -0
- package/dist/resources/platform/pipeline/coupling.d.ts +27 -0
- package/dist/resources/platform/pipeline/coupling.js +55 -0
- package/dist/resources/platform/pipeline/index.d.ts +53 -0
- package/dist/resources/platform/pipeline/index.js +75 -0
- package/dist/resources/platform/pipeline/promotion.d.ts +21 -0
- package/dist/resources/platform/pipeline/promotion.js +109 -0
- package/dist/resources/platform/pipeline-coupling.d.ts +27 -0
- package/dist/resources/platform/pipeline-coupling.js +55 -0
- package/dist/resources/platform/pipeline-promotion.d.ts +21 -0
- package/dist/resources/platform/pipeline-promotion.js +128 -0
- package/dist/resources/platform/pipeline.d.ts +53 -0
- package/dist/resources/platform/pipeline.js +75 -0
- package/dist/resources/platform/shield.d.ts +2 -0
- package/dist/resources/platform/shield.js +6 -0
- package/dist/services/data.d.ts +5 -0
- package/dist/services/data.js +5 -0
- package/dist/services/platform.d.ts +5 -0
- package/dist/services/platform.js +5 -0
- package/dist/utils/wait.d.ts +16 -0
- package/dist/utils/wait.js +35 -0
- package/package.json +54 -0
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
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 {};
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
}));
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
2
|
+
import { parseHerokuLogLine } from './parser.js';
|
|
3
|
+
export { type AttachmentAttachedEvent, type AttachmentDetachedEvent, type AttachmentUpdatedEvent, type HerokuLogEvent, parseHerokuLogLine, type ProvisioningCompletedEvent, type ScaledToEntry, type ScaledToEvent, type StartingProcessEvent, type StateChangedEvent, } from './parser.js';
|
|
4
|
+
export type StreamLogsOptions = {
|
|
5
|
+
/** Limit output to a single dyno (e.g. `web.1` on Cedar, `web-abc-123` on Fir). */
|
|
6
|
+
dyno?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Number of recent lines to fetch before tailing. Cedar-generation
|
|
9
|
+
* apps only — silently ignored on Fir.
|
|
10
|
+
*/
|
|
11
|
+
lines?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Maximum number of consecutive transport-error reconnects (TCP
|
|
14
|
+
* reset, proxy `terminated`, EOF mid-stream, etc.) before the
|
|
15
|
+
* iterator gives up and surfaces the error. Counter resets the
|
|
16
|
+
* moment a real line yields again. Defaults to 5.
|
|
17
|
+
*/
|
|
18
|
+
maxConsecutiveTransportErrors?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Cap on the per-attempt backoff after exponential growth.
|
|
21
|
+
* Defaults to 5000ms.
|
|
22
|
+
*/
|
|
23
|
+
maxRetryDelayMs?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Fires once per session creation, before the SDK opens the
|
|
26
|
+
* `logplex_url` stream. Receives the resolved generation so the
|
|
27
|
+
* caller can surface generation-specific UX (e.g. "Fetching
|
|
28
|
+
* logs..." for Fir, where provisioning the stream takes a moment).
|
|
29
|
+
*
|
|
30
|
+
* Fires on every recreate when tailing.
|
|
31
|
+
*/
|
|
32
|
+
onSessionCreated?: (info: {
|
|
33
|
+
generation: 'cedar' | 'fir' | undefined;
|
|
34
|
+
isRecreate: boolean;
|
|
35
|
+
}) => Promise<void> | void;
|
|
36
|
+
/**
|
|
37
|
+
* When `tail` is true, watch for the platform's idle timeout and
|
|
38
|
+
* recreate the log session to keep streaming. Defaults to true.
|
|
39
|
+
* Set to false if you want a single bounded stream and surface the
|
|
40
|
+
* disconnect to the caller.
|
|
41
|
+
*/
|
|
42
|
+
recreateSession?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Initial backoff between reconnect attempts after a transport
|
|
45
|
+
* error; doubles each attempt up to {@link maxRetryDelayMs}.
|
|
46
|
+
* Defaults to 500ms.
|
|
47
|
+
*/
|
|
48
|
+
retryBaseDelayMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* If `recreateSession` is true, the SDK forces a recreate after
|
|
51
|
+
* this many milliseconds without seeing data. Defaults to
|
|
52
|
+
* 15 minutes (matches the Fir platform timeout).
|
|
53
|
+
*/
|
|
54
|
+
sessionTimeoutMs?: number;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
/** Limit output to a single source (e.g. `app`, `heroku`). */
|
|
57
|
+
source?: string;
|
|
58
|
+
/**
|
|
59
|
+
* If true, the platform keeps the stream open and pushes lines as
|
|
60
|
+
* they arrive. The platform may close the connection on its own
|
|
61
|
+
* cadence (Fir is documented to time out around 15 minutes); when
|
|
62
|
+
* `recreateSession` is also true, the SDK transparently opens a
|
|
63
|
+
* new session and continues yielding lines.
|
|
64
|
+
*
|
|
65
|
+
* Defaults to false. Note: Fir-generation apps always tail (the
|
|
66
|
+
* platform doesn't support a bounded log session there), so the
|
|
67
|
+
* SDK forces `tail: true` for Fir regardless of what the caller
|
|
68
|
+
* asked for.
|
|
69
|
+
*/
|
|
70
|
+
tail?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Limit output to a process type (e.g. `web`, `worker`). On Cedar
|
|
73
|
+
* the underlying API combines `dyno` and `type` into a single
|
|
74
|
+
* field; the SDK handles that translation for you.
|
|
75
|
+
*/
|
|
76
|
+
type?: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Stream logs for an app, yielding one line at a time.
|
|
80
|
+
*
|
|
81
|
+
* Creates a log session via `logSession.create`, opens the resulting
|
|
82
|
+
* `logplex_url`, and parses the chunked plain-text response into
|
|
83
|
+
* newline-delimited entries. Use `tail: true` to keep the stream
|
|
84
|
+
* open; `recreateSession: true` (the default when tailing) makes the
|
|
85
|
+
* SDK transparently re-open a new session after the platform's idle
|
|
86
|
+
* timeout, so the iterator keeps yielding without the caller having
|
|
87
|
+
* to handle the reconnect.
|
|
88
|
+
*
|
|
89
|
+
* The iterator terminates cleanly when:
|
|
90
|
+
* - the platform closes a non-tailing stream (all lines yielded)
|
|
91
|
+
* - `signal` is aborted (an `AbortError` propagates)
|
|
92
|
+
* - `recreateSession` is false and the platform closes a tail
|
|
93
|
+
*
|
|
94
|
+
* The iterator throws on:
|
|
95
|
+
* - non-2xx response from the logplex URL
|
|
96
|
+
* - errors thrown by `logSession.create` (auth, app-not-found, etc.)
|
|
97
|
+
*/
|
|
98
|
+
export declare function streamLogs(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: StreamLogsOptions): AsyncGenerator<string, void, unknown>;
|
|
99
|
+
export declare const logSessionExtensions: import("../../../index.js").ResourceExtension<"platform", "logSession", {
|
|
100
|
+
parseHerokuLogLine: typeof parseHerokuLogLine;
|
|
101
|
+
streamLogs: (appIdentity: string, options?: StreamLogsOptions) => AsyncGenerator<string, void, unknown>;
|
|
102
|
+
}>;
|
|
@@ -0,0 +1,223 @@
|
|
|
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/index.js';
|
|
5
|
+
import { parseHerokuLogLine } from './parser.js';
|
|
6
|
+
export { parseHerokuLogLine, } from './parser.js';
|
|
7
|
+
const debug = createDebug('heroku:sdk:resources:log-session');
|
|
8
|
+
const DEFAULT_FIR_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
9
|
+
// Maximum consecutive transport errors before we give up — protects
|
|
10
|
+
// against a persistently broken upstream (DNS, TLS, etc.) busy-looping
|
|
11
|
+
// forever. The counter resets on every successful yield.
|
|
12
|
+
const MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5;
|
|
13
|
+
// Initial backoff between recreate attempts; doubles up to a cap so
|
|
14
|
+
// a bad-network burst doesn't hammer the platform.
|
|
15
|
+
const TRANSPORT_RETRY_BASE_DELAY_MS = 500;
|
|
16
|
+
const TRANSPORT_RETRY_MAX_DELAY_MS = 5000;
|
|
17
|
+
/**
|
|
18
|
+
* Stream logs for an app, yielding one line at a time.
|
|
19
|
+
*
|
|
20
|
+
* Creates a log session via `logSession.create`, opens the resulting
|
|
21
|
+
* `logplex_url`, and parses the chunked plain-text response into
|
|
22
|
+
* newline-delimited entries. Use `tail: true` to keep the stream
|
|
23
|
+
* open; `recreateSession: true` (the default when tailing) makes the
|
|
24
|
+
* SDK transparently re-open a new session after the platform's idle
|
|
25
|
+
* timeout, so the iterator keeps yielding without the caller having
|
|
26
|
+
* to handle the reconnect.
|
|
27
|
+
*
|
|
28
|
+
* The iterator terminates cleanly when:
|
|
29
|
+
* - the platform closes a non-tailing stream (all lines yielded)
|
|
30
|
+
* - `signal` is aborted (an `AbortError` propagates)
|
|
31
|
+
* - `recreateSession` is false and the platform closes a tail
|
|
32
|
+
*
|
|
33
|
+
* The iterator throws on:
|
|
34
|
+
* - non-2xx response from the logplex URL
|
|
35
|
+
* - errors thrown by `logSession.create` (auth, app-not-found, etc.)
|
|
36
|
+
*/
|
|
37
|
+
export async function* streamLogs(ctx, appIdentity, options = {}) {
|
|
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
|
+
signal?.throwIfAborted();
|
|
40
|
+
const generation = await getGeneration(ctx, appIdentity, { signal });
|
|
41
|
+
const isFir = generation === 'fir';
|
|
42
|
+
// Fir doesn't support a bounded session; the platform always streams.
|
|
43
|
+
const effectiveTail = isFir ? true : tail;
|
|
44
|
+
const recreateSession = options.recreateSession ?? effectiveTail;
|
|
45
|
+
const createOpts = buildCreateOpts({
|
|
46
|
+
dyno, isFir, lines, source, tail: effectiveTail, type,
|
|
47
|
+
});
|
|
48
|
+
// The recreate loop is inherently sequential: each session must
|
|
49
|
+
// close before we ask for the next one.
|
|
50
|
+
/* eslint-disable no-await-in-loop */
|
|
51
|
+
let isRecreate = false;
|
|
52
|
+
let consecutiveTransportErrors = 0;
|
|
53
|
+
while (true) {
|
|
54
|
+
debug('streamLogs creating session app=%s generation=%s tail=%s', appIdentity, generation ?? '<unknown>', effectiveTail);
|
|
55
|
+
const session = await ctx.platform.logSession.create(appIdentity, createOpts);
|
|
56
|
+
if (!session.logplex_url) {
|
|
57
|
+
throw new Error('Log session response did not include a logplex_url.');
|
|
58
|
+
}
|
|
59
|
+
await options.onSessionCreated?.({ generation, isRecreate });
|
|
60
|
+
isRecreate = true;
|
|
61
|
+
debug('streamLogs session=%s opening stream', session.id);
|
|
62
|
+
let timedOut = false;
|
|
63
|
+
let timeoutHandle;
|
|
64
|
+
try {
|
|
65
|
+
// Logplex URLs hit a region-specific stream host (e.g.
|
|
66
|
+
// 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
|
+
const parsed = new URL(session.logplex_url);
|
|
71
|
+
const logplexClient = new HerokuApiClient({
|
|
72
|
+
baseUrl: parsed.origin,
|
|
73
|
+
service: 'custom',
|
|
74
|
+
token: '',
|
|
75
|
+
});
|
|
76
|
+
const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`);
|
|
77
|
+
if (!response.body) {
|
|
78
|
+
throw new Error('Logplex stream returned no body.');
|
|
79
|
+
}
|
|
80
|
+
const reader = response.body.getReader();
|
|
81
|
+
const decoder = new TextDecoder();
|
|
82
|
+
let pending = '';
|
|
83
|
+
// Force a recreate after sessionTimeoutMs of silence; ignored
|
|
84
|
+
// when not tailing.
|
|
85
|
+
const armTimeout = () => {
|
|
86
|
+
if (!recreateSession)
|
|
87
|
+
return;
|
|
88
|
+
if (timeoutHandle)
|
|
89
|
+
clearTimeout(timeoutHandle);
|
|
90
|
+
timeoutHandle = setTimeout(() => {
|
|
91
|
+
timedOut = true;
|
|
92
|
+
debug('streamLogs session=%s timed out after %dms', session.id, sessionTimeoutMs);
|
|
93
|
+
reader.cancel().catch(() => { });
|
|
94
|
+
}, sessionTimeoutMs);
|
|
95
|
+
};
|
|
96
|
+
armTimeout();
|
|
97
|
+
try {
|
|
98
|
+
while (true) {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (done)
|
|
101
|
+
break;
|
|
102
|
+
armTimeout();
|
|
103
|
+
pending += decoder.decode(value, { stream: true });
|
|
104
|
+
let newlineIndex = pending.indexOf('\n');
|
|
105
|
+
while (newlineIndex !== -1) {
|
|
106
|
+
const line = pending.slice(0, newlineIndex);
|
|
107
|
+
pending = pending.slice(newlineIndex + 1);
|
|
108
|
+
if (line.length > 0) {
|
|
109
|
+
consecutiveTransportErrors = 0;
|
|
110
|
+
yield line;
|
|
111
|
+
}
|
|
112
|
+
newlineIndex = pending.indexOf('\n');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Flush any trailing partial line that didn't end in '\n'.
|
|
116
|
+
const trailing = pending + decoder.decode();
|
|
117
|
+
if (trailing.length > 0) {
|
|
118
|
+
consecutiveTransportErrors = 0;
|
|
119
|
+
yield trailing;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (timeoutHandle)
|
|
124
|
+
clearTimeout(timeoutHandle);
|
|
125
|
+
reader.releaseLock();
|
|
126
|
+
}
|
|
127
|
+
if (timedOut) {
|
|
128
|
+
debug('streamLogs session=%s recreating after timeout', session.id);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!recreateSession) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
debug('streamLogs session=%s remote closed; recreating', session.id);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (timeoutHandle)
|
|
138
|
+
clearTimeout(timeoutHandle);
|
|
139
|
+
if (signal?.aborted)
|
|
140
|
+
throw error;
|
|
141
|
+
if (timedOut)
|
|
142
|
+
continue;
|
|
143
|
+
// When recreating, treat any transport-level failure (TCP
|
|
144
|
+
// reset, proxy `terminated`, EOF mid-stream, the documented
|
|
145
|
+
// Fir timeout, etc.) as recoverable: open a fresh session and
|
|
146
|
+
// keep yielding. Without this the iterator dies on the first
|
|
147
|
+
// network blip and tail consumers (e.g. the vscode resource
|
|
148
|
+
// explorer) silently lose updates.
|
|
149
|
+
//
|
|
150
|
+
// To keep a permanently-broken upstream from busy-looping, cap
|
|
151
|
+
// consecutive errors and back off between attempts. The counter
|
|
152
|
+
// resets the moment we yield a real line again.
|
|
153
|
+
if (recreateSession && consecutiveTransportErrors < maxConsecutiveTransportErrors) {
|
|
154
|
+
consecutiveTransportErrors++;
|
|
155
|
+
const delay = Math.min(retryBaseDelayMs * (2 ** (consecutiveTransportErrors - 1)), maxRetryDelayMs);
|
|
156
|
+
debug('streamLogs session=%s transport error (%d/%d); reconnecting after %dms: %s', session.id, consecutiveTransportErrors, maxConsecutiveTransportErrors, delay, error.message);
|
|
157
|
+
await waitOrAbort(delay, signal);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/* eslint-enable no-await-in-loop */
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolves after `ms` milliseconds, or rejects if the signal aborts
|
|
167
|
+
* first. Used between recreate attempts so a persistent transport
|
|
168
|
+
* failure doesn't busy-loop the platform's session-create endpoint.
|
|
169
|
+
*/
|
|
170
|
+
async function waitOrAbort(ms, signal) {
|
|
171
|
+
if (!signal) {
|
|
172
|
+
await new Promise(resolve => {
|
|
173
|
+
setTimeout(resolve, ms);
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
if (signal.aborted) {
|
|
179
|
+
reject(signal.reason);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const handle = setTimeout(() => {
|
|
183
|
+
signal.removeEventListener('abort', onAbort);
|
|
184
|
+
resolve();
|
|
185
|
+
}, ms);
|
|
186
|
+
const onAbort = () => {
|
|
187
|
+
clearTimeout(handle);
|
|
188
|
+
reject(signal.reason);
|
|
189
|
+
};
|
|
190
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Translate the SDK's consumer-facing options into the shape the
|
|
195
|
+
* platform's `log-sessions` endpoint expects per generation:
|
|
196
|
+
* - Cedar's session takes a single `dyno` field that historically
|
|
197
|
+
* accepted either a process type or a specific dyno; we collapse
|
|
198
|
+
* `dyno || type` for the caller. `lines` and `tail` are honored.
|
|
199
|
+
* - Fir's session takes `dyno` and `type` separately and ignores
|
|
200
|
+
* `lines`; the stream always tails.
|
|
201
|
+
*/
|
|
202
|
+
function buildCreateOpts(options) {
|
|
203
|
+
const createOpts = { source: options.source };
|
|
204
|
+
if (options.isFir) {
|
|
205
|
+
if (options.dyno)
|
|
206
|
+
createOpts.dyno = options.dyno;
|
|
207
|
+
if (options.type)
|
|
208
|
+
createOpts.type = options.type;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const cedarDyno = options.dyno ?? options.type;
|
|
212
|
+
if (cedarDyno)
|
|
213
|
+
createOpts.dyno = cedarDyno;
|
|
214
|
+
if (options.lines !== undefined)
|
|
215
|
+
createOpts.lines = options.lines;
|
|
216
|
+
createOpts.tail = options.tail;
|
|
217
|
+
}
|
|
218
|
+
return createOpts;
|
|
219
|
+
}
|
|
220
|
+
export const logSessionExtensions = extendResource('platform', 'logSession', ctx => ({
|
|
221
|
+
parseHerokuLogLine,
|
|
222
|
+
streamLogs: (appIdentity, options) => streamLogs(ctx, appIdentity, options),
|
|
223
|
+
}));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsed Heroku platform log events emitted by the runtime as
|
|
3
|
+
* plain-text lines on the `heroku` source. The event shapes are
|
|
4
|
+
* derived from the line patterns in {@link parseHerokuLogLine}.
|
|
5
|
+
*
|
|
6
|
+
* Each variant has a `kind` discriminator. Consumers should
|
|
7
|
+
* switch on `kind` to narrow.
|
|
8
|
+
*/
|
|
9
|
+
export type HerokuLogEvent = AttachmentAttachedEvent | AttachmentDetachedEvent | AttachmentUpdatedEvent | ProvisioningCompletedEvent | ScaledToEvent | StartingProcessEvent | StateChangedEvent;
|
|
10
|
+
export type StateChangedEvent = {
|
|
11
|
+
/** Dyno or process token (e.g. `web.1` on Cedar, `web-abc-123` on Fir). */
|
|
12
|
+
dynoName: string;
|
|
13
|
+
from: string;
|
|
14
|
+
kind: 'state-changed';
|
|
15
|
+
/** Source token from the line prefix (e.g. `app`, `heroku`). */
|
|
16
|
+
source: string;
|
|
17
|
+
to: string;
|
|
18
|
+
};
|
|
19
|
+
export type AttachmentAttachedEvent = {
|
|
20
|
+
/** Config var name the attachment was bound to (e.g. `LOGDNA`). */
|
|
21
|
+
configVar: string;
|
|
22
|
+
kind: 'attachment-attached';
|
|
23
|
+
/** Stable platform reference (e.g. `logdna-deep-31633`). */
|
|
24
|
+
ref: string;
|
|
25
|
+
};
|
|
26
|
+
export type AttachmentDetachedEvent = {
|
|
27
|
+
configVar: string;
|
|
28
|
+
kind: 'attachment-detached';
|
|
29
|
+
ref: string;
|
|
30
|
+
};
|
|
31
|
+
export type AttachmentUpdatedEvent = {
|
|
32
|
+
/** Config var that was updated (e.g. `DATABASE`). */
|
|
33
|
+
configVar: string;
|
|
34
|
+
kind: 'attachment-updated';
|
|
35
|
+
/** Add-on service name from the line bracket (e.g. `heroku-postgres`). */
|
|
36
|
+
service: string;
|
|
37
|
+
};
|
|
38
|
+
export type ProvisioningCompletedEvent = {
|
|
39
|
+
kind: 'provisioning-completed';
|
|
40
|
+
ref: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* A single `Scaled to ...` line can list multiple dyno types in one
|
|
44
|
+
* shot (e.g. `Scaled to web@2:Standard-1X worker@1:Standard-2X`).
|
|
45
|
+
* Each entry in `entries` is one dyno type from that line.
|
|
46
|
+
*/
|
|
47
|
+
export type ScaledToEvent = {
|
|
48
|
+
entries: ScaledToEntry[];
|
|
49
|
+
kind: 'scaled-to';
|
|
50
|
+
};
|
|
51
|
+
export type ScaledToEntry = {
|
|
52
|
+
/** Dyno type (e.g. `web`). */
|
|
53
|
+
dynoType: string;
|
|
54
|
+
/** Resulting dyno count. */
|
|
55
|
+
quantity: number;
|
|
56
|
+
/** Dyno size (e.g. `Standard-1X`). */
|
|
57
|
+
size: string;
|
|
58
|
+
};
|
|
59
|
+
export type StartingProcessEvent = {
|
|
60
|
+
/** Command without surrounding backticks (e.g. `npm start`). */
|
|
61
|
+
command: string;
|
|
62
|
+
dynoName: string;
|
|
63
|
+
kind: 'starting-process';
|
|
64
|
+
source: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Parse a single log line into a structured Heroku platform event,
|
|
68
|
+
* or `undefined` if the line does not match any known event pattern.
|
|
69
|
+
*
|
|
70
|
+
* Pure function — no I/O, no side effects. Compose with
|
|
71
|
+
* {@link streamLogs} to react to runtime events while still
|
|
72
|
+
* forwarding raw lines to a UI.
|
|
73
|
+
*/
|
|
74
|
+
export declare function parseHerokuLogLine(line: string): HerokuLogEvent | undefined;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// On real platform input these patterns are mutually exclusive — each
|
|
2
|
+
// line begins with a single verb (Attach/Detach/Update, State changed,
|
|
3
|
+
// Starting process, Scaled to, ...completed provisioning), so the
|
|
4
|
+
// order of `if` blocks in `parseHerokuLogLine` does not affect the
|
|
5
|
+
// outcome. Order is for readability, not correctness.
|
|
6
|
+
const STATE_CHANGED = /([a-zA-Z0-9-]+)\[([a-zA-Z0-9-.]+)\]: State changed from (\w+) to (\w+)/;
|
|
7
|
+
const ATTACHMENT_UPDATED = /\[([a-zA-Z0-9-]+)\]: Update ([A-Z0-9_]+) by/;
|
|
8
|
+
const ATTACHMENT_DETACHED = /Detach ([A-Z0-9_]+) \(@ref:([a-zA-Z0-9-]+)/;
|
|
9
|
+
const ATTACHMENT_ATTACHED = /Attach ([A-Z0-9_]+) \(@ref:([a-zA-Z0-9-]+)/;
|
|
10
|
+
const PROVISIONING_COMPLETED = /@ref:([a-zA-Z0-9-]+) completed provisioning/;
|
|
11
|
+
const SCALED_TO_LINE = /\bScaled to ([a-zA-Z@:0-9_\- ]+)/;
|
|
12
|
+
const SCALED_TO_ENTRY = /([a-zA-Z]+)@([0-9]+):([a-zA-Z0-9_-]+)/g;
|
|
13
|
+
const STARTING_PROCESS = /([a-zA-Z0-9-]+)\[([a-zA-Z0-9-.]+)\]: Starting process with command `(.*?)`/;
|
|
14
|
+
/**
|
|
15
|
+
* Parse a single log line into a structured Heroku platform event,
|
|
16
|
+
* or `undefined` if the line does not match any known event pattern.
|
|
17
|
+
*
|
|
18
|
+
* Pure function — no I/O, no side effects. Compose with
|
|
19
|
+
* {@link streamLogs} to react to runtime events while still
|
|
20
|
+
* forwarding raw lines to a UI.
|
|
21
|
+
*/
|
|
22
|
+
export function parseHerokuLogLine(line) {
|
|
23
|
+
const stateChanged = STATE_CHANGED.exec(line);
|
|
24
|
+
if (stateChanged) {
|
|
25
|
+
const [, source, dynoName, from, to] = stateChanged;
|
|
26
|
+
return {
|
|
27
|
+
dynoName, from, kind: 'state-changed', source, to,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const attachmentUpdated = ATTACHMENT_UPDATED.exec(line);
|
|
31
|
+
if (attachmentUpdated) {
|
|
32
|
+
const [, service, configVar] = attachmentUpdated;
|
|
33
|
+
return { configVar, kind: 'attachment-updated', service };
|
|
34
|
+
}
|
|
35
|
+
const attachmentDetached = ATTACHMENT_DETACHED.exec(line);
|
|
36
|
+
if (attachmentDetached) {
|
|
37
|
+
const [, configVar, ref] = attachmentDetached;
|
|
38
|
+
return { configVar, kind: 'attachment-detached', ref };
|
|
39
|
+
}
|
|
40
|
+
const attachmentAttached = ATTACHMENT_ATTACHED.exec(line);
|
|
41
|
+
if (attachmentAttached) {
|
|
42
|
+
const [, configVar, ref] = attachmentAttached;
|
|
43
|
+
return { configVar, kind: 'attachment-attached', ref };
|
|
44
|
+
}
|
|
45
|
+
const provisioningCompleted = PROVISIONING_COMPLETED.exec(line);
|
|
46
|
+
if (provisioningCompleted) {
|
|
47
|
+
return { kind: 'provisioning-completed', ref: provisioningCompleted[1] };
|
|
48
|
+
}
|
|
49
|
+
const scaledToLine = SCALED_TO_LINE.exec(line);
|
|
50
|
+
if (scaledToLine) {
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const match of scaledToLine[1].matchAll(SCALED_TO_ENTRY)) {
|
|
53
|
+
entries.push({
|
|
54
|
+
dynoType: match[1],
|
|
55
|
+
quantity: Number.parseInt(match[2], 10),
|
|
56
|
+
size: match[3],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (entries.length > 0) {
|
|
60
|
+
return { entries, kind: 'scaled-to' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const startingProcess = STARTING_PROCESS.exec(line);
|
|
64
|
+
if (startingProcess) {
|
|
65
|
+
const [, source, dynoName, command] = startingProcess;
|
|
66
|
+
return {
|
|
67
|
+
command, dynoName, kind: 'starting-process', source,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
}>;
|