webspresso 0.0.90 → 0.0.91

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.
Files changed (94) hide show
  1. package/README.md +358 -8
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/middleware.js +9 -0
  5. package/core/compression/compressible.js +51 -0
  6. package/core/compression/compression-stream.js +59 -0
  7. package/core/compression/index.js +25 -0
  8. package/core/compression/middleware.js +301 -0
  9. package/core/compression/negotiate.js +153 -0
  10. package/core/errors/base.js +31 -0
  11. package/core/errors/domain.js +193 -0
  12. package/core/errors/http.js +133 -0
  13. package/core/errors/index.js +77 -0
  14. package/core/errors/middleware.js +162 -0
  15. package/core/errors/normalize.js +171 -0
  16. package/core/orm/complexity.js +120 -0
  17. package/core/orm/events.js +64 -14
  18. package/core/orm/index.js +45 -10
  19. package/core/orm/model.js +2 -0
  20. package/core/orm/query-builder.js +30 -4
  21. package/core/orm/repository.js +57 -25
  22. package/core/orm/transaction.js +39 -5
  23. package/core/orm/utils.js +6 -3
  24. package/core/queue/adapters/database.js +284 -0
  25. package/core/queue/adapters/memory.js +155 -0
  26. package/core/queue/adapters/redis.js +336 -0
  27. package/core/queue/index.js +21 -0
  28. package/core/queue/job.js +103 -0
  29. package/core/queue/manager.js +387 -0
  30. package/core/realtime/adapters/redis.js +431 -0
  31. package/core/realtime/adapters/socket-io.js +203 -0
  32. package/core/realtime/adapters/sse.js +210 -0
  33. package/core/realtime/adapters/websocket.js +337 -0
  34. package/core/realtime/auth-manager.js +140 -0
  35. package/core/realtime/core.js +359 -0
  36. package/core/realtime/emitter.js +98 -0
  37. package/core/realtime/errors.js +71 -0
  38. package/core/realtime/index.js +79 -0
  39. package/core/realtime/reconnect-manager.js +118 -0
  40. package/core/realtime/registry.js +243 -0
  41. package/core/shutdown/http-adapter.js +134 -0
  42. package/core/shutdown/index.js +12 -0
  43. package/core/shutdown/shutdown-manager.js +312 -0
  44. package/core/ssr/index.js +13 -0
  45. package/core/ssr/stream.js +174 -0
  46. package/index.d.ts +829 -52
  47. package/index.js +103 -0
  48. package/package.json +22 -2
  49. package/plugins/admin-panel/api.js +37 -4
  50. package/plugins/admin-panel/app.js +57 -17
  51. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  52. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  53. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  54. package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
  55. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  56. package/plugins/admin-panel/core/admin-module.js +2 -2
  57. package/plugins/admin-panel/core/api-extensions.js +149 -0
  58. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  59. package/plugins/admin-panel/index.js +90 -17
  60. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  61. package/plugins/admin-panel/modules/menu.js +85 -0
  62. package/plugins/admin-panel/modules/user-management.js +5 -2
  63. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  64. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  65. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  66. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  67. package/plugins/basic-auth/index.js +293 -0
  68. package/plugins/data-exchange/export-xlsx.js +5 -1
  69. package/plugins/data-exchange/import.js +16 -2
  70. package/plugins/data-exchange/index.js +14 -0
  71. package/plugins/email/index.js +14 -1
  72. package/plugins/index.js +29 -0
  73. package/plugins/queue/index.js +108 -0
  74. package/plugins/realtime/index.js +150 -0
  75. package/plugins/recaptcha/index.js +11 -0
  76. package/plugins/redirect/index.js +9 -1
  77. package/plugins/rest-resources/index.js +280 -11
  78. package/plugins/upload/index.js +16 -0
  79. package/src/app-context.js +46 -6
  80. package/src/file-router.js +196 -21
  81. package/src/helpers.js +20 -2
  82. package/src/plugin-manager.js +124 -4
  83. package/src/server.js +304 -44
  84. package/src/services/builtins/auth.js +273 -0
  85. package/src/services/builtins/exchange.js +229 -0
  86. package/src/services/builtins/mail.js +134 -0
  87. package/src/services/builtins/media.js +196 -0
  88. package/src/services/builtins/system.js +221 -0
  89. package/src/services/discovery.js +140 -0
  90. package/src/services/executor.js +292 -0
  91. package/src/services/index.js +70 -0
  92. package/src/services/memoize.js +256 -0
  93. package/src/services/registry.js +288 -0
  94. package/src/services/validator.js +281 -0
