webspresso 0.0.2 → 0.0.4
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/README.md +261 -5
- package/index.js +25 -2
- package/package.json +1 -1
- package/src/file-router.js +80 -6
- package/src/helpers.js +206 -1
- package/src/plugin-manager.js +451 -0
- package/src/server.js +163 -55
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Plugin Manager
|
|
3
|
+
* Handles plugin registration, lifecycle, dependencies, and inter-plugin communication
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Simple semver comparison utilities
|
|
8
|
+
* (Lightweight alternative to full semver package)
|
|
9
|
+
*/
|
|
10
|
+
const semver = {
|
|
11
|
+
/**
|
|
12
|
+
* Parse version string to components
|
|
13
|
+
*/
|
|
14
|
+
parse(version) {
|
|
15
|
+
if (!version) return null;
|
|
16
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
return {
|
|
19
|
+
major: parseInt(match[1], 10),
|
|
20
|
+
minor: parseInt(match[2], 10),
|
|
21
|
+
patch: parseInt(match[3], 10),
|
|
22
|
+
prerelease: match[4] || null
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compare two versions
|
|
28
|
+
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
|
|
29
|
+
*/
|
|
30
|
+
compare(a, b) {
|
|
31
|
+
const va = this.parse(a);
|
|
32
|
+
const vb = this.parse(b);
|
|
33
|
+
if (!va || !vb) return 0;
|
|
34
|
+
|
|
35
|
+
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
|
|
36
|
+
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
|
|
37
|
+
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
|
|
38
|
+
return 0;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if version satisfies a range
|
|
43
|
+
* Supports: ^1.0.0, ~1.0.0, >=1.0.0, >1.0.0, <=1.0.0, <1.0.0, 1.0.0, *
|
|
44
|
+
*/
|
|
45
|
+
satisfies(version, range) {
|
|
46
|
+
if (!range || range === '*') return true;
|
|
47
|
+
|
|
48
|
+
const v = this.parse(version);
|
|
49
|
+
if (!v) return false;
|
|
50
|
+
|
|
51
|
+
// Handle caret (^) - compatible with major version
|
|
52
|
+
if (range.startsWith('^')) {
|
|
53
|
+
const r = this.parse(range.slice(1));
|
|
54
|
+
if (!r) return false;
|
|
55
|
+
if (v.major !== r.major) return false;
|
|
56
|
+
if (v.major === 0) {
|
|
57
|
+
// For 0.x.x, minor must match
|
|
58
|
+
if (v.minor !== r.minor) return false;
|
|
59
|
+
return v.patch >= r.patch;
|
|
60
|
+
}
|
|
61
|
+
return this.compare(version, range.slice(1)) >= 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle tilde (~) - compatible with minor version
|
|
65
|
+
if (range.startsWith('~')) {
|
|
66
|
+
const r = this.parse(range.slice(1));
|
|
67
|
+
if (!r) return false;
|
|
68
|
+
return v.major === r.major && v.minor === r.minor && v.patch >= r.patch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle >=
|
|
72
|
+
if (range.startsWith('>=')) {
|
|
73
|
+
return this.compare(version, range.slice(2)) >= 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle >
|
|
77
|
+
if (range.startsWith('>') && !range.startsWith('>=')) {
|
|
78
|
+
return this.compare(version, range.slice(1)) > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle <=
|
|
82
|
+
if (range.startsWith('<=')) {
|
|
83
|
+
return this.compare(version, range.slice(2)) <= 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle <
|
|
87
|
+
if (range.startsWith('<') && !range.startsWith('<=')) {
|
|
88
|
+
return this.compare(version, range.slice(1)) < 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Exact match
|
|
92
|
+
return this.compare(version, range) === 0;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Simple glob/minimatch pattern matching
|
|
98
|
+
*/
|
|
99
|
+
function matchPattern(path, pattern) {
|
|
100
|
+
if (pattern === '*' || pattern === '**') return true;
|
|
101
|
+
|
|
102
|
+
// Convert glob to regex
|
|
103
|
+
const regexPattern = pattern
|
|
104
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
|
105
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
|
|
106
|
+
.replace(/\*/g, '[^/]*') // * matches anything except /
|
|
107
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*') // ** matches everything
|
|
108
|
+
.replace(/\?/g, '.'); // ? matches single char
|
|
109
|
+
|
|
110
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
111
|
+
return regex.test(path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Plugin Manager Class
|
|
116
|
+
*/
|
|
117
|
+
class PluginManager {
|
|
118
|
+
constructor() {
|
|
119
|
+
this.plugins = new Map(); // name -> plugin instance
|
|
120
|
+
this.pluginAPIs = new Map(); // name -> exposed API
|
|
121
|
+
this.registeredHelpers = new Map(); // name -> helper function
|
|
122
|
+
this.registeredFilters = new Map(); // name -> filter function
|
|
123
|
+
this.routes = []; // Collected route metadata
|
|
124
|
+
this.customRoutes = []; // Routes added by plugins
|
|
125
|
+
this.app = null;
|
|
126
|
+
this.nunjucksEnv = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Register plugins with the manager
|
|
131
|
+
* @param {Array} plugins - Array of plugin definitions or factory functions
|
|
132
|
+
* @param {Object} context - Context object { app, nunjucksEnv, options }
|
|
133
|
+
*/
|
|
134
|
+
async register(plugins, context) {
|
|
135
|
+
if (!plugins || !Array.isArray(plugins)) return;
|
|
136
|
+
|
|
137
|
+
this.app = context.app;
|
|
138
|
+
this.nunjucksEnv = context.nunjucksEnv;
|
|
139
|
+
|
|
140
|
+
// Normalize plugins (handle factory functions)
|
|
141
|
+
const normalizedPlugins = plugins.map(p => {
|
|
142
|
+
if (typeof p === 'function') {
|
|
143
|
+
// Already a factory that was called
|
|
144
|
+
return p;
|
|
145
|
+
}
|
|
146
|
+
return p;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Validate and sort by dependencies
|
|
150
|
+
const sorted = this._resolveDependencyOrder(normalizedPlugins);
|
|
151
|
+
|
|
152
|
+
// Register each plugin in order
|
|
153
|
+
for (const plugin of sorted) {
|
|
154
|
+
await this._registerPlugin(plugin, context);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve dependency order using topological sort
|
|
160
|
+
*/
|
|
161
|
+
_resolveDependencyOrder(plugins) {
|
|
162
|
+
const graph = new Map();
|
|
163
|
+
const pluginMap = new Map();
|
|
164
|
+
|
|
165
|
+
// Build graph
|
|
166
|
+
for (const plugin of plugins) {
|
|
167
|
+
if (!plugin.name) {
|
|
168
|
+
throw new Error('Plugin must have a name');
|
|
169
|
+
}
|
|
170
|
+
if (pluginMap.has(plugin.name)) {
|
|
171
|
+
throw new Error(`Duplicate plugin name: ${plugin.name}`);
|
|
172
|
+
}
|
|
173
|
+
pluginMap.set(plugin.name, plugin);
|
|
174
|
+
graph.set(plugin.name, new Set(Object.keys(plugin.dependencies || {})));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Topological sort (Kahn's algorithm)
|
|
178
|
+
const sorted = [];
|
|
179
|
+
const noIncoming = [];
|
|
180
|
+
|
|
181
|
+
// Find nodes with no dependencies
|
|
182
|
+
for (const [name, deps] of graph) {
|
|
183
|
+
// Filter to only include dependencies that are in our plugin list
|
|
184
|
+
const relevantDeps = new Set([...deps].filter(d => pluginMap.has(d)));
|
|
185
|
+
graph.set(name, relevantDeps);
|
|
186
|
+
if (relevantDeps.size === 0) {
|
|
187
|
+
noIncoming.push(name);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
while (noIncoming.length > 0) {
|
|
192
|
+
const name = noIncoming.shift();
|
|
193
|
+
sorted.push(pluginMap.get(name));
|
|
194
|
+
|
|
195
|
+
// Remove this node from all dependencies
|
|
196
|
+
for (const [n, deps] of graph) {
|
|
197
|
+
if (deps.has(name)) {
|
|
198
|
+
deps.delete(name);
|
|
199
|
+
if (deps.size === 0 && !sorted.find(p => p.name === n)) {
|
|
200
|
+
noIncoming.push(n);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check for cycles
|
|
207
|
+
if (sorted.length !== plugins.length) {
|
|
208
|
+
const remaining = plugins.filter(p => !sorted.includes(p)).map(p => p.name);
|
|
209
|
+
throw new Error(`Circular dependency detected in plugins: ${remaining.join(', ')}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return sorted;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Register a single plugin
|
|
217
|
+
*/
|
|
218
|
+
async _registerPlugin(plugin, context) {
|
|
219
|
+
// Validate dependencies
|
|
220
|
+
this._validateDependencies(plugin);
|
|
221
|
+
|
|
222
|
+
// Create plugin context
|
|
223
|
+
const ctx = this._createPluginContext(plugin, context);
|
|
224
|
+
|
|
225
|
+
// Store plugin
|
|
226
|
+
this.plugins.set(plugin.name, plugin);
|
|
227
|
+
|
|
228
|
+
// Store API if provided
|
|
229
|
+
if (plugin.api) {
|
|
230
|
+
// Bind API methods to plugin instance
|
|
231
|
+
const boundAPI = {};
|
|
232
|
+
for (const [key, value] of Object.entries(plugin.api)) {
|
|
233
|
+
boundAPI[key] = typeof value === 'function' ? value.bind(plugin) : value;
|
|
234
|
+
}
|
|
235
|
+
this.pluginAPIs.set(plugin.name, boundAPI);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Call register hook
|
|
239
|
+
if (typeof plugin.register === 'function') {
|
|
240
|
+
await plugin.register(ctx);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Apply registered helpers to nunjucks
|
|
244
|
+
this._applyHelpersAndFilters();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Validate plugin dependencies
|
|
249
|
+
*/
|
|
250
|
+
_validateDependencies(plugin) {
|
|
251
|
+
if (!plugin.dependencies) return;
|
|
252
|
+
|
|
253
|
+
for (const [depName, versionRange] of Object.entries(plugin.dependencies)) {
|
|
254
|
+
const dep = this.plugins.get(depName);
|
|
255
|
+
|
|
256
|
+
if (!dep) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Plugin "${plugin.name}" requires "${depName}" but it's not loaded`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (dep.version && !semver.satisfies(dep.version, versionRange)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Plugin "${plugin.name}" requires "${depName}@${versionRange}" ` +
|
|
265
|
+
`but found v${dep.version}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Create context object for plugin
|
|
273
|
+
*/
|
|
274
|
+
_createPluginContext(plugin, context) {
|
|
275
|
+
const self = this;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
app: context.app,
|
|
279
|
+
options: plugin._options || {},
|
|
280
|
+
nunjucksEnv: context.nunjucksEnv,
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get another plugin's API
|
|
284
|
+
*/
|
|
285
|
+
usePlugin(name) {
|
|
286
|
+
return self.pluginAPIs.get(name) || null;
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Add a template helper (available as fsy.helperName())
|
|
291
|
+
*/
|
|
292
|
+
addHelper(name, fn) {
|
|
293
|
+
self.registeredHelpers.set(name, fn);
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Add a Nunjucks filter
|
|
298
|
+
*/
|
|
299
|
+
addFilter(name, fn) {
|
|
300
|
+
self.registeredFilters.set(name, fn);
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Add a custom route
|
|
305
|
+
*/
|
|
306
|
+
addRoute(method, path, ...handlers) {
|
|
307
|
+
self.customRoutes.push({ method: method.toLowerCase(), path, handlers });
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get all registered routes (available after onRoutesReady)
|
|
312
|
+
*/
|
|
313
|
+
get routes() {
|
|
314
|
+
return self.routes;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Apply registered helpers and filters to Nunjucks
|
|
321
|
+
*/
|
|
322
|
+
_applyHelpersAndFilters() {
|
|
323
|
+
if (!this.nunjucksEnv) return;
|
|
324
|
+
|
|
325
|
+
// Add filters
|
|
326
|
+
for (const [name, fn] of this.registeredFilters) {
|
|
327
|
+
this.nunjucksEnv.addFilter(name, fn);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Get all registered helpers (to be merged with fsy)
|
|
333
|
+
*/
|
|
334
|
+
getHelpers() {
|
|
335
|
+
const helpers = {};
|
|
336
|
+
for (const [name, fn] of this.registeredHelpers) {
|
|
337
|
+
helpers[name] = fn;
|
|
338
|
+
}
|
|
339
|
+
return helpers;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Set route metadata (called by file-router after mounting)
|
|
344
|
+
*/
|
|
345
|
+
setRoutes(routes) {
|
|
346
|
+
this.routes = routes;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Mount custom routes added by plugins
|
|
351
|
+
*/
|
|
352
|
+
mountCustomRoutes() {
|
|
353
|
+
for (const { method, path, handlers } of this.customRoutes) {
|
|
354
|
+
if (this.app && this.app[method]) {
|
|
355
|
+
this.app[method](path, ...handlers);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Call onRoutesReady hook on all plugins
|
|
362
|
+
*/
|
|
363
|
+
async onRoutesReady(context) {
|
|
364
|
+
for (const [name, plugin] of this.plugins) {
|
|
365
|
+
if (typeof plugin.onRoutesReady === 'function') {
|
|
366
|
+
const ctx = this._createPluginContext(plugin, context);
|
|
367
|
+
await plugin.onRoutesReady(ctx);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Mount any routes added during onRoutesReady
|
|
372
|
+
this.mountCustomRoutes();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Call onReady hook on all plugins
|
|
377
|
+
*/
|
|
378
|
+
async onReady(context) {
|
|
379
|
+
for (const [name, plugin] of this.plugins) {
|
|
380
|
+
if (typeof plugin.onReady === 'function') {
|
|
381
|
+
const ctx = this._createPluginContext(plugin, context);
|
|
382
|
+
await plugin.onReady(ctx);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get a plugin by name
|
|
389
|
+
*/
|
|
390
|
+
getPlugin(name) {
|
|
391
|
+
return this.plugins.get(name);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Get a plugin's API by name
|
|
396
|
+
*/
|
|
397
|
+
getPluginAPI(name) {
|
|
398
|
+
return this.pluginAPIs.get(name);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Check if a plugin is registered
|
|
403
|
+
*/
|
|
404
|
+
hasPlugin(name) {
|
|
405
|
+
return this.plugins.has(name);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get all registered plugin names
|
|
410
|
+
*/
|
|
411
|
+
getPluginNames() {
|
|
412
|
+
return Array.from(this.plugins.keys());
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Singleton instance for global access
|
|
417
|
+
let globalPluginManager = null;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get or create the global plugin manager
|
|
421
|
+
*/
|
|
422
|
+
function getPluginManager() {
|
|
423
|
+
if (!globalPluginManager) {
|
|
424
|
+
globalPluginManager = new PluginManager();
|
|
425
|
+
}
|
|
426
|
+
return globalPluginManager;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Create a new plugin manager (for testing)
|
|
431
|
+
*/
|
|
432
|
+
function createPluginManager() {
|
|
433
|
+
return new PluginManager();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Reset the global plugin manager (for testing)
|
|
438
|
+
*/
|
|
439
|
+
function resetPluginManager() {
|
|
440
|
+
globalPluginManager = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = {
|
|
444
|
+
PluginManager,
|
|
445
|
+
getPluginManager,
|
|
446
|
+
createPluginManager,
|
|
447
|
+
resetPluginManager,
|
|
448
|
+
semver,
|
|
449
|
+
matchPattern
|
|
450
|
+
};
|
|
451
|
+
|