@empyria/restate 0.4.16 → 0.4.18

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,6 +79,12 @@ 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` 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
+
82
88
  `setupRestate` (in `lib/Admin.js`) is not unit tested: it binds real HTTP/2 + health-check
83
89
  ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
84
90
  `process.exit()` — none of which are safe to exercise in-process in a test run.
package/README.md CHANGED
@@ -122,9 +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
- `setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
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
+
132
+ It serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
128
133
  pinned to their deployment, so deleting it strands them: a restarted process cannot finish them and they can only be
129
134
  killed. For a process restarted at the same address, pass `keepDeploymentOnShutdown: true` and `forceRegistration: true`:
130
135
  Restate keeps the deployment ID when the same address registers again (checked against Restate 1.7), running
@@ -132,8 +137,7 @@ invocations resume on the new process, and `force` updates the services and hand
132
137
  re-registration keeps the old handler list). Only compatible changes are safe this way: an invocation of a removed or
133
138
  renamed handler cannot resume.
134
139
 
135
- When something else owns
136
- 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
137
141
  `serveEndpoint({port, services})` instead: it only serves. Invocations already running on a deployment are pinned to its
138
142
  address, so the deployment must survive the process stopping and come back at the same address.
139
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
  /**
@@ -592,8 +592,9 @@ export function defineObject(def) {
592
592
  * that was removed or renamed cannot resume.
593
593
  * @param {Object} params
594
594
  * @param {string} params.restateAdminURL
595
- * @param {string} params.host
596
- * @param {number} params.port
595
+ * @param {string} params.serviceURL Complete URL Restate uses to reach this endpoint, scheme and port included
596
+ * (e.g. `https://agent-service.internal:9080`). It is registered as given, and the endpoint listens on its port, so
597
+ * the port must be explicit. Scheme and port are the caller's to state, never guessed here.
597
598
  * @param {number} [params.healthPort] - Defaults to `port + 1`.
598
599
  * @param {Parameters<typeof restate.createEndpointHandler>[0]['services']} [params.services]
599
600
  * @param {() => void} [params.shutdownCallbackFn]
@@ -605,15 +606,17 @@ export function defineObject(def) {
605
606
  */
