@tsplc/compiler 0.2.9 → 0.2.11

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.
@@ -111,11 +111,18 @@ export class ModuleDeclarations {
111
111
  block.locals.length ||
112
112
  !matches(block.inputs, expected.inputs) ||
113
113
  !matches(block.outputs, expected.outputs) ||
114
- block.outputs.some((output) =>
115
- output.type === "BOOL"
116
- ? output.initial?.kind !== "bool" || output.initial.value !== false
117
- : output.initial?.kind !== "duration" || output.initial.nanoseconds !== "0",
118
- )
114
+ block.outputs.some(({ initial }) => {
115
+ switch (initial?.kind) {
116
+ case "bool":
117
+ return initial.value !== false;
118
+ case "duration":
119
+ return initial.nanoseconds !== "0";
120
+ case "integer":
121
+ return initial.value !== "0";
122
+ default:
123
+ return true;
124
+ }
125
+ })
119
126
  )
120
127
  reject("Invalid canonical standard block definition.", block.span);
121
128
  }
package/ir/model.ts CHANGED
@@ -158,7 +158,7 @@ export interface PlcFunction {
158
158
  export type MathHelper = "min" | "max" | "clamp";
159
159
 
160
160
  // Native vendor blocks under their TypeScript names. Ports are [name, native name, type];
161
- // every output starts FALSE or T#0s, matching the vendor library blocks.
161
+ // every output starts at its type's default, matching the vendor library blocks.
162
162
  const timer = (input: string, output: string) =>
163
163
  ({
164
164
  inputs: [
@@ -184,6 +184,45 @@ export const standardBlocks = {
184
184
  inputs: [["signal", "CLK", "BOOL"]],
185
185
  outputs: [["fell", "Q", "BOOL"]],
186
186
  },
187
+ UpCounter: {
188
+ native: "CTU",
189
+ inputs: [
190
+ ["countUp", "CU", "BOOL"],
191
+ ["reset", "RESET", "BOOL"],
192
+ ["preset", "PV", "WORD"],
193
+ ],
194
+ outputs: [
195
+ ["reachedPreset", "Q", "BOOL"],
196
+ ["value", "CV", "WORD"],
197
+ ],
198
+ },
199
+ DownCounter: {
200
+ native: "CTD",
201
+ inputs: [
202
+ ["countDown", "CD", "BOOL"],
203
+ ["load", "LOAD", "BOOL"],
204
+ ["preset", "PV", "WORD"],
205
+ ],
206
+ outputs: [
207
+ ["atZero", "Q", "BOOL"],
208
+ ["value", "CV", "WORD"],
209
+ ],
210
+ },
211
+ UpDownCounter: {
212
+ native: "CTUD",
213
+ inputs: [
214
+ ["countUp", "CU", "BOOL"],
215
+ ["countDown", "CD", "BOOL"],
216
+ ["reset", "RESET", "BOOL"],
217
+ ["load", "LOAD", "BOOL"],
218
+ ["preset", "PV", "WORD"],
219
+ ],
220
+ outputs: [
221
+ ["reachedPreset", "QU", "BOOL"],
222
+ ["atZero", "QD", "BOOL"],
223
+ ["value", "CV", "WORD"],
224
+ ],
225
+ },
187
226
  Blinker: {
188
227
  native: "Util.BLINK",
189
228
  inputs: [
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "url": "git+https://github.com/livingforjesus/tsplc.git",
18
18
  "directory": "packages/compiler"
19
19
  },
20
- "version": "0.2.9",
20
+ "version": "0.2.11",
21
21
  "type": "module",
22
22
  "exports": {
23
23
  ".": {
@@ -90,14 +90,14 @@
90
90
  "bun": ">=1.4.3"
91
91
  },
92
92
  "dependencies": {
93
- "@tsplc/agent": "0.2.9",
94
- "@tsplc/build": "0.2.9",
95
- "@tsplc/config": "0.2.9",
96
- "@tsplc/typegen": "0.2.9",
93
+ "@tsplc/agent": "0.2.11",
94
+ "@tsplc/build": "0.2.11",
95
+ "@tsplc/config": "0.2.11",
96
+ "@tsplc/typegen": "0.2.11",
97
97
  "typescript": "6.0.3",
98
98
  "zod": "4.3.6",
99
99
  "@types/bun": "1.4.2",
100
- "@tsplc/server": "0.2.9"
100
+ "@tsplc/server": "0.2.11"
101
101
  },
102
102
  "bin": {
103
103
  "tsplc": "./dist/cli.js"
package/project/plant.ts CHANGED
@@ -14,7 +14,7 @@ import { hasErrors } from "@tsplc/compiler/ir";
14
14
  import { generateProject } from "@tsplc/typegen";
15
15
  import { z } from "zod";
16
16
  import { buildProject, projectTargets } from "./project.ts";
17
- import { buildServer } from "./server.ts";
17
+ import { bundleServer } from "./server.ts";
18
18
 
19
19
  export const plantCommands = new Set([
20
20
  "deploy",
@@ -107,6 +107,15 @@ const askCredentials = async (): Promise<Credentials> => ({
107
107
 
108
108
  const progress = (message: string) => console.log(` ${message}`);
109
109
 
110
+ /** Prints progress with the minutes and seconds since it was made, so the slow steps show. */
111
+ const timedProgress = () => {
112
+ const start = Date.now();
113
+ return (message: string) => {
114
+ const seconds = Math.round((Date.now() - start) / 1000);
115
+ progress(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} ${message}`);
116
+ };
117
+ };
118
+
110
119
  const connect = () => {
111
120
  const environment = z
112
121
  .object({
@@ -168,7 +177,7 @@ const contractVariables = async (config: LoadedConfig["config"], root: string) =
168
177
  });
169
178
  };
170
179
 
171
- /** Compiles the production program and its server into the files `tsplc deploy` uploads. */
180
+ /** Compiles the production program and bundles its server into the files `tsplc deploy` uploads. */
172
181
  const prepareRelease = async (file: string, loaded: LoadedConfig, flags: PlantFlags) => {
173
182
  const { config, root } = loaded;
174
183
  if (!config.compiler) throw new Error("tsplc deploy deploys tsPLC programs; configure compiler.");
@@ -194,17 +203,17 @@ const prepareRelease = async (file: string, loaded: LoadedConfig, flags: PlantFl
194
203
  { path: "template.project", content: Bun.file(settings.template) },
195
204
  ...libraries.map((library) => ({ path: library.release, content: Bun.file(library.path) })),
196
205
  ];
197
- // Plain TypeScript programs emit their generated modules under `.tsplc/`.
198
- for await (const source of new Bun.Glob("**/*").scan({ cwd: settings.sourceDir, dot: true }))
206
+ // The agent compiles only the ST. Plain TypeScript programs emit their generated modules under `.tsplc/`.
207
+ for await (const source of new Bun.Glob("**/*.st").scan({ cwd: settings.sourceDir, dot: true }))
199
208
  files.push({
200
209
  path: `st/${portable(source)}`,
201
210
  content: Bun.file(resolve(settings.sourceDir, source)),
202
211
  });
203
212
  let server: string | undefined;
204
213
  if (config.server?.runtime) {
205
- const { executable } = (await buildServer(config, root)).server;
206
- server = `server/${basename(executable)}`;
207
- files.push({ path: server, content: Bun.file(executable) });
214
+ const script = await bundleServer(config, root);
215
+ server = `server/${basename(script)}`;
216
+ files.push({ path: server, content: Bun.file(script) });
208
217
  }
209
218
  const manifest: ReleaseManifest = {
210
219
  // Paths are relative to the release; the agent supplies the output and controller settings.
@@ -239,8 +248,10 @@ const deploy = async (
239
248
  if (status.login) throw new Error("A login is open; log out before deploying: tsplc logout");
240
249
  const target = !status.target || flags.select ? await selectTarget(agent) : status.target;
241
250
  console.log(`Building the release for ${target.host}…`);
251
+ const building = timedProgress();
242
252
  const { release, diagnostics } = await prepareRelease(file, loaded, flags);
243
253
  if (!release) return { diagnostics };
254
+ building("Built");
244
255
  if (
245
256
  flags.download &&
246
257
  !(await confirm(
@@ -258,8 +269,10 @@ const deploy = async (
258
269
  console.log(
259
270
  release.manifest.server ? "Deploying the server and the program…" : "Deploying the program…",
260
271
  );
272
+ const deploying = timedProgress();
273
+ deploying("Uploading");
261
274
  try {
262
- return await agent.deploy(release, settings, progress);
275
+ return await agent.deploy(release, settings, deploying);
263
276
  } catch (error) {
264
277
  if (
265
278
  !(error instanceof AgentOperationError && error.downloadRequired) ||
@@ -269,7 +282,7 @@ const deploy = async (
269
282
  ))
270
283
  )
271
284
  throw error;
272
- return agent.deploy(release, { ...settings, download: true }, progress);
285
+ return agent.deploy(release, { ...settings, download: true }, timedProgress());
273
286
  }
274
287
  };
275
288
 
package/project/server.ts CHANGED
@@ -18,20 +18,11 @@ const kebabCase = (name: string) =>
18
18
  .replace(/^-|-$/g, "")
19
19
  .toLowerCase();
20
20
 
21
- /** Compiles the host entry, which starts its own listener, into `build/server/<name>-services`. */
22
- export const buildServer = async (config: ResolvedTsplcConfig, root: string) => {
23
- const settings = config.server;
24
- if (!settings?.runtime) throw new Error("Configure server.runtime to build an executable.");
25
- const runtime = settings.runtime;
21
+ /** The host entry, type-checked as it will be built. */
22
+ const checkedServerEntry = (config: ResolvedTsplcConfig, root: string) => {
23
+ const runtime = config.server?.runtime;
24
+ if (!runtime) throw new Error("Configure server.runtime to build the server.");
26
25
  const entry = resolveServerModule(root, runtime.entry);
27
- const windows = runtime.target
28
- ? runtime.target.includes("windows")
29
- : process.platform === "win32";
30
- const outFile = resolve(
31
- root,
32
- "build/server",
33
- `${kebabCase(config.name)}-services${windows ? ".exe" : ""}`,
34
- );
35
26
  const tsconfig = ts.findConfigFile(dirname(entry), ts.sys.fileExists);
36
27
  if (!tsconfig) throw new Error("Server entry requires a tsconfig.json.");
37
28
  const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
@@ -51,6 +42,45 @@ export const buildServer = async (config: ResolvedTsplcConfig, root: string) =>
51
42
  getNewLine: () => "\n",
52
43
  }),
53
44
  );
45
+ return { entry, runtime };
46
+ };
47
+
48
+ /**
49
+ * Bundles the host entry, which starts its own listener, into one minified script,
50
+ * `build/server/<name>-services.js`, which the plant agent runs on its own Bun. It is a few MB
51
+ * where an executable, which embeds Bun, is over 100, and deployments upload it.
52
+ */
53
+ export const bundleServer = async (config: ResolvedTsplcConfig, root: string) => {
54
+ const { entry } = checkedServerEntry(config, root);
55
+ const outFile = resolve(root, "build/server", `${kebabCase(config.name)}-services.js`);
56
+ const result = await Bun.build({
57
+ entrypoints: [entry],
58
+ target: "bun",
59
+ minify: true,
60
+ env: "disable",
61
+ });
62
+ if (!result.success) throw new AggregateError(result.logs, "Server bundle build failed.");
63
+ const [script, ...assets] = result.outputs;
64
+ if (!script || assets.length)
65
+ throw new Error(
66
+ `The server bundle needs files beside its script (${assets.map(({ path }) => path).join(", ")}), which deployments don't carry.`,
67
+ );
68
+ await mkdir(dirname(outFile), { recursive: true });
69
+ await Bun.write(outFile, script);
70
+ return outFile;
71
+ };
72
+
73
+ /** Compiles the host entry, which starts its own listener, into `build/server/<name>-services`. */
74
+ export const buildServer = async (config: ResolvedTsplcConfig, root: string) => {
75
+ const { entry, runtime } = checkedServerEntry(config, root);
76
+ const windows = runtime.target
77
+ ? runtime.target.includes("windows")
78
+ : process.platform === "win32";
79
+ const outFile = resolve(
80
+ root,
81
+ "build/server",
82
+ `${kebabCase(config.name)}-services${windows ? ".exe" : ""}`,
83
+ );
54
84
  await mkdir(dirname(outFile), { recursive: true });
55
85
  // Compile beside the output so dependency resolution starts from the owning project.
56
86
  const staging = await mkdtemp(join(dirname(outFile), ".tsplc-server-"));
@@ -932,17 +932,22 @@ protected execute(input:{}):void {}
932
932
  const milliseconds =
933
933
  Number(interval.arguments[0]!.text) *
934
934
  (official(interval.expression, "seconds") ? 1000 : 1);
935
+ const taskPriority = Number(priority.text);
935
936
  if (
936
937
  !Number.isSafeInteger(milliseconds) ||
937
938
  milliseconds <= 0 ||
938
939
  milliseconds > 4294967295 ||
939
- Number(priority.text) < 1 ||
940
- Number(priority.text) > 31
940
+ !Number.isInteger(taskPriority) ||
941
+ taskPriority < 1 ||
942
+ taskPriority > 31
941
943
  )
942
- fail(init, "Background interval must be positive and priority must be 1..31.");
944
+ fail(
945
+ init,
946
+ "Background interval must be positive and priority must be an integer in 1..31.",
947
+ );
943
948
  const schedule = {
944
949
  intervalNanoseconds: String(BigInt(milliseconds) * 1000000n),
945
- priority: Number(priority.text),
950
+ priority: taskPriority,
946
951
  mailbox: dec.name.text,
947
952
  worker,
948
953
  };
@@ -1,7 +1,7 @@
1
1
  import { runtimeSources } from "./module-graph.ts";
2
2
  import ts from "typescript";
3
3
  import { createRequire } from "node:module";
4
- import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, relative, resolve } from "node:path";
6
6
  import {
7
7
  instancedBlock,
@@ -202,6 +202,24 @@ export const typeCheckOnly: ts.CompilerOptions = {
202
202
  tsBuildInfoFile: undefined,
203
203
  };
204
204
 
205
+ const sdkFor = (project: string, options: ts.CompilerOptions): string => {
206
+ const name = "@tsplc/compiler/globals";
207
+ const resolveSdk = (from: string) => createRequire(from).resolve(name).replaceAll("\\", "/");
208
+ try {
209
+ const installed = resolveSdk(project);
210
+ const resolved = ts.resolveModuleName(name, project, options, ts.sys).resolvedModule;
211
+ // TypeScript may preserve a workspace symlink. A path alias may only stand in
212
+ // for the SDK if it resolves to the same installed declaration.
213
+ if (resolved && realpathSync(resolved.resolvedFileName) === realpathSync(installed))
214
+ return resolved.resolvedFileName;
215
+ return installed;
216
+ } catch (cause) {
217
+ if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "MODULE_NOT_FOUND")
218
+ throw cause;
219
+ return resolveSdk(import.meta.url);
220
+ }
221
+ };
222
+
205
223
  export function analyze(options: FrontendOptions): FrontendResult {
206
224
  const filename = resolve(options.tsconfig);
207
225
  const config = ts.readConfigFile(filename, ts.sys.readFile);
@@ -214,11 +232,6 @@ export function analyze(options: FrontendOptions): FrontendResult {
214
232
  filename,
215
233
  );
216
234
  if (parsed.errors.length) return { diagnostics: parsed.errors.map(tsDiagnostic) };
217
- // TypeScript stores SourceFile paths with forward slashes on every host.
218
- // Normalize require.resolve once so canonical SDK identity also works on Windows.
219
- const sdk = createRequire(import.meta.url)
220
- .resolve("@tsplc/compiler/globals")
221
- .replaceAll("\\", "/");
222
235
  const programFiles = Object.values(options.programs).map((entry) =>
223
236
  resolve(dirname(filename), entry.module),
224
237
  );
@@ -229,7 +242,6 @@ export function analyze(options: FrontendOptions): FrontendResult {
229
242
  (file) => /\.d\.[cm]?ts$/.test(file) && file.startsWith(`${dirname(filename)}/`),
230
243
  );
231
244
  const projectFiles = scope === "project" ? parsed.fileNames : ambient;
232
- const rootNames = [...new Set([...projectFiles, ...programFiles, sdk])];
233
245
  const compilerOptions: ts.CompilerOptions = {
234
246
  ...parsed.options,
235
247
  ...typeCheckOnly,
@@ -251,6 +263,8 @@ export function analyze(options: FrontendOptions): FrontendResult {
251
263
  // Nothing is emitted, and a service client's router type imports modules outside these (TS6059).
252
264
  delete compilerOptions.rootDir;
253
265
  delete compilerOptions.outDir;
266
+ const sdk = sdkFor(filename, compilerOptions);
267
+ const rootNames = [...new Set([...projectFiles, ...programFiles, sdk])];
254
268
  let program = ts.createProgram(rootNames, compilerOptions);
255
269
  const plcFiles = (program: ts.Program) => {
256
270
  const files = new Set(runtimeSources(program));
@@ -24,6 +24,12 @@ declare global {
24
24
  const RisingEdge: typeof import("./index").RisingEdge;
25
25
  type FallingEdge = import("./index").FallingEdge;
26
26
  const FallingEdge: typeof import("./index").FallingEdge;
27
+ type UpCounter = import("./index").UpCounter;
28
+ const UpCounter: typeof import("./index").UpCounter;
29
+ type DownCounter = import("./index").DownCounter;
30
+ const DownCounter: typeof import("./index").DownCounter;
31
+ type UpDownCounter = import("./index").UpDownCounter;
32
+ const UpDownCounter: typeof import("./index").UpDownCounter;
27
33
  const ref: typeof import("./index").ref;
28
34
  const retain: typeof import("./index").retain;
29
35
  const ms: typeof import("./index").ms;
package/types/index.d.ts CHANGED
@@ -68,7 +68,7 @@ export declare abstract class FunctionBlock<I, O = Record<never, never>> {
68
68
  protected abstract execute(input: I): keyof O extends never ? void : O;
69
69
  }
70
70
 
71
- /** Alternates `on` while enabled: off for `offTime`, then on for `onTime`. Keeps its last `on` when disabled. CODESYS Util.BLINK. */
71
+ /** Starts on, then alternates `onTime` and `offTime`. Keeps its last `on` when disabled. CODESYS Util.BLINK. */
72
72
  export declare class Blinker extends FunctionBlock<
73
73
  { enabled: BOOL; offTime: TIME; onTime: TIME },
74
74
  { on: BOOL }
@@ -109,3 +109,39 @@ export declare class RisingEdge extends FunctionBlock<{ signal: BOOL }, { rose:
109
109
  export declare class FallingEdge extends FunctionBlock<{ signal: BOOL }, { fell: BOOL }> {
110
110
  protected execute(input: { signal: BOOL }): { fell: BOOL };
111
111
  }
112
+
113
+ /** Counts rising `countUp` edges. `reset` clears `value`. `reachedPreset` indicates `value >= preset`. CODESYS CTU. */
114
+ export declare class UpCounter extends FunctionBlock<
115
+ { countUp: BOOL; reset: BOOL; preset: WORD },
116
+ { reachedPreset: BOOL; value: WORD }
117
+ > {
118
+ protected execute(input: { countUp: BOOL; reset: BOOL; preset: WORD }): {
119
+ reachedPreset: BOOL;
120
+ value: WORD;
121
+ };
122
+ }
123
+
124
+ /** Counts down on rising `countDown` edges. `load` sets `value` to `preset`. `atZero` indicates `value = 0`. CODESYS CTD. */
125
+ export declare class DownCounter extends FunctionBlock<
126
+ { countDown: BOOL; load: BOOL; preset: WORD },
127
+ { atZero: BOOL; value: WORD }
128
+ > {
129
+ protected execute(input: { countDown: BOOL; load: BOOL; preset: WORD }): {
130
+ atZero: BOOL;
131
+ value: WORD;
132
+ };
133
+ }
134
+
135
+ /** Counts rising edges in either direction. `reset` clears `value`, and `load` sets `value` to `preset`. CODESYS CTUD. */
136
+ export declare class UpDownCounter extends FunctionBlock<
137
+ { countUp: BOOL; countDown: BOOL; reset: BOOL; load: BOOL; preset: WORD },
138
+ { reachedPreset: BOOL; atZero: BOOL; value: WORD }
139
+ > {
140
+ protected execute(input: {
141
+ countUp: BOOL;
142
+ countDown: BOOL;
143
+ reset: BOOL;
144
+ load: BOOL;
145
+ preset: WORD;
146
+ }): { reachedPreset: BOOL; atZero: BOOL; value: WORD };
147
+ }