package/README.md CHANGED
@@ -1244,6 +1244,82 @@ The SEO Checker panel appears as a floating widget and can be opened via:
1244
1244
  - Dev toolbar "SEO Check" button
1245
1245
  - Floating toggle button (🔍) in bottom-right corner
1246
1246
 
1247
+ **Basic Auth Plugin:**
1248
+ - Zero-dependency HTTP Basic Authentication (RFC 7617) with timing-safe password verification (`crypto.timingSafeEqual`).
1249
+ - Can protect the entire application globally or expose named route middleware (`middleware: ['basicAuth']`).
1250
+
1251
+ ```javascript
1252
+ const { basicAuthPlugin } = require('webspresso/plugins');
1253
+
1254
+ const { app } = createApp({
1255
+ pagesDir: './pages',
1256
+ plugins: [
1257
+ basicAuthPlugin({
1258
+ global: false, // Set to true to protect the whole app
1259
+ users: {
1260
+ admin: 'supersecret',
1261
+ },
1262
+ realm: 'Protected Area',
1263
+ }),
1264
+ ],
1265
+ });
1266
+ ```
1267
+
1268
+ **Realtime Layer & Plugin:**
1269
+ - Framework-agnostic, plugin-based, and adapter-supported realtime layer (`core/realtime` and `plugins/realtime`).
1270
+ - Adapters for **Generic WebSocket**, **SSE (Server-Sent Events)**, and **Socket.IO**.
1271
+ - Universal API for browsers and SSR safety (never initializes network connections during server-side rendering).
1272
+ - Built-in exponential backoff reconnection with jitter, multi-strategy authentication (token, cookie, custom), auto-recovery of subscriptions on reconnect, and token sanitization.
1273
+
1274
+ ```javascript
1275
+ const { createApp } = require('webspresso');
1276
+ const { realtimePlugin, websocket } = require('webspresso/plugins');
1277
+
1278
+ // 1. App Plugin Setup
1279
+ const { app } = createApp({
1280
+ plugins: [
1281
+ realtimePlugin({
1282
+ adapter: websocket({ url: '/realtime' }),
1283
+ auth: { strategy: 'cookie' },
1284
+ autoConnect: true,
1285
+ }),
1286
+ ],
1287
+ });
1288
+
1289
+ // 2. Subscription & RPC
1290
+ const sub = app.realtime.subscribe('project:123', { role: 'editor' }, {
1291
+ connected() {
1292
+ console.log('Connected to project:123');
1293
+ },
1294
+ received(data) {
1295
+ console.log('Live message:', data);
1296
+ },
1297
+ });
1298
+
1299
+ sub.perform('user_typing', { userId: 42, isTyping: true });
1300
+ sub.send({ text: 'Hello team!' });
1301
+
1302
+ // 3. Standalone Client Usage (with Token Auth & Refresh)
1303
+ const { realtime, websocket } = require('webspresso');
1304
+
1305
+ const client = realtime({
1306
+ adapter: websocket({ url: 'wss://api.example.com/realtime' }),
1307
+ auth: {
1308
+ strategy: 'token',
1309
+ getToken: async () => localStorage.getItem('token'),
1310
+ refreshToken: async () => {
1311
+ const res = await fetch('/api/auth/refresh', { method: 'POST' });
1312
+ const { token } = await res.json();
1313
+ localStorage.setItem('token', token);
1314
+ return token;
1315
+ },
1316
+ onUnauthorized: (err) => {
1317
+ window.location.href = '/login';
1318
+ },
1319
+ },
1320
+ });
1321
+ ```
1322
+
1247
1323
  ### Creating Custom Plugins
1248
1324
 
1249
1325
  ```javascript
@@ -1584,31 +1660,87 @@ module.exports = {
1584
1660
  };
1585
1661
  ```
