webspresso 0.0.58 → 0.0.61

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
@@ -1,5 +1,8 @@
1
1
  # Webspresso
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/webspresso.svg?style=flat-square)](https://www.npmjs.com/package/webspresso)
4
+ [![vulnerabilities](https://npmx.dev/api/registry/badge/vulnerabilities/webspresso?style=shieldsio)](https://npmx.dev/package/webspresso)
5
+
3
6
  A minimal, file-based SSR framework for Node.js with Nunjucks templating.
4
7
 
5
8
  ## Features
@@ -12,7 +15,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
12
15
  - **Lifecycle Hooks**: Global and route-level hooks for request processing
13
16
  - **Template Helpers**: Laravel-inspired helper functions available in templates
14
17
  - **Plugin System**: Extensible architecture with version control and inter-plugin communication
15
- - **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics
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
16
19
 
17
20
  ## Installation
18
21
 
@@ -1237,6 +1240,7 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
1237
1240
  |--------|-------------|---------|
1238
1241
  | `zdb.id()` | Primary key (bigint, auto-increment) | |
1239
1242
  | `zdb.uuid()` | UUID primary key | |
1243
+ | `zdb.nanoid(opts)` | Nanoid primary key (URL-safe string, stored as VARCHAR) | `maxLength` (default `21`) |
1240
1244
  | `zdb.string(opts)` | VARCHAR column | `maxLength`, `unique`, `index`, `nullable` |
1241
1245
  | `zdb.text(opts)` | TEXT column | `nullable` |
1242
1246
  | `zdb.integer(opts)` | INTEGER column | `nullable`, `default` |
@@ -1252,6 +1256,9 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
1252
1256
  | `zdb.enum(values, opts)` | ENUM column | `default`, `nullable` |
1253
1257
  | `zdb.foreignKey(table, opts)` | Foreign key (bigint) | `referenceColumn`, `nullable` |
1254
1258
  | `zdb.foreignUuid(table, opts)` | Foreign key (uuid) | `referenceColumn`, `nullable` |
1259
+ | `zdb.foreignNanoid(table, opts)` | Foreign key (nanoid string) | `referenceColumn`, `nullable`, `maxLength` (must match referenced PK) |
1260
+
1261
+ **Nanoid columns:** Migration scaffolding emits `table.string(column, maxLength)`. For a **nanoid primary key**, if you omit the primary key on `repository.create()`, Webspresso fills it with a cryptographically random ID using the same default alphabet as the [`nanoid`](https://github.com/ai/nanoid) package (implemented in-framework; no extra dependency). You can also call **`generateNanoid`** (exported from `webspresso`) anywhere you need the same generator.
1255
1262
 
1256
1263
  ### Model Definition
1257
1264
 
@@ -1776,6 +1783,64 @@ const user = plugin.api.getModel('User'); // Single model
1776
1783
  const names = plugin.api.getModelNames(); // Model names
1777
1784
  ```
1778
1785
 
1786
+ ### Health check plugin
1787
+
1788
+ 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.
1789
+
1790
+ **Setup:**
1791
+
1792
+ ```javascript
1793
+ const { createApp, healthCheckPlugin } = require('webspresso');
1794
+
1795
+ const app = createApp({
1796
+ plugins: [
1797
+ healthCheckPlugin({
1798
+ path: '/health', // default
1799
+ verbose: true, // timestamp, uptime, NODE_ENV, framework name/version
1800
+ authorize: (req) => true, // optional — restrict who can read the endpoint
1801
+ checks: async ({ db }) => {
1802
+ if (db) await db.knex.raw('select 1');
1803
+ return { database: 'ok' };
1804
+ },
1805
+ }),
1806
+ ],
1807
+ });
1808
+ ```
1809
+
1810
+ - **`checks`**: If this function throws, the handler responds with **503** and `{ status: 'unhealthy', error, ... }`. Return a plain object to merge into `checks` on success (e.g. dependency status).
1811
+ - Use a **custom `path`** if your app already serves `GET /health` from `pages/`.
1812
+
1813
+ ### Swagger / OpenAPI plugin
1814
+
1815
+ Serves **OpenAPI 3** for file-based `pages/api` routes and optional [Zod](https://zod.dev) `schema` exports, plus a **Swagger UI** page. Defaults to **development only** (same idea as the schema explorer).
1816
+
1817
+ **Setup:**
1818
+
1819
+ ```javascript
1820
+ const { createApp, swaggerPlugin } = require('webspresso');
1821
+
1822
+ const app = createApp({
1823
+ plugins: [
1824
+ swaggerPlugin({
1825
+ path: '/_swagger', // UI: GET /_swagger, spec: GET /_swagger/openapi.json
1826
+ enabled: true, // default: true in development, false in production
1827
+ title: 'My API', // optional OpenAPI info.title
1828
+ serverUrl: 'https://api.example.com', // optional servers[0].url (else BASE_URL or localhost)
1829
+ includeOrmSchemas: false, // merge ORM model schemas into components.schemas
1830
+ ormExclude: ['Secret'], // when includeOrmSchemas is true
1831
+ authorize: (req) => true, // optional gate for both UI and JSON
1832
+ }),
1833
+ ],
1834
+ });
1835
+ ```
1836
+
1837
+ **Endpoints:**
1838
+
1839
+ - `GET /_swagger/openapi.json` — Full OpenAPI document (`paths` from API routes; request/response shapes from exported `schema({ z })` when present).
1840
+ - `GET /_swagger` — Swagger UI (loads the JSON above; requires network access for CDN assets).
1841
+
1842
+ In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
1843
+
1779
1844
  ## Development
1780
1845
 
1781
1846
  ```bash
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Build OpenAPI 3 document from file-router API route metadata + Zod schemas
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { zodToJsonSchema } = require('zod-to-json-schema');
8
+ const { compileSchema } = require('../compileSchema');
9
+ const { generateOrmOpenApiSchemas } = require('./orm-components');
10
+
11
+ /**
12
+ * Express route pattern → OpenAPI path (e.g. /users/:id → /users/{id})
13
+ * @param {string} expressPath
14
+ * @returns {string}
15
+ */
16
+ function expressPathToOpenApi(expressPath) {
17
+ return String(expressPath)
18
+ .replace(/:([A-Za-z0-9_]+)/g, '{$1}')
19
+ .replace(/\*/g, '{wildcard}');
20
+ }
21
+
22
+ /**
23
+ * @param {import('zod').ZodObject<any>} zodObj
24
+ * @param {'path' | 'query'} where
25
+ * @returns {object[]}
26
+ */
27
+ function expandZodObjectToParameters(zodObj, where) {
28
+ if (!zodObj || zodObj._def?.typeName !== 'ZodObject') return [];
29
+ const shape = zodObj.shape;
30
+ return Object.entries(shape).map(([name, zType]) => ({
31
+ name,
32
+ in: where,
33
+ required: where === 'path' ? true : !zType.isOptional(),
34
+ schema: zodToJsonSchema(zType, { target: 'openApi3', $refStrategy: 'none' }),
35
+ }));
36
+ }
37
+
38
+ /**
39
+ * @param {object} route - { type, method, pattern, file }
40
+ * @param {object|null} compiled - compileSchema result
41
+ * @returns {object} OpenAPI Operation Object
42
+ */
43
+ function buildOperation(route, compiled) {
44
+ const filePath = route.file;
45
+ const method = route.method.toLowerCase();
46
+ const summary = `${method.toUpperCase()} ${filePath}`;
47
+
48
+ const op = {
49
+ tags: ['api'],
50
+ summary,
51
+ operationId: String(filePath)
52
+ .replace(/[^a-zA-Z0-9]+/g, '_')
53
+ .replace(/^_|_$/g, ''),
54
+ responses: {
55
+ 200: {
56
+ description: 'OK',
57
+ },
58
+ },
59
+ };
60
+
61
+ if (compiled?.response) {
62
+ op.responses['200'] = {
63
+ description: 'OK',
64
+ content: {
65
+ 'application/json': {
66
+ schema: zodToJsonSchema(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
67
+ },
68
+ },
69
+ };
70
+ }
71
+
72
+ const parameters = [
73
+ ...expandZodObjectToParameters(compiled?.params, 'path'),
74
+ ...expandZodObjectToParameters(compiled?.query, 'query'),
75
+ ];
76
+
77
+ if (parameters.length) {
78
+ op.parameters = parameters;
79
+ }
80
+
81
+ if (compiled?.body) {
82
+ op.requestBody = {
83
+ content: {
84
+ 'application/json': {
85
+ schema: zodToJsonSchema(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ return op;
92
+ }
93
+
94
+ /**
95
+ * @param {object} opts
96
+ * @param {object[]} opts.routes - route metadata from mountPages
97
+ * @param {string} opts.pagesDir
98
+ * @param {boolean} [opts.includeOrmSchemas]
99
+ * @param {string[]} [opts.ormExclude]
100
+ * @param {object} [opts.info]
101
+ * @param {object[]} [opts.servers]
102
+ * @returns {object} OpenAPI 3.0.x document
103
+ */
104
+ function buildOpenApiDocument(opts) {
105
+ const {
106
+ routes = [],
107
+ pagesDir,
108
+ includeOrmSchemas = false,
109
+ ormExclude = [],
110
+ info = {},
111
+ servers = [{ url: '/' }],
112
+ } = opts;
113
+
114
+ if (!pagesDir) {
115
+ throw new Error('buildOpenApiDocument: pagesDir is required');
116
+ }
117
+
118
+ const paths = {};
119
+ const apiRoutes = routes.filter((r) => r.type === 'api');
120
+ const absPages = path.resolve(pagesDir);
121
+
122
+ for (const route of apiRoutes) {
123
+ const fullPath = path.join(absPages, route.file);
124
+ if (!fs.existsSync(fullPath)) {
125
+ continue;
126
+ }
127
+
128
+ let mod;
129
+ try {
130
+ mod = require(fullPath);
131
+ } catch {
132
+ continue;
133
+ }
134
+
135
+ let compiled = null;
136
+ try {
137
+ compiled = compileSchema(fullPath, mod);
138
+ } catch {
139
+ compiled = null;
140
+ }
141
+
142
+ const openApiPath = expressPathToOpenApi(route.pattern);
143
+ const method = route.method.toLowerCase();
144
+
145
+ if (!paths[openApiPath]) {
146
+ paths[openApiPath] = {};
147
+ }
148
+
149
+ paths[openApiPath][method] = buildOperation(route, compiled);
150
+ }
151
+
152
+ const doc = {
153
+ openapi: '3.0.3',
154
+ info: {
155
+ title: info.title || 'API',
156
+ version: info.version || '1.0.0',
157
+ ...(info.description ? { description: info.description } : {}),
158
+ },
159
+ servers,
160
+ paths,
161
+ components: {
162
+ schemas: includeOrmSchemas ? generateOrmOpenApiSchemas({ exclude: ormExclude }) : {},
163
+ },
164
+ };
165
+
166
+ return doc;
167
+ }
168
+
169
+ module.exports = {
170
+ expressPathToOpenApi,
171
+ buildOpenApiDocument,
172
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
3
+ */
4
+
5
+ const { getAllModels } = require('../orm/model');
6
+
7
+ /**
8
+ * Generate OpenAPI 3 components.schemas from registered ORM models
9
+ * @param {{ exclude?: string[] }} options
10
+ * @returns {Record<string, object>}
11
+ */
12
+ function generateOrmOpenApiSchemas(options = {}) {
13
+ const { exclude = [] } = options;
14
+ const models = getAllModels();
15
+ const schemas = {};
16
+
17
+ for (const [name, model] of models) {
18
+ if (exclude.includes(name)) continue;
19
+
20
+ const properties = {};
21
+ const required = [];
22
+
23
+ if (model.columns) {
24
+ for (const [colName, meta] of model.columns) {
25
+ properties[colName] = columnToOpenApiType(meta);
26
+
27
+ if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
28
+ required.push(colName);
29
+ }
30
+ }
31
+ }
32
+
33
+ schemas[name] = {
34
+ type: 'object',
35
+ properties,
36
+ required: required.length > 0 ? required : undefined,
37
+ };
38
+
39
+ const inputProperties = {};
40
+ const inputRequired = [];
41
+
42
+ if (model.columns) {
43
+ for (const [colName, meta] of model.columns) {
44
+ if (meta.autoIncrement || meta.auto) continue;
45
+
46
+ inputProperties[colName] = columnToOpenApiType(meta);
47
+
48
+ if (!meta.nullable && meta.default === undefined) {
49
+ inputRequired.push(colName);
50
+ }
51
+ }
52
+ }
53
+
54
+ schemas[`${name}Input`] = {
55
+ type: 'object',
56
+ properties: inputProperties,
57
+ required: inputRequired.length > 0 ? inputRequired : undefined,
58
+ };
59
+ }
60
+
61
+ return schemas;
62
+ }
63
+
64
+ /**
65
+ * @param {Object} meta - Column metadata
66
+ * @returns {Object}
67
+ */
68
+ function columnToOpenApiType(meta) {
69
+ const result = {};
70
+
71
+ switch (meta.type) {
72
+ case 'bigint':
73
+ case 'integer':
74
+ result.type = 'integer';
75
+ if (meta.type === 'bigint') result.format = 'int64';
76
+ break;
77
+
78
+ case 'float':
79
+ case 'decimal':
80
+ result.type = 'number';
81
+ if (meta.type === 'decimal') result.format = 'double';
82
+ break;
83
+
84
+ case 'boolean':
85
+ result.type = 'boolean';
86
+ break;
87
+
88
+ case 'date':
89
+ result.type = 'string';
90
+ result.format = 'date';
91
+ break;
92
+
93
+ case 'datetime':
94
+ case 'timestamp':
95
+ result.type = 'string';
96
+ result.format = 'date-time';
97
+ break;
98
+
99
+ case 'uuid':
100
+ result.type = 'string';
101
+ result.format = 'uuid';
102
+ break;
103
+
104
+ case 'nanoid':
105
+ result.type = 'string';
106
+ if (meta.maxLength) {
107
+ result.maxLength = meta.maxLength;
108
+ }
109
+ break;
110
+
111
+ case 'json':
112
+ result.type = 'object';
113
+ break;
114
+
115
+ case 'enum':
116
+ result.type = 'string';
117
+ if (meta.enumValues) {
118
+ result.enum = meta.enumValues;
119
+ }
120
+ break;
121
+
122
+ case 'text':
123
+ case 'string':
124
+ default:
125
+ result.type = 'string';
126
+ if (meta.maxLength) {
127
+ result.maxLength = meta.maxLength;
128
+ }
129
+ break;
130
+ }
131
+
132
+ if (meta.nullable) {
133
+ result.nullable = true;
134
+ }
135
+
136
+ if (meta.default !== undefined) {
137
+ result.default = meta.default;
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ module.exports = {
144
+ generateOrmOpenApiSchemas,
145
+ columnToOpenApiType,
146
+ };
package/core/orm/index.js CHANGED
@@ -14,6 +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
18
 
18
19
  /**
19
20
  * Create a database instance
@@ -273,6 +274,7 @@ module.exports = {
273
274
  // Column utilities
274
275
  extractColumnsFromSchema,
275
276
  getColumnMeta,
277
+ generateNanoid,
276
278
  // Output sanitization (exclude hidden columns from API/templates)
277
279
  omitHiddenColumns,
278
280
  sanitizeForOutput,
@@ -164,6 +164,19 @@ function generateColumnLine(columnName, meta) {
164
164
  }
165
165
  break;
166
166
 
167
+ case 'nanoid': {
168
+ const nanoLen = meta.maxLength || 21;
169
+ if (meta.primary) {
170
+ parts.push(`table.string('${columnName}', ${nanoLen})`);
171
+ } else if (meta.references) {
172
+ parts.push(`table.string('${columnName}', ${nanoLen})`);
173
+ fkLine = `table.foreign('${columnName}').references('${meta.referenceColumn || 'id'}').inTable('${meta.references}');`;
174
+ } else {
175
+ parts.push(`table.string('${columnName}', ${nanoLen})`);
176
+ }
177
+ break;
178
+ }
179
+
167
180
  default:
168
181
  parts.push(`table.string('${columnName}')`);
169
182
  }
@@ -359,6 +359,30 @@ function createSchemaHelpers(z) {
359
359
  }, z);
360
360
  },
361
361
 
362
+ /**
363
+ * Nanoid primary key column (URL-safe string, default length 21)
364
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
365
+ * @returns {SchemaBuilder}
366
+ */
367
+ nanoid(options = {}) {
368
+ const { maxLength = 21, ...rest } = options;
369
+ const schema = z
370
+ .string()
371
+ .length(maxLength)
372
+ .regex(/^[A-Za-z0-9_-]+$/)
373
+ .optional();
374
+ return createSchemaBuilder(
375
+ schema,
376
+ {
377
+ type: 'nanoid',
378
+ primary: true,
379
+ maxLength,
380
+ ...rest,
381
+ },
382
+ z
383
+ );
384
+ },
385
+
362
386
  /**
363
387
  * String column (varchar)
364
388
  * @param {Partial<import('./types').ColumnMeta>} [options={}]
@@ -667,6 +691,36 @@ function createSchemaHelpers(z) {
667
691
  ...rest,
668
692
  }, z);
669
693
  },
694
+
695
+ /**
696
+ * Nanoid foreign key column
697
+ * @param {string} references - Referenced table name
698
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
699
+ * @returns {SchemaBuilder}
700
+ */
701
+ foreignNanoid(references, options = {}) {
702
+ const {
703
+ referenceColumn = 'id',
704
+ nullable = false,
705
+ maxLength = 21,
706
+ ...rest
707
+ } = options;
708
+ let schema = z
709
+ .string()
710
+ .length(maxLength)
711
+ .regex(/^[A-Za-z0-9_-]+$/);
712
+ if (nullable) {
713
+ schema = schema.nullable().optional();
714
+ }
715
+ return createSchemaBuilder(schema, {
716
+ type: 'nanoid',
717
+ references,
718
+ referenceColumn,
719
+ nullable,
720
+ maxLength,
721
+ ...rest,
722
+ }, z);
723
+ },
670
724
  };
671
725
 
672
726
  return helpers;
@@ -4,6 +4,8 @@
4
4
  * @module core/orm/scopes
5
5
  */
6
6
 
7
+ const { generateNanoid } = require('./utils/nanoid');
8
+
7
9
  /**
8
10
  * Apply soft delete scope to a query builder
9
11
  * @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
@@ -115,6 +117,29 @@ function applyInsertTenant(data, context, model) {
115
117
  };
116
118
  }
117
119
 
120
+ /**
121
+ * Generate nanoid primary key when missing on insert
122
+ * @param {Object} data - Data to insert
123
+ * @param {import('./types').ModelDefinition} model - Model definition
124
+ * @returns {Object}
125
+ */
126
+ function applyInsertNanoidPrimary(data, model) {
127
+ const pk = model.primaryKey;
128
+ const meta = model.columns && model.columns.get(pk);
129
+ if (!meta || meta.type !== 'nanoid') {
130
+ return data;
131
+ }
132
+ const val = data[pk];
133
+ if (val !== undefined && val !== null && val !== '') {
134
+ return data;
135
+ }
136
+ const len = meta.maxLength || 21;
137
+ return {
138
+ ...data,
139
+ [pk]: generateNanoid(len),
140
+ };
141
+ }
142
+
118
143
  /**
119
144
  * Get soft delete data (for UPDATE instead of DELETE)
120
145
  * @returns {Object} Soft delete update data
@@ -142,6 +167,7 @@ function applyInsertModifiers(data, context, model) {
142
167
  let modified = { ...data };
143
168
  modified = applyInsertTimestamps(modified, model);
144
169
  modified = applyInsertTenant(modified, context, model);
170
+ modified = applyInsertNanoidPrimary(modified, model);
145
171
  return modified;
146
172
  }
147
173
 
@@ -174,6 +200,7 @@ module.exports = {
174
200
  applyInsertTimestamps,
175
201
  applyUpdateTimestamps,
176
202
  applyInsertTenant,
203
+ applyInsertNanoidPrimary,
177
204
  applyInsertModifiers,
178
205
  applyUpdateModifiers,
179
206
  getSoftDeleteData,
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const { getModel, getAllModels } = require('./model');
8
+ const { generateNanoid } = require('./utils/nanoid');
8
9
 
9
10
  /**
10
11
  * @typedef {Object} SeederOptions
@@ -189,6 +190,9 @@ function createSeeder(faker, knex) {
189
190
  case 'uuid':
190
191
  return faker.string.uuid();
191
192
 
193
+ case 'nanoid':
194
+ return generateNanoid(meta.maxLength || 21);
195
+
192
196
  case 'json':
193
197
  return { key: faker.lorem.word(), value: faker.lorem.sentence() };
194
198
 
package/core/orm/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // ============================================================================
10
10
 
11
11
  /**
12
- * @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'} ColumnType
12
+ * @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
13
13
  */
14
14
 
15
15
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * URL-safe ID generation compatible with the default nanoid alphabet/algorithm.
3
+ * @module core/orm/utils/nanoid
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+
8
+ /** Same 64-char alphabet as npm `nanoid` default (URL-safe). */
9
+ const URL_ALPHABET =
10
+ 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
11
+
12
+ /**
13
+ * @param {number} [size=21] - Id length (default matches nanoid)
14
+ * @returns {string}
15
+ */
16
+ function generateNanoid(size = 21) {
17
+ const n = Math.max(1, Math.floor(size));
18
+ const bytes = new Uint8Array(n);
19
+ crypto.randomFillSync(bytes);
20
+ let id = '';
21
+ for (let i = 0; i < n; i++) {
22
+ id += URL_ALPHABET[bytes[i] & 63];
23
+ }
24
+ return id;
25
+ }
26
+
27
+ module.exports = {
28
+ generateNanoid,
29
+ URL_ALPHABET,
30
+ };
package/index.js CHANGED
@@ -30,7 +30,7 @@ const {
30
30
  const orm = require('./core/orm');
31
31
 
32
32
  // Built-in plugins
33
- const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin } = require('./plugins');
33
+ const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin } = require('./plugins');
34
34
 
35
35
  module.exports = {
36
36
  // Main API
@@ -72,4 +72,6 @@ module.exports = {
72
72
  siteAnalyticsPlugin,
73
73
  auditLogPlugin,
74
74
  recaptchaPlugin,
75
+ swaggerPlugin,
76
+ healthCheckPlugin,
75
77
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.58",
3
+ "version": "0.0.61",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "license": "MIT",
31
31
  "repository": {
32
32
  "type": "git",
33
- "url": "https://github.com/everytools/webspresso.git"
33
+ "url": "https://github.com/litepacks/webspresso.git"
34
34
  },
35
35
  "files": [
36
36
  "index.js",
@@ -54,7 +54,8 @@
54
54
  "knex": "^3.1.0",
55
55
  "nunjucks": "^3.2.4",
56
56
  "sharp": "^0.33.5",
57
- "zod": "^3.23.0"
57
+ "zod": "^3.23.0",
58
+ "zod-to-json-schema": "^3.25.2"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "@faker-js/faker": ">=8.0.0",
@@ -83,15 +84,19 @@
83
84
  "devDependencies": {
84
85
  "@faker-js/faker": "^9.9.0",
85
86
  "@playwright/test": "^1.48.0",
86
- "@vitest/coverage-v8": "^1.2.0",
87
+ "@vitest/coverage-v8": "^3.0.0",
87
88
  "better-sqlite3": "^11.10.0",
88
89
  "chokidar": "^3.5.3",
89
90
  "dotenv": "^16.3.1",
90
- "release-it": "^17.11.0",
91
+ "release-it": "^19.0.0",
91
92
  "supertest": "^6.3.4",
92
- "vitest": "^1.2.0"
93
+ "vitest": "^3.0.0"
93
94
  },
94
95
  "engines": {
95
96
  "node": ">=18.0.0"
97
+ },
98
+ "overrides": {
99
+ "tar": "^7.5.11",
100
+ "undici": "^6.24.0"
96
101
  }
97
102
  }
@@ -1083,6 +1083,7 @@ function getFieldRenderer(col, modelMeta) {
1083
1083
  json: 'json',
1084
1084
  array: 'array',
1085
1085
  uuid: 'string',
1086
+ nanoid: 'string',
1086
1087
  };
1087
1088
  return FieldRenderers[typeMap[col.type] || 'string'];
1088
1089
  }
@@ -68,6 +68,7 @@ function initializeDefaultRenderers() {
68
68
  registerFieldRenderer('timestamp', basicRenderers.DateTimeField);
69
69
  registerFieldRenderer('enum', basicRenderers.SelectField);
70
70
  registerFieldRenderer('uuid', basicRenderers.TextField);
71
+ registerFieldRenderer('nanoid', basicRenderers.TextField);
71
72
  registerFieldRenderer('id', basicRenderers.NumberField);
72
73
 
73
74
  // Complex types
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Health check plugin — liveness/readiness style HTTP endpoint for load balancers and monitoring
3
+ */
4
+
5
+ const pathMod = require('path');
6
+ const PKG = require(pathMod.join(__dirname, '..', 'package.json'));
7
+
8
+ /**
9
+ * @param {Object} [options]
10
+ * @param {string} [options.path='/health'] — GET endpoint path
11
+ * @param {boolean} [options.enabled=true] — Disable entirely when false
12
+ * @param {function} [options.authorize] — (req) => boolean; optional gate (e.g. internal network only)
13
+ * @param {boolean} [options.verbose=true] — Include timestamp, uptime, env, framework version
14
+ * @param {function} [options.checks] — async ({ req, db, options }) => Record<string, string> — custom checks; thrown error → 503
15
+ */
16
+ function healthCheckPlugin(options = {}) {
17
+ const {
18
+ path: routePath = '/health',
19
+ enabled,
20
+ authorize,
21
+ verbose = true,
22
+ checks,
23
+ } = options;
24
+
25
+ const isEnabled = enabled !== undefined ? enabled : true;
26
+ const normalizedPath = routePath.startsWith('/') ? routePath : `/${routePath}`;
27
+
28
+ return {
29
+ name: 'health-check',
30
+ version: '1.0.0',
31
+ description: 'HTTP health endpoint for probes and monitoring',
32
+
33
+ onRoutesReady(ctx) {
34
+ if (!isEnabled) {
35
+ return;
36
+ }
37
+
38
+ ctx.addRoute('get', normalizedPath, async (req, res) => {
39
+ if (authorize && !authorize(req)) {
40
+ return res.status(403).json({ error: 'Forbidden' });
41
+ }
42
+
43
+ const body = {
44
+ status: 'ok',
45
+ };
46
+
47
+ if (verbose) {
48
+ body.timestamp = new Date().toISOString();
49
+ body.uptime = process.uptime();
50
+ body.env = process.env.NODE_ENV || 'development';
51
+ body.framework = {
52
+ name: PKG.name,
53
+ version: PKG.version,
54
+ };
55
+ }
56
+
57
+ if (typeof checks === 'function') {
58
+ try {
59
+ const result = await checks({
60
+ req,
61
+ db: ctx.db,
62
+ options: ctx.options,
63
+ });
64
+ if (result && typeof result === 'object') {
65
+ body.checks = result;
66
+ }
67
+ } catch (err) {
68
+ body.status = 'unhealthy';
69
+ body.error = err.message || String(err);
70
+ return res.status(503).json(body);
71
+ }
72
+ }
73
+
74
+ res.json(body);
75
+ });
76
+
77
+ if (process.env.NODE_ENV !== 'production') {
78
+ console.log(` Health check: GET ${normalizedPath}`);
79
+ }
80
+ },
81
+ };
82
+ }
83
+
84
+ module.exports = healthCheckPlugin;
package/plugins/index.js CHANGED
@@ -12,6 +12,8 @@ const seoCheckerPlugin = require('./seo-checker');
12
12
  const siteAnalyticsPlugin = require('./site-analytics');
13
13
  const auditLogPlugin = require('./audit-log');
14
14
  const recaptchaPlugin = require('./recaptcha');
15
+ const swaggerPlugin = require('./swagger');
16
+ const healthCheckPlugin = require('./health-check');
15
17
 
16
18
  module.exports = {
17
19
  sitemapPlugin,
@@ -23,5 +25,7 @@ module.exports = {
23
25
  siteAnalyticsPlugin,
24
26
  auditLogPlugin,
25
27
  recaptchaPlugin,
28
+ swaggerPlugin,
29
+ healthCheckPlugin,
26
30
  };
27
31
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  const { getAllModels, getModel } = require('../core/orm/model');
8
8
  const { getColumnMeta } = require('../core/orm/schema-helpers');
9
+ const { generateOrmOpenApiSchemas } = require('../core/openapi/orm-components');
9
10
 
10
11
  /**
11
12
  * Create Schema Explorer plugin
@@ -99,7 +100,7 @@ function schemaExplorerPlugin(options = {}) {
99
100
  return res.status(403).json({ error: 'Forbidden' });
100
101
  }
101
102
 
102
- const openApiSchemas = generateOpenApiSchemas({ exclude });
103
+ const openApiSchemas = generateOrmOpenApiSchemas({ exclude });
103
104
 
104
105
  res.json({
105
106
  openapi: '3.0.0',
@@ -262,137 +263,5 @@ function serializeRelations(relations) {
262
263
  return result;
263
264
  }
264
265
 
265
- /**
266
- * Generate OpenAPI schemas from models
267
- * @param {Object} options
268
- * @returns {Object}
269
- */
270
- function generateOpenApiSchemas(options) {
271
- const { exclude } = options;
272
- const models = getAllModels();
273
- const schemas = {};
274
-
275
- for (const [name, model] of models) {
276
- if (exclude.includes(name)) continue;
277
-
278
- const properties = {};
279
- const required = [];
280
-
281
- if (model.columns) {
282
- for (const [colName, meta] of model.columns) {
283
- properties[colName] = columnToOpenApiType(meta);
284
-
285
- if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
286
- required.push(colName);
287
- }
288
- }
289
- }
290
-
291
- schemas[name] = {
292
- type: 'object',
293
- properties,
294
- required: required.length > 0 ? required : undefined,
295
- };
296
-
297
- // Add input schema (without auto fields)
298
- const inputProperties = {};
299
- const inputRequired = [];
300
-
301
- if (model.columns) {
302
- for (const [colName, meta] of model.columns) {
303
- // Skip auto-generated fields for input
304
- if (meta.autoIncrement || meta.auto) continue;
305
-
306
- inputProperties[colName] = columnToOpenApiType(meta);
307
-
308
- if (!meta.nullable && meta.default === undefined) {
309
- inputRequired.push(colName);
310
- }
311
- }
312
- }
313
-
314
- schemas[`${name}Input`] = {
315
- type: 'object',
316
- properties: inputProperties,
317
- required: inputRequired.length > 0 ? inputRequired : undefined,
318
- };
319
- }
320
-
321
- return schemas;
322
- }
323
-
324
- /**
325
- * Convert column metadata to OpenAPI type
326
- * @param {Object} meta - Column metadata
327
- * @returns {Object}
328
- */
329
- function columnToOpenApiType(meta) {
330
- const result = {};
331
-
332
- switch (meta.type) {
333
- case 'bigint':
334
- case 'integer':
335
- result.type = 'integer';
336
- if (meta.type === 'bigint') result.format = 'int64';
337
- break;
338
-
339
- case 'float':
340
- case 'decimal':
341
- result.type = 'number';
342
- if (meta.type === 'decimal') result.format = 'double';
343
- break;
344
-
345
- case 'boolean':
346
- result.type = 'boolean';
347
- break;
348
-
349
- case 'date':
350
- result.type = 'string';
351
- result.format = 'date';
352
- break;
353
-
354
- case 'datetime':
355
- case 'timestamp':
356
- result.type = 'string';
357
- result.format = 'date-time';
358
- break;
359
-
360
- case 'uuid':
361
- result.type = 'string';
362
- result.format = 'uuid';
363
- break;
364
-
365
- case 'json':
366
- result.type = 'object';
367
- break;
368
-
369
- case 'enum':
370
- result.type = 'string';
371
- if (meta.enumValues) {
372
- result.enum = meta.enumValues;
373
- }
374
- break;
375
-
376
- case 'text':
377
- case 'string':
378
- default:
379
- result.type = 'string';
380
- if (meta.maxLength) {
381
- result.maxLength = meta.maxLength;
382
- }
383
- break;
384
- }
385
-
386
- if (meta.nullable) {
387
- result.nullable = true;
388
- }
389
-
390
- if (meta.default !== undefined) {
391
- result.default = meta.default;
392
- }
393
-
394
- return result;
395
- }
396
-
397
266
  module.exports = schemaExplorerPlugin;
398
267
 
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Swagger / OpenAPI plugin — HTTP API docs from file-based routes + Zod, optional ORM schemas
3
+ */
4
+
5
+ const path = require('path');
6
+ const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes');
7
+
8
+ const PKG = require(path.join(__dirname, '..', 'package.json'));
9
+
10
+ /**
11
+ * @param {string} specUrl - Absolute path on same origin (e.g. /_swagger/openapi.json)
12
+ */
13
+ function buildSwaggerUiHtml(specUrl) {
14
+ const urlJson = JSON.stringify(specUrl);
15
+ return `<!DOCTYPE html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="UTF-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
20
+ <title>API documentation</title>
21
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" crossorigin />
22
+ </head>
23
+ <body>
24
+ <div id="swagger-ui"></div>
25
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
26
+ <script>
27
+ window.onload = function () {
28
+ SwaggerUIBundle({
29
+ url: ${urlJson},
30
+ dom_id: '#swagger-ui',
31
+ deepLinking: true
32
+ });
33
+ };
34
+ </script>
35
+ </body>
36
+ </html>`;
37
+ }
38
+
39
+ /**
40
+ * @param {Object} [options]
41
+ * @param {string} [options.path='/_swagger'] - Base path; JSON at `{path}/openapi.json`, UI at `{path}`
42
+ * @param {boolean} [options.enabled] - Default: development only
43
+ * @param {Function} [options.authorize] - (req) => boolean
44
+ * @param {boolean} [options.includeOrmSchemas] - Merge ORM components.schemas
45
+ * @param {string[]} [options.ormExclude] - Model names to exclude when includeOrmSchemas
46
+ * @param {string} [options.title] - OpenAPI info.title
47
+ * @param {string} [options.version] - OpenAPI info.version
48
+ * @param {string} [options.description] - OpenAPI info.description
49
+ * @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:3000)
50
+ */
51
+ function swaggerPlugin(options = {}) {
52
+ const {
53
+ path: basePath = '/_swagger',
54
+ enabled,
55
+ authorize,
56
+ includeOrmSchemas = false,
57
+ ormExclude = [],
58
+ title,
59
+ version,
60
+ description,
61
+ serverUrl,
62
+ } = options;
63
+
64
+ const normalizedBase = `/${String(basePath).replace(/^\/+|\/+$/g, '')}`;
65
+ const jsonPath = `${normalizedBase}/openapi.json`;
66
+ const uiPath = normalizedBase;
67
+
68
+ return {
69
+ name: 'swagger',
70
+ version: '1.0.0',
71
+
72
+ onRoutesReady(ctx) {
73
+ const isDev = process.env.NODE_ENV !== 'production';
74
+ const isEnabled = enabled !== undefined ? enabled : isDev;
75
+
76
+ if (!isEnabled) {
77
+ return;
78
+ }
79
+
80
+ const forbid = (req, res) => {
81
+ if (authorize && !authorize(req)) {
82
+ res.status(403).json({ error: 'Forbidden' });
83
+ return true;
84
+ }
85
+ return false;
86
+ };
87
+
88
+ const server = serverUrl || process.env.BASE_URL || 'http://localhost:3000';
89
+
90
+ ctx.addRoute('get', jsonPath, (req, res) => {
91
+ if (forbid(req, res)) return;
92
+ try {
93
+ const doc = buildOpenApiDocument({
94
+ routes: ctx.routes || [],
95
+ pagesDir: ctx.options.pagesDir,
96
+ includeOrmSchemas,
97
+ ormExclude,
98
+ info: {
99
+ title: title || PKG.name || 'API',
100
+ version: version || PKG.version || '1.0.0',
101
+ description:
102
+ description ||
103
+ 'Generated from pages/api routes and optional Zod schema exports.',
104
+ },
105
+ servers: [{ url: server.replace(/\/$/, '') }],
106
+ });
107
+ res.json(doc);
108
+ } catch (err) {
109
+ console.warn('[swagger] OpenAPI generation failed:', err.message);
110
+ res.status(500).json({ error: 'OpenAPI generation failed', message: err.message });
111
+ }
112
+ });
113
+
114
+ ctx.addRoute('get', uiPath, (req, res) => {
115
+ if (forbid(req, res)) return;
116
+ res.type('html').send(buildSwaggerUiHtml(jsonPath));
117
+ });
118
+
119
+ if (isDev) {
120
+ console.log(` Swagger UI: ${uiPath} (OpenAPI: ${jsonPath})`);
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ module.exports = swaggerPlugin;
@@ -143,13 +143,14 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
143
143
 
144
144
  ## 9. ORM overview
145
145
 
146
- **Define schema** with **`zdb`** (`zdb.id()`, `zdb.string({...})`, `zdb.foreignKey`, `zdb.timestamp`, `zdb.json`, …).
146
+ **Define schema** with **`zdb`** (`zdb.id()`, `zdb.uuid()`, `zdb.nanoid()`, `zdb.string({...})`, `zdb.foreignKey`, `zdb.foreignUuid`, `zdb.foreignNanoid`, `zdb.timestamp`, `zdb.json`, …).
147
147
 
148
148
  **Define model** with **`defineModel({ name, table, schema, relations, scopes, hidden, admin })`**.
149
149
 
150
150
  - **Relations:** `belongsTo`, `hasMany`, `hasOne` with `model: () => OtherModel`.
151
151
  - **Scopes:** `softDelete`, `timestamps`, optional `tenant` column.
152
152
  - **`hidden`:** columns never exposed in admin/API (e.g. `password_hash`).
153
+ - **Nanoid PK:** `zdb.nanoid()` / `zdb.nanoid({ maxLength: 12 })` — string primary key; migrations use `string(length)`. On **`create()`**, omitting the PK auto-fills a URL-safe id (built-in generator, same alphabet as `nanoid`). Use **`zdb.foreignNanoid('table', { maxLength })`** when the parent uses nanoid PKs; **`generateNanoid`** is exported from `webspresso` for manual ids.
153
154
 
154
155
  **Database:** `createDatabase({ client, connection, models: './models' })` — auto-loads `models/*.js` (ignore `_prefix`).
155
156