@zero-server/sdk 1.0.2 → 1.1.0

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
@@ -5,18 +5,18 @@
5
5
  <h1 align="center">zero-server</h1>
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@zero-server/sdk"><img src="https://img.shields.io/badge/%40zero--server%2Fsdk-000?style=flat-square&logo=npm&logoColor=white" alt="@zero-server/sdk"></a>
9
- <a href="https://www.npmjs.com/package/@zero-server/sdk"><img src="https://img.shields.io/npm/v/%40zero-server%2Fsdk?style=flat-square&logo=npm&logoColor=white&label=&color=00d8e0" alt="npm version"></a>
10
- <a href="https://www.npmjs.com/package/@zero-server/sdk"><img src="https://img.shields.io/npm/dm/%40zero-server%2Fsdk?style=flat-square&logo=npm&logoColor=white&label=downloads&color=ff6b35" alt="npm downloads"></a>
8
+ <a href="https://www.npmjs.com/package/@zero-server/sdk"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-sdk-name-zserver.svg?v=18d4e9a4" alt="@zero-server/sdk"></a>
9
+ <a href="https://www.npmjs.com/package/@zero-server/sdk"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-npm-zserver.svg?v=f9009a13" alt="npm version"></a>
10
+ <a href="https://www.npmjs.com/package/@zero-server/sdk"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-dm-zserver.svg?v=1d29d0dd" alt="npm downloads"></a>
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- <a href="https://github.com/tonywied17/zero-server/actions"><img src="https://img.shields.io/github/actions/workflow/status/tonywied17/zero-server/ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&label=CI" alt="CI"></a>
15
- <a href="https://github.com/tonywied17/zero-server/actions"><img src="https://img.shields.io/badge/tests-8016%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white" alt="tests"></a>
16
- <a href="https://github.com/tonywied17/zero-server"><img src="https://img.shields.io/badge/coverage-95.84%25-brightgreen?style=flat-square&logo=vitest&logoColor=white" alt="coverage"></a>
17
- <a href="https://z-server.dev"><img src="https://img.shields.io/badge/docs-z--server.dev-00d8e0?style=flat-square&logo=readthedocs&logoColor=white" alt="docs"></a>
18
- <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-00d8e0?style=flat-square&logo=opensourceinitiative&logoColor=white" alt="MIT"></a>
19
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square&logo=nodedotjs&logoColor=white" alt="node >=18"></a>
14
+ <a href="https://github.com/tonywied17/zero-server/actions/workflows/ci.yml"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-ci-zserver.svg?v=f09fb8a3" alt="CI"></a>
15
+ <a href="https://github.com/tonywied17/zero-server/actions"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-tests-zserver.svg?v=3a8e5d37" alt="tests"></a>
16
+ <a href="https://github.com/tonywied17/zero-server"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-coverage-zserver.svg?v=fdecde38" alt="coverage"></a>
17
+ <a href="https://z-server.dev"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-docs-zserver.svg?v=00dc1cc0" alt="docs"></a>
18
+ <a href="https://opensource.org/licenses/MIT"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-license-zserver.svg?v=92c4db7f" alt="license"></a>
19
+ <a href="https://nodejs.org"><img src="https://raw.githubusercontent.com/tonywied17/tonywied17/main/.github/badges/zero-server-node-zserver.svg?v=c0f8c528" alt="node >=18"></a>
20
20
  </p>
21
21
 
22
22
  > **Zero-dependency backend framework for Node.js - routing, ORM, auth, WebSocket, SSE, observability, and 20+ middleware as one SDK or focused scoped packages.**
package/lib/app.js CHANGED
@@ -172,6 +172,24 @@ class App
172
172
  * before calling `fn` so downstream sees relative paths).
173
173
  * - `use('/prefix', router)` - mount a Router sub-app at the given prefix.
174
174
  *
175
+ * Route matching order: the app matches its OWN verb routes (those declared
176
+ * with `app.get()`, `app.post()`, etc.) BEFORE probing any `use()`-mounted
177
+ * child routers. An app-level catch-all like `app.get('/*', ssr)` therefore
178
+ * shadows every mounted router. Give a catch-all its own child router and
179
+ * mount it LAST so real routes win:
180
+ *
181
+ * @example | Catch-all that does not shadow mounted routers
182
+ * const { createApp, Router } = require('@zero-server/sdk');
183
+ * const app = createApp();
184
+ *
185
+ * const api = Router();
186
+ * api.get('/users', listUsers);
187
+ * app.use('/api', api); // real routes
188
+ *
189
+ * const ssr = Router();
190
+ * ssr.get('/*', renderApp); // SPA / SSR fallback
191
+ * app.use('/', ssr); // mounted LAST - only handles what /api did not
192
+ *
175
193
  * @param {string|Function} pathOrFn - A path prefix string, or middleware function.
