@elliots/typical-tsc-plugin 0.2.0-beta.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.
package/bin/ttsc ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from "child_process";
4
+
5
+ const tscCommand = `node $(node -p "require.resolve('ts-patch/compiler/tsc.js')") ${process.argv.slice(2).join(" ")}`;
6
+ const patchTsConfig = new URL("../src/patch-tsconfig.cjs", import.meta.url);
7
+
8
+ execSync(tscCommand, {
9
+ stdio: "inherit",
10
+ cwd: process.cwd(),
11
+ env: { ...process.env, NODE_OPTIONS: `--require ${patchTsConfig.pathname}` },
12
+ });
@@ -0,0 +1,26 @@
1
+ import ts from "typescript";
2
+ import { PluginConfig, ProgramTransformerExtras } from "ts-patch";
3
+
4
+ //#region src/index.d.ts
5
+
6
+ /**
7
+ * TSC Program Transformer Plugin for typical.
8
+ *
9
+ * Uses transformProgram to intercept program creation and transform source files
10
+ * before TypeScript processes them. This allows us to inject validators into
11
+ * the source code while maintaining proper TypeScript semantics.
12
+ *
13
+ * Configure in tsconfig.json:
14
+ * {
15
+ * "compilerOptions": {
16
+ * "plugins": [
17
+ * { "transform": "@elliots/typical-tsc-plugin", "transformProgram": true }
18
+ * ]
19
+ * }
20
+ * }
21
+ */
22
+ declare function export_default(program: ts.Program, host: ts.CompilerHost | undefined, _pluginConfig: PluginConfig, {
23
+ ts: tsInstance
24
+ }: ProgramTransformerExtras): ts.Program;
25
+ //#endregion
26
+ export { export_default as default };
package/dist/index.mjs ADDED
@@ -0,0 +1,303 @@
1
+ import { createRequire } from "node:module";
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadConfig, validateConfig } from "@elliots/typical";
6
+ import deasync from "deasync";
7
+
8
+ //#region ../compiler/dist/protocol.js
9
+ /**
10
+ * MessagePack-like binary protocol for communicating with the typical compiler.
11
+ * This matches the protocol used by tsgo's --api mode.
12
+ */
13
+ const MessagePackTypeFixedArray3 = 147;
14
+ const MessagePackTypeBin8 = 196;
15
+ const MessagePackTypeBin16 = 197;
16
+ const MessagePackTypeBin32 = 198;
17
+ const MessagePackTypeU8 = 204;
18
+ function encodeRequest(method, payload) {
19
+ const methodBuf = Buffer.from(method, "utf8");
20
+ const payloadStr = JSON.stringify(payload);
21
+ const payloadBuf = Buffer.from(payloadStr, "utf8");
22
+ const methodBinSize = getBinEncodingSize(methodBuf.length);
23
+ const payloadBinSize = getBinEncodingSize(payloadBuf.length);
24
+ const totalSize = 3 + methodBinSize + methodBuf.length + payloadBinSize + payloadBuf.length;
25
+ const buf = Buffer.alloc(totalSize);
26
+ let offset = 0;
27
+ buf[offset++] = MessagePackTypeFixedArray3;
28
+ buf[offset++] = MessagePackTypeU8;
29
+ buf[offset++] = 1;
30
+ offset = writeBin(buf, offset, methodBuf);
31
+ offset = writeBin(buf, offset, payloadBuf);
32
+ return buf;
33
+ }
34
+ function decodeResponse(data) {
35
+ let offset = 0;
36
+ if (data[offset++] !== MessagePackTypeFixedArray3) throw new Error(`Expected 0x93, got 0x${data[0].toString(16)}`);
37
+ if (data[offset++] !== MessagePackTypeU8) throw new Error(`Expected 0xCC for message type`);
38
+ const messageType = data[offset++];
39
+ const { value: methodBuf, newOffset: offset2 } = readBin(data, offset);
40
+ offset = offset2;
41
+ const method = methodBuf.toString("utf8");
42
+ const { value: payload, newOffset: offset3 } = readBin(data, offset);
43
+ return {
44
+ messageType,
45
+ method,
46
+ payload,
47
+ bytesConsumed: offset3
48
+ };
49
+ }
50
+ function getBinEncodingSize(length) {
51
+ if (length < 256) return 2;
52
+ if (length < 65536) return 3;
53
+ return 5;
54
+ }
55
+ function writeBin(buf, offset, data) {
56
+ const length = data.length;
57
+ if (length < 256) {
58
+ buf[offset++] = MessagePackTypeBin8;
59
+ buf[offset++] = length;
60
+ } else if (length < 65536) {
61
+ buf[offset++] = MessagePackTypeBin16;
62
+ buf.writeUInt16BE(length, offset);
63
+ offset += 2;
64
+ } else {
65
+ buf[offset++] = MessagePackTypeBin32;
66
+ buf.writeUInt32BE(length, offset);
67
+ offset += 4;
68
+ }
69
+ data.copy(buf, offset);
70
+ return offset + length;
71
+ }
72
+ function readBin(buf, offset) {
73
+ if (offset >= buf.length) throw new Error("Not enough data: need type byte");
74
+ const type = buf[offset++];
75
+ let length;
76
+ switch (type) {
77
+ case MessagePackTypeBin8:
78
+ if (offset >= buf.length) throw new Error("Not enough data: need length byte");
79
+ length = buf[offset++];
80
+ break;
81
+ case MessagePackTypeBin16:
82
+ if (offset + 2 > buf.length) throw new Error("Not enough data: need 2 length bytes");
83
+ length = buf.readUInt16BE(offset);
84
+ offset += 2;
85
+ break;
86
+ case MessagePackTypeBin32:
87
+ if (offset + 4 > buf.length) throw new Error("Not enough data: need 4 length bytes");
88
+ length = buf.readUInt32BE(offset);
89
+ offset += 4;
90
+ break;
91
+ default: throw new Error(`Expected bin type (0xC4-0xC6), got 0x${type.toString(16)}`);
92
+ }
93
+ if (offset + length > buf.length) throw new Error(`Not enough data: need ${length} bytes, have ${buf.length - offset}`);
94
+ return {
95
+ value: buf.subarray(offset, offset + length),
96
+ newOffset: offset + length
97
+ };
98
+ }
99
+
100
+ //#endregion
101
+ //#region ../compiler/dist/client.js
102
+ const __dirname = dirname(fileURLToPath(import.meta.url));
103
+ const require = createRequire(import.meta.url);
104
+ const debug = process.env.DEBUG === "1";
105
+ function debugLog(...args) {
106
+ if (debug) console.error(...args);
107
+ }
108
+ function getBinaryPath() {
109
+ const pkgName = `@elliots/typical-compiler-${process.platform}-${process.arch}`;
110
+ try {
111
+ const pkg = require(pkgName);
112
+ debugLog(`[CLIENT] Using platform binary from ${pkgName}`);
113
+ return pkg.binaryPath;
114
+ } catch {
115
+ debugLog(`[CLIENT] Platform package ${pkgName} not found, falling back to local bin`);
116
+ return join(__dirname, "..", "bin", "typical");
117
+ }
118
+ }
119
+ var TypicalCompiler = class {
120
+ process = null;
121
+ pendingRequests = /* @__PURE__ */ new Map();
122
+ buffer = Buffer.alloc(0);
123
+ binaryPath;
124
+ cwd;
125
+ nextRequestId = 0;
126
+ constructor(options = {}) {
127
+ this.binaryPath = options.binaryPath ?? getBinaryPath();
128
+ this.cwd = options.cwd ?? process.cwd();
129
+ }
130
+ async start() {
131
+ if (this.process) throw new Error("Compiler already started");
132
+ this.process = spawn(this.binaryPath, ["--cwd", this.cwd], { stdio: [
133
+ "pipe",
134
+ "pipe",
135
+ "inherit"
136
+ ] });
137
+ this.process.unref();
138
+ this.process.stdout.on("data", (data) => {
139
+ this.handleData(data);
140
+ });
141
+ this.process.on("error", (err) => {
142
+ console.error("Compiler process error:", err);
143
+ });
144
+ this.process.on("exit", (code) => {
145
+ this.process = null;
146
+ for (const [, { reject }] of this.pendingRequests) reject(/* @__PURE__ */ new Error(`Compiler process exited with code ${code}`));
147
+ this.pendingRequests.clear();
148
+ });
149
+ const result = await this.request("echo", "ping");
150
+ if (result !== "ping") throw new Error(`Echo test failed: expected "ping", got "${result}"`);
151
+ }
152
+ async close() {
153
+ if (this.process) {
154
+ const proc = this.process;
155
+ this.process = null;
156
+ this.pendingRequests.clear();
157
+ proc.stdin?.end();
158
+ proc.kill();
159
+ }
160
+ }
161
+ async loadProject(configFileName) {
162
+ return this.request("loadProject", { configFileName });
163
+ }
164
+ async transformFile(project, fileName) {
165
+ const projectId = typeof project === "string" ? project : project.id;
166
+ return this.request("transformFile", {
167
+ project: projectId,
168
+ fileName
169
+ });
170
+ }
171
+ async release(handle) {
172
+ const id = typeof handle === "string" ? handle : handle.id;
173
+ await this.request("release", id);
174
+ }
175
+ /**
176
+ * Transform a standalone TypeScript source string.
177
+ * Creates a temporary project to enable type checking.
178
+ *
179
+ * @param fileName - Virtual filename for error messages (e.g., "test.ts")
180
+ * @param source - TypeScript source code
181
+ * @returns Transformed code with validation
182
+ */
183
+ async transformSource(fileName, source) {
184
+ return this.request("transformSource", {
185
+ fileName,
186
+ source
187
+ });
188
+ }
189
+ async request(method, payload) {
190
+ if (!this.process) throw new Error("Compiler not started");
191
+ const requestId = `${method}:${this.nextRequestId++}`;
192
+ const requestData = encodeRequest(requestId, payload);
193
+ return new Promise((resolve, reject) => {
194
+ this.pendingRequests.set(requestId, {
195
+ resolve,
196
+ reject
197
+ });
198
+ this.process.stdin.write(requestData);
199
+ });
200
+ }
201
+ handleData(data) {
202
+ this.buffer = Buffer.concat([this.buffer, data]);
203
+ debugLog(`[CLIENT DEBUG] handleData: received ${data.length} bytes, buffer now ${this.buffer.length} bytes`);
204
+ while (this.buffer.length > 0) try {
205
+ debugLog(`[CLIENT DEBUG] Attempting to decode ${this.buffer.length} bytes...`);
206
+ const { messageType, method, payload, bytesConsumed } = decodeResponse(this.buffer);
207
+ debugLog(`[CLIENT DEBUG] Decoded: type=${messageType} method=${method} payload=${payload.length} bytes, consumed=${bytesConsumed}`);
208
+ const pending = this.pendingRequests.get(method);
209
+ if (!pending) {
210
+ const pendingKeys = [...this.pendingRequests.keys()].join(", ") || "(none)";
211
+ throw new Error(`No pending request for method: ${method}. Pending requests: ${pendingKeys}. This indicates a protocol bug - received response for a request that wasn't made or was already resolved.`);
212
+ }
213
+ this.pendingRequests.delete(method);
214
+ if (messageType === 4) {
215
+ const result = payload.length > 0 ? JSON.parse(payload.toString("utf8")) : null;
216
+ pending.resolve(result);
217
+ } else if (messageType === 5) pending.reject(new Error(payload.toString("utf8")));
218
+ else pending.reject(/* @__PURE__ */ new Error(`Unexpected message type: ${messageType}`));
219
+ this.buffer = this.buffer.subarray(bytesConsumed);
220
+ } catch (e) {
221
+ debugLog(`[CLIENT DEBUG] Decode failed (waiting for more data): ${e}`);
222
+ break;
223
+ }
224
+ }
225
+ };
226
+
227
+ //#endregion
228
+ //#region src/index.ts
229
+ /**
230
+ * Synchronous wrapper around the async compiler transformFile.
231
+ */
232
+ function transformFileSync(compiler, project, fileName) {
233
+ let result;
234
+ let error;
235
+ let done = false;
236
+ compiler.transformFile(project, fileName).then((res) => {
237
+ result = res.code;
238
+ done = true;
239
+ }, (err) => {
240
+ error = err;
241
+ done = true;
242
+ });
243
+ deasync.loopWhile(() => !done);
244
+ if (error) throw error;
245
+ return result;
246
+ }
247
+ /**
248
+ * TSC Program Transformer Plugin for typical.
249
+ *
250
+ * Uses transformProgram to intercept program creation and transform source files
251
+ * before TypeScript processes them. This allows us to inject validators into
252
+ * the source code while maintaining proper TypeScript semantics.
253
+ *
254
+ * Configure in tsconfig.json:
255
+ * {
256
+ * "compilerOptions": {
257
+ * "plugins": [
258
+ * { "transform": "@elliots/typical-tsc-plugin", "transformProgram": true }
259
+ * ]
260
+ * }
261
+ * }
262
+ */
263
+ function src_default(program, host, _pluginConfig, { ts: tsInstance }) {
264
+ validateConfig(loadConfig());
265
+ const compiler = new TypicalCompiler({ cwd: process.cwd() });
266
+ let projectHandle;
267
+ let initError;
268
+ let initDone = false;
269
+ compiler.start().then(() => compiler.loadProject("tsconfig.json")).then((handle) => {
270
+ projectHandle = handle.id;
271
+ initDone = true;
272
+ }).catch((err) => {
273
+ initError = err;
274
+ initDone = true;
275
+ });
276
+ deasync.loopWhile(() => !initDone);
277
+ if (initError) throw initError;
278
+ const compilerOptions = program.getCompilerOptions();
279
+ const originalHost = host ?? tsInstance.createCompilerHost(compilerOptions);
280
+ const transformedFiles = /* @__PURE__ */ new Map();
281
+ for (const sourceFile of program.getSourceFiles()) {
282
+ if (sourceFile.isDeclarationFile || sourceFile.fileName.includes("node_modules")) continue;
283
+ try {
284
+ const transformed = transformFileSync(compiler, projectHandle, sourceFile.fileName);
285
+ transformedFiles.set(sourceFile.fileName, transformed);
286
+ } catch (err) {
287
+ console.error(`[typical] Failed to transform ${sourceFile.fileName}:`, err);
288
+ }
289
+ }
290
+ const newHost = {
291
+ ...originalHost,
292
+ getSourceFile: (fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => {
293
+ const transformed = transformedFiles.get(fileName);
294
+ if (transformed) return tsInstance.createSourceFile(fileName, transformed, typeof languageVersionOrOptions === "object" ? languageVersionOrOptions : { languageVersion: languageVersionOrOptions });
295
+ return originalHost.getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile);
296
+ }
297
+ };
298
+ const rootNames = program.getRootFileNames();
299
+ return tsInstance.createProgram(rootNames, compilerOptions, newHost, program);
300
+ }
301
+
302
+ //#endregion
303
+ export { src_default as default };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@elliots/typical-tsc-plugin",
3
+ "version": "0.2.0-beta.1",
4
+ "description": "TSC plugin for typical - runtime safe TypeScript transformer",
5
+ "keywords": [
6
+ "runtime",
7
+ "transformer",
8
+ "ts-patch",
9
+ "tsc",
10
+ "typescript",
11
+ "typical",
12
+ "validation"
13
+ ],
14
+ "homepage": "https://github.com/elliots/typical#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/elliots/typical/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Elliot Shepherd <elliot@jarofworms.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/elliots/typical.git",
23
+ "directory": "packages/tsc-plugin"
24
+ },
25
+ "bin": {
26
+ "ttsc": "bin/ttsc"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "dist",
31
+ "src/patch-tsconfig.cjs"
32
+ ],
33
+ "type": "module",
34
+ "main": "./dist/index.mjs",
35
+ "module": "./dist/index.mjs",
36
+ "types": "./dist/index.d.mts",
37
+ "exports": {
38
+ ".": "./dist/index.mjs",
39
+ "./package.json": "./package.json"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@elliots/typical": "0.2.0-beta.1",
46
+ "deasync": "0.1.30",
47
+ "strip-json-comments": "5.0.3"
48
+ },
49
+ "devDependencies": {
50
+ "@types/deasync": "0.1.5",
51
+ "@types/node": "^22.0.0",
52
+ "tsdown": "^0.18.3"
53
+ },
54
+ "peerDependencies": {
55
+ "ts-patch": "^3.0.0",
56
+ "typescript": "^5.0.0"
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "dev": "tsdown --watch",
61
+ "typecheck": "tsc --noEmit"
62
+ }
63
+ }
@@ -0,0 +1,41 @@
1
+ // monkeypatch fs.readFileSync to automatically add @elliots/typical-tsc-plugin to tsconfig.json (if not present)
2
+
3
+ const fs = require('fs')
4
+ const stripJsonComments = require('strip-json-comments').default
5
+
6
+ const origFsReadFileSync = fs.readFileSync
7
+
8
+ fs.readFileSync = function (path, ...args) {
9
+ const result = origFsReadFileSync.call(this, path, ...args)
10
+
11
+ if (typeof path === 'string' && path.endsWith('/tsconfig.json')) {
12
+ try {
13
+ const json = stripJsonComments(result.toString(), { trailingCommas: true })
14
+
15
+ const config = JSON.parse(json)
16
+
17
+ if (!config.compilerOptions) {
18
+ config.compilerOptions = {}
19
+ }
20
+
21
+ if (!config.compilerOptions.plugins) {
22
+ config.compilerOptions.plugins = []
23
+ }
24
+
25
+ const hasTypical = config.compilerOptions.plugins.some(plugin => plugin.transform === '@elliots/typical-tsc-plugin' || plugin.transform === '@elliots/typical/tsc-plugin')
26
+
27
+ if (!hasTypical) {
28
+ config.compilerOptions.plugins.push({
29
+ transform: '@elliots/typical-tsc-plugin',
30
+ transformProgram: true,
31
+ })
32
+ }
33
+
34
+ return JSON.stringify(config, null, 2)
35
+ } catch (e) {
36
+ console.error('ERROR patching tsconfig.json to add @elliots/typical-tsc-plugin', e)
37
+ throw e
38
+ }
39
+ }
40
+ return result
41
+ }