@portel/photon-core 2.8.3 → 2.9.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.
- package/dist/base.d.ts +7 -7
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +8 -8
- package/dist/base.js.map +1 -1
- package/dist/collections/Collection.d.ts +2 -2
- package/dist/collections/Collection.js +2 -2
- package/dist/compiler.js +7 -7
- package/dist/compiler.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/instance-store.d.ts +64 -0
- package/dist/instance-store.d.ts.map +1 -0
- package/dist/instance-store.js +144 -0
- package/dist/instance-store.js.map +1 -0
- package/dist/memory.d.ts +2 -2
- package/dist/memory.js +2 -2
- package/dist/middleware.d.ts +69 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +570 -0
- package/dist/middleware.js.map +1 -0
- package/dist/schema-extractor.d.ts +111 -1
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +362 -10
- package/dist/schema-extractor.js.map +1 -1
- package/dist/stateful.d.ts +2 -0
- package/dist/stateful.d.ts.map +1 -1
- package/dist/stateful.js +2 -0
- package/dist/stateful.js.map +1 -1
- package/dist/types.d.ts +111 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/duration.d.ts +24 -0
- package/dist/utils/duration.d.ts.map +1 -0
- package/dist/utils/duration.js +64 -0
- package/dist/utils/duration.js.map +1 -0
- package/dist/watcher.d.ts +62 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +270 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +2 -2
- package/src/base.ts +8 -8
- package/src/collections/Collection.ts +2 -2
- package/src/compiler.ts +7 -7
- package/src/config.ts +1 -1
- package/src/index.ts +34 -4
- package/src/instance-store.ts +155 -0
- package/src/memory.ts +2 -2
- package/src/middleware.ts +714 -0
- package/src/schema-extractor.ts +381 -10
- package/src/stateful.ts +4 -0
- package/src/types.ts +106 -5
- package/src/utils/duration.ts +67 -0
- package/src/watcher.ts +317 -0
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhotonWatcher
|
|
3
|
+
*
|
|
4
|
+
* Reusable file watcher for .photon.ts/.photon.js files.
|
|
5
|
+
* Extracted from the daemon's battle-tested implementation with:
|
|
6
|
+
* - Symlink resolution (macOS fs.watch fix)
|
|
7
|
+
* - Debouncing (configurable, default 100ms)
|
|
8
|
+
* - Temp file filtering (.swp, .bak, ~, .DS_Store, vim 4913)
|
|
9
|
+
* - Rename handling (macOS sed -i new inode → re-establish watcher)
|
|
10
|
+
* - Directory watching for added/removed photons
|
|
11
|
+
*
|
|
12
|
+
* Zero new dependencies — uses Node.js fs.watch().
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as fsPromises from 'fs/promises';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
|
|
20
|
+
export interface PhotonWatcherOptions {
|
|
21
|
+
/** Directories to scan for photon files */
|
|
22
|
+
directories: string[];
|
|
23
|
+
/** File extensions to watch (default: ['.photon.ts', '.photon.js']) */
|
|
24
|
+
extensions?: string[];
|
|
25
|
+
/** Debounce interval in ms (default: 100) */
|
|
26
|
+
debounceMs?: number;
|
|
27
|
+
/** Watch directories for new/removed files (default: true) */
|
|
28
|
+
watchDirectories?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Temp/junk files to ignore */
|
|
32
|
+
const IGNORED_PATTERNS = [
|
|
33
|
+
/\.swp$/,
|
|
34
|
+
/\.bak$/,
|
|
35
|
+
/~$/,
|
|
36
|
+
/\.DS_Store$/,
|
|
37
|
+
/^4913$/, // vim temp file check
|
|
38
|
+
/\.tmp$/,
|
|
39
|
+
/^\.#/, // emacs lock files
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function isIgnored(filename: string): boolean {
|
|
43
|
+
return IGNORED_PATTERNS.some((p) => p.test(filename));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPhotonFile(filename: string, extensions: string[]): boolean {
|
|
47
|
+
return extensions.some((ext) => filename.endsWith(ext));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function photonNameFromFile(filename: string, extensions: string[]): string | null {
|
|
51
|
+
for (const ext of extensions) {
|
|
52
|
+
if (filename.endsWith(ext)) {
|
|
53
|
+
return filename.slice(0, -ext.length);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class PhotonWatcher extends EventEmitter {
|
|
60
|
+
private options: Required<PhotonWatcherOptions>;
|
|
61
|
+
private fileWatchers = new Map<string, fs.FSWatcher>();
|
|
62
|
+
private dirWatchers = new Map<string, fs.FSWatcher>();
|
|
63
|
+
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
64
|
+
/** Maps watchPath (real) → { photonName, originalPath } */
|
|
65
|
+
private watchedFiles = new Map<string, { photonName: string; originalPath: string }>();
|
|
66
|
+
/** Tracks known photon files per directory for diff-based add/remove detection */
|
|
67
|
+
private knownFiles = new Map<string, Set<string>>();
|
|
68
|
+
private running = false;
|
|
69
|
+
|
|
70
|
+
constructor(options: PhotonWatcherOptions) {
|
|
71
|
+
super();
|
|
72
|
+
this.options = {
|
|
73
|
+
directories: options.directories,
|
|
74
|
+
extensions: options.extensions ?? ['.photon.ts', '.photon.js'],
|
|
75
|
+
debounceMs: options.debounceMs ?? 100,
|
|
76
|
+
watchDirectories: options.watchDirectories ?? true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Start watching. Scans directories for existing photon files and sets up watchers.
|
|
82
|
+
*/
|
|
83
|
+
async start(): Promise<void> {
|
|
84
|
+
if (this.running) return;
|
|
85
|
+
this.running = true;
|
|
86
|
+
|
|
87
|
+
for (const dir of this.options.directories) {
|
|
88
|
+
await this.scanDirectory(dir);
|
|
89
|
+
if (this.options.watchDirectories) {
|
|
90
|
+
this.watchDirectory(dir);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stop all watchers and clean up.
|
|
97
|
+
*/
|
|
98
|
+
async stop(): Promise<void> {
|
|
99
|
+
this.running = false;
|
|
100
|
+
|
|
101
|
+
for (const [, watcher] of this.fileWatchers) {
|
|
102
|
+
watcher.close();
|
|
103
|
+
}
|
|
104
|
+
this.fileWatchers.clear();
|
|
105
|
+
|
|
106
|
+
for (const [, watcher] of this.dirWatchers) {
|
|
107
|
+
watcher.close();
|
|
108
|
+
}
|
|
109
|
+
this.dirWatchers.clear();
|
|
110
|
+
|
|
111
|
+
for (const [, timer] of this.debounceTimers) {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
this.debounceTimers.clear();
|
|
115
|
+
|
|
116
|
+
this.watchedFiles.clear();
|
|
117
|
+
this.knownFiles.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Watch a specific photon file. Called automatically during scan,
|
|
122
|
+
* but can also be called manually for dynamically discovered files.
|
|
123
|
+
*/
|
|
124
|
+
watchFile(photonName: string, filePath: string): void {
|
|
125
|
+
// Resolve symlink so fs.watch() fires when the real file changes.
|
|
126
|
+
// On macOS, fs.watch on a symlink only detects changes to the symlink inode itself.
|
|
127
|
+
let watchPath = filePath;
|
|
128
|
+
try {
|
|
129
|
+
watchPath = fs.realpathSync(filePath);
|
|
130
|
+
} catch {
|
|
131
|
+
// Symlink target doesn't exist yet — fall back to original path
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.fileWatchers.has(watchPath)) return;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const watcher = fs.watch(watchPath, (eventType) => {
|
|
138
|
+
this.handleFileEvent(eventType, watchPath, photonName, filePath);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
watcher.on('error', (err) => {
|
|
142
|
+
this.emit('error', err, { photonName, path: filePath });
|
|
143
|
+
this.unwatchByRealPath(watchPath);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
this.fileWatchers.set(watchPath, watcher);
|
|
147
|
+
this.watchedFiles.set(watchPath, { photonName, originalPath: filePath });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
this.emit('error', err, { photonName, path: filePath });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Stop watching a specific file by its original path.
|
|
155
|
+
*/
|
|
156
|
+
unwatchFile(filePath: string): void {
|
|
157
|
+
// Find the real path entry
|
|
158
|
+
for (const [watchPath, info] of this.watchedFiles) {
|
|
159
|
+
if (info.originalPath === filePath) {
|
|
160
|
+
this.unwatchByRealPath(watchPath);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get a map of currently watched files: photonName → originalPath
|
|
168
|
+
*/
|
|
169
|
+
getWatchedFiles(): Map<string, string> {
|
|
170
|
+
const result = new Map<string, string>();
|
|
171
|
+
for (const [, info] of this.watchedFiles) {
|
|
172
|
+
result.set(info.photonName, info.originalPath);
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
// Internal
|
|
179
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
private handleFileEvent(
|
|
182
|
+
eventType: string,
|
|
183
|
+
watchPath: string,
|
|
184
|
+
photonName: string,
|
|
185
|
+
originalPath: string
|
|
186
|
+
): void {
|
|
187
|
+
// Debounce
|
|
188
|
+
const existing = this.debounceTimers.get(watchPath);
|
|
189
|
+
if (existing) clearTimeout(existing);
|
|
190
|
+
|
|
191
|
+
this.debounceTimers.set(
|
|
192
|
+
watchPath,
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
this.debounceTimers.delete(watchPath);
|
|
195
|
+
|
|
196
|
+
// On macOS, editors like sed -i replace the file (new inode),
|
|
197
|
+
// killing the watcher. Re-watch via original path to re-resolve symlinks.
|
|
198
|
+
if (eventType === 'rename') {
|
|
199
|
+
this.unwatchByRealPath(watchPath);
|
|
200
|
+
if (fs.existsSync(originalPath)) {
|
|
201
|
+
this.watchFile(photonName, originalPath);
|
|
202
|
+
} else {
|
|
203
|
+
this.emit('removed', photonName);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!fs.existsSync(originalPath)) {
|
|
209
|
+
this.emit('removed', photonName);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.emit('changed', photonName, originalPath);
|
|
214
|
+
}, this.options.debounceMs)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private unwatchByRealPath(watchPath: string): void {
|
|
219
|
+
const watcher = this.fileWatchers.get(watchPath);
|
|
220
|
+
if (watcher) {
|
|
221
|
+
watcher.close();
|
|
222
|
+
this.fileWatchers.delete(watchPath);
|
|
223
|
+
}
|
|
224
|
+
this.watchedFiles.delete(watchPath);
|
|
225
|
+
|
|
226
|
+
const timer = this.debounceTimers.get(watchPath);
|
|
227
|
+
if (timer) {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
this.debounceTimers.delete(watchPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async scanDirectory(dir: string): Promise<void> {
|
|
234
|
+
let entries: fs.Dirent[];
|
|
235
|
+
try {
|
|
236
|
+
entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
237
|
+
} catch (error: any) {
|
|
238
|
+
if (error.code !== 'ENOENT') {
|
|
239
|
+
this.emit('error', error, { directory: dir });
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const currentFiles = new Set<string>();
|
|
245
|
+
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
248
|
+
if (isIgnored(entry.name)) continue;
|
|
249
|
+
if (!isPhotonFile(entry.name, this.options.extensions)) continue;
|
|
250
|
+
|
|
251
|
+
const photonName = photonNameFromFile(entry.name, this.options.extensions);
|
|
252
|
+
if (!photonName) continue;
|
|
253
|
+
|
|
254
|
+
const filePath = path.join(dir, entry.name);
|
|
255
|
+
currentFiles.add(entry.name);
|
|
256
|
+
|
|
257
|
+
// Only emit 'added' and watch if this is a new file
|
|
258
|
+
const known = this.knownFiles.get(dir);
|
|
259
|
+
if (!known || !known.has(entry.name)) {
|
|
260
|
+
this.emit('added', photonName, filePath);
|
|
261
|
+
this.watchFile(photonName, filePath);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Detect removals (files that were known but no longer present)
|
|
266
|
+
const previousFiles = this.knownFiles.get(dir);
|
|
267
|
+
if (previousFiles) {
|
|
268
|
+
for (const filename of previousFiles) {
|
|
269
|
+
if (!currentFiles.has(filename)) {
|
|
270
|
+
const photonName = photonNameFromFile(filename, this.options.extensions);
|
|
271
|
+
if (photonName) {
|
|
272
|
+
const filePath = path.join(dir, filename);
|
|
273
|
+
this.unwatchFile(filePath);
|
|
274
|
+
this.emit('removed', photonName);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.knownFiles.set(dir, currentFiles);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private watchDirectory(dir: string): void {
|
|
284
|
+
if (this.dirWatchers.has(dir)) return;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const watcher = fs.watch(dir, (eventType, filename) => {
|
|
288
|
+
if (!filename) return;
|
|
289
|
+
if (isIgnored(filename)) return;
|
|
290
|
+
if (!isPhotonFile(filename, this.options.extensions)) return;
|
|
291
|
+
|
|
292
|
+
// Debounce directory events
|
|
293
|
+
const key = `dir:${dir}`;
|
|
294
|
+
const existing = this.debounceTimers.get(key);
|
|
295
|
+
if (existing) clearTimeout(existing);
|
|
296
|
+
|
|
297
|
+
this.debounceTimers.set(
|
|
298
|
+
key,
|
|
299
|
+
setTimeout(() => {
|
|
300
|
+
this.debounceTimers.delete(key);
|
|
301
|
+
if (this.running) {
|
|
302
|
+
this.scanDirectory(dir);
|
|
303
|
+
}
|
|
304
|
+
}, this.options.debounceMs)
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
watcher.on('error', (err) => {
|
|
309
|
+
this.emit('error', err, { directory: dir });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
this.dirWatchers.set(dir, watcher);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
this.emit('error', err, { directory: dir });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|