tywrap 0.5.1 → 0.6.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 (155) hide show
  1. package/dist/cli.js +8 -6
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +236 -147
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts.map +1 -1
  7. package/dist/core/annotation-parser.js +93 -83
  8. package/dist/core/annotation-parser.js.map +1 -1
  9. package/dist/core/discovery.d.ts.map +1 -1
  10. package/dist/core/discovery.js +26 -0
  11. package/dist/core/discovery.js.map +1 -1
  12. package/dist/core/emit-call.d.ts +58 -0
  13. package/dist/core/emit-call.d.ts.map +1 -0
  14. package/dist/core/emit-call.js +103 -0
  15. package/dist/core/emit-call.js.map +1 -0
  16. package/dist/core/generator.d.ts +2 -0
  17. package/dist/core/generator.d.ts.map +1 -1
  18. package/dist/core/generator.js +54 -211
  19. package/dist/core/generator.js.map +1 -1
  20. package/dist/core/mapper.d.ts.map +1 -1
  21. package/dist/core/mapper.js +118 -104
  22. package/dist/core/mapper.js.map +1 -1
  23. package/dist/index.d.ts +6 -25
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -31
  26. package/dist/index.js.map +1 -1
  27. package/dist/runtime/bounded-context.d.ts +2 -2
  28. package/dist/runtime/bounded-context.js +2 -2
  29. package/dist/runtime/{safe-codec.d.ts → bridge-codec.d.ts} +11 -11
  30. package/dist/runtime/bridge-codec.d.ts.map +1 -0
  31. package/dist/runtime/{safe-codec.js → bridge-codec.js} +26 -26
  32. package/dist/runtime/bridge-codec.js.map +1 -0
  33. package/dist/runtime/{http-io.d.ts → http-transport.d.ts} +6 -6
  34. package/dist/runtime/http-transport.d.ts.map +1 -0
  35. package/dist/runtime/{http-io.js → http-transport.js} +3 -3
  36. package/dist/runtime/http-transport.js.map +1 -0
  37. package/dist/runtime/http.d.ts +3 -3
  38. package/dist/runtime/http.d.ts.map +1 -1
  39. package/dist/runtime/http.js +4 -4
  40. package/dist/runtime/http.js.map +1 -1
  41. package/dist/runtime/index.d.ts +9 -1
  42. package/dist/runtime/index.d.ts.map +1 -1
  43. package/dist/runtime/index.js +9 -1
  44. package/dist/runtime/index.js.map +1 -1
  45. package/dist/runtime/node.d.ts +6 -6
  46. package/dist/runtime/node.d.ts.map +1 -1
  47. package/dist/runtime/node.js +7 -7
  48. package/dist/runtime/node.js.map +1 -1
  49. package/dist/runtime/pooled-transport.d.ts +11 -11
  50. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  51. package/dist/runtime/pooled-transport.js +10 -10
  52. package/dist/runtime/pooled-transport.js.map +1 -1
  53. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  56. package/dist/runtime/{pyodide-io.d.ts → pyodide-transport.d.ts} +8 -8
  57. package/dist/runtime/pyodide-transport.d.ts.map +1 -0
  58. package/dist/runtime/{pyodide-io.js → pyodide-transport.js} +5 -5
  59. package/dist/runtime/pyodide-transport.js.map +1 -0
  60. package/dist/runtime/pyodide.d.ts +3 -3
  61. package/dist/runtime/pyodide.d.ts.map +1 -1
  62. package/dist/runtime/pyodide.js +5 -5
  63. package/dist/runtime/pyodide.js.map +1 -1
  64. package/dist/runtime/rpc-client.d.ts +5 -5
  65. package/dist/runtime/rpc-client.d.ts.map +1 -1
  66. package/dist/runtime/rpc-client.js +6 -7
  67. package/dist/runtime/rpc-client.js.map +1 -1
  68. package/dist/runtime/{process-io.d.ts → subprocess-transport.d.ts} +9 -9
  69. package/dist/runtime/subprocess-transport.d.ts.map +1 -0
  70. package/dist/runtime/{process-io.js → subprocess-transport.js} +6 -6
  71. package/dist/runtime/subprocess-transport.js.map +1 -0
  72. package/dist/runtime/{worker-pool.d.ts → transport-pool.d.ts} +15 -15
  73. package/dist/runtime/transport-pool.d.ts.map +1 -0
  74. package/dist/runtime/{worker-pool.js → transport-pool.js} +6 -6
  75. package/dist/runtime/transport-pool.js.map +1 -0
  76. package/dist/runtime/transport.d.ts +14 -8
  77. package/dist/runtime/transport.d.ts.map +1 -1
  78. package/dist/runtime/transport.js +10 -4
  79. package/dist/runtime/transport.js.map +1 -1
  80. package/dist/types/index.d.ts +10 -1
  81. package/dist/types/index.d.ts.map +1 -1
  82. package/dist/utils/cache.d.ts +2 -2
  83. package/dist/utils/cache.d.ts.map +1 -1
  84. package/dist/utils/cache.js +2 -2
  85. package/dist/utils/cache.js.map +1 -1
  86. package/dist/utils/codec.d.ts +1 -1
  87. package/dist/utils/codec.d.ts.map +1 -1
  88. package/dist/utils/codec.js +181 -182
  89. package/dist/utils/codec.js.map +1 -1
  90. package/dist/version.d.ts +12 -0
  91. package/dist/version.d.ts.map +1 -0
  92. package/dist/version.js +12 -0
  93. package/dist/version.js.map +1 -0
  94. package/package.json +3 -2
  95. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  96. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  97. package/runtime/python_bridge.py +54 -6
  98. package/runtime/safe_codec.py +8 -8
  99. package/runtime/tywrap_bridge_core.py +159 -23
  100. package/src/cli.ts +8 -12
  101. package/src/config/index.ts +271 -165
  102. package/src/core/annotation-parser.ts +112 -105
  103. package/src/core/discovery.ts +32 -0
  104. package/src/core/emit-call.ts +171 -0
  105. package/src/core/generator.ts +67 -263
  106. package/src/core/mapper.ts +125 -110
  107. package/src/index.ts +11 -68
  108. package/src/runtime/bounded-context.ts +2 -2
  109. package/src/runtime/{safe-codec.ts → bridge-codec.ts} +27 -27
  110. package/src/runtime/{http-io.ts → http-transport.ts} +5 -5
  111. package/src/runtime/http.ts +5 -5
  112. package/src/runtime/index.ts +23 -1
  113. package/src/runtime/node.ts +15 -15
  114. package/src/runtime/pooled-transport.ts +15 -15
  115. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  116. package/src/runtime/{pyodide-io.ts → pyodide-transport.ts} +7 -7
  117. package/src/runtime/pyodide.ts +6 -6
  118. package/src/runtime/rpc-client.ts +12 -8
  119. package/src/runtime/{process-io.ts → subprocess-transport.ts} +8 -8
  120. package/src/runtime/{worker-pool.ts → transport-pool.ts} +26 -26
  121. package/src/runtime/transport.ts +15 -8
  122. package/src/types/index.ts +10 -1
  123. package/src/utils/cache.ts +2 -2
  124. package/src/utils/codec.ts +217 -196
  125. package/src/version.ts +12 -0
  126. package/dist/runtime/http-io.d.ts.map +0 -1
  127. package/dist/runtime/http-io.js.map +0 -1
  128. package/dist/runtime/optimized-node.d.ts +0 -23
  129. package/dist/runtime/optimized-node.d.ts.map +0 -1
  130. package/dist/runtime/optimized-node.js +0 -23
  131. package/dist/runtime/optimized-node.js.map +0 -1
  132. package/dist/runtime/process-io.d.ts.map +0 -1
  133. package/dist/runtime/process-io.js.map +0 -1
  134. package/dist/runtime/protocol.d.ts +0 -3
  135. package/dist/runtime/protocol.d.ts.map +0 -1
  136. package/dist/runtime/protocol.js +0 -3
  137. package/dist/runtime/protocol.js.map +0 -1
  138. package/dist/runtime/pyodide-io.d.ts.map +0 -1
  139. package/dist/runtime/pyodide-io.js.map +0 -1
  140. package/dist/runtime/safe-codec.d.ts.map +0 -1
  141. package/dist/runtime/safe-codec.js.map +0 -1
  142. package/dist/runtime/worker-pool.d.ts.map +0 -1
  143. package/dist/runtime/worker-pool.js.map +0 -1
  144. package/dist/utils/bundle-optimizer.d.ts +0 -182
  145. package/dist/utils/bundle-optimizer.d.ts.map +0 -1
  146. package/dist/utils/bundle-optimizer.js +0 -680
  147. package/dist/utils/bundle-optimizer.js.map +0 -1
  148. package/dist/utils/memory-profiler.d.ts +0 -123
  149. package/dist/utils/memory-profiler.d.ts.map +0 -1
  150. package/dist/utils/memory-profiler.js +0 -426
  151. package/dist/utils/memory-profiler.js.map +0 -1
  152. package/src/runtime/optimized-node.ts +0 -25
  153. package/src/runtime/protocol.ts +0 -2
  154. package/src/utils/bundle-optimizer.ts +0 -876
  155. package/src/utils/memory-profiler.ts +0 -582