176
194
  * @param {Function|Router} [fn] - Middleware function or Router when first arg is a path.
177
195
  */
@@ -10,13 +10,24 @@ const { URL } = require('url');
10
10
 
11
11
  const STATUS_CODES = http.STATUS_CODES;
12
12
 
13
+ /**
14
+ * Default `User-Agent` sent when a request does not specify one.
15
+ * Many APIs (e.g. GitHub) reject requests without a User-Agent, so a sensible
16
+ * default avoids surprising 403s. Override per request via `opts.headers`.
17
+ * @private
18
+ */
19
+ let DEFAULT_USER_AGENT;
20
+ try { DEFAULT_USER_AGENT = `zero-server/${require('../../package.json').version}`; }
21
+ catch (e) { DEFAULT_USER_AGENT = 'zero-server'; }
22
+
13
23
  /**
14
24
  * Perform an HTTP(S) request.
15
25
  *
16
26
  * @param {string} url - Absolute URL to fetch.
17
27
  * @param {object} [opts] - Configuration options.
18
28
  * @param {string} [opts.method='GET'] - HTTP method.
19
- * @param {object} [opts.headers] - Request headers.
29
+ * @param {object} [opts.headers] - Request headers. A default
30
+ * `User-Agent` of `zero-server/<version>` is sent unless one is provided here.
20
31
  * @param {string|Buffer|object|ReadableStream} [opts.body] - Request body.
21
32
  * @param {number} [opts.timeout] - Request timeout in ms.
22
33
  * @param {AbortSignal} [opts.signal] - Abort signal for cancellation.
@@ -59,6 +70,12 @@ function miniFetch(url, opts = {})
59
70
  const method = (opts.method || 'GET').toUpperCase();
60
71
  const headers = Object.assign({}, opts.headers || {});
61
72
 
73
+ // Send a default User-Agent unless the caller supplied one (any case).
74
+ if (!Object.keys(headers).some(h => h.toLowerCase() === 'user-agent'))
75
+ {
76
+ headers['User-Agent'] = DEFAULT_USER_AGENT;
77
+ }
78
+
62
79
  // Normalize body
63
80
  let body = opts.body;
64
81
  if (body && typeof body === 'object' && typeof body.toString === 'function' && body.constructor && body.constructor.name === 'URLSearchParams')
@@ -431,6 +431,27 @@ class SqliteAdapter extends BaseSqlAdapter
431
431
  {
432
432
  const { action, table, fields, where, orderBy, limit, offset, distinct, joins, groupBy, having } = descriptor;
433
433
 
434
+ // Raw SQL passthrough (e.g. AuditLog.install() issues DDL this way).
435
+ if (descriptor.raw)
436
+ {
437
+ const stmt = this._db.prepare(descriptor.raw);
438
+ return stmt.reader ? stmt.all(...(descriptor.params || [])) : stmt.run(...(descriptor.params || []));
439
+ }
440
+
441
+ // Row insert descriptor (e.g. AuditLog entry writes).
442
+ if (action === 'insert')
443
+ {
444
+ return this.insert(table, descriptor.data);
445
+ }
446
+
447
+ // Conditional delete descriptor (e.g. AuditLog.purge()).
448
+ if (action === 'delete')
449
+ {
450
+ const { clause, values } = this._buildWhereFromChain(where);
451
+ const result = this._prepare(`DELETE FROM "${table}"${clause}`).run(...values);
452
+ return result.changes;
453
+ }
454
+
434
455
  if (action === 'count')
435
456
  {
436
457
  const { clause, values } = this._buildWhereFromChain(where);
package/lib/orm/audit.js CHANGED
@@ -465,7 +465,8 @@ class AuditLog
465
465
  action: 'find',
466
466
  table: this.tableName,
467
467
  where,
468
- orderBy: [{ field: 'timestamp', direction: order }],
468
+ // Adapters read the canonical `dir` key ('ASC' | 'DESC').
469
+ orderBy: [{ field: 'timestamp', dir: String(order).toUpperCase() === 'ASC' ? 'ASC' : 'DESC' }],
469
470
  limit,
470
471
  offset,
471
472
  };
@@ -75,6 +75,13 @@ class Router
75
75
  this.routes = [];
76
76
  /** @type {{ prefix: string, router: Router }[]} */
