@tsplc/compiler 0.0.0-stage → 0.2.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -2
  3. package/backends/codesys/config.ts +54 -0
  4. package/backends/codesys/emit.ts +1063 -0
  5. package/backends/codesys/engineering.ts +88 -0
  6. package/backends/codesys/http.ts +598 -0
  7. package/backends/codesys/index.ts +12 -0
  8. package/backends/codesys/io-paths.ts +103 -0
  9. package/backends/codesys/names.ts +76 -0
  10. package/backends/codesys/numeric.ts +356 -0
  11. package/backends/codesys/plan.ts +512 -0
  12. package/backends/codesys/publication.ts +59 -0
  13. package/backends/codesys/storage.ts +63 -0
  14. package/backends/codesys/task-access.ts +118 -0
  15. package/backends/codesys/types.ts +178 -0
  16. package/backends/codesys/vendor.ts +108 -0
  17. package/dist/cli.js +1396 -0
  18. package/dist/cli.js.map +16 -0
  19. package/dist/clock.js +8 -0
  20. package/dist/clock.js.map +10 -0
  21. package/dist/codesys.js +2587 -0
  22. package/dist/codesys.js.map +22 -0
  23. package/dist/elaborate-capture.js +118 -0
  24. package/dist/elaborate-capture.js.map +10 -0
  25. package/dist/index.js +6859 -0
  26. package/dist/index.js.map +25 -0
  27. package/dist/ir.js +1888 -0
  28. package/dist/ir.js.map +22 -0
  29. package/dist/math.js +24 -0
  30. package/dist/math.js.map +10 -0
  31. package/dist/project.js +429 -0
  32. package/dist/project.js.map +11 -0
  33. package/ir/aggregates.ts +333 -0
  34. package/ir/allocation.ts +74 -0
  35. package/ir/core-model.ts +116 -0
  36. package/ir/core.ts +63 -0
  37. package/ir/declarations.ts +201 -0
  38. package/ir/diagnostics.ts +43 -0
  39. package/ir/domains.ts +96 -0
  40. package/ir/index.ts +7 -0
  41. package/ir/link.ts +176 -0
  42. package/ir/model.ts +268 -0
  43. package/ir/normalization-error.ts +10 -0
  44. package/ir/routine.ts +826 -0
  45. package/ir/scalars.ts +675 -0
  46. package/ir/types.ts +70 -0
  47. package/package.json +106 -4
  48. package/project/artifacts.ts +277 -0
  49. package/project/cli.ts +305 -0
  50. package/project/index.ts +1 -0
  51. package/project/plant.ts +437 -0
  52. package/project/project.ts +312 -0
  53. package/project/server.ts +83 -0
  54. package/src/frontend/async.ts +1060 -0
  55. package/src/frontend/blocks.ts +419 -0
  56. package/src/frontend/configuration.ts +167 -0
  57. package/src/frontend/const-functions.ts +153 -0
  58. package/src/frontend/cooperative.ts +75 -0
  59. package/src/frontend/elaborate/capture.ts +138 -0
  60. package/src/frontend/elaborate/graph.ts +94 -0
  61. package/src/frontend/elaborate/index.ts +302 -0
  62. package/src/frontend/elaborate/layouts.ts +390 -0
  63. package/src/frontend/elaborate/model.ts +177 -0
  64. package/src/frontend/elaborate/specialize.ts +2280 -0
  65. package/src/frontend/exposure.ts +147 -0
  66. package/src/frontend/module-graph.ts +63 -0
  67. package/src/frontend/promises.ts +751 -0
  68. package/src/frontend/services.ts +435 -0
  69. package/src/frontend/types.ts +578 -0
  70. package/src/frontend/typescript.ts +2442 -0
  71. package/src/index.ts +7 -0
  72. package/stdlib/clock.ts +2 -0
  73. package/stdlib/math.ts +19 -0
  74. package/types/async.d.ts +81 -0
  75. package/types/clock.d.ts +4 -0
  76. package/types/config.d.ts +2 -0
  77. package/types/data-types.d.ts +31 -0
  78. package/types/globals.d.ts +40 -0
  79. package/types/index.d.ts +111 -0
  80. package/types/plc.d.ts +5 -0
