webspresso 0.0.62 → 0.0.64

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 CHANGED
@@ -15,7 +15,8 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
15
15
  - **Lifecycle Hooks**: Global and route-level hooks for request processing
16
16
  - **Template Helpers**: Laravel-inspired helper functions available in templates
17
17
  - **Plugin System**: Extensible architecture with version control and inter-plugin communication
18
- - **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint
18
+ - **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models
19
+ - **TypeScript**: Published **`index.d.ts`** (via `package.json` `"types"`) for `createApp`, ORM, plugins, and router helpers — use from TS/JS with IDE autocomplete; runtime stays CommonJS
19
20
 
20
21
  ## Installation
21
22
 
@@ -25,6 +26,18 @@ npm install -g webspresso
25
26
  npm install webspresso
26
27
  ```
27
28
 
29
+ ## TypeScript
30
+
31
+ The npm package ships with **[`index.d.ts`](index.d.ts)** so consumers get typings for the public API (`createApp`, `defineModel`, `createDatabase`, `zdb`, plugins, etc.). No extra `@types/webspresso` package is required.
32
+
33
+ ```typescript
34
+ import { createApp, defineModel, zdb } from 'webspresso';
35
+ ```
36
+
37
+ Install **`@types/express`** in your app if you want full **`Express.Application`** / **`Request`** / **`Response`** inference when you touch `createApp().app` or write middleware. **`knex`** and **`zod`** bring their own types.
38
+
39
+ Framework development (this repo): run **`npm run check:types`** to typecheck the declarations against a small smoke file (`tests/ts-smoke/`).
40
+
28
41
  ## Quick Start
29
42
 
30
43
  ### Using CLI (Recommended)
@@ -1030,7 +1043,40 @@ module.exports = async function handler(req, res) {
1030
1043
  };
1031
1044
  ```
1032
1045
 
1033
- **With Schema Validation:**
1046
+ **Object export (`handler`, `middleware`, `schema`):**
1047
+
1048
+ Use a single export object when you need **named middleware** from `createApp({ middlewares })` and/or a **Zod** `schema`. Order at runtime: **`req.db`** (if configured) → **schema** (`req.input`) → **middleware** chain → **handler**.
1049
+
1050
+ ```javascript
1051
+ // pages/api/search.post.js
1052
+ module.exports = {
1053
+ middleware: ['requireAuth'],
1054
+ schema: ({ z }) => ({
1055
+ body: z.object({ q: z.string() }),
1056
+ }),
1057
+ handler: async (req, res) => {
1058
+ const { q } = req.input.body;
1059
+ const results = [];
1060
+ return res.json({ results, q });
1061
+ },
1062
+ };
1063
+ ```
1064
+
1065
+ Register `requireAuth` (and any other names) on the app:
1066
+
1067
+ ```javascript
1068
+ createApp({
1069
+ pagesDir: './pages',
1070
+ middlewares: {
1071
+ requireAuth: (req, res, next) => {
1072
+ if (!req.session?.user) return res.status(401).json({ error: 'Unauthorized' });
1073
+ next();
1074
+ },
1075
+ },
1076
+ });
1077
+ ```
1078
+
1079
+ **With Schema Validation (same object shape):**
1034
1080
 
1035
1081
  ```javascript
1036
1082
  // pages/api/posts.post.js
@@ -1066,7 +1112,25 @@ module.exports = {
1066
1112
  | `query` | Validates query string parameters |
1067
1113
  | `response` | Response schema (for documentation, not enforced) |
1068
1114
 
1069
- All schemas use [Zod](https://zod.dev) for validation. Invalid requests throw a `ZodError` which can be caught by error handlers.
1115
+ All schemas use [Zod](https://zod.dev) for validation. Invalid requests receive **`400 JSON`**: `{ error: 'Validation Error', issues: [...] }` (no `handler` / `middleware` run).
1116
+
1117
+ For **nanoid** route parameters (same alphabet and default length as **`generateNanoid`** / **`zdb.nanoid()`**), use **`z.nanoid()`** on the `z` passed into your API schema (Webspresso extends it; no extra import). Optional: **`z.nanoid(12)`** or **`z.nanoid({ maxLength: 12 })`** (matches **`zdb.nanoid({ maxLength })`**). For scripts or tests outside `schema: ({ z })`, **`zodNanoid(z, size?)`** and **`extendZ(z)`** are exported from `webspresso`.
1118
+
1119
+ ```javascript
1120
+ module.exports = {
1121
+ schema: ({ z }) => ({
1122
+ params: z.object({
1123
+ id: z.nanoid(), // default 21 chars
1124
+ shortId: z.nanoid(12), // numeric length
1125
+ other: z.nanoid({ maxLength: 8 }), // zdb-style options
1126
+ }),
1127
+ }),
1128
+ async handler(req, res) {
1129
+ const { id } = req.input.params;
1130
+ // ...
1131
+ },
1132
+ };
1133
+ ```
1070
1134
 
1071
1135
  **Dynamic Route with Params Validation:**
1072
1136
 
@@ -1831,6 +1895,34 @@ const user = plugin.api.getModel('User'); // Single model
1831
1895
  const names = plugin.api.getModelNames(); // Model names
1832
1896
  ```
