@empyria/restate 0.4.17 → 0.4.20

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/AGENTS.md CHANGED
@@ -79,11 +79,11 @@ may observe an already-mocked value. See `test/Admin.test.js`.
79
79
  same address registers again, but only `force: true` updates its handler list. Check both against a live server when
80
80
  touching them.
81
81
 
82
- `setupRestate` registers the single complete `serviceURL` it is given (scheme included — never assemble a URL from
83
- host + port, the scheme and any proxy mapping are the caller's to state). `port` is only the listen port. `host` is a
84
- deprecated fallback (`http://host:port`) kept for consumers pinned to older call sites; the URL decision is
85
- `resolveServiceURL` (unit tested). `createRestateAdmin().setupRestate` must forward every option to `setupRestate` — it
86
- once dropped `keepDeploymentOnShutdown`/`forceRegistration`/`registrationCallbackFn`.
82
+ `setupRestate` takes ONE complete `serviceURL` (scheme and explicit port included): it is registered as given and the
83
+ endpoint listens on its port. There is deliberately no `host` or `port` option — never assemble a URL from parts, the
84
+ scheme and any mapping are the caller's to state. `parseServiceURL` validates it (unit tested).
85
+ `createRestateAdmin().setupRestate` must forward every option to `setupRestate` — it once dropped
86
+ `keepDeploymentOnShutdown`/`forceRegistration`/`registrationCallbackFn`.
87
87
 
88
88
  `setupRestate` (in `lib/Admin.js`) is not unit tested: it binds real HTTP/2 + health-check
89
89
  ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
package/README.md CHANGED
@@ -122,14 +122,14 @@ endpoint, then create jobs with either:
122
122
  Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
123
123
  `payload` as its input, or no input when there is none.
124
124
 
125
- ### An endpoint that does not register itself
125
+ ### Serving and registering an endpoint
126
126
 
127
- Tell `setupRestate` where Restate reaches the endpoint with one complete URL, scheme included:
128
- `setupRestate({ restateAdminURL, port: 9080, serviceURL: 'https://agent-service.internal:9080', services })`. `port` is only
129
- what the process listens on; `serviceURL` is what gets registered (it may differ behind a proxy). Without `serviceURL`
130
- nothing is registered. The old `host` option still works (registers `http://<host>:<port>`) but is deprecated.
127
+ `setupRestate({ restateAdminURL, serviceURL: 'https://agent-service.internal:9080', services })` takes one complete URL, the
128
+ address Restate reaches the endpoint at. It is registered as given and the endpoint listens on its port, so the scheme and
129
+ an explicit port are required (there is no separate host or port option); the health endpoint listens on the next port
130
+ unless `healthPort` is set.
131
131
 
132
- `setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
132
+ It serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
133
133
  pinned to their deployment, so deleting it strands them: a restarted process cannot finish them and they can only be
134
134
  killed. For a process restarted at the same address, pass `keepDeploymentOnShutdown: true` and `forceRegistration: true`:
135
135
  Restate keeps the deployment ID when the same address registers again (checked against Restate 1.7), running
@@ -137,8 +137,7 @@ invocations resume on the new process, and `force` updates the services and hand
137
137
  re-registration keeps the old handler list). Only compatible changes are safe this way: an invocation of a removed or
138
138
  renamed handler cannot resume.
139
139
 
140
- When something else owns
141
- registration (a supervisor that registers a process's address once and restarts the process), use
140
+ To serve without registering: when something else owns registration (a supervisor that registers a process's address once and restarts the process), use
142
141
  `serveEndpoint({port, services})` instead: it only serves. Invocations already running on a deployment are pinned to its
143
142
  address, so the deployment must survive the process stopping and come back at the same address.
144
143
 
package/lib/Admin.js CHANGED
@@ -550,7 +550,7 @@ export function sendMessageAsyncWithDiscovery({ restateAdminURL }) {
550
550
  }
551
551
 
552
552
  /**
553
- * @typedef {import('node:http2').Http2Server & {deploymentId: string, services: string[], forceClose: () => void}} RestateServer
553
+ * @typedef {import('node:http2').Http2Server & {deploymentId: string|undefined, services: string[]|undefined, forceClose: () => void}} RestateServer
554
554
  */
555
555
 
556
556
  /**
@@ -578,6 +578,34 @@ export function defineObject(def) {
578
578
  return restate.object(def)
579
579
  }
580
580
 
581
+ /**
582
+ * The health check `setupRestate` uses unless given a `healthCheckFn`: `GET /health`, `/health/liveness` and
583
+ * `/health/readiness` answer 200 once the deployment is registered and 503 before, anything else 404.
584
+ * @param {import('node:http').IncomingMessage} req
585
+ * @param {import('node:http').ServerResponse} res
586
+ * @param {{isHealthy: boolean, deploymentId?: string, services?: string[]}} state
587
+ */
588
+ export function defaultHealthCheck(req, res, { isHealthy, deploymentId, services }) {
589
+ if (
590
+ (req.url === '/health' ||
591
+ req.url === '/health/liveness' ||
592
+ req.url === '/health/readiness') &&
593
+ req.method === 'GET'
594
+ ) {
595
+ res.writeHead(isHealthy ? 200 : 503, { 'Content-Type': 'application/json' })
596
+ res.end(
597
+ JSON.stringify({
598
+ status: isHealthy ? 'ok' : 'unavailable',
599
+ deployment: deploymentId,
600
+ services,
601
+ }),
602
+ )
603
+ } else {
604
+ res.writeHead(404)
605
+ res.end()
606
+ }
607
+ }
608
+
581
609
  /**
582
610
  * Starts the HTTP/2 Restate endpoint, a health-check HTTP server, registers
583
611
  * the deployment with the admin API, and wires SIGTERM/SIGINT for graceful
@@ -592,16 +620,17 @@ export function defineObject(def) {
592
620
  * that was removed or renamed cannot resume.
593
621
  * @param {Object} params
594
622
  * @param {string} params.restateAdminURL
595
- * @param {number} params.port Port this endpoint listens on (the bind, not the advertised address).
596
- * @param {string} [params.serviceURL] Complete URL Restate uses to reach this endpoint, scheme included
597
- * (e.g. `https://agent-service.internal:9080`). Registration happens only when it is set — the scheme and
598
- * any proxy/port mapping are the caller's to state, never guessed here.
599
- * @param {string} [params.host] @deprecated Use `serviceURL`. Only when `serviceURL` is absent: registers as
600
- * `http://<host>:<port>` (plain HTTP is assumed).
623
+ * @param {string} params.serviceURL Complete URL Restate uses to reach this endpoint, scheme and port included
624
+ * (e.g. `https://agent-service.internal:9080`). It is registered as given, and the endpoint listens on its port, so
625
+ * the port must be explicit. Scheme and port are the caller's to state, never guessed here.
601
626
  * @param {number} [params.healthPort] - Defaults to `port + 1`.
602
627
  * @param {Parameters<typeof restate.createEndpointHandler>[0]['services']} [params.services]
603
628
  * @param {() => void} [params.shutdownCallbackFn]
604
629
  * @param {(deploymentId: string) => void} [params.registrationCallbackFn]
630
+ * @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, state: {isHealthy: boolean, deploymentId?: string, services?: string[]}) => void | Promise<void>} [params.healthCheckFn]
631
+ * Takes over every request to the health port: routing, status code, headers and body are the function's to set and
632
+ * it must end the response. `state.isHealthy` is true once the deployment is registered. Without it, `/health`,
633
+ * `/health/liveness` and `/health/readiness` answer 200 or 503 with a small JSON body, healthy once registered.
605
634
  * @param {boolean} [params.keepDeploymentOnShutdown=false] Leave the deployment registered on SIGINT/SIGTERM.
