@crwilhit/railgrid-actions-node 0.1.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 (4) hide show
  1. package/README.md +119 -0
  2. package/index.d.ts +165 -0
  3. package/index.mjs +386 -0
  4. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # `@crwilhit/railgrid-actions-node`
2
+
3
+ `@crwilhit/railgrid-actions-node` is the published server-only SDK artifact for
4
+ generated App Studio applications. Consumers intentionally install it under
5
+ the stable `@railgrid/actions-node` import name with this exact npm alias:
6
+
7
+ ```json
8
+ {
9
+ "dependencies": {
10
+ "@railgrid/actions-node": "npm:@crwilhit/railgrid-actions-node@0.1.0"
11
+ }
12
+ }
13
+ ```
14
+
15
+ The package invokes a project integration through App Studio's authenticated
16
+ gateway; the gateway selects the provider and bound resource:
17
+
18
+ ```js
19
+ import { createActionsClient } from '@railgrid/actions-node';
20
+
21
+ const railgrid = createActionsClient({
22
+ baseURL: process.env.RAILGRID_ACTIONS_BASE_URL,
23
+ project: process.env.RAILGRID_PROJECT,
24
+ // The coordinator atomically refreshes this file; the SDK reads it for
25
+ // every request so an in-flight workload never needs the bootstrap token.
26
+ tokenFile: process.env.RAILGRID_ACTIONS_TOKEN_FILE,
27
+ });
28
+
29
+ const rows = await railgrid.integration('sales').invoke('query_table/v1', {
30
+ columns: ['order_id', 'total'],
31
+ limit: 25,
32
+ });
33
+ ```
34
+
35
+ The credential is sent only by the server-side process. Do not import this
36
+ module into browser code, expose its token through client-side configuration,
37
+ or pass provider URLs, credentials, resource references, or other topology in
38
+ action input. The SDK throws when `window` or `document` is present as a
39
+ defense against accidental browser bundling.
40
+
41
+ ## Installation and development sandboxes
42
+
43
+ The published artifact is installed through the exact alias shown above. Keep
44
+ the alias in the server component's `package.json` and keep application code on
45
+ the stable consumer import:
46
+
47
+ ```js
48
+ import { createActionsClient } from '@railgrid/actions-node';
49
+ ```
50
+
51
+ App Studio development sandboxes use the component toolchain's normal package
52
+ installation and reload flow. The platform-owned `railgrid-dev-agent` supplies
53
+ the coordinator, runtime supervisor, and executor only; it does not copy,
54
+ validate, or mount this SDK. This keeps dependency resolution explicit in the
55
+ application's manifest and makes development and production use the same
56
+ published artifact. The SDK remains server-only and the app still receives
57
+ only the short-lived workload credential and non-secret action context.
58
+
59
+ Use an atomically refreshed token file (the default when
60
+ `RAILGRID_ACTIONS_TOKEN_FILE` is set), a static workload token, or a refreshable credential provider. A provider is
61
+ called with `{ forceRefresh, signal }` and is called again with
62
+ `forceRefresh: true` after a single HTTP 401:
63
+
64
+ ```js
65
+ const railgrid = createActionsClient({
66
+ baseURL: process.env.RAILGRID_APP_STUDIO_URL,
67
+ project: process.env.RAILGRID_PROJECT,
68
+ getToken: ({ forceRefresh }) => tokenStore.get({ forceRefresh }),
69
+ });
70
+ ```
71
+
72
+ When `tokenFile` is omitted, the SDK reads `RAILGRID_ACTIONS_TOKEN_FILE` on every
73
+ request. This is the shared, read-only application token published by the
74
+ development coordinator. Never point it at the coordinator-only projected
75
+ bootstrap token path.
76
+
77
+ `baseURL`, `project`, `org`, and `workspace` default to
78
+ `RAILGRID_ACTIONS_BASE_URL`, `RAILGRID_PROJECT`, `RAILGRID_ACTIONS_ORG`, and
79
+ `RAILGRID_ACTIONS_WORKSPACE`. The latter two are sent as `X-Railgrid-Org` and
80
+ `X-Railgrid-Workspace` headers. The base URL must be absolute HTTPS; tests may
81
+ explicitly set `allowInsecureLoopback: true` for an HTTP loopback URL.
82
+
83
+ Every request can carry retry and tracing metadata. `timeoutMs` aborts the
84
+ request locally; `signal` can be used by the enclosing server request:
85
+
86
+ ```js
87
+ const value = await railgrid.integration('sales').invoke('lookup/v1', { key: 'order-1' }, {
88
+ signal: request.signal,
89
+ timeoutMs: 10_000,
90
+ idempotencyKey: 'job-42-attempt-1',
91
+ requestID: 'request-42',
92
+ actionDeadlineMs: 15_000,
93
+ });
94
+ ```
95
+
96
+ The successful return value is `result`. `invokeEnvelope` returns the complete
97
+ stable envelope (`requestID`, provider, action/version, bound `resourceRef`,
98
+ and `result`). A provider failure throws `ProviderActionError` with stable
99
+ `code`, `message`, `retryable`, request and binding metadata. Transport and
100
+ configuration failures throw `ActionsClientError` with a machine-readable
101
+ `code` such as `timeout`, `aborted`, `network_error`, or `invalid_response`.
102
+
103
+ ## Release
104
+
105
+ The GitHub Actions workflow `actions-node-release.yaml` publishes this package
106
+ from tags named `actions-node/v<version>`. The tag version must exactly match
107
+ `package.json`. The npm package must configure that workflow as a trusted
108
+ publisher; no long-lived npm token is stored in the repository.
109
+
110
+ The first public version is the bootstrap exception: npm cannot attach a
111
+ trusted publisher until the package exists. A maintainer must authenticate with
112
+ npm and publish that first version from the reviewed package directory. Then
113
+ configure `railgrid/railgrid` and `actions-node-release.yaml` as the package's npm
114
+ trusted publisher before creating subsequent release tags.
115
+
116
+ Before publishing, the workflow runs the unit suite and installs the packed
117
+ artifact into a clean consumer under the public `@railgrid/actions-node` alias.
118
+ After publishing, it repeats that alias install from the npm registry so a
119
+ successful release proves the exact generated-app dependency contract.
package/index.d.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Server-side client for App Studio Provider Actions.
3
+ *
4
+ * The package deliberately models the stable gateway envelope rather than a
5
+ * provider-specific response. `resourceRef` is selected by the App Studio
6
+ * project binding; callers may only supply action input.
7
+ */
8
+
9
+ export interface ProviderResourceReference {
10
+ name: string;
11
+ apiVersion: string;
12
+ kind: string;
13
+ resource: string;
14
+ }
15
+
16
+ export interface ProviderActionErrorEnvelope {
17
+ code: string;
18
+ message: string;
19
+ retryable: boolean;
20
+ }
21
+
22
+ export interface ProviderActionSuccessEnvelope<TResult = unknown> {
23
+ requestID: string;
24
+ provider: string;
25
+ action: string;
26
+ actionVersion: string;
27
+ resourceRef: ProviderResourceReference;
28
+ result: TResult;
29
+ error?: never;
30
+ }
31
+
32
+ export interface ProviderActionFailureEnvelope {
33
+ requestID: string;
34
+ provider: string;
35
+ action: string;
36
+ actionVersion: string;
37
+ resourceRef: ProviderResourceReference;
38
+ result?: never;
39
+ error: ProviderActionErrorEnvelope;
40
+ }
41
+
42
+ export type ProviderActionEnvelope<TResult = unknown> =
43
+ | ProviderActionSuccessEnvelope<TResult>
44
+ | ProviderActionFailureEnvelope;
45
+
46
+ export interface CredentialContext {
47
+ /** True when the previous request received 401 and a fresh token is needed. */
48
+ forceRefresh: boolean;
49
+ signal?: AbortSignal;
50
+ }
51
+
52
+ export type CredentialProvider =
53
+ (context: CredentialContext) => string | null | undefined | Promise<string | null | undefined>;
54
+
55
+ export interface CredentialProviderObject {
56
+ getToken: CredentialProvider;
57
+ }
58
+
59
+ export interface ActionsRequestOptions {
60
+ signal?: AbortSignal;
61
+ timeoutMs?: number;
62
+ idempotencyKey?: string;
63
+ requestID?: string;
64
+ requestId?: string;
65
+ correlationID?: string;
66
+ correlationId?: string;
67
+ actionDeadlineMs?: number | string;
68
+ deadlineMs?: number | string;
69
+ headers?: Record<string, string>;
70
+ }
71
+
72
+ export interface ActionsClientOptions extends ActionsRequestOptions {
73
+ /** URL of the authenticated App Studio service, not a provider backend URL. */
74
+ baseURL?: string;
75
+ baseUrl?: string;
76
+ /** Defaults to RAILGRID_PROJECT. */
77
+ project?: string;
78
+ /** Defaults to RAILGRID_ACTIONS_ORG and RAILGRID_ACTIONS_WORKSPACE. */
79
+ org?: string;
80
+ organization?: string;
81
+ workspace?: string;
82
+ /** Test/local-only escape hatch for HTTP loopback URLs. */
83
+ allowInsecureLoopback?: boolean;
84
+ token?: string | CredentialProvider;
85
+ /** Read the atomically refreshed bearer token on every request. Defaults to RAILGRID_ACTIONS_TOKEN_FILE. */
86
+ tokenFile?: string;
87
+ getToken?: CredentialProvider;
88
+ credentialProvider?: CredentialProvider | CredentialProviderObject;
89
+ fetch?: typeof globalThis.fetch;
90
+ }
91
+
92
+ export interface ActionsIntegration {
93
+ invoke<TResult = unknown>(
94
+ action: string,
95
+ input?: unknown,
96
+ options?: ActionsRequestOptions,
97
+ ): Promise<TResult>;
98
+ invokeEnvelope<TResult = unknown>(
99
+ action: string,
100
+ input?: unknown,
101
+ options?: ActionsRequestOptions,
102
+ ): Promise<ProviderActionSuccessEnvelope<TResult>>;
103
+ }
104
+
105
+ export interface ActionsClientErrorOptions {
106
+ name?: string;
107
+ code?: string;
108
+ status?: number;
109
+ requestID?: string;
110
+ requestId?: string;
111
+ provider?: string;
112
+ action?: string;
113
+ actionVersion?: string;
114
+ resourceRef?: ProviderResourceReference;
115
+ retryable?: boolean;
116
+ body?: unknown;
117
+ cause?: unknown;
118
+ }
119
+
120
+ export class ActionsClientError extends Error {
121
+ readonly name: string;
122
+ readonly code: string;
123
+ readonly status: number;
124
+ readonly requestID: string;
125
+ readonly provider: string;
126
+ readonly action: string;
127
+ readonly actionVersion: string;
128
+ readonly resourceRef?: ProviderResourceReference;
129
+ readonly retryable: boolean;
130
+ readonly body?: unknown;
131
+ readonly cause?: unknown;
132
+
133
+ constructor(message?: string, options?: ActionsClientErrorOptions);
134
+ }
135
+
136
+ export class ProviderActionError extends ActionsClientError {
137
+ constructor(message?: string, options?: ActionsClientErrorOptions);
138
+ }
139
+
140
+ export class ActionsClient {
141
+ readonly baseURL?: string;
142
+ readonly project: string;
143
+ readonly org: string;
144
+ readonly workspace: string;
145
+
146
+ constructor(options: ActionsClientOptions);
147
+
148
+ integration(alias: string): ActionsIntegration;
149
+ invoke<TResult = unknown>(
150
+ alias: string,
151
+ action: string,
152
+ input?: unknown,
153
+ options?: ActionsRequestOptions,
154
+ ): Promise<TResult>;
155
+ invokeEnvelope<TResult = unknown>(
156
+ alias: string,
157
+ action: string,
158
+ input?: unknown,
159
+ options?: ActionsRequestOptions,
160
+ ): Promise<ProviderActionSuccessEnvelope<TResult>>;
161
+ }
162
+
163
+ export function createActionsClient(options: ActionsClientOptions): ActionsClient;
164
+
165
+ export default createActionsClient;
package/index.mjs ADDED
@@ -0,0 +1,386 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ /**
4
+ * Server-side App Studio Provider Actions client.
5
+ *
6
+ * The SDK only talks to the App Studio gateway. It never accepts a provider
7
+ * URL, provider credential, or backend topology and must remain in a server
8
+ * process because the caller credential is sent in the Authorization header.
9
+ */
10
+
11
+ export class ActionsClientError extends Error {
12
+ constructor(message, options = {}) {
13
+ super(String(message ?? 'provider action request failed'));
14
+ this.name = options.name ?? 'ActionsClientError';
15
+ this.code = String(options.code ?? 'provider_action_failed');
16
+ this.status = Number.isInteger(options.status) ? options.status : 0;
17
+ this.requestID = String(options.requestID ?? options.requestId ?? '');
18
+ this.provider = String(options.provider ?? '');
19
+ this.action = String(options.action ?? '');
20
+ this.actionVersion = String(options.actionVersion ?? '');
21
+ this.resourceRef = options.resourceRef;
22
+ this.retryable = options.retryable === true;
23
+ this.body = options.body;
24
+ if (options.cause !== undefined) this.cause = options.cause;
25
+ }
26
+ }
27
+
28
+ /** Error returned by a provider action's stable `error` envelope. */
29
+ export class ProviderActionError extends ActionsClientError {
30
+ constructor(message, options = {}) {
31
+ super(message, { ...options, name: 'ProviderActionError' });
32
+ }
33
+ }
34
+
35
+ function assertServerOnly() {
36
+ if (typeof window !== 'undefined' || typeof document !== 'undefined') {
37
+ throw new ActionsClientError(
38
+ 'The Railgrid Actions SDK is server-only; never expose a caller credential to a browser',
39
+ { code: 'server_only' },
40
+ );
41
+ }
42
+ }
43
+
44
+ function normalizeToken(value) {
45
+ const token = String(value ?? '').trim();
46
+ if (!token) return '';
47
+ return /^Bearer\s+/i.test(token) ? token : `Bearer ${token}`;
48
+ }
49
+
50
+ function joinURL(baseURL, path) {
51
+ const base = String(baseURL ?? '').trim().replace(/\/+$/, '');
52
+ if (!base) throw new ActionsClientError('baseURL is required', { code: 'invalid_config' });
53
+ return `${base}/${String(path).replace(/^\/+/, '')}`;
54
+ }
55
+
56
+ function isLoopbackHost(hostname) {
57
+ const host = String(hostname ?? '').toLowerCase();
58
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
59
+ }
60
+
61
+ function validateBaseURL(raw, allowInsecureLoopback) {
62
+ const value = String(raw ?? '').trim();
63
+ if (!value) throw new ActionsClientError('baseURL is required', { code: 'invalid_config' });
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(value);
67
+ } catch {
68
+ throw new ActionsClientError('baseURL must be an absolute HTTPS URL', { code: 'invalid_config' });
69
+ }
70
+ if (!parsed.host || parsed.username || parsed.password || parsed.search || parsed.hash) {
71
+ throw new ActionsClientError('baseURL must be an absolute HTTPS URL', { code: 'invalid_config' });
72
+ }
73
+ if (parsed.protocol === 'https:') return value;
74
+ if (parsed.protocol === 'http:' && allowInsecureLoopback === true && isLoopbackHost(parsed.hostname)) return value;
75
+ throw new ActionsClientError('baseURL must use HTTPS (or explicitly allow HTTP loopback for local tests)', { code: 'invalid_config' });
76
+ }
77
+
78
+ function actionPath(project, alias) {
79
+ return `/api/projects/${encodeURIComponent(project)}/integrations/${encodeURIComponent(alias)}/invoke`;
80
+ }
81
+
82
+ function isFunction(value) {
83
+ return typeof value === 'function';
84
+ }
85
+
86
+ function providerFunction(options) {
87
+ if (isFunction(options.getToken)) return options.getToken;
88
+ if (isFunction(options.token)) return options.token;
89
+ const provider = options.credentialProvider;
90
+ if (isFunction(provider)) return provider;
91
+ if (provider && isFunction(provider.getToken)) return provider.getToken.bind(provider);
92
+ return undefined;
93
+ }
94
+
95
+ function hasRefreshableCredential(options) {
96
+ return providerFunction(options) !== undefined || tokenFilePath(options) !== '';
97
+ }
98
+
99
+ function tokenFilePath(options) {
100
+ const configured = options.tokenFile ?? process.env.RAILGRID_ACTIONS_TOKEN_FILE;
101
+ return String(configured ?? '').trim();
102
+ }
103
+
104
+ async function resolveCredential(options, { forceRefresh = false, signal } = {}) {
105
+ const provider = providerFunction(options);
106
+ if (provider) {
107
+ let token;
108
+ try {
109
+ token = await provider({ forceRefresh, signal });
110
+ } catch (error) {
111
+ throw new ActionsClientError('credential provider failed', {
112
+ code: 'credential_provider_failed',
113
+ retryable: !forceRefresh,
114
+ cause: error,
115
+ });
116
+ }
117
+ const normalized = normalizeToken(token);
118
+ if (normalized) return normalized;
119
+ } else if (options.token !== undefined) {
120
+ const normalized = normalizeToken(options.token);
121
+ if (normalized) return normalized;
122
+ }
123
+ const file = tokenFilePath(options);
124
+ if (file) {
125
+ let contents;
126
+ try {
127
+ contents = await readFile(file, 'utf8');
128
+ } catch (error) {
129
+ throw new ActionsClientError('the Railgrid caller credential file is unavailable', {
130
+ code: 'credential_file_unavailable',
131
+ retryable: !forceRefresh,
132
+ cause: error,
133
+ });
134
+ }
135
+ const normalized = normalizeToken(contents);
136
+ if (normalized) return normalized;
137
+ throw new ActionsClientError('the Railgrid caller credential file is empty', {
138
+ code: 'credential_file_unavailable',
139
+ retryable: !forceRefresh,
140
+ });
141
+ }
142
+ throw new ActionsClientError(
143
+ 'a Railgrid caller credential is required; pass token, tokenFile, getToken, or credentialProvider on the server',
144
+ { code: 'credential_required' },
145
+ );
146
+ }
147
+
148
+ function numberOption(value, fallback) {
149
+ if (value === undefined || value === null || value === '') return fallback;
150
+ const number = Number(value);
151
+ if (!Number.isFinite(number) || number < 0) {
152
+ throw new ActionsClientError('timeoutMs and actionDeadlineMs must be non-negative numbers', { code: 'invalid_config' });
153
+ }
154
+ return number;
155
+ }
156
+
157
+ function requestHeaderOptions(clientOptions, requestOptions) {
158
+ const options = { ...clientOptions, ...requestOptions };
159
+ const headers = {
160
+ ...(clientOptions.headers ?? {}),
161
+ ...(requestOptions.headers ?? {}),
162
+ };
163
+ if (options.idempotencyKey !== undefined) headers['Idempotency-Key'] = String(options.idempotencyKey);
164
+ const requestID = options.requestID ?? options.requestId ?? options.correlationID ?? options.correlationId;
165
+ if (requestID !== undefined) headers['X-Request-ID'] = String(requestID);
166
+ const deadline = options.actionDeadlineMs ?? options.deadlineMs;
167
+ if (deadline !== undefined) headers['X-Railgrid-Action-Deadline-Ms'] = String(deadline);
168
+ const org = options.org ?? options.organization ?? process.env.RAILGRID_ACTIONS_ORG;
169
+ if (org !== undefined && String(org).trim() !== '') headers['X-Railgrid-Org'] = String(org).trim();
170
+ const workspace = options.workspace ?? process.env.RAILGRID_ACTIONS_WORKSPACE;
171
+ if (workspace !== undefined && String(workspace).trim() !== '') headers['X-Railgrid-Workspace'] = String(workspace).trim();
172
+ return { options, headers };
173
+ }
174
+
175
+ function composeSignal(parent, timeoutMs) {
176
+ const timeout = timeoutMs === undefined ? undefined : numberOption(timeoutMs, undefined);
177
+ if (timeout === undefined && !parent) return { signal: undefined, cleanup: () => {}, timedOut: () => false };
178
+
179
+ const controller = new AbortController();
180
+ let didTimeout = false;
181
+ let timer;
182
+ const abortFromParent = () => controller.abort(parent?.reason);
183
+ if (parent) {
184
+ if (parent.aborted) abortFromParent();
185
+ else parent.addEventListener('abort', abortFromParent, { once: true });
186
+ }
187
+ if (timeout !== undefined) {
188
+ timer = setTimeout(() => {
189
+ didTimeout = true;
190
+ controller.abort(new Error('provider action request timed out'));
191
+ }, timeout);
192
+ }
193
+ return {
194
+ signal: controller.signal,
195
+ cleanup: () => {
196
+ if (timer !== undefined) clearTimeout(timer);
197
+ if (parent) parent.removeEventListener('abort', abortFromParent);
198
+ },
199
+ timedOut: () => didTimeout,
200
+ };
201
+ }
202
+
203
+ function isAbortError(error) {
204
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
205
+ }
206
+
207
+ function stableResourceRef(value) {
208
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
209
+ const ref = {
210
+ name: String(value.name ?? '').trim(),
211
+ apiVersion: String(value.apiVersion ?? '').trim(),
212
+ kind: String(value.kind ?? '').trim(),
213
+ resource: String(value.resource ?? '').trim(),
214
+ };
215
+ if (!ref.name || !ref.apiVersion || !ref.kind || !ref.resource) return undefined;
216
+ return ref;
217
+ }
218
+
219
+ function decodeJSON(text) {
220
+ if (!text) return undefined;
221
+ try {
222
+ return JSON.parse(text);
223
+ } catch {
224
+ return text;
225
+ }
226
+ }
227
+
228
+ function stableEnvelope(body) {
229
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
230
+ const envelope = {
231
+ requestID: String(body.requestID ?? body.requestId ?? '').trim(),
232
+ provider: String(body.provider ?? '').trim(),
233
+ action: String(body.action ?? '').trim(),
234
+ actionVersion: String(body.actionVersion ?? '').trim(),
235
+ resourceRef: stableResourceRef(body.resourceRef),
236
+ };
237
+ const hasResult = Object.prototype.hasOwnProperty.call(body, 'result');
238
+ const hasError = Object.prototype.hasOwnProperty.call(body, 'error') && body.error !== undefined && body.error !== null;
239
+ if (!envelope.requestID || !envelope.provider || !envelope.action || !envelope.actionVersion || !envelope.resourceRef) {
240
+ return undefined;
241
+ }
242
+ if (hasResult === hasError) return undefined;
243
+ if (hasError) {
244
+ if (typeof body.error !== 'object' || Array.isArray(body.error)) return undefined;
245
+ const error = {
246
+ code: String(body.error.code ?? '').trim(),
247
+ message: String(body.error.message ?? '').trim(),
248
+ retryable: body.error.retryable === true,
249
+ };
250
+ if (!error.code || !error.message || typeof body.error.retryable !== 'boolean') return undefined;
251
+ envelope.error = error;
252
+ } else {
253
+ envelope.result = body.result;
254
+ }
255
+ return envelope;
256
+ }
257
+
258
+ function errorFromEnvelope(envelope, status, body) {
259
+ return new ProviderActionError(envelope.error.message, {
260
+ code: envelope.error.code,
261
+ status,
262
+ requestID: envelope.requestID,
263
+ provider: envelope.provider,
264
+ action: envelope.action,
265
+ actionVersion: envelope.actionVersion,
266
+ resourceRef: envelope.resourceRef,
267
+ retryable: envelope.error.retryable,
268
+ body,
269
+ });
270
+ }
271
+
272
+ function httpError(status, body) {
273
+ const message = body && typeof body === 'object' ? String(body.message ?? body.error ?? '') : '';
274
+ return new ActionsClientError(message || `provider action failed with HTTP ${status}`, {
275
+ code: 'provider_action_http_error', status, body, retryable: status >= 500,
276
+ });
277
+ }
278
+
279
+ export class ActionsClient {
280
+ constructor(options = {}) {
281
+ assertServerOnly();
282
+ this.baseURL = validateBaseURL(
283
+ options.baseURL ?? options.baseUrl ?? process.env.RAILGRID_ACTIONS_BASE_URL,
284
+ options.allowInsecureLoopback === true,
285
+ );
286
+ this.project = String(options.project ?? process.env.RAILGRID_PROJECT ?? '').trim();
287
+ if (!this.project) throw new ActionsClientError('project is required', { code: 'invalid_config' });
288
+ this.fetch = options.fetch ?? globalThis.fetch;
289
+ if (typeof this.fetch !== 'function') throw new ActionsClientError('fetch is required', { code: 'invalid_config' });
290
+ this.org = String(options.org ?? options.organization ?? process.env.RAILGRID_ACTIONS_ORG ?? '').trim();
291
+ this.workspace = String(options.workspace ?? process.env.RAILGRID_ACTIONS_WORKSPACE ?? '').trim();
292
+ this.options = {
293
+ ...options,
294
+ baseURL: this.baseURL,
295
+ project: this.project,
296
+ org: this.org,
297
+ workspace: this.workspace,
298
+ };
299
+ }
300
+
301
+ integration(alias) {
302
+ const name = String(alias ?? '').trim();
303
+ if (!name) throw new ActionsClientError('integration alias is required', { code: 'invalid_request' });
304
+ return {
305
+ invoke: (action, input = {}, requestOptions = {}) => this.invoke(name, action, input, requestOptions),
306
+ invokeEnvelope: (action, input = {}, requestOptions = {}) => this.invokeEnvelope(name, action, input, requestOptions),
307
+ };
308
+ }
309
+
310
+ async invoke(alias, action, input = {}, requestOptions = {}) {
311
+ const envelope = await this.invokeEnvelope(alias, action, input, requestOptions);
312
+ return envelope.result;
313
+ }
314
+
315
+ async invokeEnvelope(alias, action, input = {}, requestOptions = {}) {
316
+ assertServerOnly();
317
+ const integration = String(alias ?? '').trim();
318
+ if (!integration) throw new ActionsClientError('integration alias is required', { code: 'invalid_request' });
319
+ const actionName = String(action ?? '').trim();
320
+ if (!actionName) throw new ActionsClientError('action is required', { code: 'invalid_request' });
321
+ if (input === undefined || input === null) input = {};
322
+ if (typeof input !== 'object') {
323
+ throw new ActionsClientError('action input must be an object, array, or null', { code: 'invalid_request' });
324
+ }
325
+
326
+ const { options, headers: customHeaders } = requestHeaderOptions(this.options, requestOptions);
327
+ const timeoutMs = numberOption(options.timeoutMs, undefined);
328
+ const { signal, cleanup, timedOut } = composeSignal(options.signal, timeoutMs);
329
+ const path = actionPath(this.project, integration);
330
+ let token;
331
+ try {
332
+ for (let attempt = 0; attempt < 2; attempt += 1) {
333
+ token = await resolveCredential(options, { forceRefresh: attempt > 0, signal });
334
+ const headers = {
335
+ Accept: 'application/json',
336
+ 'Content-Type': 'application/json',
337
+ ...customHeaders,
338
+ Authorization: token,
339
+ };
340
+ let response;
341
+ let body;
342
+ try {
343
+ response = await this.fetch(joinURL(this.baseURL, path), {
344
+ method: 'POST',
345
+ headers,
346
+ body: JSON.stringify({ action: actionName, input }),
347
+ signal,
348
+ redirect: 'error',
349
+ });
350
+ const text = await response.text();
351
+ body = decodeJSON(text);
352
+ } catch (error) {
353
+ if (timedOut()) {
354
+ throw new ActionsClientError('provider action request timed out', { code: 'timeout', retryable: true, cause: error });
355
+ }
356
+ if (isAbortError(error) || signal?.aborted) {
357
+ throw new ActionsClientError('provider action request was aborted', { code: 'aborted', retryable: true, cause: error });
358
+ }
359
+ throw new ActionsClientError('provider action request failed', { code: 'network_error', retryable: true, cause: error });
360
+ }
361
+
362
+ if (response.status === 401 && attempt === 0 && hasRefreshableCredential(options)) continue;
363
+ const envelope = stableEnvelope(body);
364
+ if (!envelope) {
365
+ if (!response.ok) throw httpError(response.status, body);
366
+ throw new ActionsClientError('provider action response did not match the stable envelope', {
367
+ code: 'invalid_response', status: response.status, body,
368
+ });
369
+ }
370
+ if (envelope.error) throw errorFromEnvelope(envelope, response.status, body);
371
+ if (!response.ok) throw httpError(response.status, body);
372
+ return envelope;
373
+ }
374
+ throw new ActionsClientError('provider action authentication failed', { code: 'authentication_failed', status: 401 });
375
+ } finally {
376
+ cleanup();
377
+ }
378
+ }
379
+
380
+ }
381
+
382
+ export function createActionsClient(options) {
383
+ return new ActionsClient(options);
384
+ }
385
+
386
+ export default createActionsClient;
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@crwilhit/railgrid-actions-node",
3
+ "version": "0.1.0",
4
+ "description": "Server-side App Studio integration gateway client for generated apps",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/railgrid/railgrid.git",
9
+ "directory": "provider-sdk/actions-node"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./index.d.ts",
18
+ "import": "./index.mjs",
19
+ "default": "./index.mjs"
20
+ }
21
+ },
22
+ "main": "./index.mjs",
23
+ "types": "./index.d.ts",
24
+ "files": [
25
+ "index.mjs",
26
+ "index.d.ts",
27
+ "README.md"
28
+ ],
29
+ "sideEffects": false,
30
+ "scripts": {
31
+ "test": "node test.mjs",
32
+ "test:install": "node install-test.mjs"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ }
37
+ }