1833
1897
 
1898
+ ### REST resources plugin
1899
+
1900
+ Registers **REST-style CRUD** routes for ORM models (`GET` list, `GET /:id`, `POST`, `PATCH /:id`, `DELETE /:id`). Relations are eager-loaded only when the client passes **`?include=relation1,relation2`**; loading uses the ORM batch eager loader (no classic N+1 from relation queries). **Nested includes** (e.g. `posts.comments`) are **not** supported—only top-level relation names defined on the model.
1901
+
1902
+ **Model opt-in** via `defineModel({ ..., rest: { enabled: true, path: 'optional-segment', allowInclude: ['company'] } })`. If you pass **`models: ['User', 'Post']`** to the plugin, those names are exposed even when `rest.enabled` is false.
1903
+
1904
+ **Setup:**
1905
+
1906
+ ```javascript
1907
+ const { createApp, restResourcePlugin } = require('webspresso');
1908
+
1909
+ const { app } = createApp({
1910
+ pagesDir: './pages',
1911
+ db,
1912
+ plugins: [
1913
+ restResourcePlugin({
1914
+ path: '/api/rest',
1915
+ middleware: [], // optional Express handlers (e.g. auth) — `attachDbMiddleware` is applied after these
1916
+ models: null, // optional whitelist of model names; when omitted, only `rest.enabled` models are exposed
1917
+ excludeModels: [],
1918
+ filter: null, // optional (model) => boolean
1919
+ }),
1920
+ ],
1921
+ });
1922
+ ```
1923
+
1924
+ List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed` (`only` / `include` when the model uses soft delete), plus **equality filters** on real columns (unknown keys are ignored).
1925
+
1834
1926
  ### Health check plugin
1835
1927
 
1836
1928
  Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
@@ -4,8 +4,12 @@
4
4
  */
5
5
 
6
6
  const z = require('zod');
7
+ const { extendZ } = require('./orm/utils/nanoid');
7
8
  const schemaCache = require('../utils/schemaCache');
8
9
 
10
+ /** Zod plus `z.nanoid()` for API schemas (does not mutate the global `z`). */
11
+ const zForApi = extendZ(z);
12
+
9
13
  /**
10
14
  * Compile schema from an API module
11
15
  * @param {string} filePath - Absolute file path to API module
@@ -32,8 +36,8 @@ function compileSchema(filePath, apiModule) {
32
36
  throw new Error(`Schema in ${filePath} must be a function`);
33
37
  }
34
38
 
35
- // Call schema function with { z }
36
- const compiled = schemaFn({ z });
39
+ // Call schema function with { z } (extended with z.nanoid, same as generateNanoid / zdb.nanoid)
40
+ const compiled = schemaFn({ z: zForApi });
37
41
 
38
42
  // Validate compiled schema structure
39
43
  if (compiled !== null && typeof compiled !== 'object') {
package/core/orm/index.js CHANGED
@@ -14,7 +14,7 @@ const { createSeeder } = require('./seeder');
14
14
  const { createScopeContext } = require('./scopes');
15
15
  const { ModelEvents, Hooks, HookCancellationError, createEventContext } = require('./events');
16
16
  const { omitHiddenColumns, sanitizeForOutput } = require('./utils');
17
- const { generateNanoid } = require('./utils/nanoid');
17
+ const { generateNanoid, zodNanoid, extendZ } = require('./utils/nanoid');
18
18
 
19
19
  /**
20
20
  * Create a database instance
@@ -275,6 +275,8 @@ module.exports = {
275
275
  extractColumnsFromSchema,
276
276
  getColumnMeta,
277
277
  generateNanoid,
278
+ zodNanoid,
279
+ extendZ,
278
280
  // Output sanitization (exclude hidden columns from API/templates)
279
281
  omitHiddenColumns,
280
282
  sanitizeForOutput,
package/core/orm/model.js CHANGED
@@ -27,6 +27,7 @@ function defineModel(options) {
27
27
  relations = {},
28
28
  scopes = {},
29
29
  admin = {},
30
+ rest = {},
30
31
  hooks = {},
31
32
  hidden = [],
32
33
  } = options;
@@ -89,6 +90,11 @@ function defineModel(options) {
89
90
  customFields: admin.customFields || {},
90
91
  queries: admin.queries || {},
91
92
  },
93
+ rest: {
94
+ enabled: rest.enabled === true,
95
+ path: typeof rest.path === 'string' && rest.path.length > 0 ? rest.path.replace(/^\/+|\/+$/g, '') : null,
96
+ allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
97
+ },
92
98
  hidden: Array.isArray(hidden) ? hidden : [],
93
99
  hooks: {},
94
100
  };
package/core/orm/types.js CHANGED
@@ -134,6 +134,13 @@
134
134
  * @property {Object.<string, QueryConfig>} [queries={}] - Custom query functions
135
135
  */
136
136
 
137
+ /**
138
+ * @typedef {Object} RestMetadata
139
+ * @property {boolean} [enabled=false] - Expose this model via restResourcePlugin (when not using plugin `models` whitelist)
140
+ * @property {string} [path] - URL segment override (no slashes); default: pluralized model name
141
+ * @property {string[]} [allowInclude] - Allowed relation names for `?include=`; default: all `model.relations` keys
142
+ */
143
+
137
144
  // ============================================================================
138
145
  // Model Types
139
146
  // ============================================================================
@@ -147,6 +154,7 @@
147
154
  * @property {RelationsMap} [relations={}] - Relation definitions
148
155
  * @property {ScopeOptions} [scopes={}] - Scope options
149
156
  * @property {AdminMetadata} [admin] - Admin panel metadata
157
+ * @property {RestMetadata} [rest] - REST resource plugin metadata
150
158
  * @property {string[]} [hidden=[]] - Column names to never expose in API/templates (e.g. password_hash, api_token)
151
159
  */
152
160
 
@@ -160,6 +168,7 @@
160
168
  * @property {ScopeOptions} scopes - Scope options
161
169
  * @property {Map<string, ColumnMeta>} columns - Parsed column metadata
162
170
  * @property {AdminMetadata} [admin] - Admin panel metadata
171
+ * @property {RestMetadata} rest - REST resource plugin metadata
163
172
  * @property {string[]} hidden - Column names never exposed in API/templates
164
173
  */
165
174
 
@@ -24,7 +24,60 @@ function generateNanoid(size = 21) {
24
24
  return id;
25
25
  }
26
26
 
27
+ /**
28
+ * Zod schema for IDs produced by {@link generateNanoid} (same default alphabet & length).
29
+ * Prefer **`z.nanoid()`** in API `schema: ({ z }) => …`; use this when building Zod schemas outside compiled routes.
30
+ * @param {typeof import('zod').z} z - Zod instance (from `schema: ({ z }) => …`)
31
+ * @param {number} [size=21]
32
+ */
33
+ function zodNanoid(z, size = 21) {
34
+ const n = Math.max(1, Math.floor(size));
35
+ const alphabet = new Set(URL_ALPHABET);
36
+ return z.string().length(n).refine((s) => [...s].every((ch) => alphabet.has(ch)), {
37
+ message: `Expected ${n}-character nanoid (default URL alphabet)`,
38
+ });
39
+ }
40
+
41
+ /**
42
+ * @param {unknown} arg - `undefined` → 21; number → length; `{ maxLength }` (zdb-compatible)
43
+ * @returns {number}
44
+ */
45
+ function resolveNanoidSize(arg) {
46
+ if (arg === undefined) return 21;
47
+ if (arg === null) return 21;
48
+ if (typeof arg === 'number' && !Number.isNaN(arg)) {
49
+ return Math.max(1, Math.floor(arg));
50
+ }
51
+ if (typeof arg === 'object' && arg !== null && 'maxLength' in arg) {
52
+ const n = /** @type {{ maxLength?: unknown }} */ (arg).maxLength;
53
+ if (typeof n === 'number' && !Number.isNaN(n)) {
54
+ return Math.max(1, Math.floor(n));
55
+ }
56
+ }
57
+ return 21;
58
+ }
59
+
60
+ /**
61
+ * Wraps Zod's `z` with **`z.nanoid()`** / **`z.nanoid(12)`** / **`z.nanoid({ maxLength: 12 })`**
62
+ * without mutating the global `z` object. Used by API `schema: ({ z }) => …`.
63
+ * @param {typeof import('zod').z} zod
64
+ */
65
+ function extendZ(zod) {
66
+ return new Proxy(zod, {
67
+ get(target, prop, receiver) {
68
+ if (prop === 'nanoid') {
69
+ return function nanoid(arg) {
70
+ return zodNanoid(zod, resolveNanoidSize(arg));
71
+ };
72
+ }
73
+ return Reflect.get(target, prop, receiver);
74
+ },
75
+ });
76
+ }
77
+
27
78
  module.exports = {
28
79
  generateNanoid,
80
+ zodNanoid,
81
+ extendZ,
29
82
  URL_ALPHABET,
30
83
  };