webspresso 0.0.101 → 0.0.102

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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Webspresso On-Demand (Lazy) Plugin Wrapper
3
+ *
4
+ * Defers execution of heavy plugin initialization, filesystem scans, route mounting,
5
+ * and dependency loading until the plugin is actually accessed via:
6
+ * 1. HTTP request matching its configured URL prefix (e.g. /_admin)
7
+ * 2. Service execution (e.g. ctx.service('email.send'))
8
+ * 3. Direct inter-plugin API access (e.g. ctx.usePlugin('admin-panel'))
9
+ * 4. Template helper execution (e.g. fsy.adminHelper())
10
+ * 5. Explicit programmatic hydration (lazyPlugin.hydrate())
11
+ *
12
+ * @module plugins/lazy
13
+ */
14
+
15
+ const express = require('express');
16
+
17
+ /**
18
+ * Wraps a plugin definition or factory function into an on-demand lazy plugin.
19
+ *
20
+ * @param {Function|Object} pluginFactory - Factory `() => plugin` or plugin definition object
21
+ * @param {Object} [lazyOptions={}]
22
+ * @param {string} [lazyOptions.name] - Plugin name (inferred if not provided)
23
+ * @param {string|string[]} [lazyOptions.prefix] - URL path prefix(es) that trigger on-demand hydration (e.g. '/_admin')
24
+ * @param {string[]} [lazyOptions.services=[]] - Service names that trigger on-demand hydration
25
+ * @param {string[]} [lazyOptions.helpers=[]] - Template helpers that trigger on-demand hydration
26
+ * @param {string[]} [lazyOptions.events=[]] - Event names that trigger on-demand hydration
27
+ * @param {Object} [lazyOptions.dependencies] - Plugin dependencies
28
+ * @param {Object} [lazyOptions.csp] - Content Security Policy requirements
29
+ * @param {Function} [lazyOptions.onHydrate] - Callback invoked when hydration finishes
30
+ * @returns {Object} Webspresso Lazy Plugin definition
31
+ */
32
+ function lazyPlugin(pluginFactory, lazyOptions = {}) {
33
+ let realPlugin = null;
34
+ let isHydrated = false;
35
+ let isHydrating = false;
36
+
37
+ let regCtx = null;
38
+ let routesCtx = null;
39
+ const router = express.Router();
40
+
41
+ const name =
42
+ lazyOptions.name ||
43
+ (typeof pluginFactory === 'object' && pluginFactory ? pluginFactory.name : null) ||
44
+ 'anonymous-lazy-plugin';
45
+
46
+ const rawPrefixes = lazyOptions.prefix
47
+ ? (Array.isArray(lazyOptions.prefix) ? lazyOptions.prefix : [lazyOptions.prefix])
48
+ : [];
49
+ // Normalize prefixes: ensure leading slash, remove trailing slash (except root '/')
50
+ const prefixes = rawPrefixes.map(p => {
51
+ let norm = String(p).trim();
52
+ if (!norm.startsWith('/')) norm = '/' + norm;
53
+ if (norm.length > 1 && norm.endsWith('/')) norm = norm.slice(0, -1);
54
+ return norm;
55
+ });
56
+
57
+ const lazyServices = Array.isArray(lazyOptions.services) ? lazyOptions.services : [];
58
+ const lazyHelpers = Array.isArray(lazyOptions.helpers) ? lazyOptions.helpers : [];
59
+ const lazyEvents = Array.isArray(lazyOptions.events) ? lazyOptions.events : [];
60
+
61
+ function hydrate() {
62
+ if (isHydrated) return realPlugin;
63
+ if (isHydrating) return realPlugin;
64
+ isHydrating = true;
65
+
66
+ try {
67
+ realPlugin = typeof pluginFactory === 'function' ? pluginFactory() : pluginFactory;
68
+ if (!realPlugin) {
69
+ throw new Error(`[lazyPlugin] Factory for plugin "${name}" did not return a valid plugin object`);
70
+ }
71
+
72
+ // 1. Call setup hook if present
73
+ if (typeof realPlugin.setup === 'function' && regCtx?.app) {
74
+ const disposer = realPlugin.setup(regCtx.app);
75
+ if (typeof disposer === 'function' && regCtx.onDispose) {
76
+ regCtx.onDispose(disposer);
77
+ }
78
+ }
79
+
80
+ // 2. Call register hook
81
+ if (typeof realPlugin.register === 'function' && regCtx) {
82
+ const disposer = realPlugin.register(regCtx);
83
+ if (typeof disposer === 'function' && regCtx.onDispose) {
84
+ regCtx.onDispose(disposer);
85
+ }
86
+ }
87
+
88
+ // 3. Register real routes onto the lazy router
89
+ if (typeof realPlugin.onRoutesReady === 'function' && routesCtx) {
90
+ const customRoutesCtx = {
91
+ ...routesCtx,
92
+ addRoute(method, routePath, ...handlers) {
93
+ const m = String(method).toLowerCase();
94
+ let matchedPrefix = null;
95
+ for (const p of prefixes) {
96
+ if (routePath === p || routePath.startsWith(p + '/')) {
97
+ matchedPrefix = p;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (matchedPrefix) {
103
+ // Convert absolute route path to router-relative subpath
104
+ let subPath = routePath.slice(matchedPrefix.length);
105
+ if (!subPath || !subPath.startsWith('/')) {
106
+ subPath = '/' + (subPath || '');
107
+ }
108
+ if (router[m]) {
109
+ router[m](subPath, ...handlers);
110
+ } else if (router.all) {
111
+ router.all(subPath, ...handlers);
112
+ }
113
+ } else {
114
+ // Route is outside configured prefix: mount directly on app
115
+ if (routesCtx.addRoute) {
116
+ routesCtx.addRoute(method, routePath, ...handlers);
117
+ } else if (routesCtx.app && routesCtx.app[m]) {
118
+ routesCtx.app[m](routePath, ...handlers);
119
+ }
120
+ }
121
+ },
122
+ };
123
+
124
+ const disposer = realPlugin.onRoutesReady(customRoutesCtx);
125
+ if (typeof disposer === 'function' && routesCtx.onDispose) {
126
+ routesCtx.onDispose(disposer);
127
+ }
128
+ }
129
+
130
+ // 4. Update exposed API
131
+ if (realPlugin.api && lazyWrapper.api) {
132
+ Object.assign(lazyWrapper.api, realPlugin.api);
133
+ }
134
+
135
+ // 5. Fire optional callback
136
+ if (typeof lazyOptions.onHydrate === 'function') {
137
+ try {
138
+ lazyOptions.onHydrate(realPlugin);
139
+ } catch (_) {}
140
+ }
141
+
142
+ isHydrated = true;
143
+ return realPlugin;
144
+ } finally {
145
+ isHydrating = false;
146
+ }
147
+ }
148
+
149
+ // Create API Proxy that auto-hydrates on property access
150
+ const apiProxy = new Proxy({}, {
151
+ get(target, prop, receiver) {
152
+ if (prop === '_isLazyProxy') return true;
153
+ if (prop === 'hydrate') return hydrate;
154
+ if (prop === 'isHydrated') return isHydrated;
155
+ hydrate();
156
+ if (realPlugin && realPlugin.api) {
157
+ const val = Reflect.get(realPlugin.api, prop, receiver);
158
+ return typeof val === 'function' ? val.bind(realPlugin.api) : val;
159
+ }
160
+ return Reflect.get(target, prop, receiver);
161
+ },
162
+ has(target, prop) {
163
+ hydrate();
164
+ return realPlugin && realPlugin.api ? Reflect.has(realPlugin.api, prop) : Reflect.has(target, prop);
165
+ },
166
+ set(target, prop, value, receiver) {
167
+ hydrate();
168
+ if (realPlugin && realPlugin.api) {
169
+ return Reflect.set(realPlugin.api, prop, value, receiver);
170
+ }
171
+ return Reflect.set(target, prop, value, receiver);
172
+ },
173
+ });
174
+
175
+ const lazyWrapper = {
176
+ name,
177
+ version: lazyOptions.version || (typeof pluginFactory === 'object' && pluginFactory?.version) || '1.0.0',
178
+ dependencies: lazyOptions.dependencies || (typeof pluginFactory === 'object' && pluginFactory?.dependencies) || {},
179
+ csp: lazyOptions.csp || (typeof pluginFactory === 'object' && pluginFactory?.csp) || null,
180
+ isLazy: true,
181
+ get isHydrated() { return isHydrated; },
182
+ hydrate,
183
+ api: apiProxy,
184
+
185
+ register(ctx) {
186
+ regCtx = ctx;
187
+
188
+ // Register proxy services in app service registry
189
+ if (ctx.app && ctx.app.serviceRegistry && lazyServices.length > 0) {
190
+ for (const svcName of lazyServices) {
191
+ if (!ctx.app.serviceRegistry.has(svcName)) {
192
+ const proxyHandler = async (input, serviceCtx) => {
193
+ hydrate();
194
+ const realDef = ctx.app.serviceRegistry.get(svcName);
195
+ if (realDef && typeof realDef.handler === 'function' && realDef.handler !== proxyHandler) {
196
+ return typeof realDef.rawHandler === 'function'
197
+ ? realDef.rawHandler(input, serviceCtx)
198
+ : realDef.handler(input, serviceCtx);
199
+ }
200
+ return null;
201
+ };
202
+
203
+ ctx.app.serviceRegistry.register(
204
+ svcName,
205
+ { handler: proxyHandler },
206
+ { overwrite: true }
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ // Register proxy helpers in template engine
213
+ if (typeof ctx.addHelper === 'function' && lazyHelpers.length > 0) {
214
+ for (const helperName of lazyHelpers) {
215
+ ctx.addHelper(helperName, (...args) => {
216
+ hydrate();
217
+ const helpers = regCtx?.pluginManager?.getHelpers ? regCtx.pluginManager.getHelpers() : {};
218
+ if (typeof helpers[helperName] === 'function') {
219
+ return helpers[helperName](...args);
220
+ }
221
+ });
222
+ }
223
+ }
224
+
225
+ // Wire event triggers if eventDispatcher is available
226
+ if (ctx.app?.events && lazyEvents.length > 0) {
227
+ for (const eventName of lazyEvents) {
228
+ ctx.app.events.on(eventName, () => {
229
+ hydrate();
230
+ });
231
+ }
232
+ }
233
+ },
234
+
235
+ onRoutesReady(ctx) {
236
+ routesCtx = ctx;
237
+
238
+ if (!ctx.app) return;
239
+
240
+ // Mount lazy trigger router for each prefix
241
+ for (const prefix of prefixes) {
242
+ ctx.app.use(prefix, (req, res, next) => {
243
+ if (!isHydrated) {
244
+ hydrate();
245
+ }
246
+ router(req, res, next);
247
+ });
248
+ }
249
+ },
250
+ };
251
+
252
+ return lazyWrapper;
253
+ }
254
+
255
+ module.exports = {
256
+ lazyPlugin,
257
+ defineLazyPlugin: lazyPlugin,
258
+ };
@@ -6,9 +6,11 @@
6
6
  * @module src/api/api-loader
7
7
  */
8
8
 
9
+ const path = require('path');
9
10
  const { ZodError } = require('zod');
10
11
  const { z: zForApi } = require('../../core/validation');
11
12
  const { resolveMiddlewares } = require('../file-router');
13
+ const { translateValidationIssues, getTranslatorForRequest } = require('./validation-i18n');
12
14
 
13
15
  /**
14
16
  * Compiles a schema definition from an API module
@@ -147,9 +149,17 @@ function createApiHandler(descriptor, context) {
147
149
  validateRequestInput(req, compiledSchema);
148
150
  } catch (err) {
149
151
  if (err instanceof ZodError || err.name === 'ZodError' || Array.isArray(err?.issues)) {
152
+ const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
153
+ const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
154
+ const translatedIssues = translateValidationIssues(err.issues, {
155
+ req,
156
+ pagesDir,
157
+ routeDir,
158
+ isDev,
159
+ });
150
160
  return res.status(400).json({
151
161
  error: 'Validation Error',
152
- issues: err.issues,
162
+ issues: translatedIssues,
153
163
  });
154
164
  }
155
165
  throw err;
@@ -185,6 +195,10 @@ function createApiHandler(descriptor, context) {
185
195
  }
186
196
 
187
197
  // 5. Execute handler
198
+ const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
199
+ const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
200
+ const tr = getTranslatorForRequest(req, { pagesDir, routeDir, isDev });
201
+
188
202
  const apiCtx = {
189
203
  req,
190
204
  res,
@@ -192,6 +206,8 @@ function createApiHandler(descriptor, context) {
192
206
  query: req.query || {},
193
207
  body: req.body || {},
194
208
  input: req.input,
209
+ locale: tr.locale,
210
+ t: tr.t,
195
211
  service: (name, input, opts) =>
196
212
  serviceRegistry ? serviceRegistry.call(name, input, { req, res, db }, opts) : null,
197
213
  module: descriptor.module || null,
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso API Validation i18n & Error Translator
5
+ * Automatically localizes Zod validation error messages and keys based on request locale
6
+ * @module src/api/validation-i18n
7
+ */
8
+
9
+ const path = require('path');
10
+
11
+ const EMPTY_OBJECT = Object.freeze({});
12
+
13
+ let _fileRouter = null;
14
+ function getFileRouter() {
15
+ if (!_fileRouter) {
16
+ _fileRouter = require('../file-router');
17
+ }
18
+ return _fileRouter;
19
+ }
20
+
21
+ /**
22
+ * Resolves or creates a cached translator function for the given request and directory context
23
+ * @param {import('express').Request} req - Express request
24
+ * @param {Object} [options]
25
+ * @param {string} [options.pagesDir] - Pages directory path
26
+ * @param {string} [options.routeDir] - Route-specific directory
27
+ * @param {boolean} [options.isDev] - Is development environment
28
+ * @returns {{ t: Function, locale: string }}
29
+ */
30
+ function getTranslatorForRequest(req, options = {}) {
31
+ if (req && req.t && req.locale) {
32
+ return { t: req.t, locale: req.locale };
33
+ }
34
+
35
+ const {
36
+ loadI18n,
37
+ createTranslator,
38
+ detectLocale,
39
+ translatorCache,
40
+ } = getFileRouter();
41
+
42
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
43
+ const locale = (req && req.locale) ? req.locale : (req ? detectLocale(req) : defaultLocale);
44
+
45
+ const rawPagesDir = options.pagesDir || (req?.app?.get?.('pagesDir')) || 'pages';
46
+ const pagesDir = path.resolve(rawPagesDir);
47
+ const routeDir = options.routeDir ? path.resolve(options.routeDir) : '';
48
+ const isDev = options.isDev !== undefined ? options.isDev : process.env.NODE_ENV !== 'production';
49
+
50
+ let t;
51
+ if (!isDev && translatorCache) {
52
+ const transKey = `${pagesDir}::${routeDir}::${locale}::${defaultLocale}`;
53
+ t = translatorCache.get(transKey);
54
+ if (!t) {
55
+ const translations = loadI18n(pagesDir, routeDir, locale, false);
56
+ const fallbackTranslations = (locale !== defaultLocale)
57
+ ? loadI18n(pagesDir, routeDir, defaultLocale, false)
58
+ : EMPTY_OBJECT;
59
+ t = createTranslator(translations, {
60
+ locale,
61
+ fallbackTranslations,
62
+ fallbackLocale: defaultLocale,
63
+ });
64
+ translatorCache.set(transKey, t);
65
+ }
66
+ } else {
67
+ const translations = loadI18n(pagesDir, routeDir, locale, true);
68
+ const fallbackTranslations = (locale !== defaultLocale)
69
+ ? loadI18n(pagesDir, routeDir, defaultLocale, true)
70
+ : EMPTY_OBJECT;
71
+ t = createTranslator(translations, {
72
+ locale,
73
+ fallbackTranslations,
74
+ fallbackLocale: defaultLocale,
75
+ });
76
+ }
77
+
78
+ if (req) {
79
+ req.locale = locale;
80
+ req.t = t;
81
+ }
82
+
83
+ return { t, locale };
84
+ }
85
+
86
+ /**
87
+ * Translates a single Zod issue using candidate keys and parameter interpolation
88
+ * @param {Object} issue - Zod issue
89
+ * @param {Function} t - Translator function
90
+ * @returns {Object} Translated issue clone
91
+ */
92
+ function translateSingleIssue(issue, t) {
93
+ if (!issue || typeof issue !== 'object') return issue;
94
+
95
+ const pathKey = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join('.') : '';
96
+ const field = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path[issue.path.length - 1] : '';
97
+ const isRequired = (issue.code === 'invalid_type' && (issue.received === 'undefined' || issue.message === 'Required'))
98
+ || issue.message === 'Required';
99
+
100
+ const params = Object.assign(
101
+ {
102
+ path: pathKey,
103
+ field,
104
+ min: issue.minimum !== undefined ? issue.minimum : undefined,
105
+ max: issue.maximum !== undefined ? issue.maximum : undefined,
106
+ minimum: issue.minimum !== undefined ? issue.minimum : undefined,
107
+ maximum: issue.maximum !== undefined ? issue.maximum : undefined,
108
+ limit: issue.minimum !== undefined ? issue.minimum : (issue.maximum !== undefined ? issue.maximum : undefined),
109
+ expected: issue.expected,
110
+ received: issue.received,
111
+ type: issue.type,
112
+ validation: issue.validation,
113
+ },
114
+ issue
115
+ );
116
+
117
+ const candidates = [];
118
+
119
+ // 1. Explicit key in custom message (e.g. z.string({ message: 'auth.email_required' }))
120
+ if (typeof issue.message === 'string' && typeof t.has === 'function' && t.has(issue.message)) {
121
+ candidates.push(issue.message);
122
+ } else if (typeof issue.message === 'string' && issue.message.includes('{{')) {
123
+ const interpolated = issue.message.replace(/{{\s*(\w+)\s*}}/g, (_, k) =>
124
+ params[k] !== undefined ? String(params[k]) : ''
125
+ );
126
+ return Object.assign({}, issue, { message: interpolated });
127
+ }
128
+
129
+ // 2. Field-specific overrides (e.g. validation.fields.email.required or validation.email.required)
130
+ if (pathKey) {
131
+ if (isRequired) {
132
+ candidates.push(`validation.fields.${pathKey}.required`);
133
+ candidates.push(`validation.${pathKey}.required`);
134
+ candidates.push(`validation.fields.${pathKey}`);
135
+ candidates.push(`validation.${pathKey}`);
136
+ } else {
137
+ if (issue.validation) {
138
+ candidates.push(`validation.fields.${pathKey}.${issue.validation}`);
139
+ candidates.push(`validation.${pathKey}.${issue.validation}`);
140
+ }
141
+ candidates.push(`validation.fields.${pathKey}.${issue.code}`);
142
+ candidates.push(`validation.${pathKey}.${issue.code}`);
143
+ candidates.push(`validation.fields.${pathKey}`);
144
+ candidates.push(`validation.${pathKey}`);
145
+ }
146
+ }
147
+
148
+ // 3. General validation code translations
149
+ if (isRequired) {
150
+ candidates.push('validation.required');
151
+ candidates.push('zod.required');
152
+ candidates.push('validation.invalid_type');
153
+ candidates.push('zod.invalid_type');
154
+ } else if (issue.code === 'invalid_string' && issue.validation) {
155
+ candidates.push(`validation.invalid_string.${issue.validation}`);
156
+ candidates.push(`validation.${issue.validation}`);
157
+ candidates.push(`zod.invalid_string.${issue.validation}`);
158
+ candidates.push(`zod.${issue.validation}`);
159
+ candidates.push('validation.invalid_string');
160
+ candidates.push('zod.invalid_string');
161
+ } else {
162
+ candidates.push(`validation.${issue.code}`);
163
+ candidates.push(`zod.${issue.code}`);
164
+ }
165
+
166
+ let matchedKey = null;
167
+ if (typeof t.has === 'function') {
168
+ for (const key of candidates) {
169
+ if (t.has(key)) {
170
+ matchedKey = key;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+
176
+ if (matchedKey) {
177
+ return Object.assign({}, issue, {
178
+ message: t(matchedKey, params),
179
+ key: matchedKey,
180
+ });
181
+ }
182
+
183
+ // If no translation found, interpolate params in custom message if it contains placeholders
184
+ if (typeof issue.message === 'string' && issue.message.includes('{{')) {
185
+ const interpolated = issue.message.replace(/{{\s*(\w+)\s*}}/g, (_, k) =>
186
+ params[k] !== undefined ? String(params[k]) : ''
187
+ );
188
+ return Object.assign({}, issue, { message: interpolated });
189
+ }
190
+
191
+ return Object.assign({}, issue);
192
+ }
193
+
194
+ /**
195
+ * Translates an array of Zod validation issues using the active request locale
196
+ * @param {Array<Object>} issues - Zod error issues array
197
+ * @param {Object} [options]
198
+ * @param {import('express').Request} [options.req] - Express request
199
+ * @param {Function} [options.t] - Optional direct translator
200
+ * @param {string} [options.locale] - Optional direct locale
201
+ * @param {string} [options.pagesDir] - Pages directory path
202
+ * @param {string} [options.routeDir] - Route directory path
203
+ * @param {boolean} [options.isDev] - Is development environment
204
+ * @returns {Array<Object>} Translated issues
205
+ */
206
+ function translateValidationIssues(issues, options = {}) {
207
+ if (!Array.isArray(issues) || issues.length === 0) {
208
+ return issues;
209
+ }
210
+
211
+ let t = options.t || options.req?.t;
212
+ let locale = options.locale || options.req?.locale;
213
+
214
+ if (!t && options.req) {
215
+ const tr = getTranslatorForRequest(options.req, options);
216
+ t = tr.t;
217
+ locale = tr.locale;
218
+ }
219
+
220
+ if (!t) {
221
+ return issues;
222
+ }
223
+
224
+ return issues.map((issue) => translateSingleIssue(issue, t));
225
+ }
226
+
227
+ module.exports = {
228
+ getTranslatorForRequest,
229
+ translateSingleIssue,
230
+ translateValidationIssues,
231
+ };