77
77
  this._children = [];
78
+ /**
79
+ * Router-level middleware. Each entry runs before the handlers of any
80
+ * matching route in this router (and in mounted child routers), in
81
+ * registration order.
82
+ * @type {{ prefix: string|null, handler: Function }[]}
83
+ */
84
+ this._middleware = [];
78
85
  /**
79
86
  * Parameter pre-processing handlers (set by parent App).
80
87
  * @type {Object<string, Function[]>}
@@ -104,23 +111,82 @@ class Router
104
111
  }
105
112
 
106
113
  /**
107
- * Mount a child Router under a path prefix.
108
- * Requests matching the prefix are delegated to the child router with
109
- * the prefix stripped from `req.url`.
114
+ * Register router-level middleware or mount a child Router.
115
+ *
116
+ * Three forms are supported:
117
+ * - `use(fn, ...fns)` - middleware that runs before the handlers of every
118
+ * matching route in this router (and its mounted children).
119
+ * - `use(prefix, fn, ...fns)` - middleware scoped to routes whose path is at
120
+ * or below `prefix`.
121
+ * - `use(prefix, router)` - mount a child Router under a path prefix.
122
+ * Requests matching the prefix are delegated to the child router with the
123
+ * prefix stripped from `req.url`.
124
+ *
125
+ * Middleware runs as part of the matched route's handler chain, so it only
126
+ * executes when a route actually matches (a 404 runs no middleware). This
127
+ * makes it a natural fit for auth/feature guards. Unlike the earlier
128
+ * releases, passing a function is no longer silently ignored - an
129
+ * unsupported argument shape throws a `TypeError`.
110
130
  *
111
- * @param {string} prefix - Path prefix (e.g. '/api').
112
- * @param {Router} router - Child router instance.
131
+ * @param {string|Function} prefixOrFn - Path prefix, or a middleware function.
132
+ * @param {...(Function|Router)} rest - A child `Router`, or one or more middleware functions.
113
133
  * @returns {Router} `this` for chaining.
134
+ * @throws {TypeError} When the argument shape is not one of the supported forms.
114
135
  */