1586
1662
 
1587
- ## i18n
1663
+ ## i18n & Localization
1588
1664
 
1589
- ### Global Translations
1665
+ Webspresso features a zero-dependency, ultra-fast internationalization engine built directly on native Node.js `Intl` APIs (`Intl.PluralRules`, `Intl.NumberFormat`, `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat`).
1590
1666
 
1591
- Add JSON files to `pages/locales/`:
1667
+ ### 1. Global & Route-Specific Translations
1668
+
1669
+ Add JSON files to `pages/locales/` for global dictionary entries, or inside any route directory `pages/catalog/locales/` for route-specific overrides:
1592
1670
 
1593
1671
  ```json
1672
+ // pages/locales/en.json
1594
1673
  {
1595
1674
  "nav": {
1596
1675
  "home": "Home",
1597
1676
  "about": "About"
1677
+ },
1678
+ "cart": {
1679
+ "zero": "Your cart is empty",
1680
+ "one": "You have 1 item in your cart",
1681
+ "other": "You have {{count}} items in your cart"
1598
1682
  }
1599
1683
  }
1600
1684
  ```
1601
1685
 
1602
- ### Route-Specific Translations
1686
+ ### 2. Basic Translation & Interpolation
1687
+
1688
+ ```nunjucks
1689
+ {# Direct function call #}
1690
+ <h1>{{ t('nav.home') }}</h1>
1691
+
1692
+ {# With parameter interpolation #}
1693
+ <p>{{ t('welcome', { name: currentUser.username }) }}</p>
1603
1694
 
1604
- Add a `locales/` folder inside any route directory to override global translations.
1695
+ {# With Nunjucks filter #}
1696
+ <h1>{{ 'nav.home' | t }}</h1>
1697
+ <p>{{ 'welcome' | t({ name: currentUser.username }) }}</p>
1698
+ ```
1605
1699
 
1606
- ### Using Translations
1700
+ ### 3. Native Pluralization (`Intl.PluralRules`)
1607
1701
 
1608
- In templates:
1702
+ Pass `count` (or `n`, `cardinal`) to automatically select the correct CLDR plural category (`zero`, `one`, `two`, `few`, `many`, `other`) or exact number (`0`, `1`):
1609
1703
 
1610
1704
  ```nunjucks
1611
- <h1>{{ t('nav.home') }}</h1>
1705
+ <p>{{ t('cart', { count: cart.items.length }) }}</p>
1706
+ ```
1707
+
1708
+ You can also use the inline `t.plural()` helper:
1709
+ ```nunjucks
1710
+ <p>{{ t.plural(items.length, { one: '1 item', other: '{{count}} items' }) }}</p>
1711
+ ```
1712
+
1713
+ ### 4. Fallback Locales Chain
1714
+
1715
+ If a translation key is missing in the requested locale (e.g. `tr`), Webspresso automatically falls back to `DEFAULT_LOCALE` (default: `en`) before returning the key name or default fallback value. Check key existence with `t.has('key')` or `t.exists('key')`.
1716
+
1717
+ ### 5. Built-in `Intl` Formatters on `t`
1718
+
1719
+ Format numbers, currencies, dates, and relative times according to the active locale without external date/number libraries:
1720
+
1721
+ ```nunjucks
1722
+ {# Numbers #}
1723
+ <span>{{ t.number(1234567.89) }}</span> {# TR: 1.234.567,89 | EN: 1,234,567.89 #}
1724
+
1725
+ {# Currencies #}
1726
+ <span>{{ t.currency(199.99, 'TRY') }}</span> {# ₺199,99 #}
1727
+ <span>{{ t.currency(49.99, 'USD') }}</span> {# $49.99 #}
1728
+
1729
+ {# Dates #}
1730
+ <span>{{ t.date(post.created_at, { dateStyle: 'long' }) }}</span>
1731
+
1732
+ {# Relative Time #}
1733
+ <span>{{ t.relativeTime(-2, 'day') }}</span> {# "2 days ago" or "2 gün önce" #}
1734
+ ```
1735
+
1736
+ ### 6. Language Switcher Helpers
1737
+
1738
+ Use `fsy.localeUrl(targetLocale)` to generate a URL with the current page path and query parameters preserved:
1739
+
1740
+ ```nunjucks
1741
+ <a href="{{ fsy.localeUrl('en') }}">English</a>
1742
+ <a href="{{ fsy.localeUrl('tr') }}">Türkçe</a>
1743
+ <a href="{{ fsy.localeUrl('de') }}">Deutsch</a>
1612
1744
  ```
