@highstate/common 0.9.18 → 0.9.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.
Files changed (43) hide show
  1. package/dist/{chunk-YYNV3MVT.js → chunk-WDYIUWYZ.js} +484 -176
  2. package/dist/chunk-WDYIUWYZ.js.map +1 -0
  3. package/dist/highstate.manifest.json +12 -8
  4. package/dist/index.js +1 -1
  5. package/dist/units/access-point/index.js +16 -0
  6. package/dist/units/access-point/index.js.map +1 -0
  7. package/dist/units/databases/existing-mariadb/index.js +17 -0
  8. package/dist/units/databases/existing-mariadb/index.js.map +1 -0
  9. package/dist/units/databases/existing-mongodb/index.js +17 -0
  10. package/dist/units/databases/existing-mongodb/index.js.map +1 -0
  11. package/dist/units/databases/existing-postgresql/index.js +17 -0
  12. package/dist/units/databases/existing-postgresql/index.js.map +1 -0
  13. package/dist/units/dns/record-set/index.js +22 -11
  14. package/dist/units/dns/record-set/index.js.map +1 -1
  15. package/dist/units/existing-server/index.js +9 -9
  16. package/dist/units/existing-server/index.js.map +1 -1
  17. package/dist/units/network/l3-endpoint/index.js +1 -1
  18. package/dist/units/network/l4-endpoint/index.js +1 -1
  19. package/dist/units/script/index.js +1 -1
  20. package/dist/units/server-dns/index.js +1 -1
  21. package/dist/units/server-patch/index.js +1 -1
  22. package/dist/units/ssh/key-pair/index.js +4 -3
  23. package/dist/units/ssh/key-pair/index.js.map +1 -1
  24. package/package.json +61 -8
  25. package/src/shared/access-point.ts +110 -0
  26. package/src/shared/command.ts +81 -14
  27. package/src/shared/dns.ts +150 -90
  28. package/src/shared/files.ts +23 -18
  29. package/src/shared/gateway.ts +117 -0
  30. package/src/shared/impl-ref.ts +123 -0
  31. package/src/shared/index.ts +4 -0
  32. package/src/shared/network.ts +39 -25
  33. package/src/shared/passwords.ts +3 -3
  34. package/src/shared/ssh.ts +109 -124
  35. package/src/shared/tls.ts +123 -0
  36. package/src/units/access-point/index.ts +12 -0
  37. package/src/units/databases/existing-mariadb/index.ts +14 -0
  38. package/src/units/databases/existing-mongodb/index.ts +14 -0
  39. package/src/units/databases/existing-postgresql/index.ts +14 -0
  40. package/src/units/dns/record-set/index.ts +21 -11
  41. package/src/units/existing-server/index.ts +9 -15
  42. package/src/units/ssh/key-pair/index.ts +4 -3
  43. package/dist/chunk-YYNV3MVT.js.map +0 -1
