@grest-ts/common 0.0.6 → 0.0.7

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.
@@ -1,314 +1,316 @@
1
- import fg from 'fast-glob';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import {pathToFileURL} from 'url';
5
-
6
- const TYPES_FILE = 'index.d.ts';
7
- const LOCK_FILE = 'index.d.ts.lock';
8
-
9
- const CACHE_START = '/* @cache-start ';
10
- const CACHE_END = ' @cache-end */';
11
-
12
- interface ExtensionCache {
13
- lockfileMtime: number;
14
- extensions: string[];
15
- }
16
-
17
- /**
18
- * Discovers extensions by scanning node_modules for packages
19
- * that follow the convention of having a {name}/index-{name}.ts file.
20
- *
21
- * For example, with name="testkit":
22
- * - Scans for: testkit/index-testkit.ts
23
- * - Types dir: node_modules/@types/grest-ts-testkits
24
- *
25
- * Generates:
26
- * - node_modules/@types/grest-ts-{name}s/index.d.ts - For IDE type completion (triple-slash references)
27
- *
28
- * Runtime loading is done via dynamic imports of discovered extensions.
29
- */
30
- export class GGExtensionDiscovery {
31
-
32
- private static loadedExtensions = new Set<string>();
33
-
34
- private readonly name: string;
35
- private readonly typesDir: string;
36
- private readonly filePattern: string;
37
-
38
- /**
39
- * Create a new extension discovery instance.
40
- * @param name The extension name (e.g., "testkit", "codegen")
41
- */
42
- constructor(name: string) {
43
- this.name = name;
44
- this.typesDir = `node_modules/@types/grest-ts-${name}s`;
45
- this.filePattern = `${name}/index-${name}.ts`;
46
- }
47
-
48
- /**
49
- * Generate types file for IDE support without loading extensions.
50
- * Use this during build/check steps to ensure IDE has proper type completion.
51
- */
52
- public async generateTypes(): Promise<void> {
53
- const cwd = process.cwd();
54
- const typesDir = path.join(cwd, this.typesDir);
55
- const typesFile = path.join(typesDir, TYPES_FILE);
56
-
57
- const lockfileMtime = this.getLockfileMtime(cwd);
58
- const extensions = await this.scan(cwd);
59
- this.writeTypesFile(typesFile, extensions, typesDir, lockfileMtime);
60
- console.log(`[GG${this.capitalize(this.name)}] Generated types for ${extensions.length} ${this.name}(s)`);
61
- }
62
-
63
- /**
64
- * Discover and load all extensions.
65
- * - Scans for extension packages
66
- * - Generates .d.ts file for IDE support
67
- * - Dynamically imports extensions for runtime
68
- */
69
- public async load(): Promise<void> {
70
- if (GGExtensionDiscovery.loadedExtensions.has(this.name)) {
71
- return;
72
- }
73
- GGExtensionDiscovery.loadedExtensions.add(this.name);
74
-
75
- const cwd = process.cwd();
76
- const typesDir = path.join(cwd, this.typesDir);
77
- const typesFile = path.join(typesDir, TYPES_FILE);
78
- const lockFile = path.join(typesDir, LOCK_FILE);
79
-
80
- // Try to acquire lock
81
- if (this.acquireLock(lockFile)) {
82
- try {
83
- await this.discoverAndLoad(cwd, typesFile, typesDir);
84
- } finally {
85
- this.releaseLock(lockFile);
86
- }
87
- } else {
88
- // Wait for lock to be released, then load from cache
89
- await this.waitForLock(lockFile);
90
- await this.loadFromCache(typesFile);
91
- }
92
- }
93
-
94
- private acquireLock(lockFile: string): boolean {
95
- try {
96
- fs.mkdirSync(path.dirname(lockFile), {recursive: true});
97
- fs.writeFileSync(lockFile, String(process.pid), {flag: 'wx'});
98
- return true;
99
- } catch {
100
- return false;
101
- }
102
- }
103
-
104
- private releaseLock(lockFile: string): void {
105
- try {
106
- fs.unlinkSync(lockFile);
107
- } catch {
108
- // Ignore
109
- }
110
- }
111
-
112
- private async waitForLock(lockFile: string, timeout = 30000): Promise<void> {
113
- const start = Date.now();
114
- while (Date.now() - start < timeout) {
115
- if (!fs.existsSync(lockFile)) {
116
- return;
117
- }
118
- await new Promise(r => setTimeout(r, 50));
119
- }
120
- // Timeout - try to clean up stale lock
121
- this.releaseLock(lockFile);
122
- }
123
-
124
- private async discoverAndLoad(cwd: string, typesFile: string, typesDir: string): Promise<void> {
125
- const lockfileMtime = this.getLockfileMtime(cwd);
126
-
127
- // Check cache embedded in types file
128
- const cached = this.readCache(typesFile);
129
- let extensions: string[];
130
-
131
- if (cached && cached.lockfileMtime === lockfileMtime) {
132
- extensions = cached.extensions;
133
- } else {
134
- extensions = await this.scan(cwd);
135
- this.writeTypesFile(typesFile, extensions, typesDir, lockfileMtime);
136
- console.log(`[GG${this.capitalize(this.name)}] Discovered ${extensions.length} ${this.name}(s)`);
137
- }
138
-
139
- // Dynamically import all extensions
140
- for (const extension of extensions) {
141
- await import(pathToFileURL(extension).href);
142
- }
143
- }
144
-
145
- private async loadFromCache(typesFile: string): Promise<void> {
146
- const cached = this.readCache(typesFile);
147
-
148
- if (cached) {
149
- for (const extension of cached.extensions) {
150
- await import(pathToFileURL(extension).href);
151
- }
152
- }
153
- }
154
-
155
- public async scan(cwd: string): Promise<string[]> {
156
- const extensions: string[] = [];
157
-
158
- // Resolve extensions by reading package.json dependencies and walking up
159
- // node_modules directories (like Node.js module resolution).
160
- // This works regardless of hoisting, workspaces, pnpm, etc.
161
- const depNames = this.readDependencyNames(cwd);
162
-
163
- for (const dep of depNames) {
164
- const pkgDir = this.resolvePackageDir(dep, cwd);
165
- if (pkgDir) {
166
- // Check source path first (local dev with tsx), then compiled dist path (published packages)
167
- const sourceFile = path.join(pkgDir, this.filePattern);
168
- const distFile = path.join(pkgDir, 'dist', this.filePattern.replace(/\.ts$/, '.js'));
169
- if (fs.existsSync(sourceFile)) {
170
- extensions.push(sourceFile);
171
- } else if (fs.existsSync(distFile)) {
172
- extensions.push(distFile);
173
- }
174
- }
175
- }
176
-
177
- // Also scan monorepo packages/ directories (for framework development)
178
- const monorepoRoot = this.findMonorepoRoot(cwd);
179
- if (monorepoRoot) {
180
- const monorepoExtensions = await fg([
181
- `packages/*/${this.filePattern}`,
182
- `packages/*/*/${this.filePattern}`,
183
- `packages-*/*/${this.filePattern}`,
184
- `packages-*/*/*/${this.filePattern}`,
185
- ], {
186
- cwd: monorepoRoot,
187
- absolute: true,
188
- onlyFiles: true
189
- });
190
- extensions.push(...monorepoExtensions);
191
- }
192
-
193
- // Resolve symlinks to real paths before deduping to avoid loading same file twice
194
- // (e.g., node_modules/@grest-ts/foo -> packages/foo would otherwise be seen as different)
195
- const resolvedExtensions = extensions.map(ext => fs.realpathSync(ext));
196
- return [...new Set(resolvedExtensions)].sort();
197
- }
198
-
199
- /**
200
- * Resolve a package's install directory by walking up node_modules directories from cwd.
201
- * Mimics Node.js module resolution: checks cwd/node_modules/<pkg>, ../node_modules/<pkg>, etc.
202
- */
203
- private resolvePackageDir(dep: string, cwd: string): string | null {
204
- let dir = cwd;
205
- const root = path.parse(dir).root;
206
- while (dir !== root) {
207
- const pkgDir = path.join(dir, 'node_modules', dep);
208
- if (fs.existsSync(path.join(pkgDir, 'package.json'))) {
209
- return pkgDir;
210
- }
211
- dir = path.dirname(dir);
212
- }
213
- return null;
214
- }
215
-
216
- private readDependencyNames(cwd: string): string[] {
217
- try {
218
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
219
- return [
220
- ...Object.keys(pkg.dependencies || {}),
221
- ...Object.keys(pkg.devDependencies || {}),
222
- ];
223
- } catch {
224
- return [];
225
- }
226
- }
227
-
228
- public findMonorepoRoot(startDir: string): string | null {
229
- let currentDir = startDir;
230
- const root = path.parse(currentDir).root;
231
-
232
- while (currentDir !== root) {
233
- const packagesPath = path.join(currentDir, 'packages');
234
- try {
235
- const stat = fs.statSync(packagesPath);
236
- if (stat.isDirectory()) {
237
- return currentDir;
238
- }
239
- } catch {
240
- // Directory doesn't exist, continue up
241
- }
242
- currentDir = path.dirname(currentDir);
243
- }
244
-
245
- return null;
246
- }
247
-
248
- private getLockfileMtime(cwd: string): number {
249
- const lockfiles = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
250
-
251
- // Walk up directories to find lockfile (handles workspaces where lockfile is at root)
252
- let dir = cwd;
253
- const root = path.parse(dir).root;
254
- while (dir !== root) {
255
- for (const lockfile of lockfiles) {
256
- try {
257
- return fs.statSync(path.join(dir, lockfile)).mtimeMs;
258
- } catch {
259
- // File doesn't exist, try next
260
- }
261
- }
262
- dir = path.dirname(dir);
263
- }
264
- return 0;
265
- }
266
-
267
- private readCache(typesFile: string): ExtensionCache | null {
268
- try {
269
- const content = fs.readFileSync(typesFile, 'utf-8');
270
- const startIdx = content.indexOf(CACHE_START);
271
- const endIdx = content.indexOf(CACHE_END);
272
- if (startIdx === -1 || endIdx === -1) {
273
- return null;
274
- }
275
- const jsonStr = content.slice(startIdx + CACHE_START.length, endIdx).trim();
276
- return JSON.parse(jsonStr);
277
- } catch {
278
- return null;
279
- }
280
- }
281
-
282
- private writeTypesFile(typesFile: string, extensions: string[], typesDir: string, lockfileMtime: number): void {
283
- const lines = [
284
- `// Auto-generated by GGExtensionDiscovery (${this.name}) - DO NOT EDIT`,
285
- '// TypeScript automatically includes @types/* packages, so no tsconfig changes needed.',
286
- ''
287
- ];
288
-
289
- for (const extension of extensions) {
290
- const relativePath = path.relative(typesDir, extension).replace(/\\/g, '/');
291
- lines.push(`/// <reference path="${relativePath}" />`);
292
- }
293
-
294
- // Embed cache as JSON block comment at end of file (single line so TypeScript ignores it)
295
- lines.push('');
296
- lines.push(CACHE_START + JSON.stringify({lockfileMtime, extensions}) + CACHE_END);
297
- lines.push('');
298
-
299
- fs.mkdirSync(typesDir, {recursive: true});
300
- fs.writeFileSync(typesFile, lines.join('\n'));
301
-
302
- // Write package.json to make it a proper @types package
303
- const packageJson = {
304
- name: `@types/grest-ts-${this.name}s`,
305
- version: '1.0.0',
306
- types: 'index.d.ts'
307
- };
308
- fs.writeFileSync(path.join(typesDir, 'package.json'), JSON.stringify(packageJson, null, 2));
309
- }
310
-
311
- private capitalize(str: string): string {
312
- return str.charAt(0).toUpperCase() + str.slice(1);
313
- }
314
- }
1
+ import fg from 'fast-glob';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import {pathToFileURL} from 'url';
5
+
6
+ const TYPES_FILE = 'index.d.ts';
7
+ const LOCK_FILE = 'index.d.ts.lock';
8
+
9
+ const CACHE_START = '/* @cache-start ';
10
+ const CACHE_END = ' @cache-end */';
11
+
12
+ interface ExtensionCache {
13
+ lockfileMtime: number;
14
+ extensions: string[];
15
+ }
16
+
17
+ /**
18
+ * Discovers extensions by scanning node_modules for packages
19
+ * that follow the convention of having a {name}/index-{name}.ts file.
20
+ *
21
+ * For example, with name="testkit":
22
+ * - Scans for: testkit/index-testkit.ts
23
+ * - Types dir: node_modules/@types/grest-ts-testkits
24
+ *
25
+ * Generates:
26
+ * - node_modules/@types/grest-ts-{name}s/index.d.ts - For IDE type completion (triple-slash references)
27
+ *
28
+ * Runtime loading is done via dynamic imports of discovered extensions.
29
+ */
30
+ export class GGExtensionDiscovery {
31
+
32
+ private static loadedExtensions = new Set<string>();
33
+
34
+ private readonly name: string;
35
+ private readonly typesDir: string;
36
+ private readonly filePattern: string;
37
+
38
+ /**
39
+ * Create a new extension discovery instance.
40
+ * @param name The extension name (e.g., "testkit", "codegen")
41
+ */
42
+ constructor(name: string) {
43
+ this.name = name;
44
+ this.typesDir = `node_modules/@types/grest-ts-${name}s`;
45
+ this.filePattern = `${name}/index-${name}.ts`;
46
+ }
47
+
48
+ /**
49
+ * Generate types file for IDE support without loading extensions.
50
+ * Use this during build/check steps to ensure IDE has proper type completion.
51
+ */
52
+ public async generateTypes(): Promise<void> {
53
+ const cwd = process.cwd();
54
+ const typesDir = path.join(cwd, this.typesDir);
55
+ const typesFile = path.join(typesDir, TYPES_FILE);
56
+
57
+ const lockfileMtime = this.getLockfileMtime(cwd);
58
+ const extensions = await this.scan(cwd);
59
+ this.writeTypesFile(typesFile, extensions, typesDir, lockfileMtime);
60
+ console.log(`[GG${this.capitalize(this.name)}] Generated types for ${extensions.length} ${this.name}(s)`);
61
+ }
62
+
63
+ /**
64
+ * Discover and load all extensions.
65
+ * - Scans for extension packages
66
+ * - Generates .d.ts file for IDE support
67
+ * - Dynamically imports extensions for runtime
68
+ */
69
+ public async load(): Promise<void> {
70
+ if (GGExtensionDiscovery.loadedExtensions.has(this.name)) {
71
+ return;
72
+ }
73
+ GGExtensionDiscovery.loadedExtensions.add(this.name);
74
+
75
+ const cwd = process.cwd();
76
+ const typesDir = path.join(cwd, this.typesDir);
77
+ const typesFile = path.join(typesDir, TYPES_FILE);
78
+ const lockFile = path.join(typesDir, LOCK_FILE);
79
+
80
+ // Try to acquire lock
81
+ if (this.acquireLock(lockFile)) {
82
+ try {
83
+ await this.discoverAndLoad(cwd, typesFile, typesDir);
84
+ } finally {
85
+ this.releaseLock(lockFile);
86
+ }
87
+ } else {
88
+ // Wait for lock to be released, then load from cache
89
+ await this.waitForLock(lockFile);
90
+ await this.loadFromCache(typesFile);
91
+ }
92
+ }
93
+
94
+ private acquireLock(lockFile: string): boolean {
95
+ try {
96
+ fs.mkdirSync(path.dirname(lockFile), {recursive: true});
97
+ fs.writeFileSync(lockFile, String(process.pid), {flag: 'wx'});
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ private releaseLock(lockFile: string): void {
105
+ try {
106
+ fs.unlinkSync(lockFile);
107
+ } catch {
108
+ // Ignore
109
+ }
110
+ }
111
+
112
+ private async waitForLock(lockFile: string, timeout = 30000): Promise<void> {
113
+ const start = Date.now();
114
+ while (Date.now() - start < timeout) {
115
+ if (!fs.existsSync(lockFile)) {
116
+ return;
117
+ }
118
+ await new Promise(r => setTimeout(r, 50));
119
+ }
120
+ // Timeout - try to clean up stale lock
121
+ this.releaseLock(lockFile);
122
+ }
123
+
124
+ private async discoverAndLoad(cwd: string, typesFile: string, typesDir: string): Promise<void> {
125
+ const lockfileMtime = this.getLockfileMtime(cwd);
126
+
127
+ // Check cache embedded in types file
128
+ const cached = this.readCache(typesFile);
129
+ let extensions: string[];
130
+
131
+ if (cached && cached.lockfileMtime === lockfileMtime) {
132
+ extensions = cached.extensions;
133
+ } else {
134
+ extensions = await this.scan(cwd);
135
+ this.writeTypesFile(typesFile, extensions, typesDir, lockfileMtime);
136
+ console.log(`[GG${this.capitalize(this.name)}] Discovered ${extensions.length} ${this.name}(s)`);
137
+ }
138
+
139
+ // Dynamically import all extensions
140
+ for (const extension of extensions) {
141
+ await import(pathToFileURL(extension).href);
142
+ }
143
+ }
144
+
145
+ private async loadFromCache(typesFile: string): Promise<void> {
146
+ const cached = this.readCache(typesFile);
147
+
148
+ if (cached) {
149
+ for (const extension of cached.extensions) {
150
+ await import(pathToFileURL(extension).href);
151
+ }
152
+ }
153
+ }
154
+
155
+ public async scan(cwd: string): Promise<string[]> {
156
+ const extensions: string[] = [];
157
+
158
+ // Resolve extensions by reading package.json dependencies and walking up
159
+ // node_modules directories (like Node.js module resolution).
160
+ // This works regardless of hoisting, workspaces, pnpm, etc.
161
+ const depNames = this.readDependencyNames(cwd);
162
+
163
+ for (const dep of depNames) {
164
+ const pkgDir = this.resolvePackageDir(dep, cwd);
165
+ if (pkgDir) {
166
+ // Check source path first (local dev with tsx), then compiled dist path (published packages)
167
+ const sourceFile = path.join(pkgDir, this.filePattern);
168
+ const distFile = path.join(pkgDir, 'dist', this.filePattern.replace(/\.ts$/, '.js'));
169
+ if (fs.existsSync(sourceFile)) {
170
+ extensions.push(sourceFile);
171
+ } else if (fs.existsSync(distFile)) {
172
+ extensions.push(distFile);
173
+ }
174
+ }
175
+ }
176
+
177
+ // Also scan monorepo packages/ directories (for framework development)
178
+ const monorepoRoot = this.findMonorepoRoot(cwd);
179
+ if (monorepoRoot) {
180
+ const monorepoExtensions = await fg([
181
+ `packages/*/${this.filePattern}`,
182
+ `packages/*/*/${this.filePattern}`,
183
+ `packages-*/*/${this.filePattern}`,
184
+ `packages-*/*/*/${this.filePattern}`,
185
+ ], {
186
+ cwd: monorepoRoot,
187
+ absolute: true,
188
+ onlyFiles: true
189
+ });
190
+ extensions.push(...monorepoExtensions);
191
+ }
192
+
193
+ // Resolve symlinks to real paths before deduping to avoid loading same file twice
194
+ // (e.g., node_modules/@grest-ts/foo -> packages/foo would otherwise be seen as different)
195
+ const resolvedExtensions = extensions.map(ext => fs.realpathSync(ext));
196
+ return [...new Set(resolvedExtensions)].sort();
197
+ }
198
+
199
+ /**
200
+ * Resolve a package's install directory by walking up node_modules directories from cwd.
201
+ * Mimics Node.js module resolution: checks cwd/node_modules/<pkg>, ../node_modules/<pkg>, etc.
202
+ */
203
+ private resolvePackageDir(dep: string, cwd: string): string | null {
204
+ let dir = cwd;
205
+ const root = path.parse(dir).root;
206
+ while (dir !== root) {
207
+ const pkgDir = path.join(dir, 'node_modules', dep);
208
+ if (fs.existsSync(path.join(pkgDir, 'package.json'))) {
209
+ return pkgDir;
210
+ }
211
+ dir = path.dirname(dir);
212
+ }
213
+ return null;
214
+ }
215
+
216
+ private readDependencyNames(cwd: string): string[] {
217
+ try {
218
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
219
+ return [
220
+ ...Object.keys(pkg.dependencies || {}),
221
+ ...Object.keys(pkg.devDependencies || {}),
222
+ ];
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ public findMonorepoRoot(startDir: string): string | null {
229
+ let currentDir = startDir;
230
+ const root = path.parse(currentDir).root;
231
+
232
+ while (currentDir !== root) {
233
+ const packagesPath = path.join(currentDir, 'packages');
234
+ try {
235
+ const stat = fs.statSync(packagesPath);
236
+ if (stat.isDirectory()) {
237
+ return currentDir;
238
+ }
239
+ } catch {
240
+ // Directory doesn't exist, continue up
241
+ }
242
+ currentDir = path.dirname(currentDir);
243
+ }
244
+
245
+ return null;
246
+ }
247
+
248
+ private getLockfileMtime(cwd: string): number {
249
+ const lockfiles = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
250
+
251
+ // Walk up directories to find lockfile (handles workspaces where lockfile is at root)
252
+ let dir = cwd;
253
+ const root = path.parse(dir).root;
254
+ while (dir !== root) {
255
+ for (const lockfile of lockfiles) {
256
+ try {
257
+ return fs.statSync(path.join(dir, lockfile)).mtimeMs;
258
+ } catch {
259
+ // File doesn't exist, try next
260
+ }
261
+ }
262
+ dir = path.dirname(dir);
263
+ }
264
+ return 0;
265
+ }
266
+
267
+ private readCache(typesFile: string): ExtensionCache | null {
268
+ try {
269
+ const content = fs.readFileSync(typesFile, 'utf-8');
270
+ const startIdx = content.indexOf(CACHE_START);
271
+ const endIdx = content.indexOf(CACHE_END);
272
+ if (startIdx === -1 || endIdx === -1) {
273
+ return null;
274
+ }
275
+ const jsonStr = content.slice(startIdx + CACHE_START.length, endIdx).trim();
276
+ return JSON.parse(jsonStr);
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ private writeTypesFile(typesFile: string, extensions: string[], typesDir: string, lockfileMtime: number): void {
283
+ const lines = [
284
+ `// Auto-generated by GGExtensionDiscovery (${this.name}) - DO NOT EDIT`,
285
+ '// TypeScript automatically includes @types/* packages, so no tsconfig changes needed.',
286
+ ''
287
+ ];
288
+
289
+ for (const extension of extensions) {
290
+ // Source .ts files can be referenced directly; compiled .js files need their .d.ts counterpart
291
+ const typesPath = extension.endsWith('.js') ? extension.replace(/\.js$/, '.d.ts') : extension;
292
+ const relativePath = path.relative(typesDir, typesPath).replace(/\\/g, '/');
293
+ lines.push(`/// <reference path="${relativePath}" />`);
294
+ }
295
+
296
+ // Embed cache as JSON block comment at end of file (single line so TypeScript ignores it)
297
+ lines.push('');
298
+ lines.push(CACHE_START + JSON.stringify({lockfileMtime, extensions}) + CACHE_END);
299
+ lines.push('');
300
+
301
+ fs.mkdirSync(typesDir, {recursive: true});
302
+ fs.writeFileSync(typesFile, lines.join('\n'));
303
+
304
+ // Write package.json to make it a proper @types package
305
+ const packageJson = {
306
+ name: `@types/grest-ts-${this.name}s`,
307
+ version: '1.0.0',
308
+ types: 'index.d.ts'
309
+ };
310
+ fs.writeFileSync(path.join(typesDir, 'package.json'), JSON.stringify(packageJson, null, 2));
311
+ }
312
+
313
+ private capitalize(str: string): string {
314
+ return str.charAt(0).toUpperCase() + str.slice(1);
315
+ }
316
+ }