1613
1745
 
1614
1746
  ## Template Helpers
@@ -1682,6 +1814,224 @@ module.exports = {
1682
1814
  10. Nunjucks render
1683
1815
  11. Route `afterRender`
1684
1816
 
1817
+ ## Services Layer
1818
+
1819
+ Webspresso includes a lightweight, framework-agnostic **Services Layer** to organize reusable business logic, query flows, and multi-step mutations under `services/`.
1820
+
1821
+ Services are automatically discovered from the filesystem and can be invoked from SSR data loaders (`ctx.service()`), API routes (`req.service()`), background jobs, cron, CLI tasks, or other services.
1822
+
1823
+ ### 1. File Structure & Service Names
1824
+
1825
+ Files inside `services/` map directly to dot-separated service names:
1826
+
1827
+ ```text
1828
+ services/
1829
+ user/
1830
+ get.js → user.get
1831
+ create.js → user.create
1832
+ profile/
1833
+ get.js → user.profile.get
1834
+ post/
1835
+ publish.js → post.publish
1836
+ listByUser.js → post.listByUser
1837
+ payment/
1838
+ refund.js → payment.refund
1839
+ ```
1840
+
1841
+ ### 2. Service Definition
1842
+
1843
+ Services export a handler and an optional input validation schema:
1844
+
1845
+ ```javascript
1846
+ // services/user/get.js
1847
+ module.exports = {
1848
+ schema: {
1849
+ id: 'number',
1850
+ },
1851
+
1852
+ async handler({ id }, ctx) {
1853
+ return ctx.db.users.findById(id);
1854
+ },
1855
+ };
1856
+ ```
1857
+
1858
+ You can also use the `defineService` helper:
1859
+
1860
+ ```javascript
1861
+ // services/payment/refund.js
1862
+ const { defineService } = require('webspresso');
1863
+
1864
+ module.exports = defineService({
1865
+ // Zod functional schema syntax ({ z }) with built-in nanoid support
1866
+ schema: ({ z }) => z.object({
1867
+ transactionId: z.string().min(3),
1868
+ amount: z.number().positive(),
1869
+ reason: z.string().optional(),
1870
+ }),
1871
+
1872
+ async handler({ transactionId, amount, reason }, ctx) {
1873
+ // Process refund logic...
1874
+ return { refundId: 'ref_123', transactionId, amount, status: 'succeeded' };
1875
+ },
1876
+ });
1877
+ ```
1878
+
1879
+ ### 3. Service-to-Service Composition
1880
+
1881
+ Services can seamlessly call other services via `ctx.service()` while sharing the exact same request context (`ctx.db`, `ctx.auth`, `ctx.session`, `ctx.user`):
1882
+
1883
+ ```javascript
1884
+ // services/post/publish.js
1885
+ module.exports = {
1886
+ schema: ({ z }) => z.object({
1887
+ title: z.string(),
1888
+ authorId: z.number(),
1889
+ }),
1890
+
1891
+ async handler({ title, authorId }, ctx) {
1892
+ const author = await ctx.service('user.get', { id: authorId });
1893
+
1894
+ return ctx.db.posts.create({
1895
+ title,
1896
+ author_id: author.id,
1897
+ published_at: new Date(),
1898
+ });
1899
+ },
1900
+ };
1901
+ ```
1902
+
1903
+ > **Circular Call Protection**: Webspresso automatically tracks service call stacks and detects recursive/circular dependencies (e.g. `user.get -> profile.get -> user.get`), throwing an informative `Circular service call detected` error.
1904
+
1905
+ ### 4. Invoking from SSR & API Routes
1906
+
1907
+ In SSR page data loaders (`pages/users/[id].njk`):
1908
+
1909
+ ```javascript
1910
+ // pages/users/[id].js
1911
+ module.exports = {
1912
+ async load(req, ctx) {
1913
+ const user = await ctx.service('user.get', { id: Number(req.params.id) });
1914
+ return { user };
1915
+ },
1916
+ };
1917
+ ```
1918
+
1919
+ In API routes or Express middleware:
1920
+
1921
+ ```javascript
1922
+ // pages/api/users.post.js
1923
+ module.exports = async function(req, res) {
1924
+ const user = await req.service('user.create', req.body);
1925
+ res.status(201).json(user);
1926
+ };
1927
+ ```
1928
+
1929
+ ### 5. Service Caching & Memoization (`cache: '5m'`)
1930
+
1931
+ Services can be cached in-memory with automatic TTL expiration and deterministic key serialization:
1932
+
1933
+ ```javascript
1934
+ // services/product/stats.js
1935
+ module.exports = {
1936
+ // Cache result for 5 minutes based on input arguments
1937
+ cache: '5m', // supports '30s', '5m', '1h', '1d', ms numbers, or { ttl: '5m', key: (input) => input.id }
1938
+
1939
+ async handler({ productId }, ctx) {
1940
+ return ctx.db.stats.getProductStats(productId);
1941
+ },
1942
+ };
1943
+ ```
1944
+
1945
+ You can invalidate cache keys explicitly on mutations:
1946
+
1947
+ ```javascript
1948
+ // From any service or route:
1949
+ ctx.service.invalidate('product.stats', { productId: 42 });
1950
+
1951
+ // Or clear all cached entries for a service:
1952
+ ctx.service.clearCache('product.stats');
1953
+ ```
1954
+
1955
+ You can also use `memoize` as a standalone utility for any async function:
1956
+
1957
+ ```javascript
1958
+ const { memoize } = require('webspresso');
1959
+
1960
+ const fetchRemoteRates = memoize(async () => {
1961
+ return fetchExchangeRates();
1962
+ }, { ttl: '10m', maxSize: 100 });
1963
+ ```
1964
+
1965
+ ### 6. Timeouts & Database Transactions (`timeout`, `transaction`)
1966
+
1967
+ You can protect services against hanging handlers and ensure automatic ACID transaction rollbacks:
1968
+
1969
+ ```javascript
1970
+ // services/order/checkout.js
1971
+ module.exports = {
1972
+ // Abort with 504 Gateway Timeout if execution exceeds 5 seconds
1973
+ timeout: '5s',
1974
+
1975
+ // Wrap execution in an automatic Knex transaction (rolls back on any error)
1976
+ transaction: true,
1977
+
1978
+ async handler({ orderId, items }, ctx) {
1979
+ // ctx.trx and ctx.db are automatically bound to the active transaction
1980
+ await ctx.db('orders').where({ id: orderId }).update({ status: 'paid' });
1981
+ await ctx.service('inventory.deduct', { items }); // Nested services reuse the exact same transaction!
1982
+
1983
+ return { orderId, success: true };
1984
+ },
1985
+ };
1986
+ ```
1987
+
1988
+ ### 7. Authorization & Role Guards (`auth`)
1989
+
1990
+ Services can enforce authentication and role-based access control (RBAC) automatically:
1991
+
1992
+ ```javascript
1993
+ // services/admin/billing.js
1994
+ module.exports = {
1995
+ // Requires authenticated user with 'admin' or 'finance' role
1996
+ auth: ['admin', 'finance'], // or auth: true (any logged in user) or auth: (user, ctx) => boolean
1997
+
1998
+ async handler(input, ctx) {
1999
+ // Guaranteed to have authorized ctx.user
2000
+ return ctx.db('billing_records').select();
2001
+ },
2002
+ };
2003
+ ```
2004
+
2005
+ Throws `UnauthorizedError` (401) when unauthenticated or `ForbiddenError` (403) when role permissions are insufficient.
2006
+
2007
+ ### 8. Interactive CLI Runner (`webspresso service`)
2008
+
2009
+ You can test and run any service directly from the command line without starting the HTTP server:
2010
+
2011
+ ```bash
2012
+ # Interactive selection & prompt mode
2013
+ npx webspresso service
2014
+
2015
+ # Run a specific service with JSON payload
2016
+ npx webspresso service user.get -i '{"id": 42}'
2017
+
2018
+ # Run with key=value query string
2019
+ npx webspresso service payment.refund -i 'transactionId=tx_123 amount=49.99'
2020
+ ```
2021
+
2022
+ ### 7. Programmatic Usage & Testing
2023
+
2024
+ You can execute services directly in unit tests or background scripts:
2025
+
2026
+ ```javascript
2027
+ const { createServiceRegistry } = require('webspresso');
2028
+
2029
+ const services = createServiceRegistry({ servicesDir: './services' });
2030
+ const user = await services.call('user.get', { id: 42 }, { db: mockDb });
2031
+ ```
2032
+
2033
+ ---
2034
+
1685
2035
  ## Environment Variables
1686
2036
 
1687
2037
  | Variable | Default | Description |
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Webspresso CLI: Service Command
3
+ * Run and test services interactively without starting the HTTP server
4
+ * @module bin/commands/service
5
+ */
6
+
7
+ const path = require('path');
8
+ const inquirer = require('inquirer');
9
+ const { createServiceRegistry } = require('../../src/services');
10
+ const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
11
+
12
+ /**
13
+ * Parse input string into an object
14
+ * @param {string} inputStr
15
+ * @returns {*}
16
+ */
17
+ function parseInputPayload(inputStr) {
18
+ if (!inputStr || typeof inputStr !== 'string') {
19
+ return {};
20
+ }
21
+
22
+ const trimmed = inputStr.trim();
23
+ if (!trimmed) return {};
24
+
25
+ // Try JSON first
26
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
27
+ try {
28
+ return JSON.parse(trimmed);
29
+ } catch (e) {
30
+ throw new Error(`Invalid JSON input: ${e.message}`);
31
+ }
32
+ }
33
+
34
+ // Try key=value pairs (e.g. "id=42 name=Alice" or "id=42&name=Alice")
35
+ const result = {};
36
+ const pairs = trimmed.includes('&') ? trimmed.split('&') : trimmed.split(/\s+/);
37
+
38
+ for (const pair of pairs) {
39
+ if (!pair.includes('=')) continue;
40
+ const [rawKey, ...valParts] = pair.split('=');
41
+ const key = rawKey.trim();
42
+ const val = valParts.join('=').trim();
43
+
44
+ // Auto coerce simple types
45
+ if (val === 'true') result[key] = true;
46
+ else if (val === 'false') result[key] = false;
47
+ else if (val === 'null') result[key] = null;
48
+ else if (!Number.isNaN(Number(val)) && val !== '') result[key] = Number(val);
49
+ else result[key] = val;
50
+ }
51
+
52
+ return Object.keys(result).length > 0 ? result : trimmed;
53
+ }
54
+
55
+ function registerCommand(program) {
56
+ program
57
+ .command('service [name]')
58
+ .alias('service:run')
59
+ .alias('service:call')
60
+ .description('Run and test services interactively without starting the HTTP server')
61
+ .option('-i, --input <payload>', 'Input payload as JSON string or key=val pairs')
62
+ .option('-d, --dir <path>', 'Path to services directory (default: ./services)')
63
+ .option('-c, --config <path>', 'Path to database config file (optional)')
64
+ .option('-E, --env <environment>', 'Environment (development, production)', 'development')
65
+ .action(async (nameArg, options) => {
66
+ let db = null;
67
+ try {
68
+ const servicesDir = options.dir
69
+ ? path.resolve(process.cwd(), options.dir)
70
+ : path.resolve(process.cwd(), 'services');
71
+
72
+ const registry = createServiceRegistry({
73
+ servicesDir,
74
+ isDev: true,
75
+ });
76
+
77
+ const serviceNames = registry.list();
78
+
79
+ if (serviceNames.length === 0) {
80
+ console.log(`\n⚠️ No services found in "${path.relative(process.cwd(), servicesDir) || '.'}".`);
81
+ console.log(' Create your first service file in services/user/get.js\n');
82
+ return;
83
+ }
84
+
85
+ let serviceName = nameArg;
86
+
87
+ // Interactive Service Selection if not provided
88
+ if (!serviceName) {
89
+ console.log(`\n⚡ Discovered ${serviceNames.length} service(s) in ${path.relative(process.cwd(), servicesDir) || 'services/'}:\n`);
90
+
91
+ const answer = await inquirer.prompt([
92
+ {
93
+ type: 'list',
94
+ name: 'selectedService',
95
+ message: 'Select a service to execute:',
96
+ choices: serviceNames.map((name) => {
97
+ const def = registry.get(name);
98
+ const hasSchema = !!def?.schema;
99
+ return {
100
+ name: `${name.padEnd(25)} ${hasSchema ? '📋 (has schema)' : ''}`,
101
+ value: name,
102
+ };
103
+ }),
104
+ },
105
+ ]);
106
+
107
+ serviceName = answer.selectedService;
108
+ }
109
+
110
+ if (!registry.has(serviceName)) {
111
+ console.error(`\n❌ Error: Service "${serviceName}" not found.`);
112
+ console.log(` Available services: ${serviceNames.join(', ')}\n`);
113
+ process.exit(1);
114
+ }
115
+
116
+ const serviceDef = registry.get(serviceName);
117
+
118
+ // Input gathering
119
+ let input = {};
120
+ if (options.input) {
121
+ input = parseInputPayload(options.input);
122
+ } else {
123
+ console.log(`\n⚙️ Target: \x1b[36m${serviceName}\x1b[0m (${serviceDef.filePath ? path.relative(process.cwd(), serviceDef.filePath) : 'in-memory'})`);
124
+
125
+ const inputAnswer = await inquirer.prompt([
126
+ {
127
+ type: 'input',
128
+ name: 'rawInput',
129
+ message: 'Enter input (JSON or key=val pairs, leave empty for {}):',
130
+ default: '{}',
131
+ },
132
+ ]);
133
+
134
+ input = parseInputPayload(inputAnswer.rawInput);
135
+ }
136
+
137
+ // Initialize DB if config exists
138
+ const dbConfigResult = resolveDbConfigIfExists(options.config);
139
+ if (dbConfigResult) {
140
+ try {
141
+ db = await createDbInstance(dbConfigResult.config, options.env);
142
+ } catch (dbErr) {
143
+ console.warn(`\n⚠️ Could not connect to database: ${dbErr.message}`);
144
+ }
145
+ }
146
+
147
+ const ctx = {
148
+ db,
149
+ user: { id: 1, role: 'admin', email: 'cli-admin@webspresso.local' },
150
+ logger: console,
151
+ };
152
+
153
+ console.log(`\n🚀 Executing \x1b[36m${serviceName}\x1b[0m with input:`, JSON.stringify(input));
154
+ const startTime = Date.now();
155
+
156
+ try {
157
+ const result = await registry.call(serviceName, input, ctx);
158
+ const duration = Date.now() - startTime;
159
+
160
+ console.log(`\n✅ \x1b[32mExecution succeeded (${duration}ms)\x1b[0m\n`);
161
+ console.log(JSON.stringify(result, null, 2));
162
+ console.log('');
163
+ } catch (execErr) {
164
+ const duration = Date.now() - startTime;
165
+ console.error(`\n❌ \x1b[31mExecution failed (${duration}ms): ${execErr.message}\x1b[0m\n`);
166
+
167
+ if (execErr.fields) {
168
+ console.error('Validation Details:');
169
+ for (const [f, msg] of Object.entries(execErr.fields)) {
170
+ console.error(` - ${f}: ${msg}`);
171
+ }
172
+ console.error('');
173
+ } else if (execErr.stack) {
174
+ console.error(execErr.stack);
175
+ console.error('');
176
+ }
177
+ process.exitCode = 1;
178
+ }
179
+ } catch (err) {
180
+ console.error(`\n❌ CLI Error: ${err.message}\n`);
181
+ process.exitCode = 1;
182
+ } finally {
183
+ if (db && typeof db.destroy === 'function') {
184
+ await db.destroy();
185
+ }
186
+ }
187
+ });
188
+ }
189
+
190
+ module.exports = {
191
+ registerCommand,
192
+ parseInputPayload,
193
+ };
package/bin/webspresso.js CHANGED
@@ -33,6 +33,7 @@ const { registerCommand: registerDoctor } = require('./commands/doctor');
33
33
  const { registerCommand: registerSkill } = require('./commands/skill');