606
607
  export async function setupRestate({
607
608
  restateAdminURL,
608
- host,
609
- port,
610
- healthPort = port + 1,
609
+ serviceURL: serviceURLParam,
610
+ healthPort: healthPortParam,
611
611
  services = [],
612
612
  shutdownCallbackFn,
613
613
  registrationCallbackFn,
614
614
  keepDeploymentOnShutdown = false,
615
615
  forceRegistration = false,
616
616
  }) {
617
+ const { serviceURL, port } = parseServiceURL(serviceURLParam)
618
+ const healthPort = healthPortParam ?? port + 1
619
+
617
620
  const handler = restate.createEndpointHandler({ services })
618
621
 
619
622
  const server = http2.createServer(handler)
@@ -684,101 +687,121 @@ export async function setupRestate({
684
687
 
685
688
  server.listen(port, () => console.log(`Server running on ${port}`))
686
689
 
687
- if (host && port) {
688
- const serviceURL = `http://${host}:${port}`
689
-
690
- /**
691
- * @param {Object} params
692
- * @param {string} params.restateAdminURL
693
- * @param {string} params.serviceURL
694
- * @param {number} [params.attempts=5]
695
- * @param {number} [params.baseDelayMs=3000]
696
- * @returns {Promise<RegisterDeploymentResponse>}
697
- */
698
- async function registerDeploymentWithRetry({
699
- restateAdminURL,
700
- serviceURL,
701
- attempts = 5,
702
- baseDelayMs = 3_000,
703
- }) {
704
- let lastErr
705
-
706
- for (let attempt = 1; attempt <= attempts; attempt++) {
690
+ /**
691
+ * @param {Object} params
692
+ * @param {string} params.restateAdminURL
693
+ * @param {string} params.serviceURL
694
+ * @param {number} [params.attempts=5]
695
+ * @param {number} [params.baseDelayMs=3000]
696
+ * @returns {Promise<RegisterDeploymentResponse>}
697
+ */
698
+ async function registerDeploymentWithRetry({
699
+ restateAdminURL,
700
+ serviceURL,
701
+ attempts = 5,
702
+ baseDelayMs = 3_000,
703
+ }) {
704
+ let lastErr
705
+
706
+ for (let attempt = 1; attempt <= attempts; attempt++) {
707
+ try {
708
+ const result = await registerDeployment({
709
+ restateAdminURL,
710
+ serviceURL,
711
+ force: forceRegistration,
712
+ })
713
+
707
714
  try {
708
- const result = await registerDeployment({
709
- restateAdminURL,
710
- serviceURL,
711
- force: forceRegistration,
712
- })
713
-
714
- try {
715
- registrationCallbackFn?.(result.id)
716
- } catch (err) {
717
- console.error(
718
- `Error in registration callback for deployment ${result.id}: ${
719
- err instanceof Error ? err.message : String(err)
720
- }`,
721
- )
722
- }
723
-
724
- return result
715
+ registrationCallbackFn?.(result.id)
725
716
  } catch (err) {
726
- lastErr = err
727
-
728
- // Deterministic — retrying won't help. Bail immediately.
729
- if (err instanceof Error && err.message.includes('META0004')) {
730
- throw err
731
- }
732
-
733
- if (attempt < attempts) {
734
- const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
735
- console.log(
736
- `Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
737
- err instanceof Error ? err.message : String(err)
738
- }`,
739
- )
740
- await sleep(delay)
741
- }
717
+ console.error(
718
+ `Error in registration callback for deployment ${result.id}: ${
719
+ err instanceof Error ? err.message : String(err)
720
+ }`,
721
+ )
742
722
  }
743
- }
744
723
 
745
- throw new Error(
746
- `Failed to register deployment after ${attempts} attempts: ${
747
- lastErr instanceof Error ? lastErr.message : String(lastErr)
748
- }`,
749
- )
750
- }
751
- const deployment = await registerDeploymentWithRetry({
752
- restateAdminURL,
753
- serviceURL,
754
- }).catch(console.error)
724
+ return result
725
+ } catch (err) {
726
+ lastErr = err
755
727
 
756
- isHealthy = !!deployment // Only healthy once registered
728
+ // Deterministic — retrying won't help. Bail immediately.
729
+ if (err instanceof Error && err.message.includes('META0004')) {
730
+ throw err
731
+ }
757
732
 
758
- return Object.assign(server, {
759
- deploymentId: deployment?.id,
760
- services: deployment?.services?.map((s) => s.name),
761
- forceClose,
762
- })
763
- } else {
764
- isHealthy = true
733
+ if (attempt < attempts) {
734
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
735
+ console.log(
736
+ `Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
737
+ err instanceof Error ? err.message : String(err)
738
+ }`,
739
+ )
740
+ await sleep(delay)
741
+ }
742
+ }
743
+ }
744
+
745
+ throw new Error(
746
+ `Failed to register deployment after ${attempts} attempts: ${
747
+ lastErr instanceof Error ? lastErr.message : String(lastErr)
748
+ }`,
749
+ )
765
750
  }
751
+ const deployment = await registerDeploymentWithRetry({
752
+ restateAdminURL,
753
+ serviceURL,
754
+ }).catch(console.error)
755
+
756
+ isHealthy = !!deployment // Only healthy once registered
766
757
 
767
758
  return Object.assign(server, {
768
- deploymentId: 'NA',
769
- services: [],
759
+ deploymentId: deployment?.id,
760
+ services: deployment?.services?.map((s) => s.name),
770
761
  forceClose,
771
762
  })
772
763
  }
773
764
 
774
765
  /**
775
- * Whether `setupRestate`'s shutdown removes the deployment: only a real one (registered, not `'NA'`), and not when the
776
- * caller keeps it for the next start.
766
+ * Validates the URL `setupRestate` registers with Restate and returns it with the port the endpoint listens on.
767
+ * @param {string} serviceURL Complete http(s) URL including an explicit port
768
+ * @returns {{serviceURL: string, port: number}} `serviceURL` unchanged
769
+ * @throws {Error} If it is missing, not an absolute http(s) URL, or has no explicit port (the scheme's default would
770
+ * be a guess)
771
+ */
772
+ export function parseServiceURL(serviceURL) {
773
+ if (!serviceURL) {
774
+ throw new Error(
775
+ 'setupRestate needs `serviceURL`: the complete URL (scheme and port) Restate reaches this endpoint at',
776
+ )
777
+ }
778
+ let url
779
+ try {
780
+ url = new URL(serviceURL)
781
+ } catch {
782
+ throw new Error(
783
+ `serviceURL must be a complete URL including http:// or https://, got '${serviceURL}'`,
784
+ )
785
+ }
786
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
787
+ throw new Error(`serviceURL must use http or https, got '${serviceURL}'`)
788
+ }
789
+ if (!url.port) {
790
+ throw new Error(
791
+ `serviceURL '${serviceURL}' has no explicit port: the endpoint listens on the port in the URL`,
792
+ )
793
+ }
794
+ return { serviceURL, port: Number(url.port) }
795
+ }
796
+
797
+ /**
798
+ * Whether `setupRestate`'s shutdown removes the deployment: only one that registered (`deploymentId` is undefined when registration
799
+ * failed), and not when the caller keeps it for the next start.
777
800
  * @param {{deploymentId: string|undefined, keepDeploymentOnShutdown?: boolean}} params
778
801
  * @returns {boolean}
779
802
  */
780
803
  export function shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown = false }) {
781
- return Boolean(deploymentId) && deploymentId !== 'NA' && !keepDeploymentOnShutdown
804
+ return Boolean(deploymentId) && !keepDeploymentOnShutdown
782
805
  }
