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.
- package/README.md +358 -8
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/middleware.js +9 -0
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +2 -0
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +90 -17
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +196 -21
- package/src/helpers.js +20 -2
- package/src/plugin-manager.js +124 -4
- package/src/server.js +304 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
1695
|
+
{# With Nunjucks filter #}
|
|
1696
|
+
<h1>{{ 'nav.home' | t }}</h1>
|
|
1697
|
+
<p>{{ 'welcome' | t({ name: currentUser.username }) }}</p>
|
|
1698
|
+
```
|
|
1605
1699
|
|
|
1606
|
-
###
|
|
1700
|
+
### 3. Native Pluralization (`Intl.PluralRules`)
|
|
1607
1701
|
|
|
1608
|
-
|
|
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
|
-
<
|
|
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();
|
package/core/auth/middleware.js
CHANGED
|
@@ -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
|
+
};
|