@tsplc/build 0.1.0 → 0.2.1

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.
@@ -3,7 +3,7 @@ import { copyFile, cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/p
3
3
  import { resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import type { ResolvedConfig } from "./config";
6
- import { controllerResultSchema } from "./controller-online";
6
+ import { controllerResultSchema, explicitDownload, loginCommandSchema } from "./controller-online";
7
7
  import {
8
8
  createCodesysWorker,
9
9
  withWorkerLock,
@@ -74,12 +74,12 @@ const readBaseline = async (
74
74
  JSON.stringify(baseline.target) !== JSON.stringify(expected.target)
75
75
  ) {
76
76
  throw new Error(
77
- "Downloaded baseline uses a different image, template or controller; use an explicit download",
77
+ `Downloaded baseline uses a different image, template or controller; ${explicitDownload}`,
78
78
  );
79
79
  }
80
80
  const project = resolve(previous, "project");
81
81
  if (JSON.stringify(await projectFiles(project)) !== JSON.stringify(baseline.files)) {
82
- throw new Error("Saved online project/reference files changed; use an explicit download");
82
+ throw new Error(`Saved online project/reference files changed; ${explicitDownload}`);
83
83
  }
84
84
  return { baseline, project };
85
85
  };
@@ -227,3 +227,72 @@ export const runControllerSession = async (
227
227
  }
228
228
  });
229
229
  };