606
635
  * @param {boolean} [params.forceRegistration=false] Register with `force: true`, updating an existing deployment at
607
636
  * the same address in place.
@@ -610,15 +639,17 @@ export function defineObject(def) {
610
639
  export async function setupRestate({
611
640
  restateAdminURL,
612
641
  serviceURL: serviceURLParam,
613
- host,
614
- port,
615
- healthPort = port + 1,
642
+ healthPort: healthPortParam,
616
643
  services = [],
617
644
  shutdownCallbackFn,
618
645
  registrationCallbackFn,
646
+ healthCheckFn,
619
647
  keepDeploymentOnShutdown = false,
620
648
  forceRegistration = false,
621
649
  }) {
650
+ const { serviceURL, port } = parseServiceURL(serviceURLParam)
651
+ const healthPort = healthPortParam ?? port + 1
652
+
622
653
  const handler = restate.createEndpointHandler({ services })
623
654
 
624
655
  const server = http2.createServer(handler)
@@ -626,25 +657,20 @@ export async function setupRestate({
626
657
  // --- Health server ---
627
658
  let isHealthy = false
628
659
 
660
+ const checkFn = healthCheckFn ?? defaultHealthCheck
629
661
  const healthServer = createHttpServer((req, res) => {
630
- if (
631
- (req.url === '/health' ||
632
- req.url === '/health/liveness' ||
633
- req.url === '/health/readiness') &&
634
- req.method === 'GET'
635
- ) {
636
- const status = isHealthy ? 200 : 503
637
- const body = JSON.stringify({
638
- status: isHealthy ? 'ok' : 'unavailable',
639
- deployment: server.deploymentId,
640
- services: server.services,
641
- })
642
- res.writeHead(status, { 'Content-Type': 'application/json' })
643
- res.end(body)
644
- } else {
645
- res.writeHead(404)
646
- res.end()
662
+ const state = {
663
+ isHealthy,
664
+ deploymentId: server.deploymentId,
665
+ services: server.services,
647
666
  }
667
+ Promise.resolve()
668
+ .then(() => checkFn(req, res, state))
669
+ .catch((err) => {
670
+ console.error('healthCheckFn failed', err)
671
+ if (!res.headersSent) res.writeHead(500)
672
+ res.end()
673
+ })
648
674
  })
649
675
 
650
676
  healthServer.listen(healthPort, () => console.log(`Health endpoint on :${healthPort}/health`))
@@ -689,125 +715,121 @@ export async function setupRestate({
689
715
 
690
716
  server.listen(port, () => console.log(`Server running on ${port}`))
691
717
 
692
- const serviceURL = resolveServiceURL({ serviceURL: serviceURLParam, host, port })
693
- if (serviceURL) {
694
- /**
695
- * @param {Object} params
696
- * @param {string} params.restateAdminURL
697
- * @param {string} params.serviceURL
698
- * @param {number} [params.attempts=5]
699
- * @param {number} [params.baseDelayMs=3000]
700
- * @returns {Promise<RegisterDeploymentResponse>}
701
- */
702
- async function registerDeploymentWithRetry({
703
- restateAdminURL,
704
- serviceURL,
705
- attempts = 5,
706
- baseDelayMs = 3_000,
707
- }) {
708
- let lastErr
709
-
710
- for (let attempt = 1; attempt <= attempts; attempt++) {
718
+ /**
719
+ * @param {Object} params
720
+ * @param {string} params.restateAdminURL
721
+ * @param {string} params.serviceURL
722
+ * @param {number} [params.attempts=5]
723
+ * @param {number} [params.baseDelayMs=3000]
724
+ * @returns {Promise<RegisterDeploymentResponse>}
725
+ */
726
+ async function registerDeploymentWithRetry({
727
+ restateAdminURL,
728
+ serviceURL,
729
+ attempts = 5,
730
+ baseDelayMs = 3_000,
731
+ }) {
732
+ let lastErr
733
+
734
+ for (let attempt = 1; attempt <= attempts; attempt++) {
735
+ try {
736
+ const result = await registerDeployment({
737
+ restateAdminURL,
738
+ serviceURL,
739
+ force: forceRegistration,
740
+ })
741
+
711
742
  try {
712
- const result = await registerDeployment({
713
- restateAdminURL,
714
- serviceURL,
715
- force: forceRegistration,
716
- })
717
-
718
- try {
719
- registrationCallbackFn?.(result.id)
720
- } catch (err) {
721
- console.error(
722
- `Error in registration callback for deployment ${result.id}: ${
723
- err instanceof Error ? err.message : String(err)
724
- }`,
725
- )
726
- }
727
-
728
- return result
743
+ registrationCallbackFn?.(result.id)
729
744
  } catch (err) {
730
- lastErr = err
731
-
732
- // Deterministic — retrying won't help. Bail immediately.
733
- if (err instanceof Error && err.message.includes('META0004')) {
734
- throw err
735
- }
736
-
737
- if (attempt < attempts) {
738
- const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
739
- console.log(
740
- `Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
741
- err instanceof Error ? err.message : String(err)
742
- }`,
743
- )
744
- await sleep(delay)
745
- }
745
+ console.error(
746
+ `Error in registration callback for deployment ${result.id}: ${
747
+ err instanceof Error ? err.message : String(err)
748
+ }`,
749
+ )
746
750
  }
747
- }
748
751
 
749
- throw new Error(
750
- `Failed to register deployment after ${attempts} attempts: ${
751
- lastErr instanceof Error ? lastErr.message : String(lastErr)
752
- }`,
753
- )
754
- }
755
- const deployment = await registerDeploymentWithRetry({
756
- restateAdminURL,
757
- serviceURL,
758
- }).catch(console.error)
752
+ return result
753
+ } catch (err) {
754
+ lastErr = err
759
755
 
760
- isHealthy = !!deployment // Only healthy once registered
756
+ // Deterministic — retrying won't help. Bail immediately.
757
+ if (err instanceof Error && err.message.includes('META0004')) {
758
+ throw err
759
+ }
761
760
 
762
- return Object.assign(server, {
763
- deploymentId: deployment?.id,
764
- services: deployment?.services?.map((s) => s.name),
765
- forceClose,
766
- })
767
- } else {
768
- isHealthy = true
761
+ if (attempt < attempts) {
762
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
763
+ console.log(
764
+ `Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
765
+ err instanceof Error ? err.message : String(err)
766
+ }`,
767
+ )
768
+ await sleep(delay)
769
+ }
770
+ }
771
+ }
772
+
773
+ throw new Error(
774
+ `Failed to register deployment after ${attempts} attempts: ${
775
+ lastErr instanceof Error ? lastErr.message : String(lastErr)
776
+ }`,
777
+ )
769
778
  }
779
+ const deployment = await registerDeploymentWithRetry({
780
+ restateAdminURL,
781
+ serviceURL,
782
+ }).catch(console.error)
783
+
784
+ isHealthy = !!deployment // Only healthy once registered
770
785
 
771
786
  return Object.assign(server, {
772
- deploymentId: 'NA',
773
- services: [],
787
+ deploymentId: deployment?.id,
788
+ services: deployment?.services?.map((s) => s.name),
774
789
  forceClose,
775
790
  })
776
791
  }
777
792
 
778
793
  /**
779
- * The URL `setupRestate` registers with Restate: `serviceURL` as given (validated as an absolute http(s) URL),
780
- * else the deprecated `host` + `port` pair as `http://host:port`, else `undefined` (no registration).
781
- * @param {{serviceURL?: string, host?: string, port?: number}} params
782
- * @returns {string|undefined}
783
- * @throws {Error} If `serviceURL` is not an absolute http(s) URL
794
+ * Validates the URL `setupRestate` registers with Restate and returns it with the port the endpoint listens on.
795
+ * @param {string} serviceURL Complete http(s) URL including an explicit port
796
+ * @returns {{serviceURL: string, port: number}} `serviceURL` unchanged
797
+ * @throws {Error} If it is missing, not an absolute http(s) URL, or has no explicit port (the scheme's default would
798
+ * be a guess)
784
799
  */
785
- export function resolveServiceURL({ serviceURL, host, port }) {
786
- if (serviceURL) {
787
- let url
788
- try {
789
- url = new URL(serviceURL)
790
- } catch {
791
- throw new Error(
792
- `serviceURL must be a complete URL including http:// or https://, got '${serviceURL}'`,
793
- )
794
- }
795
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
796
- throw new Error(`serviceURL must use http or https, got '${serviceURL}'`)
797
- }
798
- return serviceURL
800
+ export function parseServiceURL(serviceURL) {
801
+ if (!serviceURL) {
802
+ throw new Error(
803
+ 'setupRestate needs `serviceURL`: the complete URL (scheme and port) Restate reaches this endpoint at',
804
+ )
805
+ }
806
+ let url
807
+ try {
808
+ url = new URL(serviceURL)
809
+ } catch {
810
+ throw new Error(
811
+ `serviceURL must be a complete URL including http:// or https://, got '${serviceURL}'`,
812
+ )
813
+ }
814
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
815
+ throw new Error(`serviceURL must use http or https, got '${serviceURL}'`)
816
+ }
817
+ if (!url.port) {
818
+ throw new Error(
819
+ `serviceURL '${serviceURL}' has no explicit port: the endpoint listens on the port in the URL`,
820
+ )
799
821
  }
800
- return host && port ? `http://${host}:${port}` : undefined
822
+ return { serviceURL, port: Number(url.port) }
801
823
  }
802
824
 
803
825
  /**
804
- * Whether `setupRestate`'s shutdown removes the deployment: only a real one (registered, not `'NA'`), and not when the
805
- * caller keeps it for the next start.
826
+ * Whether `setupRestate`'s shutdown removes the deployment: only one that registered (`deploymentId` is undefined when registration
827
+ * failed), and not when the caller keeps it for the next start.
806
828
  * @param {{deploymentId: string|undefined, keepDeploymentOnShutdown?: boolean}} params
807
829
  * @returns {boolean}
808
830
  */
809
831
  export function shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown = false }) {
810
- return Boolean(deploymentId) && deploymentId !== 'NA' && !keepDeploymentOnShutdown
832
+ return Boolean(deploymentId) && !keepDeploymentOnShutdown
811
833
  }
812
834
 
813
835
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.17",
3
+ "version": "0.4.20",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -36,10 +36,10 @@
36
36
  "croner": "10.0.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@ai-sdk/openai-compatible": "3.0.63",
40
- "@ai-sdk/provider": "4.0.22",
39
+ "@ai-sdk/openai-compatible": "3.0.65",
40
+ "@ai-sdk/provider": "4.0.24",
41
41
  "@restatedev/restate-server": "1.7.13",
42
- "ai": "7.0.128",
42
+ "ai": "7.0.130",
43
43
  "oxfmt": "0.72.0",
44
44
  "oxlint": "1.87.0"
45
45
  },
@@ -21,7 +21,7 @@ import {
21
21
  sendMessageAsyncWithDiscovery,
22
22
  registerDeployment,
23
23
  shouldDeleteOnShutdown,
24
- resolveServiceURL,
24
+ parseServiceURL,
25
25
  sendMessageAsynSafe,
26
26
  defineWorkflow,
27
27
  defineService,
@@ -543,28 +543,32 @@ describe('registerDeployment', () => {
543
543
  })
544
544
  })
545
545
 
546
- describe('resolveServiceURL', () => {
547
- test('uses a complete serviceURL as given, scheme included', () => {
548
- expect(
549
- resolveServiceURL({ serviceURL: 'https://svc.example.com:9443/base', port: 9080 }),
550
- ).toBe('https://svc.example.com:9443/base')
551
- })
552
-
553
- test('serviceURL wins over the deprecated host', () => {
554
- expect(resolveServiceURL({ serviceURL: 'https://a', host: 'b', port: 1 })).toBe('https://a')
546
+ describe('parseServiceURL', () => {
547
+ test('returns the URL unchanged with the port it carries', () => {
548
+ expect(parseServiceURL('http://agent-service:9080')).toEqual({
549
+ serviceURL: 'http://agent-service:9080',
550
+ port: 9080,
551
+ })
552
+ expect(parseServiceURL('https://svc.example.com:9443/base')).toEqual({
553
+ serviceURL: 'https://svc.example.com:9443/base',
554
+ port: 9443,
555
+ })
555
556
  })
556
557
 
557
- test('falls back to http://host:port for the deprecated host', () => {
558
- expect(resolveServiceURL({ host: 'svc', port: 9080 })).toBe('http://svc:9080')
558
+ test('requires a serviceURL', () => {
559
+ expect(() => parseServiceURL(undefined)).toThrow(/needs `serviceURL`/)
560
+ expect(() => parseServiceURL('')).toThrow(/needs `serviceURL`/)
559
561
  })
560
562
 
561
- test('returns undefined (no registration) when nothing is given', () => {
562
- expect(resolveServiceURL({ port: 9080 })).toBeUndefined()
563
+ test('rejects a URL without a scheme or with a non-http scheme', () => {
564
+ expect(() => parseServiceURL('agent-service:9080')).toThrow(/http/)
565
+ expect(() => parseServiceURL('not a url')).toThrow(/complete URL/)
566
+ expect(() => parseServiceURL('ftp://svc:21')).toThrow(/http or https/)
563
567
  })
564
568
 
565
- test('rejects a URL without a scheme or with a non-http scheme', () => {
566
- expect(() => resolveServiceURL({ serviceURL: 'svc:9080' })).toThrow(/complete URL|http/)
567
- expect(() => resolveServiceURL({ serviceURL: 'ftp://svc' })).toThrow(/http or https/)
569
+ test('refuses to guess the scheme default when the URL has no explicit port', () => {
570
+ expect(() => parseServiceURL('https://svc.example.com')).toThrow(/no explicit port/)
571
+ expect(() => parseServiceURL('http://svc:80')).toThrow(/no explicit port/)
568
572
  })
569
573
  })
570
574
 
@@ -580,7 +584,7 @@ describe('shouldDeleteOnShutdown', () => {
580
584
  })
581
585
 
582
586
  test('never deletes without a real deployment', () => {
583
- for (const deploymentId of [undefined, '', 'NA']) {
587
+ for (const deploymentId of [undefined, '']) {
584
588
  expect(shouldDeleteOnShutdown({ deploymentId })).toBe(false)
585
589
  }
586
590
  })