@@ -17,6 +17,7 @@ import type {
17
17
  OutputConfig,
18
18
  RuntimeConfig,
19
19
  PerformanceConfig,
20
+ PythonModuleConfig,
20
21
  } from '../types/index.js';
21
22
  import { getDefaultPythonPath } from '../utils/python.js';
22
23
 
@@ -114,94 +115,174 @@ function detectLegacyFields(config: TywrapConfig): void {
114
115
  }
115
116
  }
116
117
 
117
- /**
118
- * Validate configuration values and throw user-friendly errors when invalid.
119
- */
120
- function validateConfig(config: ResolvedTywrapConfig): void {
121
- detectLegacyFields(config);
122
- const allowedTopLevel = new Set([
123
- 'pythonModules',
124
- 'pythonImportPath',
125
- 'output',
126
- 'runtime',
127
- 'performance',
128
- 'types',
129
- 'debug',
130
- ]);
131
- for (const key of Object.keys(config)) {
132
- if (!allowedTopLevel.has(key)) {
133
- throw new Error(`Unknown configuration option \"${key}\"`);
118
+ const ALLOWED_TOP_LEVEL = new Set([
119
+ 'pythonModules',
120
+ 'pythonImportPath',
121
+ 'output',
122
+ 'runtime',
123
+ 'performance',
124
+ 'types',
125
+ 'debug',
126
+ ]);
127
+
128
+ const VALID_OUTPUT_FORMATS = ['esm', 'cjs', 'both'];
129
+ const VALID_COMPRESSION = ['auto', 'gzip', 'brotli', 'none'];
130
+ const VALID_TYPE_HINTS = ['strict', 'loose', 'ignore'];
131
+ const VALID_TYPE_PRESETS = new Set([
132
+ 'numpy',
133
+ 'pandas',
134
+ 'pydantic',
135
+ 'stdlib',
136
+ 'scipy',
137
+ 'torch',
138
+ 'sklearn',
139
+ ]);
140
+
141
+ function isStringArray(value: unknown): value is string[] {
142
+ return Array.isArray(value) && value.every(item => typeof item === 'string');
143
+ }
144
+
145
+ function validateModuleConfig(scope: string, moduleConfig: PythonModuleConfig): void {
146
+ const { version, alias, functions, classes, exclude, excludePatterns, typeHints } = moduleConfig;
147
+ if (version !== undefined && typeof version !== 'string') {
148
+ throw new Error(`${scope}.version must be a string`);
149
+ }
150
+ if (alias !== undefined && typeof alias !== 'string') {
151
+ throw new Error(`${scope}.alias must be a string`);
152
+ }
153
+ const stringArrayFields: ReadonlyArray<readonly [string, unknown]> = [
154
+ ['functions', functions],
155
+ ['classes', classes],
156
+ ['exclude', exclude],
157
+ ['excludePatterns', excludePatterns],
158
+ ];
159
+ for (const [field, value] of stringArrayFields) {
160
+ if (value !== undefined && !isStringArray(value)) {
161
+ throw new Error(`${scope}.${field} must be an array of strings`);
134
162
  }
135
163
  }
136
-
137
- const out: OutputConfig = config.output;
138
- if (typeof out.dir !== 'string') {
139
- throw new Error('output.dir must be a string');
164
+ if (typeHints !== undefined && !VALID_TYPE_HINTS.includes(typeHints)) {
165
+ throw new Error(`${scope}.typeHints must be one of ${VALID_TYPE_HINTS.join(', ')}`);
140
166
  }
167
+ // Note: `runtime` is a deprecated, dead per-module field (see PythonModuleConfig).
168
+ // It is intentionally not validated so legacy configs keep loading; it has no effect.
169
+ }
141
170
 
142
- if (config.pythonImportPath !== undefined) {
143
- if (
144
- !Array.isArray(config.pythonImportPath) ||
145
- config.pythonImportPath.some(p => typeof p !== 'string')
146
- ) {
147
- throw new Error('pythonImportPath must be an array of strings');
171
+ function validatePythonModules(modules: Record<string, PythonModuleConfig>): void {
172
+ for (const [name, moduleConfig] of Object.entries(modules)) {
173
+ if (!isPlainObject(moduleConfig)) {
174
+ throw new Error(`pythonModules.${name} must be an object`);
148
175
  }
176
+ validateModuleConfig(`pythonModules.${name}`, moduleConfig);
177
+ }
178
+ }
179
+
180
+ function validateOutput(out: OutputConfig): void {
181
+ if (typeof out.dir !== 'string') {
182
+ throw new Error('output.dir must be a string');
149
183
  }
150
- if (!['esm', 'cjs', 'both'].includes(out.format)) {
184
+ if (!VALID_OUTPUT_FORMATS.includes(out.format)) {
151
185
  throw new Error('output.format must be one of "esm", "cjs" or "both"');
152
186
  }
153
187
  if (typeof out.declaration !== 'boolean' || typeof out.sourceMap !== 'boolean') {
154
188
  throw new Error('output.declaration and output.sourceMap must be boolean');
155
189
  }
190
+ }
156
191
 
157
- const runtime: RuntimeConfig = config.runtime;
192
+ function validateRuntime(runtime: RuntimeConfig): void {
158
193
  if (runtime.node) {
159
194
  if (runtime.node.pythonPath !== undefined && typeof runtime.node.pythonPath !== 'string') {
160
195
  throw new Error('runtime.node.pythonPath must be a string');
161
196
  }
162
- if (runtime.node.timeout !== undefined) {
163
- if (typeof runtime.node.timeout !== 'number' || runtime.node.timeout < 0) {
164
- throw new Error('runtime.node.timeout must be a non-negative number');
165
- }
197
+ if (runtime.node.virtualEnv !== undefined && typeof runtime.node.virtualEnv !== 'string') {
198
+ throw new Error('runtime.node.virtualEnv must be a string');
199
+ }
200
+ if (
201
+ runtime.node.timeout !== undefined &&
202
+ (typeof runtime.node.timeout !== 'number' || runtime.node.timeout < 0)
203
+ ) {
204
+ throw new Error('runtime.node.timeout must be a non-negative number');
205
+ }
206
+ }
207
+ if (runtime.http) {
208
+ if (typeof runtime.http.baseURL !== 'string' || runtime.http.baseURL.length === 0) {
209
+ throw new Error('runtime.http.baseURL must be a non-empty string');
210
+ }
211
+ if (
212
+ runtime.http.timeout !== undefined &&
213
+ (typeof runtime.http.timeout !== 'number' || runtime.http.timeout < 0)
214
+ ) {
215
+ throw new Error('runtime.http.timeout must be a non-negative number');
216
+ }
217
+ }
218
+ if (runtime.pyodide) {
219
+ if (runtime.pyodide.indexURL !== undefined && typeof runtime.pyodide.indexURL !== 'string') {
220
+ throw new Error('runtime.pyodide.indexURL must be a string');
221
+ }
222
+ if (runtime.pyodide.packages !== undefined && !isStringArray(runtime.pyodide.packages)) {
223
+ throw new Error('runtime.pyodide.packages must be an array of strings');
166
224
  }
167
225
  }
226
+ }
168
227
 
169
- const perf: PerformanceConfig = config.performance;
228
+ function validatePerformance(perf: PerformanceConfig): void {
170
229
  if (typeof perf.caching !== 'boolean') {
171
230
  throw new Error('performance.caching must be a boolean');
172
231
  }
173
232
  if (typeof perf.batching !== 'boolean') {
174
233
  throw new Error('performance.batching must be a boolean');
175
234
  }
176
- const validCompression = ['auto', 'gzip', 'brotli', 'none'];
177
- if (!validCompression.includes(perf.compression)) {
178
- throw new Error(`performance.compression must be one of ${validCompression.join(', ')}`);
235
+ if (!VALID_COMPRESSION.includes(perf.compression)) {
236
+ throw new Error(`performance.compression must be one of ${VALID_COMPRESSION.join(', ')}`);
179
237
  }
238
+ }
180
239
 
181
- if (config.types) {
182
- const presets = config.types.presets;
183
- const validPresets = new Set([
184
- 'numpy',
185
- 'pandas',
186
- 'pydantic',
187
- 'stdlib',
188
- 'scipy',
189
- 'torch',
190
- 'sklearn',
191
- ]);
192
- if (presets !== undefined) {
193
- if (!Array.isArray(presets) || presets.some(p => typeof p !== 'string')) {
194
- throw new Error('types.presets must be an array of strings');
195
- }
196
- for (const preset of presets) {
197
- if (!validPresets.has(preset)) {
198
- throw new Error(
199
- `types.presets contains invalid value "${preset}". Allowed: ${Array.from(validPresets).join(', ')}`
200
- );
201
- }
202
- }
240
+ function validateTypes(types: NonNullable<ResolvedTywrapConfig['types']>): void {
241
+ const presets = types.presets;
242
+ if (presets === undefined) {
243
+ return;
244
+ }
245
+ if (!isStringArray(presets)) {
246
+ throw new Error('types.presets must be an array of strings');
247
+ }
248
+ for (const preset of presets) {
249
+ if (!VALID_TYPE_PRESETS.has(preset)) {
250
+ throw new Error(
251
+ `types.presets contains invalid value "${preset}". Allowed: ${Array.from(VALID_TYPE_PRESETS).join(', ')}`
252
+ );
203
253
  }
204
254
  }
255
+ }
256
+
257
+ /**
258
+ * Validate configuration values and throw user-friendly errors when invalid.
259
+ */
260
+ function validateConfig(config: ResolvedTywrapConfig): void {
261
+ detectLegacyFields(config);
262
+ for (const key of Object.keys(config)) {
263
+ if (!ALLOWED_TOP_LEVEL.has(key)) {
264
+ throw new Error(`Unknown configuration option \"${key}\"`);
265
+ }
266
+ }
267
+
268
+ if (config.pythonModules !== undefined) {
269
+ if (!isPlainObject(config.pythonModules)) {
270
+ throw new Error('pythonModules must be an object');
271
+ }
272
+ validatePythonModules(config.pythonModules);
273
+ }
274
+
275
+ if (config.pythonImportPath !== undefined && !isStringArray(config.pythonImportPath)) {
276
+ throw new Error('pythonImportPath must be an array of strings');
277
+ }
278
+
279
+ validateOutput(config.output);
280
+ validateRuntime(config.runtime);
281
+ validatePerformance(config.performance);
282
+
283
+ if (config.types) {
284
+ validateTypes(config.types);
285
+ }
205
286
 
206
287
  if (typeof config.debug !== 'boolean') {
207
288
  throw new Error('debug must be a boolean');
@@ -264,128 +345,153 @@ export async function loadConfigFile(configFile: string): Promise<Partial<Tywrap
264
345
  throw new Error(`Configuration file not found: ${resolved}`);
265
346
  }
266
347
 
267
- if (ext === '.json') {
268
- const txt = await safeReadFileAsync(resolved);
348
+ // Dispatch by file extension. Each loader receives the resolved absolute path
349
+ // and the lowercased extension and returns the parsed config object.
350
+ const loader = CONFIG_LOADERS[ext];
351
+ if (!loader) {
352
+ throw new Error(`Unsupported configuration file extension: ${ext}`);
353
+ }
354
+ return loader(resolved, ext);
355
+ }
356
+
357
+ async function loadJsonConfig(resolved: string): Promise<Partial<TywrapOptions>> {
358
+ const txt = await safeReadFileAsync(resolved);
359
+ try {
360
+ const parsed = JSON.parse(txt) as unknown;
361
+ return ensureConfigObject(parsed, resolved);
362
+ } catch (err) {
363
+ throw new Error(`Failed to parse JSON config ${resolved}: ${(err as Error).message}`);
364
+ }
365
+ }
366
+
367
+ async function loadCjsConfig(resolved: string): Promise<Partial<TywrapOptions>> {
368
+ const require = createRequire(import.meta.url);
369
+ // eslint-disable-next-line security/detect-non-literal-require -- config path is user-controlled and resolved
370
+ const mod = require(resolved) as Record<string, unknown>;
371
+ const loaded = mod.default ?? mod;
372
+ return ensureConfigObject(loaded ?? {}, resolved);
373
+ }
374
+
375
+ async function loadEsmConfig(resolved: string): Promise<Partial<TywrapOptions>> {
376
+ const mod = (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
377
+ const loaded = mod.default ?? mod;
378
+ return ensureConfigObject(loaded ?? {}, resolved);
379
+ }
380
+
381
+ async function loadTypeScriptConfig(
382
+ resolved: string,
383
+ ext: string
384
+ ): Promise<Partial<TywrapOptions>> {
385
+ const ts = await import('typescript');
386
+ const source = await safeReadFileAsync(resolved);
387
+ // Why: many configs want to `import { defineConfig } from 'tywrap'`. The tywrap package is ESM,
388
+ // so evaluating a transpiled CommonJS config would try `require('tywrap')` and fail. Treat
389
+ // `.ts`/`.mts` configs as ESM, and only treat `.cts` as CommonJS to match Node conventions.
390
+ const emitCommonJs = ext === '.cts';
391
+ const output = ts.transpileModule(source, {
392
+ compilerOptions: {
393
+ module: emitCommonJs ? ts.ModuleKind.CommonJS : ts.ModuleKind.ES2020,
394
+ target: ts.ScriptTarget.ES2020,
395
+ esModuleInterop: true,
396
+ },
397
+ fileName: resolved,
398
+ });
399
+
400
+ let transpiledOutput = output.outputText;
401
+ if (!emitCommonJs) {
269
402
  try {
270
- const parsed = JSON.parse(txt) as unknown;
271
- return ensureConfigObject(parsed, resolved);
272
- } catch (err) {
273
- throw new Error(`Failed to parse JSON config ${resolved}: ${(err as Error).message}`);
403
+ // Preserve support for `import { defineConfig } from 'tywrap'` when ESM config
404
+ // is evaluated from an OS temp directory outside the package scope.
405
+ const tywrapEntryHref = await import.meta.resolve('tywrap');
406
+ transpiledOutput = transpiledOutput
407
+ .replaceAll("'tywrap'", `'${tywrapEntryHref}'`)
408
+ .replaceAll('\"tywrap\"', `\"${tywrapEntryHref}\"`);
409
+ } catch {
410
+ // Best-effort: leave source as-is when tywrap cannot be resolved.
274
411
  }
275
412
  }
276
413
 
277
- if (ext === '.cjs') {
414
+ if (emitCommonJs) {
415
+ // Why: `.cts` is explicitly CommonJS. We evaluate the transpiled output in-memory using
416
+ // Node's Module internals (`Module._compile` / `_nodeModulePaths`) to avoid writing an extra
417
+ // temp file. These are private Node APIs, so we keep this tooling path scoped and rely on
418
+ // supported Node versions (see package.json engines).
278
419
  const require = createRequire(import.meta.url);
279
- // eslint-disable-next-line security/detect-non-literal-require -- config path is user-controlled and resolved
280
- const mod = require(resolved) as Record<string, unknown>;
281
- const loaded = mod.default ?? mod;
282
- return ensureConfigObject(loaded ?? {}, resolved);
283
- }
284
-
285
- if (ext === '.js' || ext === '.mjs') {
286
- const mod = (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
287
- const loaded = mod.default ?? mod;
420
+ const nodeModule = require('module') as typeof import('module');
421
+ const moduleCtor = nodeModule.Module as unknown as typeof import('module').Module & {
422
+ _nodeModulePaths?: (path: string) => string[];
423
+ };
424
+ if (typeof moduleCtor !== 'function') {
425
+ throw new Error(
426
+ '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node Module constructor'
427
+ );
428
+ }
429
+ const nodeModulePaths = moduleCtor._nodeModulePaths;
430
+ if (typeof nodeModulePaths !== 'function') {
431
+ throw new Error(
432
+ '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node private API Module._nodeModulePaths'
433
+ );
434
+ }
435
+ const mod = new moduleCtor(resolved) as import('module').Module & {
436
+ _compile?: (code: string, filename: string) => void;
437
+ };
438
+ const compile = mod._compile;
439
+ if (typeof compile !== 'function') {
440
+ throw new Error(
441
+ '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node private API Module._compile'
442
+ );
443
+ }
444
+ mod.filename = resolved;
445
+ mod.paths = nodeModulePaths(dirname(resolved));
446
+ compile.call(mod, output.outputText, resolved);
447
+ const loaded = (mod.exports as Record<string, unknown>).default ?? mod.exports;
288
448
  return ensureConfigObject(loaded ?? {}, resolved);
289
449
  }
290
450
 
291
- if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
292
- const ts = await import('typescript');
293
- const source = await safeReadFileAsync(resolved);
294
- // Why: many configs want to `import { defineConfig } from 'tywrap'`. The tywrap package is ESM,
295
- // so evaluating a transpiled CommonJS config would try `require('tywrap')` and fail. Treat
296
- // `.ts`/`.mts` configs as ESM, and only treat `.cts` as CommonJS to match Node conventions.
297
- const emitCommonJs = ext === '.cts';
298
- const output = ts.transpileModule(source, {
299
- compilerOptions: {
300
- module: emitCommonJs ? ts.ModuleKind.CommonJS : ts.ModuleKind.ES2020,
301
- target: ts.ScriptTarget.ES2020,
302
- esModuleInterop: true,
303
- },
304
- fileName: resolved,
305
- });
306
-
307
- let transpiledOutput = output.outputText;
308
- if (!emitCommonJs) {
451
+ // Why: Node can't import ESM from a string without a custom loader. We write transpiled
452
+ // output to a temporary `.mjs` file under the OS temp directory (not next to user config),
453
+ // then clean up both file and temporary directory after loading.
454
+ const tempDir = await mkdtemp(resolve(tmpdir(), 'tywrap-config-'));
455
+ const tmpPath = resolve(tempDir, `.tywrap.config.${randomUUID()}.mjs`);
456
+ try {
457
+ const localNodeModules = resolve(process.cwd(), 'node_modules');
458
+ if (safeExists(localNodeModules)) {
459
+ const tempNodeModules = resolve(tempDir, 'node_modules');
309
460
  try {
310
- // Preserve support for `import { defineConfig } from 'tywrap'` when ESM config
311
- // is evaluated from an OS temp directory outside the package scope.
312
- const tywrapEntryHref = await import.meta.resolve('tywrap');
313
- transpiledOutput = transpiledOutput
314
- .replaceAll("'tywrap'", `'${tywrapEntryHref}'`)
315
- .replaceAll('\"tywrap\"', `\"${tywrapEntryHref}\"`);
461
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from OS temp directory
462
+ await symlink(localNodeModules, tempNodeModules, 'dir');
316
463
  } catch {
317
- // Best-effort: leave source as-is when tywrap cannot be resolved.
318
- }
319
- }
320
-
321
- if (emitCommonJs) {
322
- // Why: `.cts` is explicitly CommonJS. We evaluate the transpiled output in-memory using
323
- // Node's Module internals (`Module._compile` / `_nodeModulePaths`) to avoid writing an extra
324
- // temp file. These are private Node APIs, so we keep this tooling path scoped and rely on
325
- // supported Node versions (see package.json engines).
326
- const require = createRequire(import.meta.url);
327
- const nodeModule = require('module') as typeof import('module');
328
- const moduleCtor = nodeModule.Module as unknown as typeof import('module').Module & {
329
- _nodeModulePaths?: (path: string) => string[];
330
- };
331
- if (typeof moduleCtor !== 'function') {
332
- throw new Error(
333
- '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node Module constructor'
334
- );
335
- }
336
- const nodeModulePaths = moduleCtor._nodeModulePaths;
337
- if (typeof nodeModulePaths !== 'function') {
338
- throw new Error(
339
- '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node private API Module._nodeModulePaths'
340
- );
464
+ // Best-effort only; regular resolution may still succeed without a symlink.
341
465
  }
342
- const mod = new moduleCtor(resolved) as import('module').Module & {
343
- _compile?: (code: string, filename: string) => void;
344
- };
345
- const compile = mod._compile;
346
- if (typeof compile !== 'function') {
347
- throw new Error(
348
- '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node private API Module._compile'
349
- );
350
- }
351
- mod.filename = resolved;
352
- mod.paths = nodeModulePaths(dirname(resolved));
353
- compile.call(mod, output.outputText, resolved);
354
- const loaded = (mod.exports as Record<string, unknown>).default ?? mod.exports;
355
- return ensureConfigObject(loaded ?? {}, resolved);
356
466
  }
357
467
 
358
- // Why: Node can't import ESM from a string without a custom loader. We write transpiled
359
- // output to a temporary `.mjs` file under the OS temp directory (not next to user config),
360
- // then clean up both file and temporary directory after loading.
361
- const tempDir = await mkdtemp(resolve(tmpdir(), 'tywrap-config-'));
362
- const tmpPath = resolve(tempDir, `.tywrap.config.${randomUUID()}.mjs`);
363
- try {
364
- const localNodeModules = resolve(process.cwd(), 'node_modules');
365
- if (safeExists(localNodeModules)) {
366
- const tempNodeModules = resolve(tempDir, 'node_modules');
367
- try {
368
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from OS temp directory
369
- await symlink(localNodeModules, tempNodeModules, 'dir');
370
- } catch {
371
- // Best-effort only; regular resolution may still succeed without a symlink.
372
- }
373
- }
374
-
375
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from OS temp directory
376
- await writeFile(tmpPath, transpiledOutput, 'utf-8');
377
- const mod = (await import(pathToFileURL(tmpPath).href)) as Record<string, unknown>;
378
- const loaded = mod.default ?? mod;
379
- return ensureConfigObject(loaded ?? {}, resolved);
380
- } finally {
381
- await rm(tmpPath, { force: true });
382
- await rm(tempDir, { recursive: true, force: true });
383
- }
468
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from OS temp directory
469
+ await writeFile(tmpPath, transpiledOutput, 'utf-8');
470
+ const mod = (await import(pathToFileURL(tmpPath).href)) as Record<string, unknown>;
471
+ const loaded = mod.default ?? mod;
472
+ return ensureConfigObject(loaded ?? {}, resolved);
473
+ } finally {
474
+ await rm(tmpPath, { force: true });
475
+ await rm(tempDir, { recursive: true, force: true });
384
476
  }
385
-
386
- throw new Error(`Unsupported configuration file extension: ${ext}`);
387
477
  }
388
478
 
479
+ /**
480
+ * Loaders for each supported configuration file extension. The map keys are the
481
+ * lowercased extensions returned by `extname`; the matching loader parses the file.
482
+ */
483
+ const CONFIG_LOADERS: Readonly<
484
+ Record<string, (resolved: string, ext: string) => Promise<Partial<TywrapOptions>>>
485
+ > = {
486
+ '.json': loadJsonConfig,
487
+ '.cjs': loadCjsConfig,
488
+ '.js': loadEsmConfig,
489
+ '.mjs': loadEsmConfig,
490
+ '.ts': loadTypeScriptConfig,
491
+ '.mts': loadTypeScriptConfig,
492
+ '.cts': loadTypeScriptConfig,
493
+ };
494
+
389
495
  async function safeReadFileAsync(path: string): Promise<string> {
390
496
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- config path is user-controlled and resolved
391
497
  return readFile(path, 'utf-8');