230
+
231
+ /**
232
+ * Called inside the builder container: holds one login to the running application, made
233
+ * from the committed baseline, and serves its commands on `port` until a DELETE closes it.
234
+ */
235
+ export const serveControllerLogin = async (
236
+ config: ResolvedConfig,
237
+ options: { imageId: string; port: number },
238
+ ) => {
239
+ if (!config.online) {
240
+ throw new Error("Controller logins require online settings");
241
+ }
242
+ const root = resolve(config.outDir, ".codesys/controller");
243
+ const { network, gateway, device } = config.online;
244
+ const { baseline, project } = await readBaseline(root, {
245
+ imageId: options.imageId,
246
+ templateHash: sha256(await readFile(config.template)),
247
+ target: { network, gateway, device, controller: config.controller },
248
+ });
249
+ const jobConfig = { ...config, outDir: resolve(root, "login") };
250
+ await rm(jobConfig.outDir, { recursive: true, force: true });
251
+ // CODESYS writes into an open project, and the committed baseline must not change.
252
+ await cp(project, resolve(jobConfig.outDir, "project"), { recursive: true });
253
+ const worker = createCodesysWorker(jobConfig);
254
+ await withWorkerShutdown(jobConfig, worker, () =>
255
+ withWorkerLock(jobConfig, async () => {
256
+ const session = await worker.launch();
257
+ const send = (command: Record<string, unknown>) =>
258
+ worker.request(session, { action: "login", arguments: command }, config.timeouts.build);
259
+ await send({
260
+ command: "open",
261
+ project: await toWindowsPath(
262
+ resolve(jobConfig.outDir, "project", "Application.project"),
263
+ config.codesys.wine,
264
+ ),
265
+ online: config.online,
266
+ baseline,
267
+ });
268
+ const closed = Promise.withResolvers<void>();
269
+ // The worker has one mailbox, so commands run one at a time.
270
+ let previous: Promise<unknown> = Promise.resolve();
271
+ const server = Bun.serve({
272
+ port: options.port,
273
+ idleTimeout: 0,
274
+ fetch: async (request) => {
275
+ if (request.method === "GET") return Response.json({ ok: true });
276
+ const close = request.method === "DELETE";
277
+ const command = close
278
+ ? { command: "close" }
279
+ : loginCommandSchema.parse(await request.json());
280
+ const result = previous.then(() => send(command));
281
+ previous = result.catch(() => undefined);
282
+ try {
283
+ return Response.json({ ok: true, result: await result });
284
+ } catch (error) {
285
+ // The worker reports a Python traceback; its last line names the failure.
286
+ const message = error instanceof Error ? error.message : String(error);
287
+ const reason = message.trim().split("\n").at(-1) ?? message;
288
+ return Response.json({ ok: false, error: reason.replace(/^\w+: /, "") });
289
+ } finally {
290
+ if (close) closed.resolve();
291
+ }
292
+ },
293
+ });
294
+ await closed.promise;
295
+ await server.stop();
296
+ }),
297
+ );
298
+ };
@@ -41,6 +41,8 @@ export class CodesysCompilerError extends Error {
41
41
 
42
42
  // A failed container job publishes diagnostics separately from its successful build receipt.
43
43
  export const buildFailureFile = ".codesys/docker/failure.json";
44
+ /** Why the last download, online change or inspection failed, for the host to report. */
45
+ export const controllerFailureFile = ".codesys/controller/failure.txt";
44
46
  export const buildFailureSchema = z.strictObject({
45
47
  operationId: z.uuid(),
46
48
  message: z.string(),
@@ -90,6 +90,19 @@ export const configureRuntime = async () => {
90
90
  return runtimeImage;
91
91
  };
92
92
 
93
+ export const gatewayImage = "tsplc-edge-gateway:4.22.0.0";
94
+
95
+ /** Build the CODESYS Edge Gateway that builder containers reach a plant controller through. */
96
+ export const configureGateway = async () => {
97
+ const context = resolve(packageDirectory, "docker/edge-gateway");
98
+ console.log(`Preparing the CODESYS gateway (${gatewayImage}).`);
99
+ await runDocker(
100
+ ["build", "--platform", "linux/amd64", "--progress", "plain", "-t", gatewayImage, context],
101
+ (status) => `Gateway image build failed (${status}). Review the image-build output`,
102
+ );
103
+ return gatewayImage;
104
+ };
105
+
93
106
  export const buildInDocker = async (config: ResolvedConfig, image: string) => {
94
107
  const requestDirectory = await mkdtemp(resolve(tmpdir(), "codesys-docker-"));
95
108
  try {
@@ -98,6 +111,8 @@ export const buildInDocker = async (config: ResolvedConfig, image: string) => {
98
111
  const failurePath = resolve(config.outDir, buildFailureFile);
99
112
  await rm(failurePath, { force: true });
100
113
  const args = [
114
+ "run",
115
+ "--rm",
101
116
  ...(await prepareContainerRun(config, requestDirectory, { network: "none", sources: true })),
102
117
  image,
103
118
  "--request",
package/src/docker-run.ts CHANGED
@@ -56,8 +56,9 @@ async function mountLibraries(libraries: ResolvedConfig["libraries"], directory:
56
56
 
57
57
  /**
58
58
  * Write the builder container's request and return the `docker run` options that precede
59
- * the image. The request names only container paths; host paths enter through these mounts,
60
- * and host-only Docker, CODESYS and runtime settings stay outside.
59
+ * the image; the caller chooses `--rm` or `--detach`. The request names only container paths;
60
+ * host paths enter through these mounts, and host-only Docker, CODESYS and runtime settings
61
+ * stay outside.
61
62
  */
62
63
  export const prepareContainerRun = async (
63
64
  config: ResolvedConfig,
@@ -83,8 +84,6 @@ export const prepareContainerRun = async (
83
84
  }),
84
85
  );
85
86
  return [
86
- "run",
87
- "--rm",
88
87
  "--init",
89
88
  ...(config.docker?.cpuSet ? ["--cpuset-cpus", config.docker.cpuSet] : []),
90
89
  "--network",
@@ -30,7 +30,9 @@ const sessionSchema = z.object({
30
30
 
31
31
  type Session = z.infer<typeof sessionSchema>;
32
32
 
33
- type WorkerCommand = { action: "build" | "online" | "ping" | "stop" };
33
+ type WorkerCommand =
34
+ | { action: "build" | "online" | "ping" | "stop" }
35
+ | { action: "login"; arguments: Record<string, unknown> };
34
36
 
35
37
  const hasCode = (error: unknown, code: string) =>
36
38
  error instanceof Error && "code" in error && error.code === code;
@@ -170,6 +172,7 @@ export const createCodesysWorker = (config: ResolvedConfig) => {
170
172
  diagnostics: z.array(compilerDiagnosticSchema).optional(),
171
173
  id: z.literal(id),
172
174
  ok: z.boolean(),
175
+ result: z.unknown(),
173
176
  })
174
177
  .safeParse(result);
175
178
  if (!response.success) {
@@ -185,7 +188,7 @@ export const createCodesysWorker = (config: ResolvedConfig) => {
185
188
  }
186
189
  throw new Error(message);
187
190
  }
188
- return;
191
+ return response.data.result;
189
192
  }
190
193
  if (!isProcessAlive(session.pid)) {
191
194
  throw new Error(
@@ -216,6 +219,7 @@ export const createCodesysWorker = (config: ResolvedConfig) => {
216
219
  JSON.stringify({
217
220
  builder: await readFile(resolve(pythonDirectory, "build.py"), "utf8"),
218
221
  online: await readFile(resolve(pythonDirectory, "codesys_online.py"), "utf8"),
222
+ login: await readFile(resolve(pythonDirectory, "codesys_login.py"), "utf8"),
219
223
  command: codesysCommand,
220
224
  codesys: config.codesys,
221
225
  launcher: getHostCommand(config.codesys),
@@ -242,10 +246,9 @@ export const createCodesysWorker = (config: ResolvedConfig) => {
242
246
  await mkdir(directory, { recursive: true });
243
247
  await copyFile(resolve(pythonDirectory, "build.py"), resolve(directory, "build.py"));
244
248
  await copyFile(resolve(pythonDirectory, "worker.py"), resolve(directory, "worker.py"));
245
- await copyFile(
246
- resolve(pythonDirectory, "codesys_online.py"),
247
- resolve(directory, "codesys_online.py"),
248
- );
249
+ for (const script of ["codesys_online.py", "codesys_login.py"]) {
250
+ await copyFile(resolve(pythonDirectory, script), resolve(directory, script));
251
+ }
249
252
  console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);
250
253
  const pid = await host.startWorker(directory);
251
254
  const session = { directory, fingerprint, pid };
package/src/index.ts CHANGED
@@ -8,7 +8,19 @@ export {
8
8
  type ResolvedConfig,
9
9
  resolveConfig,
10
10
  } from "./config";
11
- export { type ControllerResult, inspectController, updateController } from "./controller-online";
11
+ export {
12
+ type ControllerLogin,
13
+ type ControllerResult,
14
+ explicitDownload,
15
+ findControllerLogin,
16
+ inspectController,
17
+ type LoginCommand,
18
+ loginCommandSchema,
19
+ type LoginResult,
20
+ loginResultSchema,
21
+ openControllerLogin,
22
+ updateController,
23
+ } from "./controller-online";
12
24
  export { type CodesysCompilerDiagnostic, CodesysCompilerError } from "./diagnostics";
13
25
  export { runProject } from "./run";
14
26
  export { loadSources, type SourceObject } from "./sources";
@@ -22,5 +34,10 @@ export {
22
34
  type InstalledLibrary,
23
35
  } from "./libraries.ts";
24
36
 
25
- export { configureDocker, configureRuntime } from "./docker-build.ts";
37
+ export {
38
+ configureDocker,
39
+ configureGateway,
40
+ configureRuntime,
41
+ gatewayImage,
42
+ } from "./docker-build.ts";
26
43
  export { readBuild } from "./artifacts.ts";