@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/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
+ }