783
806
 
784
807
  /**
@@ -885,15 +908,7 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
885
908
  listHandlers: ({ name }) => listHandlers({ restateAdminURL, name }),
886
909
 
887
910
  // Management queries
888
- setupRestate: ({ port, healthPort, host, services, shutdownCallbackFn }) =>
889
- setupRestate({
890
- restateAdminURL,
891
- host,
892
- port,
893
- healthPort: healthPort ?? port + 1,
894
- services,
895
- shutdownCallbackFn,
896
- }),
911
+ setupRestate: (params) => setupRestate({ ...params, restateAdminURL }),
897
912
  registerDeployment: ({ serviceURL }) => registerDeployment({ restateAdminURL, serviceURL }),
898
913
 
899
914
  submitWorkflow: ({ name, key, payload }) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -21,6 +21,7 @@ import {
21
21
  sendMessageAsyncWithDiscovery,
22
22
  registerDeployment,
23
23
  shouldDeleteOnShutdown,
24
+ parseServiceURL,
24
25
  sendMessageAsynSafe,
25
26
  defineWorkflow,
26
27
  defineService,
@@ -542,6 +543,35 @@ describe('registerDeployment', () => {
542
543
  })
543
544
  })
544
545
 
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
+ })
556
+ })
557
+
558
+ test('requires a serviceURL', () => {
559
+ expect(() => parseServiceURL(undefined)).toThrow(/needs `serviceURL`/)
560
+ expect(() => parseServiceURL('')).toThrow(/needs `serviceURL`/)
561
+ })
562
+
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/)
567
+ })
568
+
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/)
572
+ })
573
+ })
574
+
545
575
  describe('shouldDeleteOnShutdown', () => {
546
576
  test('deletes a registered deployment by default', () => {
547
577
  expect(shouldDeleteOnShutdown({ deploymentId: 'dep-1' })).toBe(true)
@@ -554,7 +584,7 @@ describe('shouldDeleteOnShutdown', () => {
554
584
  })
555
585
 
556
586
  test('never deletes without a real deployment', () => {
557
- for (const deploymentId of [undefined, '', 'NA']) {
587
+ for (const deploymentId of [undefined, '']) {
558
588
  expect(shouldDeleteOnShutdown({ deploymentId })).toBe(false)
559
589
  }
560
590
  })