@wandelbots/nova-js 3.13.0-pr.307.ea0533d → 3.13.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.
Files changed (45) hide show
  1. package/dist/Nova--8gozkLR.d.mts +85 -0
  2. package/dist/Nova--8gozkLR.d.mts.map +1 -0
  3. package/dist/Nova-B1o5iTQ6.d.cts +85 -0
  4. package/dist/Nova-B1o5iTQ6.d.cts.map +1 -0
  5. package/dist/{context-Cu5mXcFZ.cjs → context-BZELA2eh.cjs} +3 -146
  6. package/dist/context-BZELA2eh.cjs.map +1 -0
  7. package/dist/{context-CmXqEEwW.mjs → context-ffr5Q2dc.mjs} +3 -86
  8. package/dist/context-ffr5Q2dc.mjs.map +1 -0
  9. package/dist/converters-DnG1fX23.mjs +87 -0
  10. package/dist/converters-DnG1fX23.mjs.map +1 -0
  11. package/dist/converters-EYS27XJE.cjs +146 -0
  12. package/dist/converters-EYS27XJE.cjs.map +1 -0
  13. package/dist/experimental/nats/index.cjs +96 -17
  14. package/dist/experimental/nats/index.cjs.map +1 -1
  15. package/dist/experimental/nats/index.d.cts +1079 -67
  16. package/dist/experimental/nats/index.d.cts.map +1 -1
  17. package/dist/experimental/nats/index.d.mts +1079 -67
  18. package/dist/experimental/nats/index.d.mts.map +1 -1
  19. package/dist/experimental/nats/index.mjs +96 -18
  20. package/dist/experimental/nats/index.mjs.map +1 -1
  21. package/dist/index.cjs +13 -12
  22. package/dist/index.mjs +3 -2
  23. package/dist/v1/index.cjs +3 -3
  24. package/dist/v1/index.cjs.map +1 -1
  25. package/dist/v1/index.mjs +2 -2
  26. package/dist/v2/index.cjs +4 -3
  27. package/dist/v2/index.cjs.map +1 -1
  28. package/dist/v2/index.d.cts +5 -83
  29. package/dist/v2/index.d.cts.map +1 -1
  30. package/dist/v2/index.d.mts +4 -82
  31. package/dist/v2/index.d.mts.map +1 -1
  32. package/dist/v2/index.mjs +2 -1
  33. package/dist/v2/index.mjs.map +1 -1
  34. package/dist/{wandelscriptUtils-CKhiZJsB.mjs → wandelscriptUtils-CpUXdLVc.mjs} +3 -2
  35. package/dist/{wandelscriptUtils-CKhiZJsB.mjs.map → wandelscriptUtils-CpUXdLVc.mjs.map} +1 -1
  36. package/dist/{wandelscriptUtils-BdqeVDCY.cjs → wandelscriptUtils-DcY1aLWu.cjs} +13 -12
  37. package/dist/{wandelscriptUtils-BdqeVDCY.cjs.map → wandelscriptUtils-DcY1aLWu.cjs.map} +1 -1
  38. package/package.json +1 -3
  39. package/src/experimental/nats/index.ts +7 -0
  40. package/src/lib/experimental/nats/NovaNatsClient.ts +86 -15
  41. package/src/lib/experimental/nats/buildNatsServerUrl.ts +15 -0
  42. package/src/lib/experimental/nats/buildSubject.ts +53 -5
  43. package/src/lib/experimental/nats/generated/operations.ts +918 -71
  44. package/dist/context-CmXqEEwW.mjs.map +0 -1
  45. package/dist/context-Cu5mXcFZ.cjs.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wandelbots/nova-js",
3
3
  "type": "module",
4
- "version": "3.13.0-pr.307.ea0533d",
4
+ "version": "3.13.0",
5
5
  "description": "Official JS client for the Wandelbots API",
6
6
  "sideEffects": false,
7
7
  "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