115
- use(prefix, router)
136
+ use(prefixOrFn, ...rest)
116
137
  {
117
- if (typeof prefix === 'string' && router instanceof Router)
138
+ // use(prefix, router) - mount a child router
139
+ if (typeof prefixOrFn === 'string' && rest.length === 1 && rest[0] instanceof Router)
118
140
  {
119
- const cleanPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
120
- this._children.push({ prefix: cleanPrefix, router });
141
+ const cleanPrefix = prefixOrFn.endsWith('/') ? prefixOrFn.slice(0, -1) : prefixOrFn;
142
+ this._children.push({ prefix: cleanPrefix, router: rest[0] });
121
143
  log.debug('mounted child router at %s', cleanPrefix);
144
+ return this;
145
+ }
146
+
147
+ // use(fn, ...fns) - global router middleware
148
+ if (typeof prefixOrFn === 'function')
149
+ {
150
+ for (const fn of [prefixOrFn, ...rest])
151
+ {
152
+ if (typeof fn !== 'function')
153
+ throw new TypeError('Router.use(): every middleware argument must be a function');
154
+ this._middleware.push({ prefix: null, handler: fn });
155
+ }
156
+ log.debug('registered %d router middleware', 1 + rest.length);
157
+ return this;
158
+ }
159
+
160
+ // use(prefix, fn, ...fns) - prefix-scoped router middleware
161
+ if (typeof prefixOrFn === 'string' && rest.length > 0 && rest.every(f => typeof f === 'function'))
162
+ {
163
+ const cleanPrefix = prefixOrFn.endsWith('/') ? prefixOrFn.slice(0, -1) : prefixOrFn;
164
+ for (const fn of rest) this._middleware.push({ prefix: cleanPrefix, handler: fn });
165
+ log.debug('registered %d router middleware at %s', rest.length, cleanPrefix);
166
+ return this;
167
+ }
168
+
169
+ throw new TypeError(
170
+ 'Router.use() expects use(fn), use(prefix, fn), or use(prefix, router)'
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Collect the router-level middleware whose scope matches a path.
176
+ * @param {string} url - Router-local request path (query already stripped).
177
+ * @returns {Function[]} Applicable middleware handlers in registration order.
178
+ * @private
179
+ */
180
+ _applicableMiddleware(url)
181
+ {
182
+ if (this._middleware.length === 0) return [];
183
+ const out = [];
184
+ for (const m of this._middleware)
185
+ {
186
+ if (m.prefix === null || url === m.prefix || url.startsWith(m.prefix + '/'))
187
+ out.push(m.handler);
122
188
  }
123
- return this;
189
+ return out;
124
190
  }
125
191
 
126
192
  /**
@@ -159,15 +225,20 @@ class Router
159
225
  *
160
226
  * @param {import('./request')} req - HTTP request object.
161
227
  * @param {import('./response')} res - HTTP response object.
228
+ * @param {Function[]} [inherited] - Middleware inherited from parent routers,
229
+ * already resolved as applicable to this request.
162
230
  * @returns {boolean} Boolean result.
163
231
  * @private
164
232
  */
165
- _matchAndExecute(req, res)
233
+ _matchAndExecute(req, res, inherited = [])
166
234
  {
167
235
  const method = req.method.toUpperCase();
168
236
  const url = req.url.split('?')[0];
169
237
  log.debug('%s %s', method, url);
170
238
 
239
+ // Middleware applicable at this router for the current path
240
+ const ownMiddleware = this._applicableMiddleware(url);
241
+
171
242
  // Try own routes first
172
243
  for (let ri = 0; ri < this.routes.length; ri++)
173
244
  {
@@ -248,8 +319,37 @@ class Router
248
319
  }
249
320
  };
250
321
 
251
- if (paramCount > 0) runParams();
252
- else runHandlers();
322
+ // Middleware (inherited from parents, then this router's own) runs
323
+ // ahead of param handlers and route handlers. A middleware that
324
+ // sends a response without calling next() short-circuits the chain.
325
+ const middleware = inherited.length || ownMiddleware.length
326
+ ? [...inherited, ...ownMiddleware]
327
+ : null;
328
+ const startChain = () => (paramCount > 0 ? runParams() : runHandlers());
329
+
330
+ if (middleware)
331
+ {
332
+ let mIdx = 0;
333
+ const runMiddleware = () =>
334
+ {
335
+ if (mIdx < middleware.length)
336
+ {
337
+ const fn = middleware[mIdx++];
338
+ try
339
+ {
340
+ const result = fn(req, res, runMiddleware);
341
+ if (result && typeof result.catch === 'function')
342
+ {
343
+ result.catch(e => this._handleRouteError(e, req, res));
344
+ }
345
+ }
346
+ catch (e) { this._handleRouteError(e, req, res); }
347
+ }
348
+ else { startChain(); }
349
+ };
350
+ runMiddleware();
351
+ }
352
+ else { startChain(); }
253
353
  return true;
254
354
  }
255
355
 
@@ -262,11 +362,21 @@ class Router
262
362
  const origUrl = req.url;
263
363
  const origBaseUrl = req.baseUrl || '';
264
364
  req.baseUrl = origBaseUrl + child.prefix;
265
- req.url = req.url.slice(child.prefix.length) || '/';
365
+ // Keep the query string attached to a normalized path: a
366
+ // request for exactly the mount prefix plus a query
367
+ // ('/api/files?x=1') must delegate as '/?x=1', not '?x=1'.
368
+ const rest = req.url.slice(child.prefix.length);
369
+ req.url = rest === '' || rest.startsWith('?') ? '/' + rest : rest;
266
370
  child.router._paramHandlers = this._paramHandlers;
371
+ // Carry this router's applicable middleware down so mounted
372
+ // routes are guarded too. Applicability is resolved here, against
373
+ // the parent-relative URL, before the prefix is stripped.
374
+ const childInherited = inherited.length || ownMiddleware.length
375
+ ? [...inherited, ...ownMiddleware]
376
+ : inherited;
267
377
  try
268
378
  {
269
- const found = child.router._matchAndExecute(req, res);
379
+ const found = child.router._matchAndExecute(req, res, childInherited);
270
380
  if (found) return true;
271
381
  }
272
382
  catch (e) { this._handleRouteError(e, req, res); return true; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zero-server/sdk",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Zero-dependency backend framework for Node.js - routing, ORM, auth, WebSocket, SSE, gRPC, observability, and 20+ middleware. Distributed as a single SDK and as scoped @zero-server/* packages.",
5
5
  "main": "index.js",
6
6
  "bin": {