@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.
Files changed (109) hide show
  1. package/LICENSE.txt +207 -0
  2. package/README.md +94 -0
  3. package/dist/compositions/add-on.d.ts +7 -0
  4. package/dist/compositions/add-on.js +6 -0
  5. package/dist/compositions/app.d.ts +8 -0
  6. package/dist/compositions/app.js +11 -0
  7. package/dist/compositions/dyno.d.ts +17 -0
  8. package/dist/compositions/dyno.js +23 -0
  9. package/dist/compositions/pg.d.ts +15 -0
  10. package/dist/compositions/pg.js +56 -0
  11. package/dist/compositions/pipeline.d.ts +33 -0
  12. package/dist/compositions/pipeline.js +146 -0
  13. package/dist/core/create-client.d.ts +32 -0
  14. package/dist/core/create-client.js +48 -0
  15. package/dist/core/dispatcher.d.ts +3 -0
  16. package/dist/core/dispatcher.js +80 -0
  17. package/dist/core/extend-resource.d.ts +24 -0
  18. package/dist/core/extend-resource.js +3 -0
  19. package/dist/core/extensions-proxy.d.ts +2 -0
  20. package/dist/core/extensions-proxy.js +56 -0
  21. package/dist/core/heroku-sdk.d.ts +14 -0
  22. package/dist/core/heroku-sdk.js +54 -0
  23. package/dist/core/interpolate-path.d.ts +3 -0
  24. package/dist/core/interpolate-path.js +13 -0
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.js +2 -0
  27. package/dist/resources/data/backup.d.ts +9 -0
  28. package/dist/resources/data/backup.js +10 -0
  29. package/dist/resources/data/database.d.ts +54 -0
  30. package/dist/resources/data/database.js +44 -0
  31. package/dist/resources/data/internal/resolve-addon-id.d.ts +2 -0
  32. package/dist/resources/data/internal/resolve-addon-id.js +13 -0
  33. package/dist/resources/data/internal/resolve-pg-database.d.ts +23 -0
  34. package/dist/resources/data/internal/resolve-pg-database.js +52 -0
  35. package/dist/resources/data/maintenance.d.ts +9 -0
  36. package/dist/resources/data/maintenance.js +10 -0
  37. package/dist/resources/data/postgres-credential.d.ts +11 -0
  38. package/dist/resources/data/postgres-credential.js +16 -0
  39. package/dist/resources/data/postgres-database.d.ts +32 -0
  40. package/dist/resources/data/postgres-database.js +14 -0
  41. package/dist/resources/data/postgres.d.ts +9 -0
  42. package/dist/resources/data/postgres.js +10 -0
  43. package/dist/resources/data/transfer-schedule.d.ts +9 -0
  44. package/dist/resources/data/transfer-schedule.js +10 -0
  45. package/dist/resources/data/transfer.d.ts +49 -0
  46. package/dist/resources/data/transfer.js +14 -0
  47. package/dist/resources/extensions/data.d.ts +4 -0
  48. package/dist/resources/extensions/data.js +4 -0
  49. package/dist/resources/extensions/platform.d.ts +8 -0
  50. package/dist/resources/extensions/platform.js +8 -0
  51. package/dist/resources/platform/add-on/create-and-wait.d.ts +28 -0
  52. package/dist/resources/platform/add-on/create-and-wait.js +63 -0
  53. package/dist/resources/platform/add-on/debug.d.ts +2 -0
  54. package/dist/resources/platform/add-on/debug.js +2 -0
  55. package/dist/resources/platform/add-on/describe.d.ts +20 -0
  56. package/dist/resources/platform/add-on/describe.js +51 -0
  57. package/dist/resources/platform/add-on/errors.d.ts +51 -0
  58. package/dist/resources/platform/add-on/errors.js +61 -0
  59. package/dist/resources/platform/add-on/index.d.ts +23 -0
  60. package/dist/resources/platform/add-on/index.js +25 -0
  61. package/dist/resources/platform/add-on/list-plans.d.ts +34 -0
  62. package/dist/resources/platform/add-on/list-plans.js +53 -0
  63. package/dist/resources/platform/add-on/pricing.d.ts +72 -0
  64. package/dist/resources/platform/add-on/pricing.js +100 -0
  65. package/dist/resources/platform/add-on/resolve.d.ts +43 -0
  66. package/dist/resources/platform/add-on/resolve.js +103 -0
  67. package/dist/resources/platform/add-on/types.d.ts +49 -0
  68. package/dist/resources/platform/add-on/types.js +1 -0
  69. package/dist/resources/platform/add-on/upgrade.d.ts +21 -0
  70. package/dist/resources/platform/add-on/upgrade.js +36 -0
  71. package/dist/resources/platform/add-on.d.ts +211 -0
  72. package/dist/resources/platform/add-on.js +354 -0
  73. package/dist/resources/platform/app/index.d.ts +58 -0
  74. package/dist/resources/platform/app/index.js +53 -0
  75. package/dist/resources/platform/app/info.d.ts +30 -0
  76. package/dist/resources/platform/app/info.js +27 -0
  77. package/dist/resources/platform/app.d.ts +56 -0
  78. package/dist/resources/platform/app.js +50 -0
  79. package/dist/resources/platform/dyno/index.d.ts +45 -0
  80. package/dist/resources/platform/dyno/index.js +26 -0
  81. package/dist/resources/platform/dyno.d.ts +45 -0
  82. package/dist/resources/platform/dyno.js +26 -0
  83. package/dist/resources/platform/log-session/index.d.ts +102 -0
  84. package/dist/resources/platform/log-session/index.js +223 -0
  85. package/dist/resources/platform/log-session/parser.d.ts +74 -0
  86. package/dist/resources/platform/log-session/parser.js +71 -0
  87. package/dist/resources/platform/log-session.d.ts +81 -0
  88. package/dist/resources/platform/log-session.js +167 -0
  89. package/dist/resources/platform/pipeline/coupling.d.ts +27 -0
  90. package/dist/resources/platform/pipeline/coupling.js +55 -0
  91. package/dist/resources/platform/pipeline/index.d.ts +53 -0
  92. package/dist/resources/platform/pipeline/index.js +75 -0
  93. package/dist/resources/platform/pipeline/promotion.d.ts +21 -0
  94. package/dist/resources/platform/pipeline/promotion.js +109 -0
  95. package/dist/resources/platform/pipeline-coupling.d.ts +27 -0
  96. package/dist/resources/platform/pipeline-coupling.js +55 -0
  97. package/dist/resources/platform/pipeline-promotion.d.ts +21 -0
  98. package/dist/resources/platform/pipeline-promotion.js +128 -0
  99. package/dist/resources/platform/pipeline.d.ts +53 -0
  100. package/dist/resources/platform/pipeline.js +75 -0
  101. package/dist/resources/platform/shield.d.ts +2 -0
  102. package/dist/resources/platform/shield.js +6 -0
  103. package/dist/services/data.d.ts +5 -0
  104. package/dist/services/data.js +5 -0
  105. package/dist/services/platform.d.ts +5 -0
  106. package/dist/services/platform.js +5 -0
  107. package/dist/utils/wait.d.ts +16 -0
  108. package/dist/utils/wait.js +35 -0
  109. 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
+ }>;