@@ -56,7 +56,6 @@
56
56
  "devDependencies": {
57
57
  "@asyncapi/parser": "^3.6.0",
58
58
  "@biomejs/biome": "^2.4.16",
59
- "@types/lodash-es": "^4.17.12",
60
59
  "@types/node": "^25.9.1",
61
60
  "@types/ws": "^8.18.1",
62
61
  "concurrently": "^10.0.3",
@@ -64,7 +63,6 @@
64
63
  "jsdom": "^29.1.1",
65
64
  "json-schema-to-typescript": "^15.0.4",
66
65
  "knip": "^6.15.0",
67
- "lodash-es": "^4.18.1",
68
66
  "nodemon": "^3.1.14",
69
67
  "semantic-release": "^25.0.3",
70
68
  "semantic-release-export-data": "^1.2.0",
@@ -4,13 +4,20 @@
4
4
  *
5
5
  * This API is experimental and may change without a major version bump.
6
6
  */
7
+ export { buildNatsServerUrl } from "../../lib/experimental/nats/buildNatsServerUrl.ts"
7
8
  export type {
8
9
  NatsOperationParams,
10
+ NatsPublishPayloads,
11
+ NatsPublishSubject,
9
12
  NatsReplyPayloads,
10
13
  NatsRequestPayloads,
11
14
  NatsRequestSubject,
12
15
  NatsSubscribePayloads,
13
16
  NatsSubscribeSubject,
14
17
  } from "../../lib/experimental/nats/generated/operations.ts"
18
+ // Message payload types for every schema in src/asyncapi.yaml, e.g. `Cell`,
19
+ // `App`, `ProgramStatus` (see NatsSubscribePayloads/NatsRequestPayloads for
20
+ // which type applies to which subject).
21
+ export type * from "../../lib/experimental/nats/generated/types.ts"
15
22
  export { NovaNatsClient } from "../../lib/experimental/nats/NovaNatsClient.ts"
16
23
  export type { NovaNatsClientConfig } from "../../lib/experimental/nats/NovaNatsClient.ts"
@@ -4,9 +4,13 @@ import {
4
4
  type NatsConnection,
5
5
  wsconnect,
6
6
  } from "@nats-io/nats-core"
7
+ import type { Nova } from "../../Nova.ts"
8
+ import { buildNatsServerUrl } from "./buildNatsServerUrl.ts"
7
9
  import { buildSubject } from "./buildSubject.ts"
8
10
  import type {
9
11
  NatsOperationParams,
12
+ NatsPublishPayloads,
13
+ NatsPublishSubject,
10
14
  NatsReplyPayloads,
11
15
  NatsRequestPayloads,
12
16
  NatsRequestSubject,
@@ -16,6 +20,16 @@ import type {
16
20
 
17
21
  export type NovaNatsClientConfig = ConnectionOptions
18
22
 
23
+ type NatsMessageHandler<K extends NatsSubscribeSubject> = (
24
+ payload: NatsSubscribePayloads[K],
25
+ msg: Msg,
26
+ ) => void | Promise<void>
27
+
28
+ type SubscribeArgs<K extends NatsSubscribeSubject> =
29
+ keyof NatsOperationParams[K] extends never
30
+ ? [handler: NatsMessageHandler<K>]
31
+ : [params: NatsOperationParams[K], handler: NatsMessageHandler<K>]
32
+
19
33
  /**
20
34
  * Typed NATS client for the Wandelbots NOVA messaging API, generated from
21
35
  * src/asyncapi.yaml (see scripts/generate-nats-client.ts).
@@ -24,24 +38,46 @@ export type NovaNatsClientConfig = ConnectionOptions
24
38
  */
25
39
  export class NovaNatsClient {
26
40
  readonly config: NovaNatsClientConfig
27
- connection: NatsConnection | null = null
41
+ private connectionPromise: Promise<NatsConnection> | null = null
28
42
 
29
- constructor(config: NovaNatsClientConfig) {
30
- this.config = config
43
+ constructor(nova: Nova, config: NovaNatsClientConfig = {}) {
44
+ this.config = {
45
+ servers: buildNatsServerUrl(nova.instanceUrl.href),
46
+ // Reuse the Nova instance's access token for NATS auth, if it has one
47
+ // (e.g. from login or a passed-in config.accessToken). Explicit auth
48
+ // options in `config` (token/user/pass/authenticator) still win.
49
+ ...(nova.accessToken ? { token: nova.accessToken } : {}),
50
+ ...config,
51
+ }
31
52
  }
32
53
 
33
- /** Connects to NATS if not already connected, and returns the connection. */
34
- async connect(): Promise<NatsConnection> {
35
- if (!this.connection) {
36
- this.connection = await wsconnect(this.config)
54
+ /**
55
+ * Connects to NATS if not already connected or connecting, and returns the
56
+ * connection. Safe to call concurrently: all callers share the same
57
+ * in-flight connection attempt instead of each starting their own.
58
+ */
59
+ connect(): Promise<NatsConnection> {
60
+ if (!this.connectionPromise) {
61
+ this.connectionPromise = wsconnect(this.config).catch((err: unknown) => {
62
+ // Allow a subsequent connect() call to retry after a failed attempt.
63
+ this.connectionPromise = null
64
+ throw err
65
+ })
37
66
  }
38
- return this.connection
67
+ return this.connectionPromise
39
68
  }
40
69
 
41
- /** Closes the underlying NATS connection, if open. */
70
+ /** Closes the underlying NATS connection, if open or connecting. */
42
71
  async close(): Promise<void> {
43
- await this.connection?.close()
44
- this.connection = null
72
+ const connectionPromise = this.connectionPromise
73
+ this.connectionPromise = null
74
+ if (!connectionPromise) return
75
+ try {
76
+ const nc = await connectionPromise
77
+ await nc.close()
78
+ } catch {
79
+ // Connection never succeeded; nothing to close.
80
+ }
45
81
  }
46
82
 
47
83
  /**
@@ -52,24 +88,41 @@ export class NovaNatsClient {
52
88
  * `"nova.v2.cells.{cell}"`, with `{param}` placeholders filled in from
53
89
  * `params`.
54
90
  *
91
+ * Errors decoding a message or thrown/rejected by `handler` are caught and
92
+ * logged per-message, so one bad message doesn't stop later messages on
93
+ * the same subscription from being handled.
94
+ *
55
95
  * Returns a function that unsubscribes when called.
56
96
  */
57
97
  async subscribe<K extends NatsSubscribeSubject>(
58
98
  subject: K,
59
- params: NatsOperationParams[K],
60
- handler: (payload: NatsSubscribePayloads[K], msg: Msg) => void,
99
+ ...args: SubscribeArgs<K>
61
100
  ): Promise<() => void> {
101
+ const [params, handler] =
102
+ args.length === 1
103
+ ? ([{}, args[0]] as const)
104
+ : ([args[0], args[1]] as const)
105
+
62
106
  const nc = await this.connect()
63
107
  const resolvedSubject = buildSubject(subject, params)
64
108
  const sub = nc.subscribe(resolvedSubject)
65
109
 
66
110
  ;(async () => {
67
111
  for await (const msg of sub) {
68
- handler(msg.json<NatsSubscribePayloads[K]>(), msg)
112
+ // Handled per-message: a bad payload or a throwing/rejecting handler
113
+ // should not stop the subscription from processing later messages.
114
+ try {
115
+ await handler(msg.json<NatsSubscribePayloads[K]>(), msg)
116
+ } catch (err) {
117
+ console.error(
118
+ `Error handling NATS message on subject "${resolvedSubject}"`,
119
+ err,
120
+ )
121
+ }
69
122
  }
70
123
  })().catch((err: unknown) => {
71
124
  console.error(
72
- `Error handling NATS subscription for "${resolvedSubject}"`,
125
+ `NATS subscription iterator failed for "${resolvedSubject}"`,
73
126
  err,
74
127
  )
75
128
  })
@@ -98,4 +151,22 @@ export class NovaNatsClient {
98
151
  })
99
152
  return msg.json<NatsReplyPayloads[K]>()
100
153
  }
154
+
155
+ /**
156
+ * Publishes a JSON payload to any NATS subject defined in the spec,
157
+ * without waiting for a reply.
158
+ *
159
+ * `subject` is the subject template as it appears on the wire, e.g.
160
+ * `"nova.v2.cells.{cell}.bus-ios.ios.set"`, with `{param}` placeholders
161
+ * filled in from `params`.
162
+ */
163
+ async publish<K extends NatsPublishSubject>(
164
+ subject: K,
165
+ params: NatsOperationParams[K],
166
+ payload: NatsPublishPayloads[K],
167
+ ): Promise<void> {
168
+ const nc = await this.connect()
169
+ const resolvedSubject = buildSubject(subject, params)
170
+ nc.publish(resolvedSubject, JSON.stringify(payload))
171
+ }
101
172
  }
@@ -0,0 +1,15 @@
1
+ import { parseNovaInstanceUrl } from "../../converters.ts"
2
+
3
+ /**
4
+ * Builds the WebSocket URL for a NOVA instance's NATS gateway from its
5
+ * instance URL, e.g. `https://foo.instance.wandelbots.io` becomes
6
+ * `wss://foo.instance.wandelbots.io/api/nats`.
7
+ *
8
+ * Pass the result as `servers` in the `NovaNatsClientConfig` passed to
9
+ * `NovaNatsClient`.
10
+ */
11
+ export function buildNatsServerUrl(instanceUrl: string): string {
12
+ const url = parseNovaInstanceUrl(instanceUrl)
13
+ const protocol = url.protocol === "https:" ? "wss:" : "ws:"
14
+ return `${protocol}//${url.host}/api/nats`
15
+ }
@@ -3,22 +3,70 @@
3
3
  * (e.g. `"{instance}.v2.cells.{cell}"`) by substituting each `{param}`
4
4
  * placeholder with the corresponding value from `params`.
5
5
  */
6
+
7
+ function isValidSubjectChar(char: string): boolean {
8
+ const code = char.charCodeAt(0)
9
+ return (
10
+ (code >= 48 && code <= 57) || // 0-9
11
+ (code >= 65 && code <= 90) || // A-Z
12
+ (code >= 97 && code <= 122) || // a-z
13
+ char === "-" ||
14
+ char === "_"
15
+ )
16
+ }
17
+
18
+ function isValidSubjectValue(value: string): boolean {
19
+ // A bare "*" is the NATS single-token wildcard, allowed on its own (e.g.
20
+ // subscribing to all cells with `{ cell: "*" }") but not as part of a
21
+ // larger value, since it wouldn't act as a wildcard there anyway.
22
+ if (value === "*") return true
23
+ if (value.length === 0) return false
24
+ for (const char of value) {
25
+ if (!isValidSubjectChar(char)) return false
26
+ }
27
+ return true
28
+ }
29
+
6
30
  export function buildSubject(
7
31
  template: string,
8
32
  params: Record<string, string>,
9
33
  ): string {
10
- return template.replace(/\{([^}]+)\}/g, (match, paramName: string) => {
34
+ // Scanned manually (rather than with a regex like /\{([^}]+)\}/g) to avoid
35
+ // a polynomial-time backtracking blowup on pathological input, e.g. a
36
+ // template consisting of many "{" characters with no closing "}".
37
+ let result = ""
38
+ let cursor = 0
39
+
40
+ while (cursor < template.length) {
41
+ const openIndex = template.indexOf("{", cursor)
42
+ if (openIndex === -1) {
43
+ result += template.slice(cursor)
44
+ break
45
+ }
46
+
47
+ const closeIndex = template.indexOf("}", openIndex + 1)
48
+ if (closeIndex === -1) {
49
+ result += template.slice(cursor)
50
+ break
51
+ }
52
+
53
+ result += template.slice(cursor, openIndex)
54
+ const paramName = template.slice(openIndex + 1, closeIndex)
11
55
  const value = params[paramName]
12
56
  if (value === undefined) {
13
57
  throw new Error(
14
58
  `Missing value for subject parameter "${paramName}" in template "${template}"`,
15
59
  )
16
60
  }
17
- if (value === "" || /[\s.>*]/.test(value)) {
61
+ if (!isValidSubjectValue(value)) {
18
62
  throw new Error(
19
- `Invalid value for subject parameter "${paramName}": "${value}" (must be non-empty and must not contain whitespace or the NATS special characters ".", ">", "*")`,
63
+ `Invalid value for subject parameter "${paramName}": "${value}" (must be non-empty and contain only letters, digits, "-", and "_")`,
20
64
  )
21
65
  }
22
- return value
23
- })
66
+ result += value
67
+
68
+ cursor = closeIndex + 1
69
+ }
70
+
71
+ return result
24
72
  }