34
34
  const { registerCommand: registerUpgrade } = require('./commands/upgrade');
35
35
  const { registerCommand: registerOrmMap } = require('./commands/orm-map');
36
+ const { registerCommand: registerService } = require('./commands/service');
36
37
 
37
38
  registerNew(program);
38
39
  registerPage(program);
@@ -54,6 +55,7 @@ registerDoctor(program);
54
55
  registerSkill(program);
55
56
  registerUpgrade(program);
56
57
  registerOrmMap(program);
58
+ registerService(program);
57
59
 
58
60
  // Parse arguments
59
61
  program.parse();
@@ -17,6 +17,15 @@ function createAuthMiddleware(authManager) {
17
17
 
18
18
  const sessionConfig = authManager.getSessionConfig();
19
19
 
20
+ if (!sessionConfig.store && process.env.NODE_ENV === 'production') {
21
+ const warnMsg = '[webspresso:auth] Warning: Default MemoryStore is being used in production. It is not designed for production environments and will leak memory under high load. Please configure a persistent session store (e.g. Knex or Redis).';
22
+ if (typeof process !== 'undefined' && typeof process.emitWarning === 'function') {
23
+ process.emitWarning(warnMsg, 'SecurityWarning');
24
+ } else {
25
+ console.warn(warnMsg);
26
+ }
27
+ }
28
+
20
29
  /**
21
30
  * Session middleware
22
31
  * Sets up express-session with secure cookie enforcement
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Content-Type Compressibility Filter
3
+ * Determines if a given MIME type benefits from HTTP compression.
4
+ * @module core/compression/compressible
5
+ */
6
+
7
+ // Regex patterns for MIME types that benefit from compression
8
+ const COMPRESSIBLE_TYPE_REGEX = /^(?:text\/[a-z0-9.+_-]+|application\/(?:javascript|x-javascript|json|ld\+json|manifest\+json|graphql\+json|geo\+json|xml|atom\+xml|rss\+xml|xhtml\+xml|x-yaml|yaml|vnd\.api\+json|schema\+json|hal\+json)|image\/svg\+xml)$/i;
9
+
10
+ // Regex patterns for already-compressed or non-compressible types
11
+ const INCOMPRESSIBLE_TYPE_REGEX = /^(?:image\/(?:jpeg|png|gif|webp|avif|heic|heif)|video\/[a-z0-9.+_-]+|audio\/[a-z0-9.+_-]+|application\/(?:zip|gzip|x-gzip|x-bzip2|x-compress|x-7z-compressed|x-rar-compressed|pdf|wasm|octet-stream))$/i;
12
+
13
+ /**
14
+ * Determine if a Content-Type is compressible
15
+ * @param {string|undefined|null} contentType - Raw Content-Type header (e.g. 'text/html; charset=utf-8')
16
+ * @returns {boolean}
17
+ */
18
+ function isCompressible(contentType) {
19
+ if (!contentType || typeof contentType !== 'string') {
20
+ return false;
21
+ }
22
+
23
+ // Extract base MIME type (strip parameters like ; charset=utf-8)
24
+ const semiIndex = contentType.indexOf(';');
25
+ const mime = (semiIndex !== -1 ? contentType.slice(0, semiIndex) : contentType).trim().toLowerCase();
26
+
27
+ if (!mime) {
28
+ return false;
29
+ }
30
+
31
+ // Fast negative check
32
+ if (INCOMPRESSIBLE_TYPE_REGEX.test(mime)) {
33
+ return false;
34
+ }
35
+
36
+ // Check compressible match
37
+ if (COMPRESSIBLE_TYPE_REGEX.test(mime)) {
38
+ return true;
39
+ }
40
+
41
+ // General text or suffix match (+json, +xml, +yaml)
42
+ if (mime.startsWith('text/') || mime.endsWith('+json') || mime.endsWith('+xml') || mime.endsWith('+yaml')) {
43
+ return true;
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ module.exports = {
50
+ isCompressible,
51
+ };