@empyria/restate 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/.oxfmtrc.json +9 -0
- package/AGENTS.md +64 -0
- package/README.md +78 -0
- package/index.js +7 -0
- package/lib/Admin.js +737 -0
- package/lib/Caller.js +50 -0
- package/lib/Cron.js +109 -0
- package/lib/Validation.js +39 -0
- package/package.json +44 -0
- package/test/Admin.test.js +470 -0
- package/test/Caller.test.js +42 -0
- package/test/Cron.test.js +151 -0
- package/test/Validation.test.js +34 -0
package/lib/Admin.js
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import http2 from 'node:http2'
|
|
3
|
+
import { createServer as createHttpServer } from 'http'
|
|
4
|
+
|
|
5
|
+
import { setTimeout as sleep } from 'timers/promises'
|
|
6
|
+
|
|
7
|
+
import * as restate from '@restatedev/restate-sdk'
|
|
8
|
+
import * as clients from '@restatedev/restate-sdk-clients'
|
|
9
|
+
|
|
10
|
+
export { restate, clients }
|
|
11
|
+
|
|
12
|
+
import { CallerServiceDef } from './Caller.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A subset of the OpenAPI-generated `Components['schemas']` shapes (see
|
|
16
|
+
* `../admin.types.ts`, regenerated via `../generateTypes.sh`) covering just the
|
|
17
|
+
* fields this module actually reads. Not consumed at runtime — for editor hover only.
|
|
18
|
+
* @typedef {Object} HandlerMetadata
|
|
19
|
+
* @property {string} name
|
|
20
|
+
* @property {'Exclusive'|'Shared'|'Workflow'} [ty]
|
|
21
|
+
* @typedef {Object} ServiceMetadata
|
|
22
|
+
* @property {string} name
|
|
23
|
+
* @property {HandlerMetadata[]} handlers
|
|
24
|
+
* @property {string} [ty]
|
|
25
|
+
* @property {string} [deployment_id]
|
|
26
|
+
* @typedef {Object} ServicesResponse
|
|
27
|
+
* @property {ServiceMetadata[]} services
|
|
28
|
+
* @typedef {Object} RegisterDeploymentRequest
|
|
29
|
+
* @property {string} uri
|
|
30
|
+
* @property {boolean|null} force
|
|
31
|
+
* @typedef {Object} RegisterDeploymentResponse
|
|
32
|
+
* @property {string} id
|
|
33
|
+
* @property {{name: string}[]} services
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const OK = 'OK'
|
|
37
|
+
|
|
38
|
+
/** @type {Record<string, string[]>} */
|
|
39
|
+
let RESTATE_CACHE = {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Error codes
|
|
43
|
+
*/
|
|
44
|
+
export const ERROR_VALIDATION = 52000
|
|
45
|
+
export const ERROR_INVOCATION = 52001
|
|
46
|
+
export const ERROR_INFRASTRUCTURE = 52002
|
|
47
|
+
export const ERROR_ARCHITECTURE = 52003
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cap of retrying strategy's delay
|
|
51
|
+
*/
|
|
52
|
+
export const DELAY_CAP = 30_000
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if service and handler exist in Restate
|
|
56
|
+
* @param {string} restateAdminURL
|
|
57
|
+
* @param {string} serviceName
|
|
58
|
+
* @param {string} handlerName
|
|
59
|
+
* @param {'Exclusive'|'Shared'|'Workflow'} [expectedType]
|
|
60
|
+
* @returns {Promise<boolean>}
|
|
61
|
+
* @throws {RestateError} If service or handler not found
|
|
62
|
+
*/
|
|
63
|
+
export async function checkServiceHandler(restateAdminURL, serviceName, handlerName, expectedType) {
|
|
64
|
+
if (RESTATE_CACHE?.[serviceName]?.includes(handlerName)) return true
|
|
65
|
+
|
|
66
|
+
const response = await fetch(`${restateAdminURL}/services`)
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new restate.TerminalError(
|
|
70
|
+
`Failed to fetch services: ${response.status} ${response.statusText}`,
|
|
71
|
+
{ errorCode: ERROR_INFRASTRUCTURE },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @type {ServicesResponse} */
|
|
76
|
+
const data = await response.json()
|
|
77
|
+
|
|
78
|
+
/** @type {Record<string, string[]>} */
|
|
79
|
+
const _RESTATE_CACHE = {}
|
|
80
|
+
for (const _service of data.services) {
|
|
81
|
+
_RESTATE_CACHE[_service.name] = _service.handlers.map((h) => h.name)
|
|
82
|
+
}
|
|
83
|
+
RESTATE_CACHE = _RESTATE_CACHE
|
|
84
|
+
|
|
85
|
+
const service = data.services.find((s) => s.name === serviceName)
|
|
86
|
+
|
|
87
|
+
if (!service) {
|
|
88
|
+
throw new restate.TerminalError(`Service '${serviceName}' not registered in Restate`, {
|
|
89
|
+
errorCode: ERROR_ARCHITECTURE,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const handler = service.handlers.find((h) => h.name === handlerName)
|
|
94
|
+
|
|
95
|
+
if (!handler) {
|
|
96
|
+
throw new restate.TerminalError(
|
|
97
|
+
`Handler '${handlerName}' not found in service '${serviceName}'`,
|
|
98
|
+
{ errorCode: ERROR_ARCHITECTURE },
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (expectedType && handler.ty !== expectedType) {
|
|
103
|
+
throw new restate.TerminalError(
|
|
104
|
+
`Handler '${handlerName}' is '${handler.ty}', expected '${expectedType}'`,
|
|
105
|
+
{ errorCode: ERROR_ARCHITECTURE },
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List all registered services
|
|
114
|
+
* @param {{restateAdminURL: string}} params
|
|
115
|
+
* @returns {Promise<ServiceMetadata[]>}
|
|
116
|
+
*/
|
|
117
|
+
export async function listServices({ restateAdminURL }) {
|
|
118
|
+
const response = await fetch(`${restateAdminURL}/services`)
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new restate.TerminalError(
|
|
121
|
+
`Failed to fetch services: ${response.status} ${response.statusText}`,
|
|
122
|
+
{ errorCode: ERROR_INFRASTRUCTURE },
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
/** @type {ServicesResponse} */
|
|
126
|
+
const data = await response.json()
|
|
127
|
+
return data.services // Array of { name, handlers, ty, deployment_id, ... }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Force-deletes a deployment from Restate, draining any in-flight invocations.
|
|
132
|
+
* @param {string} restateAdminURL
|
|
133
|
+
* @param {string} deploymentId
|
|
134
|
+
* @returns {Promise<void>}
|
|
135
|
+
*/
|
|
136
|
+
export async function deleteDeployment(restateAdminURL, deploymentId) {
|
|
137
|
+
const response = await fetch(`${restateAdminURL}/deployments/${deploymentId}?force=true`, {
|
|
138
|
+
method: 'DELETE',
|
|
139
|
+
})
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new restate.TerminalError(
|
|
142
|
+
`Failed to delete deployment: ${response.status} ${response.statusText}`,
|
|
143
|
+
{ errorCode: ERROR_INFRASTRUCTURE },
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get a specific service's details including its handlers
|
|
150
|
+
* @param {{restateAdminURL: string, name: string}} params
|
|
151
|
+
* @returns {Promise<ServiceMetadata>}
|
|
152
|
+
*/
|
|
153
|
+
export async function service({ restateAdminURL, name }) {
|
|
154
|
+
const response = await fetch(`${restateAdminURL}/services/${name}`)
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
if (response.status === 404) {
|
|
157
|
+
throw new restate.TerminalError(`Service '${name}' not found`, {
|
|
158
|
+
errorCode: ERROR_ARCHITECTURE,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
throw new restate.TerminalError(
|
|
162
|
+
`Failed to fetch service: ${response.status} ${response.statusText}`,
|
|
163
|
+
{ errorCode: ERROR_INFRASTRUCTURE },
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
return await response.json()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* List handlers for a given service
|
|
171
|
+
* @param {{restateAdminURL: string, name: string}} params
|
|
172
|
+
* @returns {Promise<HandlerMetadata[]>}
|
|
173
|
+
*/
|
|
174
|
+
export async function listHandlers({ restateAdminURL, name }) {
|
|
175
|
+
const _service = await service({ restateAdminURL, name })
|
|
176
|
+
return _service.handlers
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @typedef {Object} BaseMessage Common fields shared by all Restate message types.
|
|
181
|
+
* @property {string} restateURL
|
|
182
|
+
* @property {string} name
|
|
183
|
+
* @property {object} payload
|
|
184
|
+
* @typedef {BaseMessage & {key: string}} WorkflowMessage Message targeting a Restate
|
|
185
|
+
* workflow — `key` is required to address the workflow instance.
|
|
186
|
+
* @typedef {BaseMessage & {key?: string, message: string}} ServiceMessage Message
|
|
187
|
+
* targeting a Restate service or virtual object handler. `key` is the Virtual
|
|
188
|
+
* Object key (omit for plain services); `message` is the handler name.
|
|
189
|
+
* @typedef {Object} InvocationSubmission Response returned by Restate when an
|
|
190
|
+
* invocation is enqueued asynchronously.
|
|
191
|
+
* @property {string} invocationId
|
|
192
|
+
* @property {'Accepted'|'PreviouslyAccepted'} status
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Starts a sub-workflow and returns a handle to the invocation.
|
|
197
|
+
* @template P
|
|
198
|
+
* @param {import('@restatedev/restate-sdk').WorkflowContext} ctx Existing workflow context
|
|
199
|
+
* @param {string} workflowName name of the workflow to start
|
|
200
|
+
* @param {string} workflowKey key of the workflow instance to start
|
|
201
|
+
* @param {P} payload payload to pass to the workflow
|
|
202
|
+
* @returns {import('@restatedev/restate-sdk').InvocationHandle} handle to the invocation
|
|
203
|
+
*/
|
|
204
|
+
export function startSubWorkflow(ctx, workflowName, workflowKey, payload) {
|
|
205
|
+
return ctx.genericSend({
|
|
206
|
+
service: workflowName,
|
|
207
|
+
method: 'run',
|
|
208
|
+
key: workflowKey,
|
|
209
|
+
parameter: payload,
|
|
210
|
+
inputSerde: restate.serde.json,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Executes a sub-workflow and returns a promise resolving to the result.
|
|
216
|
+
* @template P
|
|
217
|
+
* @template [R=unknown]
|
|
218
|
+
* @param {import('@restatedev/restate-sdk').WorkflowContext} ctx Existing workflow context
|
|
219
|
+
* @param {string} workflowName name of the workflow to execute
|
|
220
|
+
* @param {string} workflowKey key of the workflow instance to execute
|
|
221
|
+
* @param {P} payload payload to pass to the workflow
|
|
222
|
+
* @returns {Promise<R>} promise resolving to the result
|
|
223
|
+
*/
|
|
224
|
+
export function callSubWorkflow(ctx, workflowName, workflowKey, payload) {
|
|
225
|
+
return ctx.genericCall({
|
|
226
|
+
service: workflowName,
|
|
227
|
+
method: 'run',
|
|
228
|
+
key: workflowKey,
|
|
229
|
+
parameter: payload,
|
|
230
|
+
inputSerde: restate.serde.json,
|
|
231
|
+
outputSerde: restate.serde.json,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Submits a Restate workflow with configurable retry policy.
|
|
237
|
+
* @template TPayload
|
|
238
|
+
* @param {Object} options
|
|
239
|
+
* @param {string} options.restateURL - Restate endpoint URL
|
|
240
|
+
* @param {string} options.name - Workflow name
|
|
241
|
+
* @param {string} [options.key] - Unique workflow instance key
|
|
242
|
+
* @param {TPayload} options.payload - Request payload
|
|
243
|
+
* @returns {Promise<InvocationSubmission>}
|
|
244
|
+
*/
|
|
245
|
+
export async function submitWorkflow({ restateURL, name, key, payload }) {
|
|
246
|
+
const workflowKey = key ?? randomUUID()
|
|
247
|
+
|
|
248
|
+
const response = await fetch(`${restateURL}/${name}/${workflowKey}/run/send`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
body: JSON.stringify(payload),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
if (!response.ok) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Failed to submit workflow '${name}/${workflowKey}': ${response.status} ${response.statusText}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** @type {InvocationSubmission} */
|
|
261
|
+
const submission = await response.json()
|
|
262
|
+
|
|
263
|
+
if (submission.status !== 'Accepted' && submission.status !== 'PreviouslyAccepted') {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Unexpected workflow submission status '${submission.status}' for '${name}/${workflowKey}'`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return submission
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Returns a submit function that validates the workflow's `run` handler exists
|
|
274
|
+
* in Restate before forwarding to {@link submitWorkflow}.
|
|
275
|
+
* @param {{restateAdminURL: string}} params
|
|
276
|
+
* @returns {(message: WorkflowMessage) => Promise<InvocationSubmission>}
|
|
277
|
+
*/
|
|
278
|
+
export function submitWorkflowDiscovery({ restateAdminURL }) {
|
|
279
|
+
return async ({ restateURL, name, key, payload }) => {
|
|
280
|
+
await checkServiceHandler(restateAdminURL, name, 'run')
|
|
281
|
+
|
|
282
|
+
return submitWorkflow({ restateURL, name, key, payload })
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Long-polls the `/attach` endpoint until the invocation completes and returns its result.
|
|
288
|
+
* @template TResult
|
|
289
|
+
* @param {{restateURL: string, invocationId: string}} params
|
|
290
|
+
* @returns {Promise<TResult>}
|
|
291
|
+
*/
|
|
292
|
+
export async function waitForInvocation({ restateURL, invocationId }) {
|
|
293
|
+
const response = await fetch(`${restateURL}/restate/invocation/${invocationId}/attach`)
|
|
294
|
+
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`Failed to attach to invocation '${invocationId}': ${response.status} ${response.statusText}`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return await response.json()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Polls the `/output` endpoint at `intervalMs` intervals until output is
|
|
306
|
+
* available (HTTP 470 means not yet ready).
|
|
307
|
+
* @template TResult
|
|
308
|
+
* @param {{restateURL: string, invocationId: string, intervalMs?: number}} params
|
|
309
|
+
* @returns {Promise<TResult>}
|
|
310
|
+
*/
|
|
311
|
+
export async function pollInvocation({ restateURL, invocationId, intervalMs = 250 }) {
|
|
312
|
+
while (true) {
|
|
313
|
+
const response = await fetch(`${restateURL}/restate/invocation/${invocationId}/output`)
|
|
314
|
+
|
|
315
|
+
if (response.ok) return await response.json()
|
|
316
|
+
|
|
317
|
+
if (response.status !== 470) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Unexpected status ${response.status} polling invocation '${invocationId}'`,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Sends a message to a Restate service with configurable retry policy.
|
|
329
|
+
* @template TResult
|
|
330
|
+
* @param {ServiceMessage} params
|
|
331
|
+
* @returns {Promise<TResult>}
|
|
332
|
+
*/
|
|
333
|
+
export async function sendMessage({ restateURL, name, message, key, payload }) {
|
|
334
|
+
const path = key ? `${name}/${key}/${message}` : `${name}/${message}`
|
|
335
|
+
const response = await fetch(`${restateURL}/${path}`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: { 'Content-Type': 'application/json' },
|
|
338
|
+
body: JSON.stringify(payload),
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
if (!response.ok) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Failed to send '${name}/${message}': ${response.status} ${response.statusText}`,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const text = await response.text()
|
|
348
|
+
return text ? JSON.parse(text) : undefined
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Fire-and-forget variant of {@link sendMessage} — returns the invocation ID without waiting for a result.
|
|
353
|
+
* @param {ServiceMessage} params
|
|
354
|
+
* @returns {Promise<InvocationSubmission>}
|
|
355
|
+
*/
|
|
356
|
+
export async function sendMessageAsync({ restateURL, name, message, key, payload }) {
|
|
357
|
+
const path = key ? `${name}/${key}/${message}` : `${name}/${message}`
|
|
358
|
+
const response = await fetch(`${restateURL}/${path}/send`, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { 'Content-Type': 'application/json' },
|
|
361
|
+
body: JSON.stringify(payload),
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
if (!response.ok) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Failed to send '${name}/${message}': ${response.status} ${response.statusText}`,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** @type {InvocationSubmission} */
|
|
371
|
+
const submission = await response.json()
|
|
372
|
+
|
|
373
|
+
if (submission.status !== 'Accepted' && submission.status !== 'PreviouslyAccepted') {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Unexpected submission status '${submission.status}' for '${name}/${message}'`,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return submission
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Returns a send function that validates the service handler exists in Restate
|
|
384
|
+
* before calling {@link sendMessage} synchronously.
|
|
385
|
+
* @template TResult
|
|
386
|
+
* @param {{restateAdminURL: string}} params
|
|
387
|
+
* @returns {(message: ServiceMessage) => Promise<TResult>}
|
|
388
|
+
*/
|
|
389
|
+
export function sendMessageWithDiscovery({ restateAdminURL }) {
|
|
390
|
+
return async ({ restateURL, name, message, key, payload }) => {
|
|
391
|
+
await checkServiceHandler(restateAdminURL, name, message)
|
|
392
|
+
|
|
393
|
+
return sendMessage({ restateURL, name, message, key, payload })
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Returns a fire-and-forget send function that validates the service handler
|
|
399
|
+
* exists in Restate before calling {@link sendMessageAsync}.
|
|
400
|
+
* @param {{restateAdminURL: string}} params
|
|
401
|
+
* @returns {(message: ServiceMessage) => Promise<InvocationSubmission>}
|
|
402
|
+
*/
|
|
403
|
+
export function sendMessageAsyncWithDiscovery({ restateAdminURL }) {
|
|
404
|
+
return async ({ restateURL, name, message, key, payload }) => {
|
|
405
|
+
await checkServiceHandler(restateAdminURL, name, message)
|
|
406
|
+
|
|
407
|
+
return sendMessageAsync({ restateURL, name, message, key, payload })
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @typedef {import('node:http2').Http2Server & {deploymentId: string, services: string[], forceClose: () => void}} RestateServer
|
|
413
|
+
*/
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Typed wrapper around `restate.workflow` that preserves handler map inference in
|
|
417
|
+
* TypeScript consumers; in plain JS this is a passthrough kept for API-surface parity.
|
|
418
|
+
* @param {Parameters<typeof restate.workflow>[0]} def
|
|
419
|
+
*/
|
|
420
|
+
export function defineWorkflow(def) {
|
|
421
|
+
return restate.workflow(def)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Typed wrapper around `restate.service`; see {@link defineWorkflow}.
|
|
426
|
+
* @param {Parameters<typeof restate.service>[0]} def
|
|
427
|
+
*/
|
|
428
|
+
export function defineService(def) {
|
|
429
|
+
return restate.service(def)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Typed wrapper around `restate.object`; see {@link defineWorkflow}.
|
|
434
|
+
* @param {Parameters<typeof restate.object>[0]} def
|
|
435
|
+
*/
|
|
436
|
+
export function defineObject(def) {
|
|
437
|
+
return restate.object(def)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Starts the HTTP/2 Restate endpoint, a health-check HTTP server, registers
|
|
442
|
+
* the deployment with the admin API, and wires SIGTERM/SIGINT for graceful
|
|
443
|
+
* shutdown (deregistering the deployment before exiting).
|
|
444
|
+
* @param {Object} params
|
|
445
|
+
* @param {string} params.restateAdminURL
|
|
446
|
+
* @param {string} params.host
|
|
447
|
+
* @param {number} params.port
|
|
448
|
+
* @param {number} [params.healthPort] - Defaults to `port + 1`.
|
|
449
|
+
* @param {Parameters<typeof restate.createEndpointHandler>[0]['services']} [params.services]
|
|
450
|
+
* @param {() => void} [params.shutdownCallbackFn]
|
|
451
|
+
* @param {(deploymentId: string) => void} [params.registrationCallbackFn]
|
|
452
|
+
* @returns {Promise<RestateServer>}
|
|
453
|
+
*/
|
|
454
|
+
export async function setupRestate({
|
|
455
|
+
restateAdminURL,
|
|
456
|
+
host,
|
|
457
|
+
port,
|
|
458
|
+
healthPort = port + 1,
|
|
459
|
+
services = [],
|
|
460
|
+
shutdownCallbackFn,
|
|
461
|
+
registrationCallbackFn,
|
|
462
|
+
}) {
|
|
463
|
+
const handler = restate.createEndpointHandler({ services })
|
|
464
|
+
|
|
465
|
+
const server = http2.createServer(handler)
|
|
466
|
+
|
|
467
|
+
// --- Health server ---
|
|
468
|
+
let isHealthy = false
|
|
469
|
+
|
|
470
|
+
const healthServer = createHttpServer((req, res) => {
|
|
471
|
+
if (
|
|
472
|
+
(req.url === '/health' ||
|
|
473
|
+
req.url === '/health/liveness' ||
|
|
474
|
+
req.url === '/health/readiness') &&
|
|
475
|
+
req.method === 'GET'
|
|
476
|
+
) {
|
|
477
|
+
const status = isHealthy ? 200 : 503
|
|
478
|
+
const body = JSON.stringify({
|
|
479
|
+
status: isHealthy ? 'ok' : 'unavailable',
|
|
480
|
+
deployment: server.deploymentId,
|
|
481
|
+
services: server.services,
|
|
482
|
+
})
|
|
483
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
484
|
+
res.end(body)
|
|
485
|
+
} else {
|
|
486
|
+
res.writeHead(404)
|
|
487
|
+
res.end()
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
healthServer.listen(healthPort, () => console.log(`Health endpoint on :${healthPort}/health`))
|
|
492
|
+
|
|
493
|
+
const sessions = new Set()
|
|
494
|
+
server.on('session', (session) => {
|
|
495
|
+
sessions.add(session)
|
|
496
|
+
session.on('close', () => sessions.delete(session))
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
const forceClose = async () => shutdown('SIGTERM')
|
|
500
|
+
|
|
501
|
+
const shutdown = async (signal) => {
|
|
502
|
+
const deploymentId = server.deploymentId
|
|
503
|
+
|
|
504
|
+
console.log(`${signal} received, shutting down... Deployment: ${deploymentId}`)
|
|
505
|
+
|
|
506
|
+
isHealthy = false
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
if (deploymentId && deploymentId !== 'NA') {
|
|
510
|
+
await deleteDeployment(restateAdminURL, deploymentId)
|
|
511
|
+
}
|
|
512
|
+
} catch (err) {
|
|
513
|
+
console.error(err)
|
|
514
|
+
} finally {
|
|
515
|
+
shutdownCallbackFn?.()
|
|
516
|
+
server.close()
|
|
517
|
+
process.exit()
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const gracefulShutdown = (signal) => {
|
|
521
|
+
shutdown(signal).catch(console.error)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
process.on('SIGTERM', gracefulShutdown)
|
|
525
|
+
process.on('SIGINT', gracefulShutdown)
|
|
526
|
+
|
|
527
|
+
server.listen(port, () => console.log(`Server running on ${port}`))
|
|
528
|
+
|
|
529
|
+
if (host && port) {
|
|
530
|
+
const serviceURL = `http://${host}:${port}`
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* @param {Object} params
|
|
534
|
+
* @param {string} params.restateAdminURL
|
|
535
|
+
* @param {string} params.serviceURL
|
|
536
|
+
* @param {number} [params.attempts=5]
|
|
537
|
+
* @param {number} [params.baseDelayMs=3000]
|
|
538
|
+
* @returns {Promise<RegisterDeploymentResponse>}
|
|
539
|
+
*/
|
|
540
|
+
async function registerDeploymentWithRetry({
|
|
541
|
+
restateAdminURL,
|
|
542
|
+
serviceURL,
|
|
543
|
+
attempts = 5,
|
|
544
|
+
baseDelayMs = 3_000,
|
|
545
|
+
}) {
|
|
546
|
+
let lastErr
|
|
547
|
+
|
|
548
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
549
|
+
try {
|
|
550
|
+
const result = await registerDeployment({ restateAdminURL, serviceURL })
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
registrationCallbackFn?.(result.id)
|
|
554
|
+
} catch (err) {
|
|
555
|
+
console.error(
|
|
556
|
+
`Error in registration callback for deployment ${result.id}: ${
|
|
557
|
+
err instanceof Error ? err.message : String(err)
|
|
558
|
+
}`,
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return result
|
|
563
|
+
} catch (err) {
|
|
564
|
+
lastErr = err
|
|
565
|
+
|
|
566
|
+
// Deterministic — retrying won't help. Bail immediately.
|
|
567
|
+
if (err instanceof Error && err.message.includes('META0004')) {
|
|
568
|
+
throw err
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (attempt < attempts) {
|
|
572
|
+
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
|
|
573
|
+
console.log(
|
|
574
|
+
`Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
|
|
575
|
+
err instanceof Error ? err.message : String(err)
|
|
576
|
+
}`,
|
|
577
|
+
)
|
|
578
|
+
await sleep(delay)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Failed to register deployment after ${attempts} attempts: ${
|
|
585
|
+
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
586
|
+
}`,
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
const deployment = await registerDeploymentWithRetry({
|
|
590
|
+
restateAdminURL,
|
|
591
|
+
serviceURL,
|
|
592
|
+
}).catch(console.error)
|
|
593
|
+
|
|
594
|
+
isHealthy = !!deployment // Only healthy once registered
|
|
595
|
+
|
|
596
|
+
return Object.assign(server, {
|
|
597
|
+
deploymentId: deployment?.id,
|
|
598
|
+
services: deployment?.services?.map((s) => s.name),
|
|
599
|
+
forceClose,
|
|
600
|
+
})
|
|
601
|
+
} else {
|
|
602
|
+
isHealthy = true
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return Object.assign(server, {
|
|
606
|
+
deploymentId: 'NA',
|
|
607
|
+
services: [],
|
|
608
|
+
forceClose,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* POSTs this process's HTTP endpoint to the Restate admin API so its services are discoverable.
|
|
614
|
+
* @param {{restateAdminURL: string, serviceURL: string}} params
|
|
615
|
+
* @returns {Promise<RegisterDeploymentResponse>}
|
|
616
|
+
*/
|
|
617
|
+
export async function registerDeployment({ restateAdminURL, serviceURL }) {
|
|
618
|
+
const uri = serviceURL
|
|
619
|
+
|
|
620
|
+
/** @type {RegisterDeploymentRequest} */
|
|
621
|
+
const body = { uri, force: null }
|
|
622
|
+
|
|
623
|
+
console.log(`Registering: ${uri} at: ${restateAdminURL}/deployments`)
|
|
624
|
+
|
|
625
|
+
const response = await fetch(`${restateAdminURL}/deployments`, {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
628
|
+
body: JSON.stringify(body),
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
if (response.ok) {
|
|
632
|
+
console.log(`✅ Deployment registered for ${uri}`)
|
|
633
|
+
/** @type {RegisterDeploymentResponse} */
|
|
634
|
+
const result = await response.json()
|
|
635
|
+
console.log(
|
|
636
|
+
'Discovered services:',
|
|
637
|
+
result.services?.map((s) => s.name),
|
|
638
|
+
)
|
|
639
|
+
console.log(`Deployment details: ${JSON.stringify(result, null, 2)}`)
|
|
640
|
+
return result
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const text = await response.text()
|
|
644
|
+
console.log(`Restate response details: ${text}`)
|
|
645
|
+
throw new Error(`⚠️ Failed to register uri ${uri} (HTTP ${response.status}): ${text}`)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* @typedef {Object} SendMessageAsynSafeParams Parameters for {@link sendMessageAsynSafe}.
|
|
650
|
+
* @property {string} restateURL
|
|
651
|
+
* @property {import('./Caller.js').CallerParams} callerParams
|
|
652
|
+
* @property {number} [timeout] Milliseconds to wait for the CallerService to respond
|
|
653
|
+
* before throwing. Defaults to 1000.
|
|
654
|
+
*/
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Dispatches a call via {@link CallerServiceDef} with a timeout guard.
|
|
658
|
+
* Throws a `TerminalError` if the CallerService does not respond within
|
|
659
|
+
* `timeout` ms (default 1000).
|
|
660
|
+
* @param {SendMessageAsynSafeParams} params
|
|
661
|
+
* @returns {Promise<string>}
|
|
662
|
+
*/
|
|
663
|
+
export const sendMessageAsynSafe = async ({ restateURL, callerParams, timeout }) => {
|
|
664
|
+
const rs = clients.connect({ url: restateURL })
|
|
665
|
+
const client = rs.serviceClient({ name: CallerServiceDef.name })
|
|
666
|
+
|
|
667
|
+
const invocationId = await Promise.any([client.dispatch(callerParams), sleep(timeout ?? 1000)])
|
|
668
|
+
|
|
669
|
+
if (invocationId) {
|
|
670
|
+
return invocationId
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
throw new restate.TerminalError(
|
|
674
|
+
`Failed to reach services: ${callerParams.name} ${callerParams.handler}`,
|
|
675
|
+
{ errorCode: ERROR_INFRASTRUCTURE },
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* @typedef {Object} RestateAdmin
|
|
681
|
+
* @property {() => Promise<ServiceMetadata[]>} listServices
|
|
682
|
+
* @property {(params: {name: string}) => Promise<ServiceMetadata>} service
|
|
683
|
+
* @property {(params: {name: string}) => Promise<HandlerMetadata[]>} listHandlers
|
|
684
|
+
* @property {(params: Omit<Parameters<typeof setupRestate>[0], 'restateAdminURL'>) => Promise<RestateServer>} setupRestate
|
|
685
|
+
* @property {(params: {serviceURL: string}) => Promise<RegisterDeploymentResponse>} registerDeployment
|
|
686
|
+
* @property {(params: {name: string, key?: string, payload: *}) => Promise<InvocationSubmission>} submitWorkflow
|
|
687
|
+
* @property {() => (message: WorkflowMessage) => Promise<InvocationSubmission>} submitWorkflowDiscovery
|
|
688
|
+
* @property {(params: {invocationId: string}) => Promise<*>} waitForInvocation
|
|
689
|
+
* @property {(params: {invocationId: string, intervalMs?: number}) => Promise<*>} pollInvocation
|
|
690
|
+
* @property {(params: Omit<ServiceMessage, 'restateURL'>) => Promise<*>} sendMessage
|
|
691
|
+
* @property {(params: Omit<ServiceMessage, 'restateURL'>) => Promise<InvocationSubmission>} sendMessageAsync
|
|
692
|
+
* @property {() => (message: ServiceMessage) => Promise<*>} sendMessageWithDiscovery
|
|
693
|
+
* @property {() => (message: ServiceMessage) => Promise<InvocationSubmission>} sendMessageAsyncWithDiscovery
|
|
694
|
+
*/
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Creates a {@link RestateAdmin} facade that binds `restateAdminURL` and
|
|
698
|
+
* `restateURL` once so callers don't pass them on every operation.
|
|
699
|
+
* @param {{restateAdminURL: string, restateURL: string}} params
|
|
700
|
+
* @returns {RestateAdmin}
|
|
701
|
+
*/
|
|
702
|
+
export function createRestateAdmin({ restateAdminURL, restateURL }) {
|
|
703
|
+
return {
|
|
704
|
+
// Services
|
|
705
|
+
listServices: () => listServices({ restateAdminURL }),
|
|
706
|
+
service: ({ name }) => service({ restateAdminURL, name }),
|
|
707
|
+
listHandlers: ({ name }) => listHandlers({ restateAdminURL, name }),
|
|
708
|
+
|
|
709
|
+
// Management queries
|
|
710
|
+
setupRestate: ({ port, healthPort, host, services, shutdownCallbackFn }) =>
|
|
711
|
+
setupRestate({
|
|
712
|
+
restateAdminURL,
|
|
713
|
+
host,
|
|
714
|
+
port,
|
|
715
|
+
healthPort: healthPort ?? port + 1,
|
|
716
|
+
services,
|
|
717
|
+
shutdownCallbackFn,
|
|
718
|
+
}),
|
|
719
|
+
registerDeployment: ({ serviceURL }) => registerDeployment({ restateAdminURL, serviceURL }),
|
|
720
|
+
|
|
721
|
+
submitWorkflow: ({ name, key, payload }) =>
|
|
722
|
+
submitWorkflow({ restateURL, name, key, payload }),
|
|
723
|
+
submitWorkflowDiscovery: () => submitWorkflowDiscovery({ restateAdminURL }),
|
|
724
|
+
|
|
725
|
+
waitForInvocation: ({ invocationId }) => waitForInvocation({ restateURL, invocationId }),
|
|
726
|
+
|
|
727
|
+
pollInvocation: ({ invocationId, intervalMs }) =>
|
|
728
|
+
pollInvocation({ restateURL, invocationId, intervalMs }),
|
|
729
|
+
|
|
730
|
+
sendMessage: ({ name, message, key, payload }) =>
|
|
731
|
+
sendMessage({ restateURL, name, message, key, payload }),
|
|
732
|
+
sendMessageAsync: ({ name, message, key, payload }) =>
|
|
733
|
+
sendMessageAsync({ restateURL, name, message, key, payload }),
|
|
734
|
+
sendMessageWithDiscovery: () => sendMessageWithDiscovery({ restateAdminURL }),
|
|
735
|
+
sendMessageAsyncWithDiscovery: () => sendMessageAsyncWithDiscovery({ restateAdminURL }),
|
|
736
|
+
}
|
|
737
|
+
}
|