@sequenceholdings/artifact-studio 0.4.0 → 0.6.1
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 +35 -1
- package/VERSION-PIN.md +29 -0
- package/dist/active-deploy-policy.d.ts +9 -3
- package/dist/active-deploy-policy.js +20 -8
- package/dist/artifact-env-bindings.d.ts +9 -0
- package/dist/artifact-env-bindings.js +32 -0
- package/dist/build.js +6 -0
- package/dist/cli.js +62 -13
- package/dist/deployment-validation.d.ts +2 -0
- package/dist/deployment-validation.js +2 -0
- package/dist/dev-link.js +28 -2
- package/dist/env-binding-scan.d.ts +10 -0
- package/dist/env-binding-scan.js +179 -0
- package/dist/env-bindings.d.ts +52 -0
- package/dist/env-bindings.js +180 -0
- package/dist/gateway/control.d.ts +33 -0
- package/dist/gateway/control.js +29 -0
- package/dist/gateway/hosted-proxy.d.ts +5 -0
- package/dist/gateway/hosted-proxy.js +4 -1
- package/dist/gateway/lattice-invoke.d.ts +48 -0
- package/dist/gateway/lattice-invoke.js +245 -0
- package/dist/gateway/lattice-upstream.d.ts +38 -0
- package/dist/gateway/lattice-upstream.js +136 -0
- package/dist/gateway/server.js +2 -3
- package/dist/gateway/session.d.ts +3 -1
- package/dist/gateway/session.js +10 -3
- package/dist/manifest-next.d.ts +55 -0
- package/dist/manifest-next.js +35 -2
- package/dist/manifest-static.d.ts +35 -0
- package/dist/manifest-static.js +12 -0
- package/dist/manifest.d.ts +48 -0
- package/dist/next-build.d.ts +2 -1
- package/dist/next-build.js +12 -7
- package/dist/offline/cache.d.ts +4 -0
- package/dist/offline/cache.js +7 -0
- package/dist/offline/connector.d.ts +28 -0
- package/dist/offline/connector.js +122 -0
- package/dist/offline/crypto.d.ts +5 -0
- package/dist/offline/crypto.js +24 -0
- package/dist/offline/index.d.ts +8 -0
- package/dist/offline/index.js +7 -0
- package/dist/offline/indexed-db.d.ts +10 -0
- package/dist/offline/indexed-db.js +104 -0
- package/dist/offline/management-types.d.ts +25 -0
- package/dist/offline/management-types.js +1 -0
- package/dist/offline/outbox.d.ts +7 -0
- package/dist/offline/outbox.js +46 -0
- package/dist/offline/replay.d.ts +25 -0
- package/dist/offline/replay.js +192 -0
- package/dist/offline/secure-store.d.ts +6 -0
- package/dist/offline/secure-store.js +51 -0
- package/dist/offline/types.d.ts +162 -0
- package/dist/offline/types.js +1 -0
- package/dist/orm-json-scalars.d.ts +21 -0
- package/dist/orm-json-scalars.js +21 -0
- package/dist/runtime-contracts.d.ts +7 -7
- package/dist/runtime-contracts.js +1 -1
- package/dist/sdk-orm.d.ts +7 -1
- package/dist/sdk-orm.js +12 -5
- package/dist/sdk.d.ts +134 -1
- package/dist/server/api-client.d.ts +26 -0
- package/dist/server/api-client.js +38 -0
- package/dist/server/broker-contract.d.ts +191 -5
- package/dist/server/broker-contract.js +142 -1
- package/dist/server/client.d.ts +82 -47
- package/dist/server/client.js +138 -59
- package/dist/server/connection-retry.d.ts +12 -0
- package/dist/server/connection-retry.js +40 -0
- package/dist/server/errors.d.ts +61 -0
- package/dist/server/errors.js +89 -0
- package/dist/server/external-context.d.ts +21 -0
- package/dist/server/external-context.js +19 -0
- package/dist/server/internal/signed-token.d.ts +15 -0
- package/dist/server/internal/signed-token.js +67 -0
- package/dist/server/internal/v1-context.d.ts +18 -0
- package/dist/server/internal/v1-context.js +26 -0
- package/dist/server/internal/verify-any.d.ts +22 -0
- package/dist/server/internal/verify-any.js +53 -0
- package/dist/server/logger.d.ts +1 -1
- package/dist/server/managed-server-path.d.ts +6 -0
- package/dist/server/managed-server-path.js +14 -0
- package/dist/server/principal.d.ts +48 -0
- package/dist/server/principal.js +39 -0
- package/dist/server/processes.d.ts +290 -0
- package/dist/server/processes.js +56 -0
- package/dist/server/request-context.d.ts +121 -3
- package/dist/server/request-context.js +111 -33
- package/dist/server/testing.d.ts +40 -0
- package/dist/server/testing.js +86 -0
- package/dist/server.d.ts +13 -4
- package/dist/server.js +6 -2
- package/dist/source-resolver.d.ts +9 -2
- package/dist/source-resolver.js +55 -8
- package/dist/templates/nextjs/AGENTS.md +10 -0
- package/dist/templates/nextjs/CLAUDE.md +1 -0
- package/dist/templates/nextjs/README.md +88 -0
- package/dist/templates/nextjs/package.json +3 -1
- package/dist/templates/nextjs/tests/server.test.mjs +25 -0
- package/dist/templates/react-vite/AGENTS.md +8 -88
- package/dist/templates/react-vite/CLAUDE.md +0 -6
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/templates/react-vite/src/App.tsx +37 -14
- package/dist/templates/react-vite/src/styles.css +10 -0
- package/package.json +15 -3
- package/templates/nextjs/AGENTS.md +10 -0
- package/templates/nextjs/CLAUDE.md +1 -0
- package/templates/nextjs/README.md +88 -0
- package/templates/nextjs/package.json +3 -1
- package/templates/nextjs/tests/server.test.mjs +25 -0
- package/templates/react-vite/AGENTS.md +8 -88
- package/templates/react-vite/CLAUDE.md +0 -6
- package/templates/react-vite/package.json +1 -1
- package/templates/react-vite/src/App.tsx +37 -14
- package/templates/react-vite/src/styles.css +10 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { ORM_LOSSLESS_JSON_SCALARS } from '../orm-json-scalars.js';
|
|
3
|
+
export { ORM_JSON_SCALARS_HEADER, ORM_LOSSLESS_JSON_SCALARS } from '../orm-json-scalars.js';
|
|
2
4
|
export const artifactServerMethodSchema = z.enum(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
3
5
|
export const artifactServerModeSchema = z.enum(['user', 'service']).default('user');
|
|
4
6
|
/** Reject paths that intermediaries can normalize into a different authorization target. */
|
|
@@ -21,13 +23,152 @@ const path = z.string().min(6).max(8192).refine(isArtifactApiPath, 'Expected a c
|
|
|
21
23
|
const method = artifactServerMethodSchema;
|
|
22
24
|
const mode = artifactServerModeSchema;
|
|
23
25
|
const id = z.string().min(1).max(255).regex(/^[a-zA-Z0-9_-]+$/);
|
|
26
|
+
// Lattice ids become FGA object ids, so they keep to the FGA-safe class.
|
|
27
|
+
const latticeId = z.string().min(2).max(128).regex(/^[A-Za-z0-9._-]+$/);
|
|
28
|
+
// A process version is the registry's own label (the bundle hash prefix today); FGA-safe class.
|
|
29
|
+
const latticeVersion = z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/);
|
|
30
|
+
/** A Lattice run's lifecycle status, as `GET /api/lattice/runs` filters it. */
|
|
31
|
+
export const ARTIFACT_PROCESS_RUN_STATUSES = ['pending', 'running', 'completed', 'failed', 'cancelled', 'timed_out'];
|
|
32
|
+
/** Lattice lists at most this many runs per call. */
|
|
33
|
+
export const ARTIFACT_PROCESS_RUNS_LIMIT = 500;
|
|
34
|
+
/** Which of the user's human steps to list: parked and waiting on them, or already done. */
|
|
35
|
+
export const ARTIFACT_PROCESS_TASK_VIEWS = ['active', 'completed'];
|
|
36
|
+
/** Lattice lists at most this many human steps per call, as its own inbox does. */
|
|
37
|
+
export const ARTIFACT_PROCESS_TASKS_LIMIT = 500;
|
|
38
|
+
/** A grant or revocation names at most this many users and groups together. */
|
|
39
|
+
export const ARTIFACT_PROCESS_PRINCIPALS_PER_CALL = 100;
|
|
40
|
+
/** A principal search returns at most this many users and groups together, as Lattice's own picker. */
|
|
41
|
+
export const ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT = 50;
|
|
42
|
+
export const ARTIFACT_PROCESS_PRINCIPAL_TYPES = ['user', 'group'];
|
|
43
|
+
// A user's Auth0 `sub` or a chat group id, as FGA subjects are keyed: no whitespace, `:`, `#` or `*`.
|
|
44
|
+
const principalId = z.string().min(1).max(255).regex(/^[^\s:#*]+$/);
|
|
45
|
+
/** The users and groups a grant, revocation or reassignment names: at least one principal, bounded. */
|
|
46
|
+
const principalSet = {
|
|
47
|
+
users: z.array(principalId).max(ARTIFACT_PROCESS_PRINCIPALS_PER_CALL).optional(),
|
|
48
|
+
groups: z.array(principalId).max(ARTIFACT_PROCESS_PRINCIPALS_PER_CALL).optional(),
|
|
49
|
+
};
|
|
50
|
+
function namesPrincipals(value) {
|
|
51
|
+
const count = (value.users?.length ?? 0) + (value.groups?.length ?? 0);
|
|
52
|
+
return count > 0 && count <= ARTIFACT_PROCESS_PRINCIPALS_PER_CALL;
|
|
53
|
+
}
|
|
54
|
+
const PRINCIPAL_SET_MESSAGE = `Between 1 and ${ARTIFACT_PROCESS_PRINCIPALS_PER_CALL} users and groups`;
|
|
55
|
+
/** A start seeds assignments for at most this many human nodes. */
|
|
56
|
+
export const ARTIFACT_PROCESS_START_ASSIGNMENT_NODES = 64;
|
|
57
|
+
// A human node of the started run (`execute`), or one inside a nested subprocess (`application::qa_review`).
|
|
58
|
+
const assignmentNodeKey = z.string().min(1).max(255).regex(/^[A-Za-z0-9._-]+(?:::[A-Za-z0-9._-]+)*$/);
|
|
59
|
+
/**
|
|
60
|
+
* Per-run default assignees for human nodes, as `POST /api/lattice/runs` takes them: each node's
|
|
61
|
+
* principals become its advancers when the run reaches it. Atlas refuses principals it has tombstoned.
|
|
62
|
+
*/
|
|
63
|
+
const runAssignments = z.record(assignmentNodeKey, z.object(principalSet).strict().refine(namesPrincipals, PRINCIPAL_SET_MESSAGE))
|
|
64
|
+
.refine((value) => Object.keys(value).length > 0 && Object.keys(value).length <= ARTIFACT_PROCESS_START_ASSIGNMENT_NODES, `Between 1 and ${ARTIFACT_PROCESS_START_ASSIGNMENT_NODES} assigned nodes`);
|
|
65
|
+
const entityRef = z.string().min(1).max(255);
|
|
66
|
+
// Unleash feature names are URL-safe.
|
|
67
|
+
const flagName = z.string().min(1).max(255).regex(/^[A-Za-z0-9._~-]+$/);
|
|
68
|
+
export const ARTIFACT_FLAGS_PER_REQUEST = 32;
|
|
69
|
+
/** The audit actions an application may record. Sign-in, identity merges and webhooks stay Atlas's. */
|
|
70
|
+
export const ARTIFACT_AUDIT_ACTIONS = [
|
|
71
|
+
'view', 'search', 'create', 'update', 'delete', 'download', 'upload', 'export', 'import', 'share', 'submit', 'approve', 'reject', 'execute',
|
|
72
|
+
];
|
|
73
|
+
/** One bulk action (archive the 100 selected applications) is one event naming each of them. */
|
|
74
|
+
export const ARTIFACT_AUDIT_RESOURCES_PER_EVENT = 100;
|
|
75
|
+
export const ARTIFACT_AUDIT_METADATA_KEYS = 32;
|
|
76
|
+
// Resource types are identifiers, as Atlas's own (`application`, `applicationDocument`), so an
|
|
77
|
+
// application that takes over a product's routes can keep the types its audit queries filter on.
|
|
78
|
+
const auditResourceType = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/);
|
|
79
|
+
const auditMetadataKey = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/);
|
|
80
|
+
const auditEvent = {
|
|
81
|
+
/** Names the logical event. A retry of the same event reuses it, so the audit sink keeps one copy. */
|
|
82
|
+
eventId: z.uuid(),
|
|
83
|
+
action: z.enum(ARTIFACT_AUDIT_ACTIONS),
|
|
84
|
+
/** What happened, as the product names it (`applications.view`). Recorded as the event's `action_detail`. */
|
|
85
|
+
detail: z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/),
|
|
86
|
+
// None for an event about no one record (a list or a settings view), as Atlas's own list events.
|
|
87
|
+
resources: z.array(z.object({ type: auditResourceType, id: z.string().min(1).max(255) }).strict())
|
|
88
|
+
.max(ARTIFACT_AUDIT_RESOURCES_PER_EVENT),
|
|
89
|
+
// Scalars only: an audit record names what happened, it doesn't copy payloads.
|
|
90
|
+
metadata: z.record(auditMetadataKey, z.union([z.string().max(256), z.number(), z.boolean(), z.null()]))
|
|
91
|
+
.refine(value => Object.keys(value).length <= ARTIFACT_AUDIT_METADATA_KEYS, `At most ${ARTIFACT_AUDIT_METADATA_KEYS} metadata keys`)
|
|
92
|
+
.optional(),
|
|
93
|
+
};
|
|
24
94
|
/** Atlas validates these operations against current manifest, bindings and principal grants. */
|
|
25
95
|
export const artifactServerRequestSchema = z.discriminatedUnion('operation', [
|
|
26
96
|
z.object({ operation: z.literal('api'), mode, method, path, body: z.unknown().optional() }).strict(),
|
|
27
|
-
|
|
97
|
+
// jsonScalars asks the data plane for exact numeric and bigint strings; Atlas forwards it as
|
|
98
|
+
// the data plane's own header and passes the acknowledgment back.
|
|
99
|
+
z.object({
|
|
100
|
+
operation: z.literal('orm'), mode, namespace: id, op: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
101
|
+
variables: z.record(z.string(), z.unknown()).optional(), jsonScalars: z.literal(ORM_LOSSLESS_JSON_SCALARS).optional(),
|
|
102
|
+
}).strict(),
|
|
28
103
|
z.object({ operation: z.literal('storage'), mode, method, path: path.refine(value => {
|
|
29
104
|
const pathname = value.split('?')[0];
|
|
30
105
|
return pathname === '/api/storage' || pathname.startsWith('/api/storage/');
|
|
31
106
|
}, 'Expected a Storage API path'), body: z.unknown().optional() }).strict(),
|
|
32
107
|
z.object({ operation: z.literal('function'), mode, id, params: z.record(z.string(), z.unknown()).optional() }).strict(),
|
|
108
|
+
// Starts a run of a declared process, as `POST /api/lattice/runs` does: the application names the
|
|
109
|
+
// run, passes its input and may seed who works each human node. The start node, version and
|
|
110
|
+
// engine stay with the process and its owning routes.
|
|
111
|
+
z.object({
|
|
112
|
+
operation: z.literal('lattice'), mode, process: latticeId, runId: latticeId.optional(),
|
|
113
|
+
title: z.string().max(500).optional(),
|
|
114
|
+
entity: z.object({ type: entityRef, id: entityRef }).strict().optional(),
|
|
115
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
116
|
+
assignments: runAssignments.optional(),
|
|
117
|
+
}).strict(),
|
|
118
|
+
// Lists runs of a declared process, as `GET /api/lattice/runs` does: newest first, only the runs
|
|
119
|
+
// the user may view. Soft-archived runs are excluded unless `archived` is set.
|
|
120
|
+
z.object({
|
|
121
|
+
operation: z.literal('lattice_runs'), mode, process: latticeId,
|
|
122
|
+
status: z.enum(ARTIFACT_PROCESS_RUN_STATUSES).optional(),
|
|
123
|
+
entity: z.object({ type: entityRef, id: entityRef.optional() }).strict().optional(),
|
|
124
|
+
limit: z.number().int().min(1).max(ARTIFACT_PROCESS_RUNS_LIMIT).optional(),
|
|
125
|
+
archived: z.boolean().optional(),
|
|
126
|
+
}).strict(),
|
|
127
|
+
// Reads one run the user may view, as `GET /api/lattice/runs/[runId]` does: the run, its steps, the
|
|
128
|
+
// collapsed per-node states and the event cursor to resume its stream from.
|
|
129
|
+
z.object({ operation: z.literal('lattice_run'), mode, runId: latticeId }).strict(),
|
|
130
|
+
// Describes a declared process — the applied template its runs are filled in from: nodes, edges and
|
|
131
|
+
// what a person working each human step is shown. The active version, or the one a run pinned.
|
|
132
|
+
z.object({ operation: z.literal('lattice_process'), mode, process: latticeId, version: latticeVersion.optional() }).strict(),
|
|
133
|
+
// Lists the user's own human steps across declared processes (a subset, or every declared one),
|
|
134
|
+
// as `GET /api/lattice/inbox` does for the Tasks app: newest first, from the assignment index.
|
|
135
|
+
z.object({
|
|
136
|
+
operation: z.literal('lattice_tasks'), mode,
|
|
137
|
+
processes: z.array(latticeId).min(1).max(256).optional(),
|
|
138
|
+
view: z.enum(ARTIFACT_PROCESS_TASK_VIEWS).optional(),
|
|
139
|
+
limit: z.number().int().min(1).max(ARTIFACT_PROCESS_TASKS_LIMIT).optional(),
|
|
140
|
+
}).strict(),
|
|
141
|
+
// Completes one of the user's parked human steps on a run of a process declared under
|
|
142
|
+
// `processes.act`, as `POST /api/lattice/runs/[runId]/advance` does. The user's own step
|
|
143
|
+
// `advancer` grant decides; the application cannot complete it as someone else.
|
|
144
|
+
z.object({
|
|
145
|
+
operation: z.literal('lattice_advance'), mode, runId: latticeId,
|
|
146
|
+
stepId: latticeId.optional(), edgeId: z.string().min(1).max(255).optional(),
|
|
147
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
148
|
+
}).strict(),
|
|
149
|
+
// Reassigns a parked human step to users and groups, as `POST .../steps/[stepId]/assign` does.
|
|
150
|
+
// The user's run `owner` relation decides; the node's assignee allowlist still applies.
|
|
151
|
+
z.object({ operation: z.literal('lattice_assign'), mode, runId: latticeId, stepId: latticeId, ...principalSet }).strict()
|
|
152
|
+
.refine(namesPrincipals, PRINCIPAL_SET_MESSAGE),
|
|
153
|
+
// The explicit viewer grants on a run, for its owner.
|
|
154
|
+
z.object({ operation: z.literal('lattice_viewers'), mode, runId: latticeId }).strict(),
|
|
155
|
+
// Shares a run with users and groups (grants `viewer`), or takes a share back. Owner-gated;
|
|
156
|
+
// grants Atlas has tombstoned (disabled users, deleted groups) are refused.
|
|
157
|
+
z.object({ operation: z.literal('lattice_viewer_grant'), mode, runId: latticeId, ...principalSet }).strict()
|
|
158
|
+
.refine(namesPrincipals, PRINCIPAL_SET_MESSAGE),
|
|
159
|
+
z.object({ operation: z.literal('lattice_viewer_revoke'), mode, runId: latticeId, ...principalSet }).strict()
|
|
160
|
+
.refine(namesPrincipals, PRINCIPAL_SET_MESSAGE),
|
|
161
|
+
// Finds users in the user's own tenant and groups they may see, to name in an assignment or a
|
|
162
|
+
// share. The user is the directory's own, so no manifest binding beyond `processes.act` applies.
|
|
163
|
+
z.object({
|
|
164
|
+
operation: z.literal('lattice_principals'), mode, q: z.string().trim().max(200),
|
|
165
|
+
types: z.array(z.enum(ARTIFACT_PROCESS_PRINCIPAL_TYPES)).min(1).max(2).optional(),
|
|
166
|
+
limit: z.number().int().min(1).max(ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT).optional(),
|
|
167
|
+
}).strict(),
|
|
168
|
+
// Evaluates feature flags as Atlas evaluates its own: for the user, or with no user in service mode.
|
|
169
|
+
// A read: it needs no manifest declaration, as the browser bridge's flags.isEnabled needs none.
|
|
170
|
+
z.object({ operation: z.literal('flags'), mode, names: z.array(flagName).min(1).max(ARTIFACT_FLAGS_PER_REQUEST) }).strict(),
|
|
171
|
+
// Records an audit event for the request's user, under the service `server.capabilities.audit`
|
|
172
|
+
// declares. It changes no data, so read-only and stale contexts may record what they read.
|
|
173
|
+
z.object({ operation: z.literal('audit'), mode, ...auditEvent }).strict(),
|
|
33
174
|
]);
|
package/dist/server/client.d.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import { storageClient } from '@sequenceholdings/storage';
|
|
2
2
|
import { type OperationExecutor } from '@sequenceholdings/orm/client';
|
|
3
|
-
import type
|
|
4
|
-
import { type
|
|
3
|
+
import { type ArtifactAuditAction, type ArtifactServerMode } from './broker-contract.js';
|
|
4
|
+
import { type ArtifactServerProcesses } from './processes.js';
|
|
5
|
+
import { type ArtifactOrmJsonScalars } from '../orm-json-scalars.js';
|
|
5
6
|
import { createArtifactLogger, type ArtifactLogRecord } from './logger.js';
|
|
6
|
-
import { type ArtifactRequestContext } from './request-context.js';
|
|
7
|
+
import { type ArtifactRequestContext, type RequestContextV2External } from './request-context.js';
|
|
8
|
+
import { type ArtifactExternalAccept } from './external-context.js';
|
|
9
|
+
import { createApiClient } from './api-client.js';
|
|
10
|
+
import { type ArtifactPrincipal } from './principal.js';
|
|
7
11
|
import { type ArtifactRuntimeConfig, type ArtifactRequestHeaders } from './runtime-config.js';
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
constructor(status: number, code: string, message: string);
|
|
12
|
-
}
|
|
13
|
-
export interface ArtifactServerFetchOptions {
|
|
14
|
-
method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
15
|
-
body?: unknown;
|
|
16
|
-
signal?: AbortSignal;
|
|
17
|
-
}
|
|
12
|
+
export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './errors.js';
|
|
13
|
+
export type * from './processes.js';
|
|
14
|
+
export type { ArtifactServerFetchOptions } from './api-client.js';
|
|
18
15
|
/**
|
|
19
16
|
* The namespace's generated operations.manifest.json. Only each operation's persisted hash
|
|
20
17
|
* is read, so the file can be passed as imported: a JSON import types `kind` as string.
|
|
@@ -29,8 +26,54 @@ export interface ArtifactOrmOperations {
|
|
|
29
26
|
readonly requires?: unknown;
|
|
30
27
|
}>>;
|
|
31
28
|
}
|
|
32
|
-
export interface
|
|
33
|
-
|
|
29
|
+
export interface ArtifactOrmOptions {
|
|
30
|
+
namespace: string;
|
|
31
|
+
manifest: ArtifactOrmOperations;
|
|
32
|
+
/**
|
|
33
|
+
* `lossless-v1` returns numeric and bigint values as strings instead of JSON numbers,
|
|
34
|
+
* so money and large ids survive a read-then-write. The executor refuses any successful
|
|
35
|
+
* result Atlas does not acknowledge in that form. Omit it for the JSON-number default.
|
|
36
|
+
*/
|
|
37
|
+
jsonScalars?: ArtifactOrmJsonScalars;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Feature flags, evaluated by Atlas for this request's user (with no user in service mode), the
|
|
41
|
+
* same evaluation Atlas routes make. Any flag may be read, as in the browser's `seq.flags`. A
|
|
42
|
+
* refused or failed request rejects with ArtifactServerError; treat that as off where a flag gates
|
|
43
|
+
* a change.
|
|
44
|
+
*/
|
|
45
|
+
export interface ArtifactServerFlags {
|
|
46
|
+
isEnabled(flagName: string): Promise<boolean>;
|
|
47
|
+
/** Up to ARTIFACT_FLAGS_PER_REQUEST flags in one broker call. */
|
|
48
|
+
evaluate(flagNames: readonly string[]): Promise<Record<string, boolean>>;
|
|
49
|
+
}
|
|
50
|
+
/** One audit event, recorded for this request's user under the manifest's `server.capabilities.audit` service. */
|
|
51
|
+
export interface ArtifactAuditEvent {
|
|
52
|
+
action: ArtifactAuditAction;
|
|
53
|
+
/** What happened, as the product names it (`applications.view`). Recorded as the event's `action_detail`. */
|
|
54
|
+
detail: string;
|
|
55
|
+
/** What it happened to, at most ARTIFACT_AUDIT_RESOURCES_PER_EVENT: `{ type: 'application', id }`. Empty for a list. */
|
|
56
|
+
resources: ReadonlyArray<{
|
|
57
|
+
type: string;
|
|
58
|
+
id: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** Scalars that qualify the event (counts, kinds, outcomes). Never payloads, document contents or secrets. */
|
|
61
|
+
metadata?: Readonly<Record<string, string | number | boolean | null>>;
|
|
62
|
+
/** Names the logical event; one is generated when omitted. Pass the same id to retry the same event. */
|
|
63
|
+
eventId?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Audit events, delivered before `record` resolves. A refused or undeliverable event rejects with
|
|
67
|
+
* ArtifactServerError; decide per route whether that should fail the request.
|
|
68
|
+
*/
|
|
69
|
+
export interface ArtifactServerAudit {
|
|
70
|
+
record(event: ArtifactAuditEvent): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export type ArtifactServerContext = ArtifactRequestContext | RequestContextV2External;
|
|
73
|
+
export interface ArtifactServerClient<TContext extends ArtifactServerContext = ArtifactRequestContext> {
|
|
74
|
+
context: TContext;
|
|
75
|
+
/** Who the request acts for. An `external` principal reaches platform services only through `asService()`. */
|
|
76
|
+
principal: ArtifactPrincipal;
|
|
34
77
|
mode: ArtifactServerMode;
|
|
35
78
|
log: ReturnType<typeof createArtifactLogger>;
|
|
36
79
|
api: ReturnType<typeof createApiClient>;
|
|
@@ -38,44 +81,36 @@ export interface ArtifactServerClient {
|
|
|
38
81
|
functions: {
|
|
39
82
|
invoke<TOutput = unknown>(id: string, params?: Record<string, unknown>): Promise<TOutput>;
|
|
40
83
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
asService(): ArtifactServerClient
|
|
84
|
+
processes: ArtifactServerProcesses;
|
|
85
|
+
flags: ArtifactServerFlags;
|
|
86
|
+
audit: ArtifactServerAudit;
|
|
87
|
+
orm(input: ArtifactOrmOptions): OperationExecutor;
|
|
88
|
+
asService(): ArtifactServerClient<TContext>;
|
|
46
89
|
}
|
|
47
|
-
|
|
48
|
-
export declare function createArtifactServerClient({ headers, runtime, fetch: fetcher, method, path, logSink }: {
|
|
90
|
+
export interface ArtifactServerClientOptions {
|
|
49
91
|
headers: ArtifactRequestHeaders;
|
|
50
92
|
/** Optional explicit platform config; otherwise reads SEQUENCE_ARTIFACT_RUNTIME_FILE. */
|
|
51
93
|
runtime?: ArtifactRuntimeConfig;
|
|
52
94
|
fetch?: typeof globalThis.fetch;
|
|
95
|
+
/** Required, with `path`, to accept an external caller. */
|
|
53
96
|
method?: string;
|
|
54
97
|
path?: string;
|
|
55
98
|
logSink?: (record: ArtifactLogRecord) => void;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* One instance per incoming request. Never cache it at module scope.
|
|
102
|
+
*
|
|
103
|
+
* Accepts Atlas-user and platform-service contexts. A handler that also serves external callers
|
|
104
|
+
* names their ingresses in `accept`; its client then carries an external context, whose
|
|
105
|
+
* `principal` reaches platform services only through `asService()`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function createArtifactServerClient(options: ArtifactServerClientOptions & {
|
|
108
|
+
accept?: undefined;
|
|
56
109
|
}): Promise<ArtifactServerClient>;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
};
|
|
65
|
-
type BrokerTransport = ReturnType<typeof createBrokerTransport>;
|
|
66
|
-
declare function createBrokerTransport({ context, token, getRuntime, fetcher }: ClientArguments): (input: ArtifactServerRequest, signal?: AbortSignal) => Promise<Response>;
|
|
67
|
-
declare function createApiClient({ mode, request, json }: {
|
|
68
|
-
mode: ArtifactServerMode;
|
|
69
|
-
request: BrokerTransport;
|
|
70
|
-
json: (operation: ArtifactServerRequest, signal?: AbortSignal) => Promise<unknown>;
|
|
71
|
-
}): {
|
|
72
|
-
request: (path: string, options?: ArtifactServerFetchOptions) => Promise<Response>;
|
|
73
|
-
fetch<TBody = unknown>(path: string, options?: ArtifactServerFetchOptions): Promise<SequenceApiFetchResponse<TBody>>;
|
|
74
|
-
stream(path: string, options?: ArtifactServerFetchOptions): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
|
|
75
|
-
get: <T = unknown>(path: string) => Promise<T>;
|
|
76
|
-
post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
|
|
77
|
-
put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
|
|
78
|
-
patch: <T = unknown>(path: string, body?: unknown) => Promise<T>;
|
|
79
|
-
delete: <T = unknown>(path: string, body?: unknown) => Promise<T>;
|
|
80
|
-
};
|
|
81
|
-
export {};
|
|
110
|
+
export declare function createArtifactServerClient(options: ArtifactServerClientOptions & {
|
|
111
|
+
accept: ArtifactExternalAccept;
|
|
112
|
+
}): Promise<ArtifactServerClient<ArtifactServerContext>>;
|
|
113
|
+
/** For a helper that forwards an optional `accept`: the context may be either version. */
|
|
114
|
+
export declare function createArtifactServerClient(options: ArtifactServerClientOptions & {
|
|
115
|
+
accept?: ArtifactExternalAccept;
|
|
116
|
+
}): Promise<ArtifactServerClient<ArtifactServerContext>>;
|
package/dist/server/client.js
CHANGED
|
@@ -1,33 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { StorageApiError, storageClient, storageErrorSchema } from '@sequenceholdings/storage';
|
|
2
3
|
import { OrmOperationError } from '@sequenceholdings/orm/client';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { artifactServerRequestSchema } from './broker-contract.js';
|
|
6
|
+
import { createArtifactProcessesClient } from './processes.js';
|
|
7
|
+
import { assertJsonScalarsAcknowledged, ORM_JSON_SCALARS_HEADER } from '../orm-json-scalars.js';
|
|
5
8
|
import { createArtifactLogger } from './logger.js';
|
|
6
9
|
import { ARTIFACT_REQUEST_CONTEXT_HEADER, ARTIFACT_RUNTIME_TOKEN_HEADER, ARTIFACT_SERVER_REQUEST_PATH, verifyArtifactRequestContext } from './request-context.js';
|
|
10
|
+
import { verifyArtifactExternalContext } from './external-context.js';
|
|
11
|
+
import { isExternalContextToken } from './internal/signed-token.js';
|
|
12
|
+
import { createApiClient } from './api-client.js';
|
|
13
|
+
import { pause, transientConnectionCode } from './connection-retry.js';
|
|
14
|
+
import { principalFromContext } from './principal.js';
|
|
7
15
|
import { artifactRuntimeConfigSchema, readArtifactRuntimeConfig } from './runtime-config.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
code;
|
|
11
|
-
constructor(status, code, message) {
|
|
12
|
-
super(message);
|
|
13
|
-
this.status = status;
|
|
14
|
-
this.code = code;
|
|
15
|
-
this.name = 'ArtifactServerError';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
16
|
+
import { ArtifactPrincipalError, ArtifactServerError, refusalFromResponse, stepUpError, stepUpErrorFromResponse } from './errors.js';
|
|
17
|
+
export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './errors.js';
|
|
18
18
|
const graphqlResponseSchema = z.object({
|
|
19
19
|
data: z.unknown().optional(),
|
|
20
20
|
errors: z.array(z.object({ message: z.string(), extensions: z.object({ code: z.string().optional() }).passthrough().optional() })).optional(),
|
|
21
21
|
});
|
|
22
|
-
const
|
|
22
|
+
const flagsResponseSchema = z.object({ flags: z.record(z.string(), z.boolean()) });
|
|
23
23
|
const maxBodyBytes = 1024 * 1024;
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/** A read whose connection failed is sent at most three times, pausing up to 100 ms, then up to 200 ms. */
|
|
25
|
+
const reconnect = { attempts: 3, baseDelayMs: 100 };
|
|
26
|
+
export async function createArtifactServerClient({ headers, runtime, fetch: fetcher = globalThis.fetch, method, path, logSink, accept }) {
|
|
26
27
|
const config = runtime ? artifactRuntimeConfigSchema.parse(runtime) : await readArtifactRuntimeConfig();
|
|
27
28
|
if (!config)
|
|
28
29
|
throw new ArtifactServerError(401, 'runtime_not_connected', 'Run seq-studio artifact dev to connect platform services, or use a hosted preview.');
|
|
29
30
|
const token = headers.get(ARTIFACT_REQUEST_CONTEXT_HEADER);
|
|
30
|
-
|
|
31
|
+
let context;
|
|
32
|
+
if (accept && isExternalContextToken(token)) {
|
|
33
|
+
if (!method || !path)
|
|
34
|
+
throw new ArtifactServerError(401, 'invalid_request_context', 'An external request context is verified only against the route method and path');
|
|
35
|
+
context = verifyArtifactExternalContext({ token, publicKeys: config.publicKeys, projectId: config.projectId, environment: config.environment, method, path, accept });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
context = verifyArtifactRequestContext({ token, publicKeys: config.publicKeys, projectId: config.projectId, environment: config.environment, method, path });
|
|
39
|
+
}
|
|
31
40
|
if (!token || (config.devSessionId && context.devSessionId !== config.devSessionId) ||
|
|
32
41
|
(config.deploymentId && context.deploymentId !== config.deploymentId))
|
|
33
42
|
throw new ArtifactServerError(401, 'invalid_runtime_session', 'Artifact request does not belong to this runtime');
|
|
@@ -37,29 +46,42 @@ export async function createArtifactServerClient({ headers, runtime, fetch: fetc
|
|
|
37
46
|
throw new ArtifactServerError(401, 'runtime_changed', 'Artifact runtime disconnected or changed; reload the preview');
|
|
38
47
|
return current;
|
|
39
48
|
};
|
|
40
|
-
return buildClient({ context, token, getRuntime, fetcher, mode: 'user', logSink });
|
|
49
|
+
return buildClient({ context, principal: principalFromContext(context), token, getRuntime, fetcher, mode: 'user', logSink });
|
|
41
50
|
}
|
|
42
51
|
function buildClient(args) {
|
|
43
|
-
const { context, mode, logSink } = args;
|
|
44
|
-
const
|
|
52
|
+
const { context, principal, mode, logSink } = args;
|
|
53
|
+
const log = createArtifactLogger({ context, sink: logSink });
|
|
54
|
+
const request = createBrokerTransport({ ...args, log });
|
|
45
55
|
const json = async (operation, signal) => {
|
|
46
|
-
const response = await request(operation, signal);
|
|
56
|
+
const response = await request(operation, { signal });
|
|
57
|
+
// One read of the body: a step-up refusal keeps reason and maxAge, any other keeps Atlas's { detail, code }.
|
|
47
58
|
if (!response.ok)
|
|
48
|
-
throw
|
|
59
|
+
throw await refusalFromResponse({ response, operation: operation.operation });
|
|
49
60
|
return response.status === 204 ? undefined : response.json();
|
|
50
61
|
};
|
|
51
62
|
// storageClient retries replay-safe calls on 503 permissions_pending / storage_busy, so Storage
|
|
52
63
|
// failures keep the envelope's status and code rather than collapsing into platform_request_failed.
|
|
53
64
|
const storageJson = async (operation) => {
|
|
54
65
|
const response = await request(operation);
|
|
55
|
-
if (!response.ok)
|
|
56
|
-
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
// A step-up refusal keeps its reason and maxAge; storageApiError reads the body, hence the clone.
|
|
68
|
+
throw (response.status === 401 ? await stepUpErrorFromResponse(response.clone()) : null) ?? await storageApiError(response);
|
|
69
|
+
}
|
|
57
70
|
return response.status === 204 ? undefined : response.json();
|
|
58
71
|
};
|
|
72
|
+
const evaluateFlags = async (flagNames) => {
|
|
73
|
+
const names = [...new Set(flagNames)];
|
|
74
|
+
const parsed = flagsResponseSchema.safeParse(await json(artifactServerRequestSchema.parse({ operation: 'flags', mode, names })));
|
|
75
|
+
if (!parsed.success)
|
|
76
|
+
throw new ArtifactServerError(502, 'invalid_response', 'Atlas returned a malformed flags response');
|
|
77
|
+
// A flag Atlas didn't answer reads off rather than undefined.
|
|
78
|
+
return Object.fromEntries(names.map(name => [name, parsed.data.flags[name] === true]));
|
|
79
|
+
};
|
|
59
80
|
return {
|
|
60
81
|
context,
|
|
82
|
+
principal,
|
|
61
83
|
mode,
|
|
62
|
-
log
|
|
84
|
+
log,
|
|
63
85
|
api: createApiClient({ mode, request, json }),
|
|
64
86
|
storage: storageClient({ request: (method, path, body) => storageJson(artifactServerRequestSchema.parse({ operation: 'storage', mode, method, path, body })) }),
|
|
65
87
|
functions: {
|
|
@@ -68,18 +90,32 @@ function buildClient(args) {
|
|
|
68
90
|
return await json(artifactServerRequestSchema.parse({ operation: 'function', mode, id, params }));
|
|
69
91
|
},
|
|
70
92
|
},
|
|
71
|
-
|
|
93
|
+
processes: createArtifactProcessesClient({ mode, json }),
|
|
94
|
+
flags: {
|
|
95
|
+
evaluate: evaluateFlags,
|
|
96
|
+
isEnabled: async (flagName) => (await evaluateFlags([flagName]))[flagName] === true,
|
|
97
|
+
},
|
|
98
|
+
audit: { record: (event) => recordAuditEvent({ request, mode, event }) },
|
|
99
|
+
orm: ({ namespace, manifest, jsonScalars }) => ({
|
|
72
100
|
async execute(op, variables = {}) {
|
|
73
|
-
const
|
|
101
|
+
const listed = op.startsWith('sha256:') ? Object.values(manifest.operations).find(entry => entry.hash === op) : manifest.operations[op];
|
|
102
|
+
const hash = op.startsWith('sha256:') ? op : listed?.hash;
|
|
74
103
|
if (!hash)
|
|
75
104
|
throw new ArtifactServerError(400, 'unknown_orm_operation', 'ORM operation is not in the generated manifest; run seq-studio orm generate');
|
|
76
|
-
const response = await request(artifactServerRequestSchema.parse({ operation: 'orm', mode, namespace, op: hash, variables }))
|
|
77
|
-
|
|
105
|
+
const response = await request(artifactServerRequestSchema.parse({ operation: 'orm', mode, namespace, op: hash, variables, ...(jsonScalars ? { jsonScalars } : {}) }),
|
|
106
|
+
// Only an operation the manifest lists as a query is known to change nothing.
|
|
107
|
+
{ replaySafe: listed?.kind === 'query' });
|
|
108
|
+
const body = await response.json().catch(() => null);
|
|
109
|
+
const stepUp = stepUpError(response.status, body);
|
|
110
|
+
if (stepUp)
|
|
111
|
+
throw stepUp;
|
|
112
|
+
const parsed = graphqlResponseSchema.safeParse(body);
|
|
78
113
|
if (!parsed.success)
|
|
79
114
|
throw new OrmOperationError('Malformed Data API response', response.status, 'invalid_response');
|
|
80
115
|
const first = parsed.data.errors?.[0];
|
|
81
116
|
if (!response.ok || first)
|
|
82
117
|
throw new OrmOperationError(first?.message ?? `Data API request failed (${response.status})`, response.status, first?.extensions?.code ?? null, parsed.data.errors);
|
|
118
|
+
assertJsonScalarsAcknowledged({ requested: jsonScalars, acknowledged: response.headers.get(ORM_JSON_SCALARS_HEADER), status: response.status });
|
|
83
119
|
if (parsed.data.data === undefined || parsed.data.data === null)
|
|
84
120
|
throw new OrmOperationError('Malformed Data API response: missing data', response.status, 'invalid_response');
|
|
85
121
|
// Mirrors the generated ORM executor contract: consumers provide their generated result type.
|
|
@@ -94,13 +130,66 @@ function buildClient(args) {
|
|
|
94
130
|
},
|
|
95
131
|
};
|
|
96
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Sends one audit event, and once more with the same event id when Atlas couldn't confirm delivery
|
|
135
|
+
* (503, or no answer): Atlas derives the stored event id from it and the request, so the audit sink
|
|
136
|
+
* keeps one copy whichever attempt landed.
|
|
137
|
+
*/
|
|
138
|
+
async function recordAuditEvent({ request, mode, event }) {
|
|
139
|
+
const { eventId = randomUUID(), ...rest } = event;
|
|
140
|
+
const operation = artifactServerRequestSchema.parse({ operation: 'audit', mode, eventId, ...rest });
|
|
141
|
+
let failure;
|
|
142
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await request(operation);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
// A local refusal (expired context, oversized body) won't change on a retry.
|
|
149
|
+
if (error instanceof ArtifactServerError)
|
|
150
|
+
throw error;
|
|
151
|
+
failure = new ArtifactServerError(503, 'audit_unavailable', 'Atlas did not answer the audit request');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// A step-up refusal is the broker's answer, not a lost delivery: never resent.
|
|
155
|
+
const stepUp = await stepUpErrorFromResponse(response);
|
|
156
|
+
if (stepUp)
|
|
157
|
+
throw stepUp;
|
|
158
|
+
if (!response.bodyUsed)
|
|
159
|
+
await response.body?.cancel();
|
|
160
|
+
if (response.ok)
|
|
161
|
+
return;
|
|
162
|
+
failure = new ArtifactServerError(response.status, response.status === 503 ? 'audit_unavailable' : 'platform_request_failed', `Atlas rejected the audit request (${response.status})`);
|
|
163
|
+
if (response.status !== 503)
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
throw failure;
|
|
167
|
+
}
|
|
97
168
|
/** Mirrors createStorageClient: Storage's `{ error: { code, message } }` envelope survives the broker. */
|
|
98
169
|
async function storageApiError(response) {
|
|
99
170
|
const envelope = storageErrorSchema.safeParse(await response.json().catch(() => undefined));
|
|
100
171
|
return new StorageApiError(response.status, envelope.success ? envelope.data.error.code : 'request_failed', envelope.success ? envelope.data.error.message : `Storage request failed (${response.status})`);
|
|
101
172
|
}
|
|
102
|
-
|
|
103
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Broker operations that change nothing in Atlas. An ORM call's caller decides from its manifest
|
|
175
|
+
* kind; function invokes and process starts can act, and audit delivery resends on its own terms.
|
|
176
|
+
*/
|
|
177
|
+
function isReadOperation(operation) {
|
|
178
|
+
switch (operation.operation) {
|
|
179
|
+
case 'api':
|
|
180
|
+
case 'storage':
|
|
181
|
+
return operation.method === 'GET' || operation.method === 'HEAD';
|
|
182
|
+
case 'flags':
|
|
183
|
+
return true;
|
|
184
|
+
default:
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function createBrokerTransport({ context, principal, token, getRuntime, fetcher, log }) {
|
|
189
|
+
const send = async (input, signal) => {
|
|
190
|
+
// The one choke point for every broker operation: an external caller never acts as a user.
|
|
191
|
+
if (input.mode === 'user' && principal.kind === 'external')
|
|
192
|
+
throw new ArtifactPrincipalError('external_requires_service');
|
|
104
193
|
if (context.exp <= Math.floor(Date.now() / 1000))
|
|
105
194
|
throw new ArtifactServerError(401, 'request_context_expired', 'Artifact request context expired; begin a new request');
|
|
106
195
|
const operation = artifactServerRequestSchema.parse(input);
|
|
@@ -124,36 +213,26 @@ function createBrokerTransport({ context, token, getRuntime, fetcher }) {
|
|
|
124
213
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
|
|
125
214
|
});
|
|
126
215
|
};
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const response = await fetchResponse(path, options);
|
|
149
|
-
if (!response.ok || !response.body)
|
|
150
|
-
throw new ArtifactServerError(response.status, 'stream_unavailable', 'Atlas did not return a readable response');
|
|
151
|
-
return response.body;
|
|
152
|
-
},
|
|
153
|
-
get: (path) => typedJson(path),
|
|
154
|
-
post: (path, body) => typedJson(path, { method: 'POST', body }),
|
|
155
|
-
put: (path, body) => typedJson(path, { method: 'PUT', body }),
|
|
156
|
-
patch: (path, body) => typedJson(path, { method: 'PATCH', body }),
|
|
157
|
-
delete: (path, body) => typedJson(path, { method: 'DELETE', body }),
|
|
216
|
+
/**
|
|
217
|
+
* Sends a read again when its connection to Atlas failed before Atlas answered: such failures
|
|
218
|
+
* clear within moments, and a read changes nothing by arriving twice. A write is never sent
|
|
219
|
+
* again, because a reset can come after Atlas acted on it. A response, whatever its status, is
|
|
220
|
+
* Atlas's answer and is returned as is.
|
|
221
|
+
*/
|
|
222
|
+
return async (input, { signal, replaySafe = isReadOperation(input) } = {}) => {
|
|
223
|
+
for (let attempt = 1;; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
return await send(input, signal);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
const code = replaySafe && attempt < reconnect.attempts ? transientConnectionCode(error) : null;
|
|
229
|
+
if (code === null)
|
|
230
|
+
throw error;
|
|
231
|
+
log.warn('broker.reconnect', { operationId: input.operation, errorCode: code, count: attempt });
|
|
232
|
+
// Equal jitter, so the reads of one burst don't all reconnect at the same moment.
|
|
233
|
+
const ceiling = reconnect.baseDelayMs * 2 ** (attempt - 1);
|
|
234
|
+
await pause({ ms: ceiling / 2 + (Math.random() * ceiling) / 2, signal });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
158
237
|
};
|
|
159
238
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Recognising a lost connection to Atlas, and waiting before a broker read is resent. */
|
|
2
|
+
/**
|
|
3
|
+
* The transient connection code behind a failed fetch, or null. Node's fetch throws
|
|
4
|
+
* `TypeError: fetch failed` with the socket error as its cause. When every address of the host
|
|
5
|
+
* failed to connect, that cause is an AggregateError carrying the first address's code.
|
|
6
|
+
*/
|
|
7
|
+
export declare function transientConnectionCode(error: unknown): string | null;
|
|
8
|
+
/** Resolves after `ms`, or rejects with the caller's abort reason as soon as it aborts. */
|
|
9
|
+
export declare function pause({ ms, signal }: {
|
|
10
|
+
ms: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}): Promise<void>;
|