@@ -0,0 +1,88 @@
1
+ import { resolve } from "node:path";
2
+ import type { EmitResult } from "@tsplc/compiler/ir";
3
+ import { buildProject, configSchema, type InstalledLibrary, resolveConfig } from "@tsplc/build";
4
+ import type { Controller, DeviceTree } from "@tsplc/config";
5
+ import { z } from "zod";
6
+ import type { CodesysPlan } from "./plan.ts";
7
+
8
+ export const engineeringBuildSchema = configSchema.omit({
9
+ sourceDir: true,
10
+ entry: true,
11
+ outDir: true,
12
+ task: true,
13
+ tasks: true,
14
+ });
15
+ export const engineeringSchema = z.strictObject({
16
+ kind: z.literal("v3.5"),
17
+ build: engineeringBuildSchema.optional(),
18
+ });
19
+ export type CodesysEngineeringBuild = z.input<typeof engineeringBuildSchema>;
20
+
21
+ export function codesysBuildConfig(
22
+ plan: CodesysPlan,
23
+ bundle: Pick<EmitResult, "objects">,
24
+ paths: {
25
+ configFile: string;
26
+ sourceDir: string;
27
+ outDir: string;
28
+ libraries?: InstalledLibrary[];
29
+ deviceTree?: DeviceTree;
30
+ controller?: Controller;
31
+ },
32
+ ) {
33
+ const settings = plan.options.engineering.build;
34
+ if (!settings)
35
+ throw new Error(
36
+ "CODESYS build requires engineering.build settings (codesys installation and template). Configure them or use --emit-only.",
37
+ );
38
+ const programs = new Map(
39
+ plan.core.programs.map((program) => {
40
+ const matches = bundle.objects.filter(
41
+ (object) => object.id === program.id && object.kind === "program",
42
+ );
43
+ const generated = matches[0];
44
+ if (matches.length !== 1 || !generated)
45
+ throw new Error(`Generated bundle must contain one PROGRAM object for ${program.name}.`);
46
+ return [program.name, generated] as const;
47
+ }),
48
+ );
49
+ const tasks = plan.deployment.tasks.map((task) => ({
50
+ ...task,
51
+ programs: task.programs.map((name) => {
52
+ const program = programs.get(name);
53
+ if (!program)
54
+ throw new Error(`Task ${task.name} references a missing generated PROGRAM ${name}.`);
55
+ return program.name;
56
+ }),
57
+ }));
58
+ const first = plan.core.programs[0],
59
+ entry = first && programs.get(first.name);
60
+ if (!entry) throw new Error("CODESYS build requires a generated PROGRAM entry.");
61
+ const sourceDir = resolve(paths.sourceDir);
62
+ return resolveConfig(
63
+ {
64
+ ...settings,
65
+ libraryReferences: [
66
+ ...(settings.libraryReferences ?? []),
67
+ ...(Object.values(plan.http).some((endpoint) => endpoint.transport !== "opcua")
68
+ ? ["Net Base Services, 4.0.0.0 (CODESYS)"]
69
+ : []),
70
+ ...(Object.values(plan.http).some((endpoint) => endpoint.tls)
71
+ ? ["CmpX509Cert Implementation, 3.5.22.0 (System)"]
72
+ : []),
73
+ ],
74
+ ...(paths.libraries ? { libraries: paths.libraries } : {}),
75
+ ...(paths.deviceTree ? { deviceTree: paths.deviceTree } : {}),
76
+ ...(paths.controller ? { controller: paths.controller } : {}),
77
+ sourceDir,
78
+ entry: resolve(sourceDir, entry.sourcePath ?? `${entry.name}/declaration.st`),
79
+ outDir: resolve(paths.outDir),
80
+ tasks,
81
+ },
82
+ paths.configFile,
83
+ );
84
+ }
85
+
86
+ export async function buildCodesys(...arguments_: Parameters<typeof codesysBuildConfig>) {
87
+ return buildProject(codesysBuildConfig(...arguments_));
88
+ }
@@ -0,0 +1,598 @@
1
+ import type { CoreFunctionBlock, GeneratedObject, PlcType } from "@tsplc/compiler/ir";
2
+ import type { HttpOptions } from "@tsplc/config";
3
+ import type { CodesysTypes } from "./types.ts";
4
+
5
+ export const opcuaServiceCapacity = 16;
6
+
7
+ export interface HttpEndpoint {
8
+ transport?: "http" | "opcua";
9
+ host: string;
10
+ port: number;
11
+ authority: string;
12
+ path: string;
13
+ token: string;
14
+ timeoutMs: number;
15
+ tls: boolean;
16
+ }
17
+ export const resolveHttpEndpoint = (value: string, input: HttpOptions): HttpEndpoint => {
18
+ const url = new URL(value);
19
+ if (
20
+ !["http:", "https:"].includes(url.protocol) ||
21
+ url.username ||
22
+ url.password ||
23
+ url.hash ||
24
+ url.hostname.includes(":")
25
+ )
26
+ throw new Error(
27
+ "fetch requires an HTTP(S) URL with an IPv4 address or DNS hostname, without credentials or fragment.",
28
+ );
29
+ if (
30
+ (url.pathname + url.search).length > 2048 ||
31
+ url.hostname.length > 253 ||
32
+ Number(url.port || (url.protocol === "https:" ? 443 : 80)) === 0
33
+ )
34
+ throw new Error("HTTP URL exceeds the native host/path limits or has an invalid port.");
35
+ let token = "";
36
+ const origins = new Set<string>();
37
+ for (const credential of input.credentials) {
38
+ const configured = process.env[credential.originEnv],
39
+ secret = process.env[credential.bearerTokenEnv];
40
+ if (!configured || !secret)
41
+ throw new Error(
42
+ `HTTP credentials require ${credential.originEnv} and ${credential.bearerTokenEnv}.`,
43
+ );
44
+ const origin = new URL(configured);
45
+ if (
46
+ !["http:", "https:"].includes(origin.protocol) ||
47
+ origin.username ||
48
+ origin.password ||
49
+ origin.pathname !== "/" ||
50
+ origin.search ||
51
+ origin.hash
52
+ )
53
+ throw new Error(
54
+ `HTTP credential origin ${credential.originEnv} must contain only a scheme, host and port.`,
55
+ );
56
+ if (origins.has(origin.origin)) throw new Error("HTTP credential origins must be unique.");
57
+ origins.add(origin.origin);
58
+ if (/[\r\n\0]/.test(secret) || secret.length > 512)
59
+ throw new Error(`Invalid bearer token in ${credential.bearerTokenEnv}.`);
60
+ if (origin.origin === url.origin) token = secret;
61
+ }
62
+ return {
63
+ host: url.hostname,
64
+ port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
65
+ authority: url.host,
66
+ path: url.pathname + url.search,
67
+ token,
68
+ timeoutMs: input.timeoutMs,
69
+ tls: url.protocol === "https:",
70
+ };
71
+ };
72
+ const quote = (text: string) =>
73
+ "'" +
74
+ text.replaceAll("$", "$$").replaceAll("'", "$'").replaceAll("\r", "$R").replaceAll("\n", "$N") +
75
+ "'";
76
+
77
+ const httpLayouts = (block: CoreFunctionBlock, types: ReadonlyMap<string, PlcType>) => {
78
+ const request = block.inputs.find((v) => v.name === "request"),
79
+ response = block.outputs.find((v) => v.name === "result");
80
+ if (!request || !response) throw new Error("Missing HTTP record ports.");
81
+ const requestLayout = types.get(request.type),
82
+ responseLayout = types.get(response.type);
83
+ if (
84
+ requestLayout?.kind !== "record" ||
85
+ responseLayout?.kind !== "record" ||
86
+ responseLayout.fields.length === 0 ||
87
+ responseLayout.fields.length > 31
88
+ )
89
+ throw new Error("HTTP requires closed request/response records with 1..31 response fields.");
90
+ const scalarSize = (id: string): number => {
91
+ const type = types.get(id);
92
+ if (type?.kind === "string" && type.encoding === "utf8" && type.capacity <= 2048)
93
+ return type.capacity * 6 + 2;
94
+ if (type?.kind === "bool") return 5;
95
+ if (type?.kind === "enum")
96
+ return Math.max(...type.members.map((member) => member.value.length));
97
+ if ((type?.kind === "integer" || type?.kind === "bits") && type.bits <= 32) {
98
+ const signed = type.kind === "integer" && type.signed;
99
+ return String(signed ? -(2 ** (type.bits - 1)) : 2 ** type.bits - 1).length;
100
+ }
101
+ throw new Error(
102
+ "HTTP JSON supports bounded UTF-8 strings, BOOL, enums, and integers up to 32 bits.",
103
+ );
104
+ };
105
+ const recordSize = (record: typeof requestLayout, response: boolean) =>
106
+ 2 +
107
+ record.fields.reduce((size, field) => {
108
+ const type = types.get(field.type);
109
+ const valueSize =
110
+ type?.kind === "array" && !response
111
+ ? 2 + type.length * (scalarSize(type.element) + 1)
112
+ : scalarSize(field.type);
113
+ return size + field.name.length + 4 + valueSize;
114
+ }, 0);
115
+ if (recordSize(requestLayout, false) > 2048 || recordSize(responseLayout, true) > 2048)
116
+ throw new Error(
117
+ "The worst-case HTTP JSON payload exceeds the native 2048-byte buffer; reduce record/string/array bounds.",
118
+ );
119
+ return { request, response, requestLayout, responseLayout };
120
+ };
121
+ export const validateHttpLayout = (
122
+ block: CoreFunctionBlock,
123
+ types: ReadonlyMap<string, PlcType>,
124
+ ): void => {
125
+ httpLayouts(block, types);
126
+ };
127
+
128
+ /** Native transport and typed JSON codecs; no application protocol lives here. */
129
+ export const emitHttp = (
130
+ block: CoreFunctionBlock,
131
+ name: string,
132
+ fields: ReadonlyMap<string, string>,
133
+ types: ReadonlyMap<string, PlcType>,
134
+ target: CodesysTypes,
135
+ endpoint: HttpEndpoint,
136
+ network: string,
137
+ rpcGlobal?: string,
138
+ ): GeneratedObject => {
139
+ const port = (label: string) => {
140
+ const variable = [...block.inputs, ...block.outputs].find((v) => v.name === label);
141
+ if (!variable) throw new Error("Missing HTTP port " + label);
142
+ return fields.get(variable.id)!;
143
+ };
144
+ const { request, response, requestLayout, responseLayout } = httpLayouts(block, types);
145
+ const encode = (typeId: string, value: string): string => {
146
+ const type = types.get(typeId)!;
147
+ if (type.kind === "array")
148
+ return Array.from(
149
+ { length: type.length },
150
+ (_, i) =>
151
+ `${i ? "Append(',');" : ""}${encode(type.element, `${value}[${i + type.lowerBound}]`)}`,
152
+ ).join("\n");
153
+ if (type.kind === "string" && type.encoding === "utf8" && type.capacity <= 2048)
154
+ return `WriteString(${value});`;
155
+ if (type.kind === "bool")
156
+ return `IF ${value} THEN Append('true'); ELSE Append('false'); END_IF;`;
157
+ if (type.kind === "enum") return `Append(DINT_TO_STRING(TO_DINT(${value})));`;
158
+ if (type.kind === "bits" && type.bits <= 32)
159
+ return `Append(UDINT_TO_STRING(TO_UDINT(${value})));`;
160
+ if (type.kind === "integer" && type.bits <= 32)
161
+ return `Append(${target.name(typeId)}_TO_STRING(${value}));`;
162
+ throw new Error(
163
+ "HTTP JSON supports bounded UTF-8 strings, BOOL, enums, integers up to 32 bits and fixed arrays.",
164
+ );
165
+ };
166
+ const requestCode = requestLayout.fields
167
+ .map((field, i) => {
168
+ const type = types.get(field.type)!,
169
+ value = `${port("request")}.${target.field(request.type, field.name)}`;
170
+ return `Append(${quote((i ? "," : "") + '"' + field.name + '":')});${type.kind === "array" ? "Append('[');" : ""}${encode(field.type, value)}${type.kind === "array" ? "Append(']');" : ""}`;
171
+ })
172
+ .join("\n");
173
+ const responseCode = responseLayout.fields
174
+ .map((field, i) => {
175
+ const type = types.get(field.type)!,
176
+ value = `${port("result")}.${target.field(response.type, field.name)}`;
177
+ let decode: string;
178
+ if (type.kind === "string" && type.encoding === "utf8" && type.capacity <= 2048)
179
+ decode = `ReadString(); IF ByteLength(token) > ${type.capacity} THEN malformed := TRUE; ELSE ${value} := token; END_IF;`;
180
+ else if (type.kind === "bool")
181
+ decode = `IF MatchLiteral('true') THEN ${value}:=TRUE; ELSIF MatchLiteral('false') THEN ${value}:=FALSE; ELSE malformed:=TRUE;END_IF;`;
182
+ else if (
183
+ ((type.kind === "integer" || type.kind === "bits") && type.bits <= 32) ||
184
+ type.kind === "enum"
185
+ ) {
186
+ const signed = type.kind === "enum" || (type.kind === "integer" && type.signed),
187
+ bits = type.kind === "enum" ? 32 : type.bits;
188
+ const min = signed ? -(2 ** (bits - 1)) : 0,
189
+ max = signed ? 2 ** (bits - 1) - 1 : 2 ** bits - 1;
190
+ decode = `ReadInteger(); IF numberValue < ${min} OR numberValue > ${max} THEN malformed:=TRUE; ELSE `;
191
+ if (type.kind === "enum")
192
+ decode += `CASE numberValue OF ${type.members.map((m) => `${m.value}: ${value}:=${target.name(field.type)}.${target.field(field.type, m.name)};`).join(" ")} ELSE malformed:=TRUE; END_CASE;`;
193
+ else decode += `${value}:=TO_${target.name(field.type)}(numberValue);`;
194
+ decode += " END_IF;";
195
+ } else throw new Error("HTTP responses currently require flat scalar records.");
196
+ return `${i === 0 ? "IF" : "ELSIF"} part = ${quote(field.name)} THEN IF (seen AND SHL(DWORD#1,${i})) <> 0 THEN malformed:=TRUE; END_IF; seen:=seen OR SHL(DWORD#1,${i}); ${decode}`;
197
+ })
198
+ .join("\n");
199
+ const inputs = block.inputs
200
+ .map((v) => `${fields.get(v.id)} : ${target.name(v.type)};`)
201
+ .join("\n");
202
+ const outputs = block.outputs
203
+ .map((v) => `${fields.get(v.id)} : ${target.name(v.type)};`)
204
+ .join("\n");
205
+ const decodeResponse = `cursor:=1;malformed:=FALSE;seen:=0;
206
+ SkipWhitespace();Expect(123);SkipWhitespace();
207
+ WHILE cursor<=TO_UINT(ByteLength(payload)) AND NOT malformed DO
208
+ IF payload[cursor-1]=125 THEN cursor:=cursor+1;EXIT;END_IF;
209
+ ReadString();part:=token;SkipWhitespace();Expect(58);SkipWhitespace();
210
+ ${responseCode} ELSE malformed:=TRUE;END_IF;
211
+ SkipWhitespace();
212
+ IF payload[cursor-1]=44 THEN cursor:=cursor+1;SkipWhitespace();IF payload[cursor-1]=125 THEN malformed:=TRUE;END_IF;
213
+ ELSIF payload[cursor-1]<>125 THEN malformed:=TRUE;END_IF;
214
+ END_WHILE;
215
+ SkipWhitespace();
216
+ IF malformed OR cursor<>TO_UINT(ByteLength(payload))+1 OR seen<>DWORD#${2 ** responseLayout.fields.length - 1} THEN ${port("error")}:=4;END_IF;`;
217
+ if (endpoint.transport === "opcua") {
218
+ if (!rpcGlobal) throw new Error("Missing OPC UA RPC global.");
219
+ const channel = (field: string, index = "slotIndex") => `${rpcGlobal}.${field}[${index}]`;
220
+ return {
221
+ id: block.id,
222
+ kind: "functionBlock",
223
+ name,
224
+ declaration: `FUNCTION_BLOCK ${name}
225
+ VAR_INPUT ${inputs} END_VAR
226
+ VAR_OUTPUT ${outputs} END_VAR
227
+ VAR
228
+ timeout:TON;stage:UINT;slotIndex:UINT;candidate:UINT;
229
+ identity:STRING(64);epoch:STRING(32);prefix:STRING(80);
230
+ payload:STRING(2048);token:STRING(2048);part:STRING(2048);
231
+ cursor:UINT;malformed:BOOL;seen:DWORD;numberValue:LINT;tokenUsed:UINT;
232
+ END_VAR`,
233
+ implementation: `IF NOT ${port("start")} OR ${port("cancel")} THEN
234
+ IF slotIndex>0 THEN ${channel("requestIds")}:='';${channel("requests")}:='';${channel("inUse")}:=0;END_IF;
235
+ slotIndex:=0;stage:=0;timeout(IN:=FALSE);${port("busy")}:=FALSE;${port("done")}:=FALSE;${port("error")}:=0;RETURN;
236
+ END_IF;
237
+ IF stage=0 THEN
238
+ FOR candidate:=1 TO ${opcuaServiceCapacity} DO
239
+ (* __COMPARE_AND_SWAP can fail spuriously on ARM64; retry while the slot reads free. *)
240
+ WHILE slotIndex=0 AND ${channel("epochs", "candidate")}<>'' AND ${channel("inUse", "candidate")}=0 DO
241
+ IF __COMPARE_AND_SWAP(ADR(${channel("inUse", "candidate")}),0,1) THEN slotIndex:=candidate;END_IF;
242
+ END_WHILE;
243
+ IF slotIndex>0 THEN EXIT;END_IF;
244
+ END_FOR;
245
+ IF slotIndex=0 THEN ${port("error")}:=7;stage:=90;
246
+ ELSE
247
+ ${channel("sequence")}:=${channel("sequence")}+1;
248
+ IF ${channel("sequence")}=0 THEN ${channel("epochs")}:='';${port("error")}:=5;stage:=90;
249
+ ELSE
250
+ epoch:=${channel("epochs")};identity:=CONCAT(CONCAT(epoch,':'),UDINT_TO_STRING(${channel("sequence")}));
251
+ prefix:=CONCAT(identity,'|');payload:='';malformed:=FALSE;
252
+ Append('{');${requestCode}Append('}');
253
+ IF malformed THEN ${port("error")}:=3;stage:=90;
254
+ ELSIF ByteLength(payload)+LEN(prefix)>2048 THEN ${port("error")}:=3;stage:=90;
255
+ ELSE
256
+ FOR candidate:=0 TO LEN(prefix)-1 DO ${channel("requests")}[candidate]:=prefix[candidate];END_FOR;
257
+ FOR candidate:=0 TO ByteLength(payload)-1 DO ${channel("requests")}[candidate+LEN(prefix)]:=payload[candidate];END_FOR;
258
+ ${channel("requests")}[ByteLength(payload)+LEN(prefix)]:=0;${channel("requestIds")}:=identity;stage:=1;
259
+ END_IF;
260
+ END_IF;
261
+ END_IF;
262
+ END_IF;
263
+ timeout(IN:=stage<90,PT:=T#${endpoint.timeoutMs}ms);
264
+ IF timeout.Q THEN ${port("error")}:=2;stage:=90;END_IF;
265
+ IF stage=1 THEN
266
+ IF ${channel("epochs")}<>epoch THEN ${port("error")}:=5;stage:=90;
267
+ ELSIF ${channel("replyIds")}=identity AND_THEN LEFT(${channel("replies")},LEN(prefix))=prefix THEN
268
+ payload:='';cursor:=LEN(prefix);
269
+ IF ByteLength(${channel("replies")})<cursor+4 THEN ${port("error")}:=4;
270
+ ELSIF ${channel("replies")}[cursor]<>48 OR ${channel("replies")}[cursor+1]<>124 THEN ${port("error")}:=5;
271
+ ELSE
272
+ FOR candidate:=cursor+2 TO ByteLength(${channel("replies")})-1 DO payload[candidate-cursor-2]:=${channel("replies")}[candidate];END_FOR;
273
+ payload[ByteLength(${channel("replies")})-cursor-2]:=0;
274
+ ${decodeResponse}
275
+ END_IF;
276
+ stage:=90;
277
+ END_IF;
278
+ END_IF;
279
+ ${port("busy")}:=stage<90;
280
+ IF stage=90 THEN
281
+ IF slotIndex>0 THEN ${channel("requestIds")}:='';${channel("requests")}:='';${channel("inUse")}:=0;slotIndex:=0;END_IF;
282
+ timeout(IN:=FALSE);${port("busy")}:=FALSE;${port("done")}:=TRUE;
283
+ END_IF;`,
284
+ };
285
+ }
286
+ // Helpers are emitted as native methods under the HTTP block by the emitter.
287
+ return {
288
+ id: block.id,
289
+ kind: "functionBlock",
290
+ name,
291
+ declaration: `FUNCTION_BLOCK ${name}
292
+ VAR_INPUT
293
+ ${inputs}
294
+ END_VAR
295
+ VAR_OUTPUT
296
+ ${outputs}
297
+ END_VAR
298
+ VAR
299
+ remoteAddress : NBS.IPv4Address;
300
+ remoteIP : NBS.IIPAddress := remoteAddress;
301
+ serverHost : STRING(255) := ${quote(endpoint.host)};
302
+ resolver : NBS.ResolveHostname := (itfAsyncProperty:=${network});
303
+ ${endpoint.tls ? `certificateVerifier : ${name}_CertificateVerifier;\n tlsContext : NBS.TLSContext := (itfCertVerifer:=certificateVerifier,sUseCaseName:='tsPLC HTTPS',ePurpose:=NBS.PURPOSE.CLIENT_SIDE,sTLSVersion:='1.2',sHostname:=${quote(endpoint.host)},udiVerificationMode:=2);` : ""}
304
+ client : NBS.TCP_Client := (itfAsyncProperty:=${network},itfIPAddress:=remoteAddress,uiPort:=${endpoint.port},udiTimeOut:=${endpoint.timeoutMs * 1000}${endpoint.tls ? ",itfTLSContext:=tlsContext" : ""});
305
+ writer : NBS.TCP_Write;
306
+ reader : NBS.TCP_Read;
307
+ timeout : TON;
308
+ stage : UINT;
309
+ wire : STRING(8192);
310
+ received : STRING(8192);
311
+ chunk : ARRAY[0..8191] OF BYTE;
312
+ payload : STRING(2048);
313
+ token : STRING(2048);
314
+ part : STRING(2048);
315
+ used : UINT;
316
+ headerEnd : UINT;
317
+ contentLength : DINT;
318
+ status : UINT;
319
+ cursor : UINT;
320
+ malformed : BOOL;
321
+ seen : DWORD;
322
+ numberValue : LINT;
323
+ wireUsed : UINT;
324
+ tokenUsed : UINT;
325
+ index : UINT;
326
+ END_VAR
327
+ `,
328
+ implementation: `IF NOT ${port("start")} OR ${port("cancel")} THEN
329
+ resolver(xExecute:=FALSE,itfIPAddress:=remoteIP);client(xEnable:=FALSE); writer(xExecute:=FALSE); reader(xEnable:=FALSE); timeout(IN:=FALSE);
330
+ stage:=0; ${port("busy")}:=FALSE; ${port("done")}:=FALSE; ${port("error")}:=0;
331
+ RETURN;
332
+ END_IF;
333
+ IF stage=0 THEN
334
+ payload:=''; malformed:=FALSE; used:=0; received:='';
335
+ ${block.runtime?.kind === "http" && block.runtime.json ? `Append('{'); ${requestCode} Append('}');` : ""}
336
+ IF malformed THEN ${port("error")}:=3; stage:=90;
337
+ ELSE
338
+ wire:='';wireUsed:=0;
339
+ AppendWire(${quote((block.runtime?.kind === "http" ? block.runtime.method : "GET") + " " + endpoint.path + " HTTP/1.1\r\nHost: " + endpoint.authority + "\r\n" + (endpoint.token ? "Authorization: Bearer " + endpoint.token + "\r\n" : "") + (block.runtime?.kind === "http" && block.runtime.json ? "Content-Type: application/json\r\n" : "") + "Accept: application/json\r\nConnection: close\r\nContent-Length: ")});
340
+ AppendWire(UINT_TO_STRING(ByteLength(payload)));AppendWire('$R$N$R$N');AppendWire(payload); stage:=10;
341
+ END_IF;
342
+ END_IF;
343
+ timeout(IN:=stage<90,PT:=T#${endpoint.timeoutMs}ms);
344
+ IF timeout.Q THEN ${port("error")}:=2; stage:=90; END_IF;
345
+ ${port("busy")}:=stage<90;
346
+ IF stage=10 THEN
347
+ resolver(xExecute:=TRUE,sHostname:=serverHost,itfIPAddress:=remoteIP);
348
+ IF resolver.xError THEN ${port("error")}:=5;stage:=90;ELSIF resolver.xDone THEN stage:=1;END_IF;
349
+ END_IF;
350
+ IF stage=1 THEN
351
+ client(xEnable:=TRUE);
352
+ IF client.xError THEN ${port("error")}:=${endpoint.tls ? 6 : 5};stage:=90; ELSIF client.xActive THEN stage:=2;END_IF;
353
+ END_IF;
354
+ IF stage=2 THEN
355
+ writer(xExecute:=TRUE,itfConnection:=client.itfConnection,pData:=ADR(wire),udiSize:=UINT_TO_UDINT(wireUsed));
356
+ IF writer.xError THEN ${port("error")}:=5;stage:=90;ELSIF writer.xDone THEN stage:=3;END_IF;
357
+ END_IF;
358
+ IF stage=3 THEN
359
+ IF used>=8191 THEN ${port("error")}:=3;stage:=90;
360
+ ELSE
361
+ reader(xEnable:=TRUE,itfConnection:=client.itfConnection,pData:=ADR(chunk),udiSize:=8191);
362
+ IF reader.xError THEN ${port("error")}:=5;stage:=90;
363
+ ELSIF reader.xReady AND reader.udiCount>0 THEN
364
+ IF TO_UDINT(used)+reader.udiCount>8191 THEN ${port("error")}:=3;stage:=90;
365
+ ELSE
366
+ FOR index:=0 TO TO_UINT(reader.udiCount)-1 DO received[used+index]:=chunk[index];END_FOR;
367
+ used:=used+TO_UINT(reader.udiCount);received[used]:=0;
368
+ FindHeaders();
369
+ IF headerEnd=0 AND used>4096 THEN ${port("error")}:=3;stage:=90;END_IF;
370
+ IF headerEnd>0 THEN
371
+ IF headerEnd>4096 THEN ${port("error")}:=3;stage:=90;
372
+ ELSE
373
+ ReadHeaders();
374
+ IF malformed OR contentLength<0 OR contentLength>2048 THEN ${port("error")}:=3;stage:=90;
375
+ ELSIF TO_DINT(used)>=TO_DINT(headerEnd)+3+contentLength THEN
376
+ IF status<200 OR status>=300 THEN ${port("error")}:=10000+status;stage:=90;
377
+ ELSE
378
+ payload:='';IF contentLength>0 THEN FOR index:=0 TO TO_UINT(contentLength)-1 DO payload[index]:=received[headerEnd+3+index]; END_FOR;END_IF;payload[TO_UINT(contentLength)]:=0; ${decodeResponse}
379
+ stage:=90;
380
+ END_IF;
381
+ END_IF;
382
+ END_IF;
383
+ END_IF;
384
+ END_IF;
385
+ END_IF;
386
+ END_IF;
387
+ END_IF;
388
+ IF stage=90 THEN
389
+ resolver(xExecute:=FALSE,itfIPAddress:=remoteIP);client(xEnable:=FALSE);writer(xExecute:=FALSE);reader(xEnable:=FALSE);timeout(IN:=FALSE);
390
+ ${port("busy")}:=FALSE;${port("done")}:=TRUE;
391
+ END_IF;
392
+ `,
393
+ };
394
+ };
395
+
396
+ export const httpCertificateVerifier = (
397
+ owner: string,
398
+ name: string,
399
+ endpoint: HttpEndpoint,
400
+ ): GeneratedObject[] => {
401
+ if (!endpoint.tls) return [];
402
+ const id = `${owner}:certificateVerifier`;
403
+ // NBS represents certificate handles as pointers; verification keeps the native trust result.
404
+ const ipv4 = /^\d+\.\d+\.\d+\.\d+$/.test(endpoint.host);
405
+ return [
406
+ {
407
+ id,
408
+ kind: "functionBlock",
409
+ name: `${name}_CertificateVerifier`,
410
+ declaration: `FUNCTION_BLOCK ${name}_CertificateVerifier IMPLEMENTS NBS.ICertificateVerifier
411
+ VAR
412
+ hostname:STRING(255):=${quote(endpoint.host)};
413
+ ${ipv4 ? `address:ARRAY[0..3] OF BYTE := [${endpoint.host.split(".").join(",")}];` : ""}
414
+ END_VAR`,
415
+ implementation: "",
416
+ },
417
+ {
418
+ id: `${id}:VerifyCertificate`,
419
+ parent: id,
420
+ kind: "method",
421
+ name: "VerifyCertificate",
422
+ declaration: `METHOD PUBLIC VerifyCertificate : UDINT
423
+ VAR_INPUT hCert:POINTER TO BYTE;eCurState:UDINT;END_VAR
424
+ VAR matched:BOOL;result:UDINT;END_VAR`,
425
+ implementation: `VerifyCertificate:=eCurState;
426
+ IF eCurState<>0 THEN RETURN;END_IF;
427
+ matched:=CmpX509Cert_Implementation.${ipv4 ? "X509CertCheckIP(hCert,ADR(address),0,ADR(result))" : "X509CertCheckHost(hCert,ADR(hostname),4,ADR(result))"};
428
+ IF result<>0 THEN VerifyCertificate:=result;ELSIF NOT matched THEN VerifyCertificate:=1;END_IF;`,
429
+ },
430
+ ];
431
+ };
432
+
433
+ export const httpMethods = (
434
+ owner: string,
435
+ transport: "http" | "opcua" = "http",
436
+ ): GeneratedObject[] => {
437
+ const method = (
438
+ name: string,
439
+ declaration: string,
440
+ implementation: string,
441
+ result?: string,
442
+ ): GeneratedObject => ({
443
+ id: `${owner}:${name}`,
444
+ parent: owner,
445
+ kind: "method",
446
+ name,
447
+ declaration: `METHOD PRIVATE ${name}${result ? ` : ${result}` : ""}\n${declaration}`,
448
+ implementation,
449
+ });
450
+ return [
451
+ // Standard string helpers accept STRING(255); native buffers must retain their full byte length.
452
+ method(
453
+ "ByteLength",
454
+ "VAR_INPUT value:STRING(8192);END_VAR\nVAR i:UINT;END_VAR",
455
+ "ByteLength:=0;FOR i:=0 TO 8191 DO IF value[i]=0 THEN ByteLength:=i;RETURN;END_IF;END_FOR;ByteLength:=8192;",
456
+ "UINT",
457
+ ),
458
+ method(
459
+ "Append",
460
+ "VAR_INPUT value : STRING(2048); END_VAR\nVAR offset:UINT;i:UINT;END_VAR",
461
+ `offset:=TO_UINT(ByteLength(payload));IF offset+TO_UINT(ByteLength(value))>2048 THEN malformed:=TRUE; ELSE IF ByteLength(value)>0 THEN FOR i:=0 TO TO_UINT(ByteLength(value))-1 DO payload[offset+i]:=value[i];END_FOR;END_IF;payload[offset+TO_UINT(ByteLength(value))]:=0;END_IF;`,
462
+ ),
463
+ method(
464
+ "AppendWire",
465
+ "VAR_INPUT value:STRING(8192);END_VAR\nVAR i:UINT;END_VAR",
466
+ `IF wireUsed+TO_UINT(ByteLength(value))>8191 THEN malformed:=TRUE;RETURN;END_IF;IF ByteLength(value)>0 THEN FOR i:=0 TO TO_UINT(ByteLength(value))-1 DO wire[wireUsed+i]:=value[i];END_FOR;END_IF;wireUsed:=wireUsed+TO_UINT(ByteLength(value));wire[wireUsed]:=0;`,
467
+ ),
468
+ method(
469
+ "FindHeaders",
470
+ "VAR i:UINT;END_VAR",
471
+ `headerEnd:=0;IF used<4 THEN RETURN;END_IF;
472
+ FOR i:=0 TO used-4 DO IF received[i]=13 AND received[i+1]=10 AND received[i+2]=13 AND received[i+3]=10 THEN headerEnd:=i+1;RETURN;END_IF;END_FOR;`,
473
+ ),
474
+ method(
475
+ "MatchLiteral",
476
+ "VAR_INPUT value:STRING(8);END_VAR\nVAR i:UINT;size:UINT;END_VAR",
477
+ `MatchLiteral:=FALSE;size:=TO_UINT(ByteLength(value));IF cursor+size-1>TO_UINT(ByteLength(payload)) THEN RETURN;END_IF;
478
+ FOR i:=0 TO size-1 DO IF payload[cursor-1+i]<>value[i] THEN RETURN;END_IF;END_FOR;cursor:=cursor+size;MatchLiteral:=TRUE;`,
479
+ "BOOL",
480
+ ),
481
+ method(
482
+ "ReadHeaders",
483
+ `VAR lineStart:UINT;lineEnd:UINT;i:UINT;j:UINT;key:STRING(64);octet:BYTE;found:BOOL;digits:UINT;END_VAR`,
484
+ `malformed:=FALSE;found:=FALSE;contentLength:=-1;
485
+ IF LEFT(received,9)<>'HTTP/1.1 ' AND LEFT(received,9)<>'HTTP/1.0 ' THEN malformed:=TRUE;RETURN;END_IF;
486
+ status:=0;FOR i:=9 TO 11 DO IF received[i]<48 OR received[i]>57 THEN malformed:=TRUE;RETURN;END_IF;status:=status*10+TO_UINT(received[i]-48);END_FOR;
487
+ lineStart:=1;
488
+ WHILE lineStart<headerEnd DO
489
+ lineEnd:=lineStart;WHILE lineEnd<headerEnd AND received[lineEnd-1]<>13 DO lineEnd:=lineEnd+1;END_WHILE;
490
+ key:='';j:=0;i:=lineStart;
491
+ WHILE i<lineEnd AND received[i-1]<>58 AND j<63 DO octet:=received[i-1];IF octet>=65 AND octet<=90 THEN octet:=octet+32;END_IF;key[j]:=octet;j:=j+1;i:=i+1;END_WHILE;key[j]:=0;
492
+ IF key='transfer-encoding' THEN malformed:=TRUE;RETURN;END_IF;
493
+ IF key='content-length' THEN
494
+ IF found THEN malformed:=TRUE;RETURN;END_IF;found:=TRUE;contentLength:=0;digits:=0;i:=i+1;
495
+ WHILE i<lineEnd AND received[i-1]=32 DO i:=i+1;END_WHILE;
496
+ WHILE i<lineEnd DO octet:=received[i-1];IF octet<48 OR octet>57 OR digits>=5 THEN malformed:=TRUE;RETURN;END_IF;contentLength:=contentLength*10+TO_DINT(octet-48);digits:=digits+1;i:=i+1;END_WHILE;
497
+ IF digits=0 THEN malformed:=TRUE;RETURN;END_IF;
498
+ END_IF;
499
+ lineStart:=lineEnd+2;
500
+ END_WHILE;`,
501
+ ),
502
+ method(
503
+ "WriteString",
504
+ `VAR_INPUT value : STRING(2048); END_VAR\nVAR i:UINT; one:STRING(1); hex:STRING(16):='0123456789abcdef'; END_VAR`,
505
+ `Append('"');IF ByteLength(value)>0 THEN FOR i:=0 TO ByteLength(value)-1 DO
506
+ one[0]:=value[i];one[1]:=0;
507
+ IF value[i]=34 OR value[i]=92 THEN Append('\\');Append(one);
508
+ ELSIF value[i]<32 THEN Append('\\u00');one[0]:=hex[SHR(value[i],4)];Append(one);one[0]:=hex[value[i] AND 15];Append(one);
509
+ ELSE Append(one);END_IF;
510
+ END_FOR;END_IF;Append('"');`,
511
+ ),
512
+ method(
513
+ "SkipWhitespace",
514
+ "",
515
+ `WHILE cursor<=TO_UINT(ByteLength(payload)) DO IF payload[cursor-1]<>32 AND payload[cursor-1]<>9 AND payload[cursor-1]<>10 AND payload[cursor-1]<>13 THEN EXIT;END_IF;cursor:=cursor+1;END_WHILE;`,
516
+ ),
517
+ method(
518
+ "Expect",
519
+ "VAR_INPUT value:BYTE; END_VAR",
520
+ `IF cursor>TO_UINT(ByteLength(payload)) OR_ELSE payload[cursor-1]<>value THEN malformed:=TRUE;ELSE cursor:=cursor+1;END_IF;`,
521
+ ),
522
+ method(
523
+ "TokenByte",
524
+ "VAR_INPUT value:BYTE;END_VAR",
525
+ `IF tokenUsed>=2048 OR value=0 THEN malformed:=TRUE;RETURN;END_IF;token[tokenUsed]:=value;tokenUsed:=tokenUsed+1;token[tokenUsed]:=0;`,
526
+ ),
527
+ method(
528
+ "ReadHex",
529
+ "VAR value:UDINT;i:UINT;octet:BYTE;END_VAR",
530
+ `numberValue:=0;FOR i:=1 TO 4 DO
531
+ IF cursor>TO_UINT(ByteLength(payload)) THEN malformed:=TRUE;RETURN;END_IF;
532
+ octet:=payload[cursor-1];cursor:=cursor+1;
533
+ IF octet>=48 AND octet<=57 THEN value:=TO_UDINT(octet-48);ELSIF octet>=65 AND octet<=70 THEN value:=TO_UDINT(octet-55);ELSIF octet>=97 AND octet<=102 THEN value:=TO_UDINT(octet-87);ELSE malformed:=TRUE;RETURN;END_IF;
534
+ numberValue:=numberValue*16+TO_LINT(value);END_FOR;`,
535
+ ),
536
+ method(
537
+ "ReadString",
538
+ `VAR octet:BYTE;point:UDINT;high:UDINT;END_VAR`,
539
+ `token:='';tokenUsed:=0;Expect(34);
540
+ WHILE cursor<=TO_UINT(ByteLength(payload)) AND NOT malformed DO
541
+ octet:=payload[cursor-1];cursor:=cursor+1;
542
+ IF octet=34 THEN ValidateUtf8();RETURN;END_IF;
543
+ IF octet=92 THEN
544
+ IF cursor>TO_UINT(ByteLength(payload)) THEN malformed:=TRUE;RETURN;END_IF;
545
+ octet:=payload[cursor-1];cursor:=cursor+1;
546
+ CASE octet OF
547
+ 34,47,92: TokenByte(octet);
548
+ 98:TokenByte(8);102:TokenByte(12);110:TokenByte(10);114:TokenByte(13);116:TokenByte(9);
549
+ 117:
550
+ ReadHex();point:=TO_UDINT(numberValue);
551
+ IF point>=55296 AND point<=56319 THEN
552
+ high:=point;Expect(92);Expect(117);ReadHex();
553
+ IF numberValue<56320 OR numberValue>57343 THEN malformed:=TRUE;RETURN;END_IF;
554
+ point:=65536+(high-55296)*1024+TO_UDINT(numberValue)-56320;
555
+ ELSIF point>=56320 AND point<=57343 THEN malformed:=TRUE;RETURN;END_IF;
556
+ IF point<128 THEN TokenByte(TO_BYTE(point));
557
+ ELSIF point<2048 THEN TokenByte(TO_BYTE(192+SHR(point,6)));TokenByte(TO_BYTE(128+(point AND 63)));
558
+ ELSIF point<65536 THEN TokenByte(TO_BYTE(224+SHR(point,12)));TokenByte(TO_BYTE(128+(SHR(point,6) AND 63)));TokenByte(TO_BYTE(128+(point AND 63)));
559
+ ELSE TokenByte(TO_BYTE(240+SHR(point,18)));TokenByte(TO_BYTE(128+(SHR(point,12) AND 63)));TokenByte(TO_BYTE(128+(SHR(point,6) AND 63)));TokenByte(TO_BYTE(128+(point AND 63)));END_IF;
560
+ ELSE malformed:=TRUE;RETURN;
561
+ END_CASE;
562
+ ELSIF octet<32 THEN malformed:=TRUE;RETURN;ELSE TokenByte(octet);END_IF;
563
+ END_WHILE;malformed:=TRUE;`,
564
+ ),
565
+ method(
566
+ "ValidateUtf8",
567
+ "VAR i:UINT;j:UINT;count:UINT;first:BYTE;next:BYTE;END_VAR",
568
+ `i:=0;
569
+ WHILE i<tokenUsed AND NOT malformed DO
570
+ first:=token[i];
571
+ IF first<128 THEN i:=i+1;
572
+ ELSE
573
+ IF first>=194 AND first<=223 THEN count:=1;ELSIF first>=224 AND first<=239 THEN count:=2;ELSIF first>=240 AND first<=244 THEN count:=3;ELSE malformed:=TRUE;RETURN;END_IF;
574
+ IF i+count>=tokenUsed THEN malformed:=TRUE;RETURN;END_IF;
575
+ next:=token[i+1];
576
+ IF (first=224 AND next<160) OR (first=237 AND next>=160) OR (first=240 AND next<144) OR (first=244 AND next>=144) THEN malformed:=TRUE;RETURN;END_IF;
577
+ FOR j:=1 TO count DO IF token[i+j]<128 OR token[i+j]>191 THEN malformed:=TRUE;RETURN;END_IF;END_FOR;
578
+ i:=i+count+1;
579
+ END_IF;
580
+ END_WHILE;`,
581
+ ),
582
+ method(
583
+ "ReadInteger",
584
+ "VAR negative:BOOL;digits:UINT;octet:BYTE;END_VAR",
585
+ `numberValue:=0;negative:=FALSE;digits:=0;
586
+ IF payload[cursor-1]=45 THEN negative:=TRUE;cursor:=cursor+1;END_IF;
587
+ WHILE cursor<=TO_UINT(ByteLength(payload)) DO
588
+ octet:=payload[cursor-1];IF octet<48 OR octet>57 THEN EXIT;END_IF;
589
+ IF digits>=10 THEN malformed:=TRUE;RETURN;END_IF;
590
+ IF digits>0 AND numberValue=0 THEN malformed:=TRUE;RETURN;END_IF;numberValue:=numberValue*10+TO_LINT(octet-48);digits:=digits+1;cursor:=cursor+1;
591
+ END_WHILE;
592
+ IF digits=0 THEN malformed:=TRUE;END_IF;IF negative THEN numberValue:=-numberValue;END_IF;`,
593
+ ),
594
+ ].filter(
595
+ (item) =>
596
+ transport === "http" || !["AppendWire", "FindHeaders", "ReadHeaders"].includes(item.name),
597
+ );
598
+ };