@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.
- package/README.md +119 -0
- package/index.d.ts +165 -0
- package/index.mjs +386 -0
- 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
|
+
}
|