webspresso 0.0.65 → 0.0.67
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 +120 -2
- package/core/auth/middleware.js +3 -3
- package/core/orm/cache/fingerprint.js +73 -0
- package/core/orm/cache/index.js +73 -0
- package/core/orm/cache/layer.js +314 -0
- package/core/orm/cache/listeners.js +67 -0
- package/core/orm/cache/memory-provider.js +109 -0
- package/core/orm/cache/types.js +27 -0
- package/core/orm/index.js +19 -6
- package/core/orm/model.js +2 -0
- package/core/orm/query-builder.js +206 -59
- package/core/orm/repository.js +134 -75
- package/core/orm/types.js +21 -0
- package/index.d.ts +52 -1
- package/index.js +6 -1
- package/package.json +9 -4
- package/plugins/index.js +2 -0
- package/plugins/orm-cache-admin/admin-component.js +146 -0
- package/plugins/orm-cache-admin/api-handlers.js +78 -0
- package/plugins/orm-cache-admin/index.js +72 -0
- package/src/client-runtime/bootstrap-alpine-swup.js +34 -0
- package/src/client-runtime/bootstrap-swup.js +26 -0
- package/src/client-runtime/mount.js +65 -0
- package/src/client-runtime/resolve.js +40 -0
- package/src/file-router.js +77 -14
- package/src/server.js +11 -2
- package/templates/skills/webspresso-usage/SKILL.md +5 -1
- package/views/partials/webspresso-client-runtime.njk +15 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin SPA page for ORM cache (Mithril string component)
|
|
3
|
+
* @module plugins/orm-cache-admin/admin-component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function generateOrmCacheAdminComponent() {
|
|
7
|
+
return `
|
|
8
|
+
(function() {
|
|
9
|
+
|
|
10
|
+
function ormCacheApi(method, path, body) {
|
|
11
|
+
var opts = { method: method, credentials: 'same-origin' };
|
|
12
|
+
if (body !== undefined) {
|
|
13
|
+
opts.headers = { 'Content-Type': 'application/json' };
|
|
14
|
+
opts.body = JSON.stringify(body);
|
|
15
|
+
}
|
|
16
|
+
return fetch('/_admin/api/orm-cache' + path, opts).then(function(r) {
|
|
17
|
+
return r.json().then(function(j) {
|
|
18
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
19
|
+
return j;
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var OrmCachePage = {
|
|
25
|
+
oninit: function(vnode) {
|
|
26
|
+
vnode.state.loading = true;
|
|
27
|
+
vnode.state.error = null;
|
|
28
|
+
vnode.state.stats = null;
|
|
29
|
+
vnode.state.modelName = '';
|
|
30
|
+
this.refresh(vnode);
|
|
31
|
+
},
|
|
32
|
+
refresh: function(vnode) {
|
|
33
|
+
vnode.state.loading = true;
|
|
34
|
+
vnode.state.error = null;
|
|
35
|
+
ormCacheApi('get', '/stats').then(function(s) {
|
|
36
|
+
vnode.state.stats = s;
|
|
37
|
+
vnode.state.loading = false;
|
|
38
|
+
m.redraw();
|
|
39
|
+
}).catch(function(e) {
|
|
40
|
+
vnode.state.error = e.message || String(e);
|
|
41
|
+
vnode.state.loading = false;
|
|
42
|
+
m.redraw();
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
purge: function(vnode) {
|
|
46
|
+
if (!confirm('Purge entire ORM cache?')) return;
|
|
47
|
+
ormCacheApi('post', '/purge').then(function() {
|
|
48
|
+
return this.refresh(vnode);
|
|
49
|
+
}.bind(this)).catch(function(e) {
|
|
50
|
+
vnode.state.error = e.message;
|
|
51
|
+
m.redraw();
|
|
52
|
+
}.bind(this));
|
|
53
|
+
},
|
|
54
|
+
invalidateModel: function(vnode) {
|
|
55
|
+
var name = (vnode.state.modelName || '').trim();
|
|
56
|
+
if (!name) return;
|
|
57
|
+
ormCacheApi('post', '/invalidate', { model: name }).then(function() {
|
|
58
|
+
vnode.state.modelName = '';
|
|
59
|
+
return this.refresh(vnode);
|
|
60
|
+
}.bind(this)).catch(function(e) {
|
|
61
|
+
vnode.state.error = e.message;
|
|
62
|
+
m.redraw();
|
|
63
|
+
}.bind(this));
|
|
64
|
+
},
|
|
65
|
+
resetMetrics: function(vnode) {
|
|
66
|
+
ormCacheApi('post', '/metrics/reset').then(function() {
|
|
67
|
+
return this.refresh(vnode);
|
|
68
|
+
}.bind(this)).catch(function(e) {
|
|
69
|
+
vnode.state.error = e.message;
|
|
70
|
+
m.redraw();
|
|
71
|
+
}.bind(this));
|
|
72
|
+
},
|
|
73
|
+
view: function(vnode) {
|
|
74
|
+
var s = vnode.state;
|
|
75
|
+
var self = this;
|
|
76
|
+
return m(Layout, [
|
|
77
|
+
m(Breadcrumb, { items: [{ label: 'ORM Cache', href: '/orm-cache' }] }),
|
|
78
|
+
m('div.mb-6', [
|
|
79
|
+
m('h1.text-2xl.font-bold.text-gray-900', 'ORM query cache'),
|
|
80
|
+
m('p.text-gray-500.text-sm.mt-1', 'Hit/miss metrics and manual invalidation (memory provider)'),
|
|
81
|
+
]),
|
|
82
|
+
s.loading
|
|
83
|
+
? m('div.flex.justify-center.py-16', m(Spinner))
|
|
84
|
+
: s.error
|
|
85
|
+
? m('div.bg-red-50.text-red-700.p-4.rounded-lg', s.error)
|
|
86
|
+
: m('div.space-y-6', [
|
|
87
|
+
m('div.grid.grid-cols-2.md:grid-cols-4.gap-4', [
|
|
88
|
+
statBox('Hits', s.stats && s.stats.hits),
|
|
89
|
+
statBox('Misses', s.stats && s.stats.misses),
|
|
90
|
+
statBox('Hit rate', formatRate(s.stats && s.stats.hitRate)),
|
|
91
|
+
statBox('Bypassed', s.stats && s.stats.bypassed),
|
|
92
|
+
]),
|
|
93
|
+
m('div.grid.grid-cols-2.md:grid-cols-4.gap-4', [
|
|
94
|
+
statBox('Sets', s.stats && s.stats.sets),
|
|
95
|
+
statBox('Invalidations', s.stats && s.stats.invalidations),
|
|
96
|
+
statBox('Approx keys', s.stats && s.stats.approxKeys),
|
|
97
|
+
statBox('Approx tags', s.stats && s.stats.approxTags),
|
|
98
|
+
]),
|
|
99
|
+
m('div.flex.flex-wrap.gap-2', [
|
|
100
|
+
m('button.px-4.py-2.bg-red-600.text-white.rounded-md.text-sm', {
|
|
101
|
+
onclick: function() { self.purge(vnode); },
|
|
102
|
+
}, 'Purge all'),
|
|
103
|
+
m('button.px-4.py-2.bg-gray-200.text-gray-800.rounded-md.text-sm', {
|
|
104
|
+
onclick: function() { self.resetMetrics(vnode); },
|
|
105
|
+
}, 'Reset metrics'),
|
|
106
|
+
m('button.px-4.py-2.bg-blue-600.text-white.rounded-md.text-sm', {
|
|
107
|
+
onclick: function() { self.refresh(vnode); },
|
|
108
|
+
}, 'Refresh'),
|
|
109
|
+
]),
|
|
110
|
+
m('div.bg-white.rounded-lg.shadow.p-4', [
|
|
111
|
+
m('h3.text-sm.font-semibold.mb-2', 'Invalidate by model'),
|
|
112
|
+
m('div.flex.gap-2', [
|
|
113
|
+
m('input.flex-1.border.rounded.px-3.py-2.text-sm', {
|
|
114
|
+
placeholder: 'Model name (e.g. User)',
|
|
115
|
+
value: s.modelName,
|
|
116
|
+
oninput: function(e) { s.modelName = e.target.value; },
|
|
117
|
+
}),
|
|
118
|
+
m('button.px-4.py-2.bg-amber-600.text-white.rounded-md.text-sm', {
|
|
119
|
+
onclick: function() { self.invalidateModel(vnode); },
|
|
120
|
+
}, 'Invalidate'),
|
|
121
|
+
]),
|
|
122
|
+
]),
|
|
123
|
+
]),
|
|
124
|
+
]);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function statBox(label, value) {
|
|
129
|
+
return m('div.bg-white.rounded-lg.shadow.p-4', [
|
|
130
|
+
m('p.text-xs.text-gray-500.uppercase', label),
|
|
131
|
+
m('p.text-xl.font-semibold.text-gray-900', value === undefined || value === null ? '—' : String(value)),
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatRate(r) {
|
|
136
|
+
if (r === null || r === undefined || isNaN(r)) return '—';
|
|
137
|
+
return (r * 100).toFixed(1) + '%';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
window.__customPages = window.__customPages || {};
|
|
141
|
+
window.__customPages['orm-cache'] = OrmCachePage;
|
|
142
|
+
})();
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { generateOrmCacheAdminComponent };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API for ORM cache metrics and purge
|
|
3
|
+
* @module plugins/orm-cache-admin/api-handlers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {import('../../core/orm/types').DatabaseInstance} options.db
|
|
9
|
+
*/
|
|
10
|
+
function createOrmCacheAdminHandlers({ db }) {
|
|
11
|
+
function requireCache(res) {
|
|
12
|
+
if (!db.cache) {
|
|
13
|
+
res.status(503).json({ error: 'ORM cache is not enabled on this database' });
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return db.cache;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getStats(req, res) {
|
|
20
|
+
try {
|
|
21
|
+
const cache = requireCache(res);
|
|
22
|
+
if (!cache) return;
|
|
23
|
+
res.json(cache.getMetrics());
|
|
24
|
+
} catch (e) {
|
|
25
|
+
res.status(500).json({ error: e.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function postPurge(req, res) {
|
|
30
|
+
try {
|
|
31
|
+
const cache = requireCache(res);
|
|
32
|
+
if (!cache) return;
|
|
33
|
+
cache.purge();
|
|
34
|
+
res.json({ ok: true });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
res.status(500).json({ error: e.message });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function postInvalidate(req, res) {
|
|
41
|
+
try {
|
|
42
|
+
const cache = requireCache(res);
|
|
43
|
+
if (!cache) return;
|
|
44
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
45
|
+
if (Array.isArray(body.tags) && body.tags.length > 0) {
|
|
46
|
+
cache.invalidateTags(body.tags.map(String));
|
|
47
|
+
return res.json({ ok: true, mode: 'tags' });
|
|
48
|
+
}
|
|
49
|
+
if (typeof body.model === 'string' && body.model.trim()) {
|
|
50
|
+
cache.invalidateModel(body.model.trim());
|
|
51
|
+
return res.json({ ok: true, mode: 'model' });
|
|
52
|
+
}
|
|
53
|
+
res.status(400).json({ error: 'Provide { model: string } or { tags: string[] }' });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
res.status(500).json({ error: e.message });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function postResetMetrics(req, res) {
|
|
60
|
+
try {
|
|
61
|
+
const cache = requireCache(res);
|
|
62
|
+
if (!cache) return;
|
|
63
|
+
cache.resetMetrics();
|
|
64
|
+
res.json({ ok: true });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
res.status(500).json({ error: e.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
getStats,
|
|
72
|
+
postPurge,
|
|
73
|
+
postInvalidate,
|
|
74
|
+
postResetMetrics,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { createOrmCacheAdminHandlers };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORM Cache Admin — metrics, purge, invalidate (requires admin-panel + db.cache)
|
|
3
|
+
* @module plugins/orm-cache-admin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createOrmCacheAdminHandlers } = require('./api-handlers');
|
|
7
|
+
const { generateOrmCacheAdminComponent } = require('./admin-component');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {import('../../core/orm/types').DatabaseInstance} options.db - From createDatabase({ cache: true })
|
|
12
|
+
*/
|
|
13
|
+
function ormCacheAdminPlugin(options = {}) {
|
|
14
|
+
const { db } = options;
|
|
15
|
+
|
|
16
|
+
if (!db) {
|
|
17
|
+
throw new Error('orm-cache-admin plugin requires `db` (database instance from createDatabase)');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
name: 'orm-cache-admin',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
description: 'Admin UI for ORM query cache metrics and purge',
|
|
24
|
+
dependencies: { 'admin-panel': '*' },
|
|
25
|
+
|
|
26
|
+
register() {},
|
|
27
|
+
|
|
28
|
+
onRoutesReady(ctx) {
|
|
29
|
+
const adminApi = ctx.usePlugin('admin-panel');
|
|
30
|
+
if (!adminApi) {
|
|
31
|
+
console.warn('[orm-cache-admin] admin-panel not found, skipping registration');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!db.cache) {
|
|
36
|
+
console.warn('[orm-cache-admin] db.cache is disabled; enable createDatabase({ cache: true })');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handlers = createOrmCacheAdminHandlers({ db });
|
|
41
|
+
|
|
42
|
+
adminApi.registerModule({
|
|
43
|
+
id: 'orm-cache',
|
|
44
|
+
pages: [{
|
|
45
|
+
id: 'orm-cache',
|
|
46
|
+
title: 'ORM Cache',
|
|
47
|
+
path: '/orm-cache',
|
|
48
|
+
icon: 'database',
|
|
49
|
+
component: generateOrmCacheAdminComponent(),
|
|
50
|
+
}],
|
|
51
|
+
menu: [{
|
|
52
|
+
id: 'orm-cache',
|
|
53
|
+
label: 'ORM Cache',
|
|
54
|
+
path: '/orm-cache',
|
|
55
|
+
icon: 'database',
|
|
56
|
+
order: 15,
|
|
57
|
+
}],
|
|
58
|
+
api: {
|
|
59
|
+
prefix: '/orm-cache',
|
|
60
|
+
routes: [
|
|
61
|
+
{ method: 'get', path: '/stats', handler: handlers.getStats },
|
|
62
|
+
{ method: 'post', path: '/purge', handler: handlers.postPurge },
|
|
63
|
+
{ method: 'post', path: '/invalidate', handler: handlers.postInvalidate },
|
|
64
|
+
{ method: 'post', path: '/metrics/reset', handler: handlers.postResetMetrics },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = ormCacheAdminPlugin;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* global Swup, SwupHeadPlugin, SwupScriptsPlugin, Alpine */
|
|
2
|
+
(function () {
|
|
3
|
+
if (typeof Swup === 'undefined' || typeof SwupHeadPlugin === 'undefined' || typeof SwupScriptsPlugin === 'undefined' || typeof Alpine === 'undefined') {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function ignoreVisit(url, ctx) {
|
|
8
|
+
var el = ctx && ctx.el;
|
|
9
|
+
if (el && el.closest && el.closest('[data-no-swup]')) return true;
|
|
10
|
+
try {
|
|
11
|
+
var u = new URL(url, window.location.origin);
|
|
12
|
+
var p = u.pathname;
|
|
13
|
+
if (p.indexOf('/_admin') === 0 || p.indexOf('/_webspresso') === 0) return true;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var swup = new Swup({
|
|
21
|
+
containers: ['#swup'],
|
|
22
|
+
plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
|
|
23
|
+
ignoreVisit: ignoreVisit,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
swup.hooks.on('content:replace', function () {
|
|
27
|
+
var root = document.querySelector('#swup');
|
|
28
|
+
if (root && typeof Alpine.initTree === 'function') {
|
|
29
|
+
Alpine.initTree(root);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
window.__webspressoSwup = swup;
|
|
34
|
+
})();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* global Swup, SwupHeadPlugin, SwupScriptsPlugin */
|
|
2
|
+
(function () {
|
|
3
|
+
if (typeof Swup === 'undefined' || typeof SwupHeadPlugin === 'undefined' || typeof SwupScriptsPlugin === 'undefined') {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function ignoreVisit(url, ctx) {
|
|
8
|
+
var el = ctx && ctx.el;
|
|
9
|
+
if (el && el.closest && el.closest('[data-no-swup]')) return true;
|
|
10
|
+
try {
|
|
11
|
+
var u = new URL(url, window.location.origin);
|
|
12
|
+
var p = u.pathname;
|
|
13
|
+
if (p.indexOf('/_admin') === 0 || p.indexOf('/_webspresso') === 0) return true;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var swup = new Swup({
|
|
21
|
+
containers: ['#swup'],
|
|
22
|
+
plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
|
|
23
|
+
ignoreVisit: ignoreVisit,
|
|
24
|
+
});
|
|
25
|
+
window.__webspressoSwup = swup;
|
|
26
|
+
})();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount Express routes that serve vendored Alpine / Swup UMD builds from node_modules
|
|
3
|
+
* and framework bootstrap scripts from src/client-runtime/.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const express = require('express');
|
|
8
|
+
|
|
9
|
+
const CLIENT_RUNTIME_BASE = '/__webspresso/client-runtime';
|
|
10
|
+
|
|
11
|
+
/** Resolve a file next to the package's resolved main entry (works with package "exports"). */
|
|
12
|
+
function pkgFileFromMain(pkg, ...segments) {
|
|
13
|
+
const entry = require.resolve(pkg);
|
|
14
|
+
return path.join(path.dirname(entry), ...segments);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('express').Express} app
|
|
19
|
+
* @param {{ alpine: boolean, swup: boolean }} flags
|
|
20
|
+
*/
|
|
21
|
+
function mountClientRuntime(app, flags) {
|
|
22
|
+
if (!flags || (!flags.alpine && !flags.swup)) return;
|
|
23
|
+
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
|
|
26
|
+
function send(res, filePath) {
|
|
27
|
+
res.type('application/javascript');
|
|
28
|
+
res.sendFile(filePath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (flags.alpine) {
|
|
32
|
+
router.get('/alpine.min.js', (req, res) => {
|
|
33
|
+
send(res, pkgFileFromMain('alpinejs', 'cdn.min.js'));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (flags.swup) {
|
|
38
|
+
router.get('/swup.umd.js', (req, res) => {
|
|
39
|
+
send(res, pkgFileFromMain('swup', 'Swup.umd.js'));
|
|
40
|
+
});
|
|
41
|
+
router.get('/swup-head-plugin.umd.js', (req, res) => {
|
|
42
|
+
send(res, pkgFileFromMain('@swup/head-plugin', 'index.umd.js'));
|
|
43
|
+
});
|
|
44
|
+
router.get('/swup-scripts-plugin.umd.js', (req, res) => {
|
|
45
|
+
send(res, pkgFileFromMain('@swup/scripts-plugin', 'index.umd.js'));
|
|
46
|
+
});
|
|
47
|
+
const runtimeDir = __dirname;
|
|
48
|
+
if (flags.alpine) {
|
|
49
|
+
router.get('/bootstrap-alpine-swup.js', (req, res) => {
|
|
50
|
+
send(res, path.join(runtimeDir, 'bootstrap-alpine-swup.js'));
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
router.get('/bootstrap-swup.js', (req, res) => {
|
|
54
|
+
send(res, path.join(runtimeDir, 'bootstrap-swup.js'));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
app.use(CLIENT_RUNTIME_BASE, router);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
mountClientRuntime,
|
|
64
|
+
CLIENT_RUNTIME_BASE,
|
|
65
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve clientRuntime flags from createApp({ clientRuntime }) and optional env overrides.
|
|
3
|
+
* Env: WEBSPRESSO_ALPINE=1|true, WEBSPRESSO_SWUP=1|true (override explicit false from options when set).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function envTruthy(name) {
|
|
7
|
+
const v = process.env[name];
|
|
8
|
+
if (v == null || v === '') return undefined;
|
|
9
|
+
return v === '1' || /^true$/i.test(String(v));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function optionEnabled(v) {
|
|
13
|
+
if (v === true) return true;
|
|
14
|
+
if (v && typeof v === 'object') return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {object} [options.clientRuntime]
|
|
21
|
+
* @param {boolean|object} [options.clientRuntime.alpine]
|
|
22
|
+
* @param {boolean|object} [options.clientRuntime.swup]
|
|
23
|
+
* @returns {{ alpine: boolean, swup: boolean }}
|
|
24
|
+
*/
|
|
25
|
+
function resolveClientRuntime(options = {}) {
|
|
26
|
+
const cr = options.clientRuntime;
|
|
27
|
+
let alpine = false;
|
|
28
|
+
let swup = false;
|
|
29
|
+
if (cr && typeof cr === 'object') {
|
|
30
|
+
alpine = optionEnabled(cr.alpine);
|
|
31
|
+
swup = optionEnabled(cr.swup);
|
|
32
|
+
}
|
|
33
|
+
const envA = envTruthy('WEBSPRESSO_ALPINE');
|
|
34
|
+
const envS = envTruthy('WEBSPRESSO_SWUP');
|
|
35
|
+
if (envA !== undefined) alpine = envA;
|
|
36
|
+
if (envS !== undefined) swup = envS;
|
|
37
|
+
return { alpine, swup };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { resolveClientRuntime };
|
package/src/file-router.js
CHANGED
|
@@ -276,9 +276,58 @@ function detectLocale(req) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
|
|
279
|
+
* True when the registry entry is (options) => (req, res, next) => …
|
|
280
|
+
* Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
|
|
281
|
+
*/
|
|
282
|
+
function isMiddlewareFactory(fn) {
|
|
283
|
+
return typeof fn === 'function' && fn.length <= 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolve a named middleware from createApp({ middlewares }).
|
|
288
|
+
* @param {string} name
|
|
289
|
+
* @param {Function} entry
|
|
290
|
+
* @param {boolean} fromTuple - true when route used ['name', options]
|
|
291
|
+
* @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
|
|
292
|
+
* @param {Object} middlewareRegistry - for error messages
|
|
293
|
+
* @returns {Function} Express middleware
|
|
294
|
+
*/
|
|
295
|
+
function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
|
|
296
|
+
if (!entry) {
|
|
297
|
+
throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
|
|
298
|
+
}
|
|
299
|
+
if (typeof entry !== 'function') {
|
|
300
|
+
throw new Error(`Middleware "${name}" must be a function`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (fromTuple) {
|
|
304
|
+
if (!isMiddlewareFactory(entry)) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const produced = entry(tupleOptions);
|
|
310
|
+
if (typeof produced !== 'function') {
|
|
311
|
+
throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
|
|
312
|
+
}
|
|
313
|
+
return produced;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (isMiddlewareFactory(entry)) {
|
|
317
|
+
const produced = entry({});
|
|
318
|
+
if (typeof produced !== 'function') {
|
|
319
|
+
throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
|
|
320
|
+
}
|
|
321
|
+
return produced;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return entry;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolve middleware from config — functions, string names, or [name, options] tuples
|
|
329
|
+
* @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
|
|
330
|
+
* @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
|
|
282
331
|
* @returns {Array} Array of resolved middleware functions
|
|
283
332
|
*/
|
|
284
333
|
function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
@@ -292,17 +341,17 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
|
292
341
|
}
|
|
293
342
|
|
|
294
343
|
if (typeof mw === 'string') {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
return resolved;
|
|
344
|
+
return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
|
|
348
|
+
const name = mw[0];
|
|
349
|
+
return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
|
|
303
350
|
}
|
|
304
351
|
|
|
305
|
-
throw new Error(
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
|
|
354
|
+
);
|
|
306
355
|
});
|
|
307
356
|
}
|
|
308
357
|
|
|
@@ -316,10 +365,22 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
|
316
365
|
* @param {Object} options.pluginManager - Plugin manager instance
|
|
317
366
|
* @param {boolean} options.silent - Suppress console output
|
|
318
367
|
* @param {Object} options.db - Database instance (exposed as ctx.db in load/meta)
|
|
368
|
+
* @param {{ alpine?: boolean, swup?: boolean }} [options.clientRuntime] - Passed to Nunjucks as `clientRuntime` (default both false)
|
|
319
369
|
* @returns {Array} Route metadata for plugins
|
|
320
370
|
*/
|
|
321
371
|
function mountPages(app, options) {
|
|
322
|
-
const {
|
|
372
|
+
const {
|
|
373
|
+
pagesDir,
|
|
374
|
+
nunjucks,
|
|
375
|
+
middlewares = {},
|
|
376
|
+
pluginManager = null,
|
|
377
|
+
silent = false,
|
|
378
|
+
db = null,
|
|
379
|
+
clientRuntime: clientRuntimeOpt = null,
|
|
380
|
+
} = options;
|
|
381
|
+
const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
|
|
382
|
+
? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
|
|
383
|
+
: { alpine: false, swup: false };
|
|
323
384
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
324
385
|
const log = silent ? () => {} : console.log.bind(console);
|
|
325
386
|
|
|
@@ -499,7 +560,8 @@ function mountPages(app, options) {
|
|
|
499
560
|
indexable: true,
|
|
500
561
|
canonical: null
|
|
501
562
|
},
|
|
502
|
-
fsy: { ...baseHelpers, ...pluginHelpers }
|
|
563
|
+
fsy: { ...baseHelpers, ...pluginHelpers },
|
|
564
|
+
clientRuntime,
|
|
503
565
|
};
|
|
504
566
|
|
|
505
567
|
// Execute hooks: onRequest
|
|
@@ -563,6 +625,7 @@ function mountPages(app, options) {
|
|
|
563
625
|
locale: ctx.locale,
|
|
564
626
|
t: ctx.t,
|
|
565
627
|
fsy: ctx.fsy,
|
|
628
|
+
clientRuntime: ctx.clientRuntime,
|
|
566
629
|
req: {
|
|
567
630
|
path: req.path,
|
|
568
631
|
query: req.query,
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,8 @@ const nunjucks = require('nunjucks');
|
|
|
9
9
|
const timeout = require('connect-timeout');
|
|
10
10
|
|
|
11
11
|
const { setAppContext } = require('./app-context');
|
|
12
|
+
const { mountClientRuntime } = require('./client-runtime/mount');
|
|
13
|
+
const { resolveClientRuntime } = require('./client-runtime/resolve');
|
|
12
14
|
const { mountPages } = require('./file-router');
|
|
13
15
|
const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
|
|
14
16
|
const { createPluginManager } = require('./plugin-manager');
|
|
@@ -258,6 +260,7 @@ function haltOnTimedout(req, res, next) {
|
|
|
258
260
|
* @param {string|boolean} options.timeout - Request timeout (default: '30s', false to disable)
|
|
259
261
|
* @param {Object} options.auth - Authentication manager instance (from createAuth)
|
|
260
262
|
* @param {Object} options.db - Database instance (exposed as ctx.db to plugins)
|
|
263
|
+
* @param {Object} [options.clientRuntime] - Optional client assets: `{ alpine?: boolean|object, swup?: boolean|object }`. Overridable by env `WEBSPRESSO_ALPINE` / `WEBSPRESSO_SWUP` (=1 or true). Serves `/__webspresso/client-runtime/*` when either flag is on.
|
|
261
264
|
* @param {function(import('express').Express, Object): void} [options.setupRoutes] - Called after file routes and plugins, before 404 handler
|
|
262
265
|
* @returns {Object} { app, nunjucksEnv, pluginManager, authMiddleware }
|
|
263
266
|
*/
|
|
@@ -294,6 +297,8 @@ function createApp(options = {}) {
|
|
|
294
297
|
throw new Error('pagesDir is required');
|
|
295
298
|
}
|
|
296
299
|
|
|
300
|
+
const clientRuntime = resolveClientRuntime(options);
|
|
301
|
+
|
|
297
302
|
setAppContext({ db: options.db ?? null });
|
|
298
303
|
|
|
299
304
|
const app = express();
|
|
@@ -378,7 +383,9 @@ function createApp(options = {}) {
|
|
|
378
383
|
etag: true
|
|
379
384
|
}));
|
|
380
385
|
}
|
|
381
|
-
|
|
386
|
+
|
|
387
|
+
mountClientRuntime(app, clientRuntime);
|
|
388
|
+
|
|
382
389
|
// Configure Nunjucks
|
|
383
390
|
const templateDirs = viewsDir ? [pagesDir, viewsDir] : [pagesDir];
|
|
384
391
|
|
|
@@ -439,7 +446,8 @@ function createApp(options = {}) {
|
|
|
439
446
|
middlewares,
|
|
440
447
|
pluginManager,
|
|
441
448
|
silent: isTest,
|
|
442
|
-
db: options.db ?? null
|
|
449
|
+
db: options.db ?? null,
|
|
450
|
+
clientRuntime,
|
|
443
451
|
});
|
|
444
452
|
|
|
445
453
|
// Set route metadata in plugin manager
|
|
@@ -480,6 +488,7 @@ function createApp(options = {}) {
|
|
|
480
488
|
authMiddleware,
|
|
481
489
|
pluginManager,
|
|
482
490
|
options,
|
|
491
|
+
clientRuntime,
|
|
483
492
|
});
|
|
484
493
|
}
|
|
485
494
|
|
|
@@ -66,8 +66,9 @@ project/
|
|
|
66
66
|
| `timeout` | e.g. `'30s'` or `false` |
|
|
67
67
|
| `helmet` | `true` / `false` / object |
|
|
68
68
|
| `assets` | `{ version, manifestPath, prefix }` for `fsy.asset` / `fsy.css` / `fsy.js` |
|
|
69
|
+
| `clientRuntime` | Opt-in `{ alpine?, swup? }` — serves `/__webspresso/client-runtime/*`, template `clientRuntime`, partial `views/partials/webspresso-client-runtime.njk`, `<main id="swup">` when swup on. Env: `WEBSPRESSO_ALPINE`, `WEBSPRESSO_SWUP`. |
|
|
69
70
|
| `auth` | Auth manager from `createAuth` (session routes) |
|
|
70
|
-
| `setupRoutes(app, ctx)` | **Register custom Express routes here** — runs **after** file routes and plugins’ `onRoutesReady`, **before** 404. Do not rely on `app.get` *after* `createApp` returns unless routes are appended before the 404 middleware (see [`src/server.js`](../../../src/server.js)). |
|
|
71
|
+
| `setupRoutes(app, ctx)` | **Register custom Express routes here** — runs **after** file routes and plugins’ `onRoutesReady`, **before** 404. `ctx.clientRuntime` included. Do not rely on `app.get` *after* `createApp` returns unless routes are appended before the 404 middleware (see [`src/server.js`](../../../src/server.js)). |
|
|
71
72
|
|
|
72
73
|
**Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware? }` (and related).
|
|
73
74
|
|
|
@@ -164,6 +165,8 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
|
|
|
164
165
|
|
|
165
166
|
**Transactions:** `db.transaction(async (trx) => { trx.getRepository('User') })`.
|
|
166
167
|
|
|
168
|
+
**Query cache (optional):** `createDatabase({ ..., cache: true })` or `cache: { defaultStrategy: 'auto'|'smart', memory: { maxEntries, defaultTtlMs }, provider?: custom }`. Opt-in per model: `defineModel({ ..., cache: 'auto'|'smart'|true })`. API: `db.cache` → `getMetrics()`, `purge()`, `invalidateModel(name)`, `invalidateTags(tags[])`, `resetMetrics()`. Reads bypass cache when using a transaction knex. Admin UI: `ormCacheAdminPlugin({ db })` (needs `admin-panel` and `cache` enabled).
|
|
169
|
+
|
|
167
170
|
Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and plugins. **`pages/api/`** handlers receive **`req.db`** (and route **`middleware`** runs after it). Outside requests, use **`getDb()`** / **`hasDb()`**; for **`setupRoutes`**-only routes, use **`attachDbMiddleware`**.
|
|
168
171
|
|
|
169
172
|
---
|
|
@@ -180,6 +183,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
180
183
|
| `auditLogPlugin` | Admin mutation audit trail |
|
|
181
184
|
| `recaptchaPlugin` | v2/v3 + middleware |
|
|
182
185
|
| `seoCheckerPlugin` | Dev SEO panel |
|
|
186
|
+
| `ormCacheAdminPlugin` | Admin page for ORM cache metrics / purge / invalidate (`db.cache` required) |
|
|
183
187
|
|
|
184
188
|
**Custom plugin:** `name`, `version`, `register(ctx)`, `onRoutesReady(ctx)` — use `ctx.app`, `ctx.db`, `ctx.addHelper`, `ctx.addRoute`, `ctx.usePlugin('other')`. Plugin failures **warn**; app keeps running.
|
|
185
189
|
|