package/src/shared/dns.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { ArrayPatchMode, dns, network } from "@highstate/library"
2
+ import { getOrCreate, z } from "@highstate/contract"
2
3
  import {
3
4
  ComponentResource,
4
- normalize,
5
+ interpolate,
6
+ normalizeInputsAndMap,
5
7
  output,
6
- Output,
7
- Resource,
8
+ type Output,
8
9
  toPromise,
9
10
  type Input,
10
11
  type InputArray,
@@ -12,7 +13,7 @@ import {
12
13
  type ResourceOptions,
13
14
  type Unwrap,
14
15
  } from "@highstate/pulumi"
15
- import { capitalize, groupBy, uniqueBy } from "remeda"
16
+ import { flat, groupBy, uniqueBy } from "remeda"
16
17
  import { Command, type CommandHost } from "./command"
17
18
  import {
18
19
  filterEndpoints,
@@ -21,6 +22,13 @@ import {
21
22
  parseL3Endpoint,
22
23
  type InputL3Endpoint,
23
24
  } from "./network"
25
+ import { ImplementationMediator } from "./impl-ref"
26
+
27
+ export const dnsRecordMediator = new ImplementationMediator(
28
+ "dns-record",
29
+ z.object({ name: z.string(), args: z.custom<ResolvedDnsRecordArgs>() }),
30
+ z.instanceof(ComponentResource),
31
+ )
24
32
 
25
33
  export type DnsRecordArgs = {
26
34
  /**
@@ -74,23 +82,23 @@ export type DnsRecordArgs = {
74
82
  waitAt?: InputOrArray<CommandHost>
75
83
  }
76
84
 
77
- export type ResolvedDnsRecordArgs = Unwrap<Omit<DnsRecordArgs, "value" | "type">> & {
85
+ export type ResolvedDnsRecordArgs = Pick<DnsRecordArgs, "name" | "priority" | "ttl" | "proxied"> & {
78
86
  /**
79
87
  * The value of the DNS record.
80
88
  */
81
- value: string
89
+ value: Input<string>
82
90
 
83
91
  /**
84
92
  * The type of the DNS record.
85
93
  */
86
- type: string
94
+ type: Input<string>
87
95
  }
88
96
 
89
97
  export type DnsRecordSetArgs = Omit<DnsRecordArgs, "provider" | "value"> & {
90
98
  /**
91
99
  * The DNS providers to use to create the DNS records.
92
100
  *
93
- * If multiple providers matched the specified domain, multiple DNS records will be created.
101
+ * If multiple providers matched the specified domain, records will be created for each of them.
94
102
  */
95
103
  providers: Input<dns.Provider[]>
96
104
 
@@ -116,125 +124,177 @@ function getTypeByEndpoint(endpoint: network.L3Endpoint): string {
116
124
  }
117
125
  }
118
126
 
119
- export abstract class DnsRecord extends ComponentResource {
127
+ /**
128
+ * Creates a DNS record for the specified value and waits for it to be resolved.
129
+ *
130
+ * Uses the specified DNS provider to create the record.
131
+ */
132
+ export class DnsRecord extends ComponentResource {
120
133
  /**
121
134
  * The underlying dns record resource.
122
135
  */
123
- public readonly dnsRecord: Output<Resource>
136
+ readonly dnsRecord: Output<ComponentResource>
124
137
 
125
138
  /**
126
- * The wait commands to be executed after the DNS record is created/updated.
139
+ * The commands to be executed after the DNS record is created/updated.
127
140
  *
128
- * Use this field as a dependency for other resources.
141
+ * These commands will wait for the DNS record to be resolved to the specified value.
129
142
  */
130
- public readonly waitCommands: Output<Command[]>
143
+ readonly waitCommands: Output<Command[]>
131
144
 
132
- protected constructor(name: string, args: DnsRecordArgs, opts?: ResourceOptions) {
145
+ constructor(name: string, args: DnsRecordArgs, opts?: ResourceOptions) {
133
146
  super("highstate:common:DnsRecord", name, args, opts)
134
147
 
135
- this.dnsRecord = output(args).apply(args => {
136
- const l3Endpoint = parseL3Endpoint(args.value)
137
- const type = args.type ?? getTypeByEndpoint(l3Endpoint)
138
-
139
- return output(
140
- this.create(
141
- name,
142
- {
143
- ...args,
144
- type,
145
- value: l3EndpointToString(l3Endpoint),
146
- },
147
- { ...opts, parent: this },
148
- ),
149
- )
148
+ const l3Endpoint = output(args.value).apply(value => parseL3Endpoint(value))
149
+ const resolvedValue = l3Endpoint.apply(l3EndpointToString)
150
+ const resolvedType = args.type ? output(args.type) : l3Endpoint.apply(getTypeByEndpoint)
151
+
152
+ this.dnsRecord = output(args.provider).apply(provider => {
153
+ return dnsRecordMediator.call(provider.implRef, {
154
+ name,
155
+ args: {
156
+ name: args.name,
157
+ priority: args.priority,
158
+ ttl: args.ttl,
159
+ value: resolvedValue,
160
+ type: resolvedType,
161
+ },
162
+ })
150
163
  })
151
164
 
152
- this.waitCommands = output(args).apply(args => {
153
- const waitAt = args.waitAt ? (Array.isArray(args.waitAt) ? args.waitAt : [args.waitAt]) : []
154
-
155
- return waitAt.map(host => {
165
+ this.waitCommands = output({
166
+ waitAt: args.waitAt,
167
+ resolvedType,
168
+ proxied: args.proxied,
169
+ }).apply(({ waitAt, resolvedType, proxied }) => {
170
+ if (resolvedType === "CNAME") {
171
+ // TODO: handle CNAME records
172
+ return []
173
+ }
174
+
175
+ const resolvedHosts = waitAt ? [waitAt].flat() : []
176
+
177
+ if (proxied) {
178
+ // for proxied records do not verify the value since we do not know the actual IP addressa
179
+
180
+ return (resolvedHosts as Unwrap<CommandHost>[]).map(host => {
181
+ const hostname = host === "local" ? "local" : host.hostname
182
+
183
+ return new Command(
184
+ `${name}.wait-for-dns.${hostname}`,
185
+ {
186
+ host,
187
+ create: [
188
+ interpolate`while ! getent hosts "${args.name}";`,
189
+ interpolate`do echo "Waiting for DNS record \"${args.name}\" to be available...";`,
190
+ `sleep 5;`,
191
+ `done`,
192
+ ],
193
+ },
194
+ { parent: this },
195
+ )
196
+ })
197
+ }
198
+
199
+ return (resolvedHosts as Unwrap<CommandHost>[]).map(host => {
156
200
  const hostname = host === "local" ? "local" : host.hostname
157
201
 
158
202
  return new Command(
159
- `${name}-wait-${hostname}`,
203
+ `${name}.wait-for-dns.${hostname}`,
160
204
  {
161
205
  host,
162
- create: `while ! getent hosts ${args.name} >/dev/null; do echo "Waiting for DNS record ${args.name} to be created"; sleep 5; done`,
163
- triggers: [args.type, args.ttl, args.priority, args.proxied],
206
+ create: [
207
+ interpolate`while ! getent hosts "${args.name}" | grep "${resolvedValue}";`,
208
+ interpolate`do echo "Waiting for DNS record \"${args.name}" to resolve to "${resolvedValue}"...";`,
209
+ `sleep 5;`,
210
+ `done`,
211
+ ],
164
212
  },
165
213
  { parent: this },
166
214
  )
167
215
  })
168
216
  })
169
217
  }
170
-
171
- protected abstract create(
172
- name: string,
173
- args: ResolvedDnsRecordArgs,
174
- opts?: ResourceOptions,
175
- ): Input<Resource>
176
-
177
- static create(name: string, args: DnsRecordArgs, opts?: ResourceOptions): Output<DnsRecord> {
178
- return output(args).apply(async args => {
179
- const providerType = args.provider.type
180
- const implName = `${capitalize(providerType)}DnsRecord`
181
- const implModule = (await import(`@highstate/${providerType}`)) as Record<string, unknown>
182
-
183
- const implClass = implModule[implName] as new (
184
- name: string,
185
- args: Unwrap<DnsRecordArgs>,
186
- opts?: ResourceOptions,
187
- ) => DnsRecord
188
-
189
- return new implClass(name, args, opts)
190
- })
191
- }
192
218
  }
193
219
 
220
+ /**
221
+ * Creates a set of DNS records for the specified values and waits for them to be resolved.
222
+ */
194
223
  export class DnsRecordSet extends ComponentResource {
195
224
  /**
196
225
  * The underlying dns record resources.
197
226
  */
198
- public readonly dnsRecords: Output<DnsRecord[]>
227
+ readonly dnsRecords: Output<DnsRecord[]>
199
228
 
200
229
  /**
201
- * The wait commands to be executed after the DNS records are created/updated.
230
+ * The flat list of all wait commands for the DNS records.
202
231
  */
203
- public readonly waitCommands: Output<Command[]>
232
+ readonly waitCommands: Output<Command[]>
204
233
 
205
- private constructor(name: string, records: Output<DnsRecord[]>, opts?: ResourceOptions) {
206
- super("highstate:common:DnsRecordSet", name, records, opts)
234
+ constructor(name: string, args: DnsRecordSetArgs, opts?: ResourceOptions) {
235
+ super("highstate:common:DnsRecordSet", name, args, opts)
207
236
 
208
- this.dnsRecords = records
237
+ const matchedProviders = output({
238
+ providers: args.providers,
239
+ name: args.name ?? name,
240
+ }).apply(({ providers }) => {
241
+ const matchedProviders = providers.filter(provider => name.endsWith(provider.domain))
209
242
 
210
- this.waitCommands = records.apply(records =>
211
- records.flatMap(record => record.waitCommands),
212
- ) as unknown as Output<Command[]>
213
- }
243
+ if (matchedProviders.length === 0) {
244
+ throw new Error(`No DNS provider matched the domain "${name}"`)
245
+ }
214
246
 
215
- static create(name: string, args: DnsRecordSetArgs, opts?: ResourceOptions): DnsRecordSet {
216
- const records = output(args).apply(args => {
217
- const recordName = args.name ?? name
218
- const values = normalize(args.value, args.values)
219
-
220
- return output(
221
- args.providers
222
- .filter(provider => recordName.endsWith(provider.domain))
223
- .flatMap(provider => {
224
- return values.map(value => {
225
- const l3Endpoint = parseL3Endpoint(value)
226
-
227
- return DnsRecord.create(
228
- `${provider.type}-from-${recordName}-to-${l3EndpointToString(l3Endpoint)}`,
229
- { name: recordName, ...args, value: l3Endpoint, provider },
230
- opts,
231
- )
232
- })
233
- }),
234
- )
247
+ return matchedProviders
235
248
  })
236
249
 
237
- return new DnsRecordSet(name, records, opts)
250
+ this.dnsRecords = normalizeInputsAndMap(args.value, args.values, value => {
251
+ return output({
252
+ name: args.name ?? name,
253
+ providers: matchedProviders,
254
+ }).apply(({ name, providers }) => {
255
+ return providers.flatMap(provider => {
256
+ const l3Endpoint = parseL3Endpoint(value)
257
+
258
+ return new DnsRecord(
259
+ `${name}.${provider.id}.${l3EndpointToString(l3Endpoint)}`,
260
+ {
261
+ name,
262
+ provider,
263
+ value: l3Endpoint,
264
+ type: args.type ?? getTypeByEndpoint(l3Endpoint),
265
+ proxied: args.proxied,
266
+ ttl: args.ttl,
267
+ priority: args.priority,
268
+ waitAt: args.waitAt,
269
+ },
270
+ { parent: this },
271
+ )
272
+ })
273
+ })
274
+ }).apply(flat)
275
+
276
+ this.waitCommands = this.dnsRecords
277
+ .apply(records => records.flatMap(record => record.waitCommands))
278
+ .apply(flat)
279
+ }
280
+
281
+ private static readonly dnsRecordSetCache = new Map<string, DnsRecordSet>()
282
+
283
+ /**
284
+ * Creates a DNS record set for the specified endpoints and waits for it to be resolved.
285
+ *
286
+ * If a DNS record set with the same name already exists, it will be reused.
287
+ *
288
+ * @param name The name of the DNS record set.
289
+ * @param args The arguments for the DNS record set.
290
+ * @param opts The options for the resource.
291
+ */
292
+ static createOnce(name: string, args: DnsRecordSetArgs, opts?: ResourceOptions): DnsRecordSet {
293
+ return getOrCreate(
294
+ DnsRecordSet.dnsRecordSetCache,
295
+ name,
296
+ () => new DnsRecordSet(name, args, opts),
297
+ )
238
298
  }
239
299
  }
240
300
 
@@ -268,7 +328,7 @@ export async function updateEndpointsWithFqdn<TEndpoint extends network.L34Endpo
268
328
 
269
329
  const filteredEndpoints = filterEndpoints(resolvedEndpoints, fqdnEndpointFilter)
270
330
 
271
- const dnsRecordSet = DnsRecordSet.create(fqdn, {
331
+ const dnsRecordSet = new DnsRecordSet(fqdn, {
272
332
  providers: dnsProviders,
273
333
  values: filteredEndpoints,
274
334
  waitAt: "local",
@@ -1,18 +1,17 @@
1
1
  import type { common, network } from "@highstate/library"
2
- import { tmpdir } from "node:os"
3
- import { cp, mkdir, mkdtemp, rm, writeFile, rename, stat } from "node:fs/promises"
4
- import { join, extname, basename, dirname } from "node:path"
2
+ import { createHash } from "node:crypto"
5
3
  import { createReadStream } from "node:fs"
6
- import { pipeline } from "node:stream/promises"
4
+ import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises"
5
+ import { tmpdir } from "node:os"
6
+ import { basename, dirname, extname, join } from "node:path"
7
7
  import { Readable } from "node:stream"
8
- import { createHash } from "node:crypto"
8
+ import { pipeline } from "node:stream/promises"
9
+ import { type CommonObjectMeta, type File, HighstateSignature } from "@highstate/contract"
10
+ import { asset, toPromise } from "@highstate/pulumi"
9
11
  import { minimatch } from "minimatch"
10
-
11
- import { HighstateSignature, type File } from "@highstate/contract"
12
- import { asset, toPromise, type ObjectMeta } from "@highstate/pulumi"
13
12
  import * as tar from "tar"
14
13
  import unzipper from "unzipper"
15
- import { l7EndpointToString, parseL7Endpoint, type InputL7Endpoint } from "./network"
14
+ import { type InputL7Endpoint, l7EndpointToString, parseL7Endpoint } from "./network"
16
15
 
17
16
  export type FolderPackOptions = {
18
17
  /**
@@ -50,7 +49,7 @@ export function assetFromFile(file: common.File): asset.Asset {
50
49
  )
51
50
  }
52
51
 
53
- if (file.meta.isBinary) {
52
+ if (file.content.isBinary) {
54
53
  throw new Error(
55
54
  "Cannot create asset from inline binary file content. Please open an issue if you need this feature.",
56
55
  )
@@ -171,12 +170,16 @@ export class MaterializedFile implements AsyncDisposable {
171
170
  private _path!: string
172
171
  private _disposed = false
173
172
 
174
- readonly artifactMeta: ObjectMeta = {}
173
+ readonly artifactMeta: CommonObjectMeta
175
174
 
176
175
  constructor(
177
176
  readonly entity: common.File,
178
177
  readonly parent?: MaterializedFolder,
179
- ) {}
178
+ ) {
179
+ this.artifactMeta = {
180
+ title: `Materialized file "${entity.meta.name}"`,
181
+ }
182
+ }
180
183
 
181
184
  get path(): string {
182
185
  return this._path
@@ -196,7 +199,7 @@ export class MaterializedFile implements AsyncDisposable {
196
199
 
197
200
  switch (this.entity.content.type) {
198
201
  case "embedded": {
199
- const content = this.entity.meta.isBinary
202
+ const content = this.entity.content.isBinary
200
203
  ? Buffer.from(this.entity.content.value, "base64")
201
204
  : this.entity.content.value
202
205
 
@@ -301,7 +304,6 @@ export class MaterializedFile implements AsyncDisposable {
301
304
  name: this.entity.meta.name,
302
305
  mode: fileStats.mode & 0o777, // extract only permission bits
303
306
  size: fileStats.size,
304
- isBinary: this.entity.meta.isBinary, // keep original binary flag as we can't reliably detect this from filesystem
305
307
  }
306
308
 
307
309
  // return file entity with artifact content using actual filesystem stats
@@ -338,7 +340,6 @@ export class MaterializedFile implements AsyncDisposable {
338
340
  name,
339
341
  mode,
340
342
  size: 0,
341
- isBinary: false,
342
343
  },
343
344
  content: {
344
345
  type: "embedded",
@@ -389,12 +390,16 @@ export class MaterializedFolder implements AsyncDisposable {
389
390
 
390
391
  private readonly _disposables: AsyncDisposable[] = []
391
392
 
392
- readonly artifactMeta: ObjectMeta = {}
393
+ readonly artifactMeta: CommonObjectMeta
393
394
 
394
395
  constructor(
395
396
  readonly entity: common.Folder,
396
397
  readonly parent?: MaterializedFolder,
397
- ) {}
398
+ ) {
399
+ this.artifactMeta = {
400
+ title: `Materialized folder "${entity.meta.name}"`,
401
+ }
402
+ }
398
403
 
399
404
  get path(): string {
400
405
  return this._path
@@ -708,7 +713,7 @@ export async function fetchFileSize(endpoint: network.L7Endpoint): Promise<numbe
708
713
  }
709
714
 
710
715
  const size = parseInt(contentLength, 10)
711
- if (isNaN(size)) {
716
+ if (Number.isNaN(size)) {
712
717
  throw new Error(`Invalid Content-Length value: ${contentLength}`)
713
718
  }
714
719
 
@@ -0,0 +1,117 @@
1
+ import type { TlsCertificate } from "./tls"
2
+ import { z } from "@highstate/contract"
3
+ import { type common, network } from "@highstate/library"
4
+ import {
5
+ ComponentResource,
6
+ type ComponentResourceOptions,
7
+ type Input,
8
+ type Output,
9
+ output,
10
+ Resource,
11
+ } from "@highstate/pulumi"
12
+ import { ImplementationMediator } from "./impl-ref"
13
+
14
+ export const gatewayRouteMediator = new ImplementationMediator(
15
+ "gateway-route",
16
+ z.object({
17
+ name: z.string(),
18
+ spec: z.custom<GatewayRouteSpec>(),
19
+ opts: z.custom<ComponentResourceOptions>().optional(),
20
+ }),
21
+ z.object({
22
+ resource: z.instanceof(Resource),
23
+ endpoints: network.l3EndpointEntity.schema.array(),
24
+ }),
25
+ )
26
+
27
+ export type GatewayRouteSpec = {
28
+ /**
29
+ * The FQDN to expose the workload on.
30
+ */
31
+ fqdn?: Input<string>
32
+
33
+ /**
34
+ * The endpoints of the backend workload to route traffic to.
35
+ */
36
+ endpoints: Input<network.L4Endpoint[]>
37
+
38
+ /**
39
+ * The native data to pass to the implementation.
40
+ *
41
+ * This is used for data which implementation may natively understand,
42
+ * such as Kubernetes `Service` resources.
43
+ *
44
+ * Implementations may use this data to create more efficient routes
45
+ * using native resources.
46
+ */
47
+ nativeData?: Input<unknown>
48
+
49
+ /**
50
+ * The TLS certificate to use for the route.
51
+ */
52
+ tlsCertificate?: Input<TlsCertificate | undefined>
53
+ } & (
54
+ | {
55
+ type: "http"
56
+
57
+ /**
58
+ * Whether to expose the workload over plain HTTP.
59
+ *
60
+ * By default, the workload will be exposed over HTTPS.
61
+ */
62
+ insecure?: Input<boolean>
63
+
64
+ /**
65
+ * The relative path to expose the workload on.
66
+ *
67
+ * By default, the workload will be exposed on the root path (`/`).
68
+ */
69
+ path?: Input<string>
70
+ }
71
+ | {
72
+ type: "l3"
73
+
74
+ /**
75
+ * The ports to request for the workload.
76
+ *
77
+ * The order must match the order of the ports in the service.
78
+ *
79
+ * If not provided, random ports will be assigned.
80
+ */
81
+ ports?: number
82
+ }
83
+ )
84
+
85
+ export type GatewayRouteArgs = GatewayRouteSpec & {
86
+ /**
87
+ * The gateway to attach the route to.
88
+ */
89
+ gateway: Input<common.Gateway>
90
+ }
91
+
92
+ export class GatewayRoute extends ComponentResource {
93
+ /**
94
+ * The underlying resource created by the implementation.
95
+ */
96
+ readonly resource: Output<Resource>
97
+
98
+ /**
99
+ * The endpoints of the gateway which serve this route.
100
+ *
101
+ * In most cases, this will be a single endpoint of the gateway shared for all routes.
102
+ */
103
+ readonly endpoints: Output<network.L3Endpoint[]>
104
+
105
+ constructor(name: string, args: GatewayRouteArgs, opts?: ComponentResourceOptions) {
106
+ super("highstate:common:GatewayRoute", name, args, opts)
107
+
108
+ const { resource, endpoints } = gatewayRouteMediator.callOutput(output(args.gateway).implRef, {
109
+ name,
110
+ spec: args,
111
+ opts: { ...opts, parent: this },
112
+ })
113
+
114
+ this.resource = resource
115
+ this.endpoints = endpoints
116
+ }
117
+ }
@@ -0,0 +1,123 @@
1
+ import type { ImplementationReference } from "@highstate/library"
2
+ import type { z } from "@highstate/contract"
3
+ import { type Output, output, toPromise, type Input, type Unwrap } from "@highstate/pulumi"
4
+
5
+ /**
6
+ * The ImplementationMediator is used as a contract between the calling code and the implementation.
7
+ *
8
+ * From the calling code perspective, it provides a way to define the input and output schemas for the implementation
9
+ * and call the implementation with the provided input.
10
+ *
11
+ * From the implementation perspective, it provides a way to get zod function with automatic type inference and validation.
12
+ */
13
+ export class ImplementationMediator<
14
+ TInputSchema extends z.ZodType,
15
+ TOutputSchema extends z.ZodType,
16
+ > {
17
+ constructor(
18
+ readonly path: string,
19
+ private readonly inputSchema: TInputSchema,
20
+ private readonly outputSchema: TOutputSchema,
21
+ ) {}
22
+
23
+ implement<TDataSchema extends z.ZodType>(
24
+ dataSchema: TDataSchema,
25
+ func: (
26
+ input: z.infer<TInputSchema>,
27
+ data: z.infer<TDataSchema>,
28
+ ) => z.infer<TOutputSchema> | Promise<z.infer<TOutputSchema>>,
29
+ ) {
30
+ return async (
31
+ input: z.infer<TInputSchema>,
32
+ data: z.infer<TDataSchema>,
33
+ ): Promise<z.infer<TOutputSchema>> => {
34
+ const parsedInput = this.inputSchema.safeParse(input)
35
+ if (!parsedInput.success) {
36
+ throw new Error(
37
+ `Invalid input for implementation "${this.path}": ${parsedInput.error.message}`,
38
+ )
39
+ }
40
+
41
+ const parsedData = dataSchema.safeParse(data)
42
+ if (!parsedData.success) {
43
+ throw new Error(
44
+ `Invalid data for implementation "${this.path}": ${parsedData.error.message}`,
45
+ )
46
+ }
47
+
48
+ const result = await func(parsedInput.data, parsedData.data)
49
+ const parsedResult = this.outputSchema.safeParse(result)
50
+
51
+ if (!parsedResult.success) {
52
+ throw new Error(
53
+ `Invalid output from implementation "${this.path}": ${parsedResult.error.message}`,
54
+ )
55
+ }
56
+
57
+ return parsedResult.data
58
+ }
59
+ }
60
+
61
+ async call(
62
+ implRef: Input<ImplementationReference>,
63
+ input: Input<z.infer<TInputSchema>>,
64
+ ): Promise<z.infer<TOutputSchema>> {
65
+ const resolvedImplRef = await toPromise(implRef)
66
+ const resolvedInput = await toPromise(input)
67
+
68
+ const importPath = `${resolvedImplRef.package}/impl/${this.path}`
69
+
70
+ let impl: Record<string, unknown>
71
+ try {
72
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
73
+ impl = await import(importPath)
74
+ } catch (error) {
75
+ throw new Error(`Failed to import module "${importPath}" required by implementation.`, {
76
+ cause: error,
77
+ })
78
+ }
79
+
80
+ const funcs = Object.entries(impl).filter(value => typeof value[1] === "function") as [
81
+ string,
82
+ Function,
83
+ ][]
84
+
85
+ if (funcs.length === 0) {
86
+ throw new Error(`No implementation functions found in module "${importPath}".`)
87
+ }
88
+
89
+ if (funcs.length > 1) {
90
+ throw new Error(
91
+ `Multiple implementation functions found in module "${importPath}": ${funcs.map(func => func[0]).join(", ")}. ` +
92
+ "Ensure only one function is exported.",
93
+ )
94
+ }
95
+
96
+ const [funcName, implFunc] = funcs[0]
97
+
98
+ let result: unknown
99
+ try {
100
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
101
+ result = await implFunc(resolvedInput, resolvedImplRef.data)
102
+ } catch (error) {
103
+ console.error(`Error in implementation function "${funcName}":`, error)
104
+ throw new Error(`Implementation function "${funcName}" failed`)
105
+ }
106
+
107
+ const parsedResult = this.outputSchema.safeParse(result)
108
+ if (!parsedResult.success) {
109
+ throw new Error(
110
+ `Implementation function "${funcName}" returned invalid result: ${parsedResult.error.message}`,
111
+ )
112
+ }
113
+
114
+ return parsedResult.data
115
+ }
116
+
117
+ callOutput(
118
+ implRef: Input<ImplementationReference>,
119
+ input: Input<z.infer<TInputSchema>>,
120
+ ): Output<Unwrap<z.infer<TOutputSchema>>> {
121
+ return output(this.call(implRef, input))
122
+ }
123
+ }
@@ -4,3 +4,7 @@ export * from "./passwords"
4
4
  export * from "./ssh"
5
5
  export * from "./network"
6
6
  export * from "./files"
7
+ export * from "./impl-ref"
8
+ export * from "./gateway"
9
+ export * from "./tls"
10
+ export * from "./access-point"