webspresso 0.0.110 → 0.0.111

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.
@@ -3,10 +3,11 @@
3
3
  /**
4
4
  * Webspresso Lightweight Client Runtime (`core/client/lite.js`)
5
5
  *
6
- * Micro-footprint (~8 KB Brotli) client runtime for server-driven applications.
6
+ * Micro-footprint (~16.1 KB Brotli) client runtime for server-driven applications.
7
7
  * Includes:
8
- * - SSR Fragment coordinator (batch refresh, invalidation, DOM morphing, focus preservation)
9
- * - Native HTML Form enhancement (`<form data-w-form>`, validation errors, double-submit protection)
8
+ * - SSR Fragment coordinator (batch refresh, invalidation, auto-revalidation, DOM morphing, focus preservation)
9
+ * - Native HTML Form enhancement (`<form data-w-form>`, validation errors, lifecycle hooks, draft persistence)
10
+ * - Shared Client Services & optional lazy UI hosts (`defineClientService`, `services.get`)
10
11
  * - Typed API client with auto-invalidation
11
12
  * Excludes:
12
13
  * - Reactive signals engine, controller proxies, and complex task coordinators.
@@ -45,6 +46,46 @@ const {
45
46
 
46
47
  const { NOOP_DEBUGGER } = require('./utils');
47
48
 
49
+ const NOOP_SERVICES = Object.freeze({
50
+ get(name) {
51
+ const err = new Error(`Unknown client service "${name}".`);
52
+ err.code = 'CLIENT_SERVICE_NOT_FOUND';
53
+ return Promise.reject(err);
54
+ },
55
+ has() {
56
+ return false;
57
+ },
58
+ isInitialized() {
59
+ return false;
60
+ },
61
+ peek() {
62
+ return undefined;
63
+ },
64
+ register() {},
65
+ registerMany() {},
66
+ dispose() {},
67
+ destroy() {},
68
+ isDisposed: false,
69
+ });
70
+
71
+ let _servicesMod = null;
72
+ function getServicesModule() {
73
+ if (!_servicesMod) {
74
+ const reqFn = typeof module !== 'undefined' && module.require ? module.require.bind(module) : require;
75
+ _servicesMod = reqFn('./services');
76
+ }
77
+ return _servicesMod;
78
+ }
79
+
80
+ let _hostMod = null;
81
+ function getHostModule() {
82
+ if (!_hostMod) {
83
+ const reqFn = typeof module !== 'undefined' && module.require ? module.require.bind(module) : require;
84
+ _hostMod = reqFn('./host');
85
+ }
86
+ return _hostMod;
87
+ }
88
+
48
89
  let _serverFragments = null;
49
90
  function getServerFragments() {
50
91
  if (!_serverFragments) {
@@ -66,6 +107,14 @@ function createLiteClientRuntime(options = {}) {
66
107
  fetch: options.fetch,
67
108
  endpoint: options.fragmentEndpoint || DEFAULT_FRAGMENT_ENDPOINT,
68
109
  debug,
110
+ staleTime: options.staleTime,
111
+ refetchOnWindowFocus: options.refetchOnWindowFocus,
112
+ refetchOnFocus: options.refetchOnFocus,
113
+ refetchOnReconnect: options.refetchOnReconnect,
114
+ refetchOnMount: options.refetchOnMount,
115
+ refetchOnVisit: options.refetchOnVisit,
116
+ refetchInterval: options.refetchInterval,
117
+ refetchIntervalInBackground: options.refetchIntervalInBackground,
69
118
  getCsrfToken: options.getCsrfToken || (() => discoverCsrfToken(rootOption)),
70
119
  });
71
120
 
@@ -80,6 +129,21 @@ function createLiteClientRuntime(options = {}) {
80
129
 
81
130
  const api = createApiClient(apiConfig);
82
131
 
132
+ let runtime = null;
133
+
134
+ const services =
135
+ options.services && typeof options.services.get === 'function' && typeof options.services.register === 'function'
136
+ ? options.services
137
+ : options.services && typeof options.services === 'object'
138
+ ? getServicesModule().createClientServiceRegistry({
139
+ root: rootOption,
140
+ api,
141
+ debug,
142
+ getRuntime: () => runtime,
143
+ services: options.services,
144
+ })
145
+ : NOOP_SERVICES;
146
+
83
147
  const formsRegistry = new Map();
84
148
  function getFormHandle(nameOrEl) {
85
149
  if (!nameOrEl) return null;
@@ -93,6 +157,7 @@ function createLiteClientRuntime(options = {}) {
93
157
  el,
94
158
  createFormHandle(el, {
95
159
  api,
160
+ services,
96
161
  onInvalidate: (tags) => fragments.invalidate(tags),
97
162
  })
98
163
  );
@@ -105,6 +170,7 @@ function createLiteClientRuntime(options = {}) {
105
170
  nameOrEl,
106
171
  createFormHandle(nameOrEl, {
107
172
  api,
173
+ services,
108
174
  onInvalidate: (tags) => fragments.invalidate(tags),
109
175
  })
110
176
  );
@@ -126,16 +192,35 @@ function createLiteClientRuntime(options = {}) {
126
192
  }
127
193
  }
128
194
 
129
- return {
195
+ function destroy() {
196
+ for (const handle of formsRegistry.values()) {
197
+ try {
198
+ handle.destroy();
199
+ } catch (_e) {}
200
+ }
201
+ formsRegistry.clear();
202
+ if (fragments && typeof fragments.dispose === 'function') {
203
+ fragments.dispose();
204
+ }
205
+ services.dispose();
206
+ }
207
+
208
+ runtime = {
130
209
  fragments,
131
210
  api,
211
+ services,
212
+ registerService: (name, def) => services.register(name, def),
132
213
  forms: {
133
214
  get: getFormHandle,
134
215
  },
135
216
  invalidate: (tags, meta) => fragments.invalidate(tags, meta),
136
217
  refresh: (idOrName, input, opts) => fragments.refresh(idOrName, input, opts),
137
218
  boot,
219
+ destroy,
220
+ dispose: destroy,
138
221
  };
222
+
223
+ return runtime;
139
224
  }
140
225
 
141
226
  /**
@@ -171,5 +256,17 @@ module.exports = {
171
256
  defaultStorage,
172
257
  StorageAdapter,
173
258
  MemoryStorage,
259
+ get defineClientService() {
260
+ return getServicesModule().defineClientService;
261
+ },
262
+ get createClientServiceRegistry() {
263
+ return getServicesModule().createClientServiceRegistry;
264
+ },
265
+ get createServiceError() {
266
+ return getServicesModule().createServiceError;
267
+ },
268
+ get createClientHostManager() {
269
+ return getHostModule().createClientHostManager;
270
+ },
174
271
  };
175
272
 
@@ -15,12 +15,13 @@
15
15
  const { z } = require('../validation');
16
16
  const { ValidationError, NotFoundError, BadRequestError, UnauthorizedError, ForbiddenError } = require('../errors');
17
17
  const { onTransactionCommit, getAmbientTransaction } = require('../orm/transaction');
18
+ const { isArray, isNumber } = require('./utils');
18
19
 
19
20
  /**
20
21
  * Execute an array of Express-style middlewares sequentially.
21
22
  */
22
23
  async function runMiddlewares(middlewares, req, res) {
23
- if (!Array.isArray(middlewares) || middlewares.length === 0) return;
24
+ if (!isArray(middlewares) || middlewares.length === 0) return;
24
25
  for (const mw of middlewares) {
25
26
  if (typeof mw !== 'function') continue;
26
27
  await new Promise((resolve, reject) => {
@@ -89,11 +90,28 @@ function defineFragment(config = {}) {
89
90
  }
90
91
  return {
91
92
  template: config.template.trim(),
92
- dependsOn: Array.isArray(config.dependsOn) ? config.dependsOn.map(String) : [],
93
+ dependsOn: isArray(config.dependsOn) ? config.dependsOn.map(String) : [],
93
94
  schema: config.schema || null,
94
95
  auth: config.auth !== undefined ? config.auth : null,
95
- middleware: Array.isArray(config.middleware) ? config.middleware : [],
96
+ middleware: isArray(config.middleware) ? config.middleware : [],
96
97
  load: typeof config.load === 'function' ? config.load : null,
98
+ staleTime: config.staleTime !== undefined ? config.staleTime : undefined,
99
+ refetchOnWindowFocus:
100
+ config.refetchOnWindowFocus !== undefined
101
+ ? config.refetchOnWindowFocus
102
+ : config.refetchOnFocus !== undefined
103
+ ? config.refetchOnFocus
104
+ : undefined,
105
+ refetchOnReconnect: config.refetchOnReconnect !== undefined ? config.refetchOnReconnect : undefined,
106
+ refetchOnMount:
107
+ config.refetchOnMount !== undefined
108
+ ? config.refetchOnMount
109
+ : config.refetchOnVisit !== undefined
110
+ ? config.refetchOnVisit
111
+ : undefined,
112
+ refetchInterval: config.refetchInterval !== undefined ? config.refetchInterval : undefined,
113
+ refetchIntervalInBackground:
114
+ config.refetchIntervalInBackground !== undefined ? config.refetchIntervalInBackground : undefined,
97
115
  };
98
116
  }
99
117
 
@@ -114,7 +132,7 @@ function defineFragment(config = {}) {
114
132
  * @returns {{ __isWebspressoApiResult: true, data: T, invalidate: string[], status?: number, headers?: Record<string, string> }}
115
133
  */
116
134
  function apiResult(data, meta = {}) {
117
- const rawInvalidate = meta && Array.isArray(meta.invalidate) ? meta.invalidate : [];
135
+ const rawInvalidate = meta && isArray(meta.invalidate) ? meta.invalidate : [];
118
136
  const invalidate = Array.from(
119
137
  new Set(rawInvalidate.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim()))
120
138
  );
@@ -122,7 +140,7 @@ function apiResult(data, meta = {}) {
122
140
  __isWebspressoApiResult: true,
123
141
  data,
124
142
  invalidate,
125
- status: meta && typeof meta.status === 'number' ? meta.status : undefined,
143
+ status: meta && isNumber(meta.status) ? meta.status : undefined,
126
144
  headers: meta && meta.headers && typeof meta.headers === 'object' ? { ...meta.headers } : undefined,
127
145
  };
128
146
  }
@@ -131,11 +149,11 @@ function apiResult(data, meta = {}) {
131
149
  * Check whether a value returned by an API handler is an `apiResult` or `{ data, invalidate }` envelope.
132
150
  */
133
151
  function isApiResultEnvelope(val) {
134
- if (!val || typeof val !== 'object' || Array.isArray(val)) return false;
152
+ if (!val || typeof val !== 'object' || isArray(val)) return false;
135
153
  if (val.__isWebspressoApiResult === true) return true;
136
154
  if (
137
155
  'data' in val &&
138
- Array.isArray(val.invalidate) &&
156
+ isArray(val.invalidate) &&
139
157
  val.invalidate.every((item) => typeof item === 'string')
140
158
  ) {
141
159
  return true;
@@ -153,7 +171,7 @@ function isApiResultEnvelope(val) {
153
171
  */
154
172
  function sendApiResult(req, res, rawResult) {
155
173
  const status = rawResult.status || (res.statusCode && res.statusCode !== 200 ? res.statusCode : 200);
156
- const rawTags = Array.isArray(rawResult.invalidate) ? rawResult.invalidate : [];
174
+ const rawTags = isArray(rawResult.invalidate) ? rawResult.invalidate : [];
157
175
  const tags = Array.from(
158
176
  new Set(rawTags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim()))
159
177
  );
@@ -265,7 +283,7 @@ function ensureRequestMemo(req, ctx) {
265
283
  * @returns {Record<string, unknown>}
266
284
  */
267
285
  function validateFragmentInput(fragmentName, fragmentDef, rawInput = {}) {
268
- const inputObj = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {};
286
+ const inputObj = rawInput && typeof rawInput === 'object' && !isArray(rawInput) ? rawInput : {};
269
287
  if (!fragmentDef || !fragmentDef.schema) {
270
288
  return { ...inputObj };
271
289
  }
@@ -295,7 +313,7 @@ function validateFragmentInput(fragmentName, fragmentDef, rawInput = {}) {
295
313
  const parsed = resolvedSchema.safeParse(inputObj);
296
314
  if (!parsed.success) {
297
315
  const issues = (parsed.error && parsed.error.issues ? parsed.error.issues : []).map((iss) => ({
298
- path: Array.isArray(iss.path) ? iss.path.join('.') : String(iss.path || ''),
316
+ path: isArray(iss.path) ? iss.path.join('.') : String(iss.path || ''),
299
317
  message: iss.message,
300
318
  code: iss.code,
301
319
  }));
@@ -329,18 +347,18 @@ async function enforceFragmentAuth(fragmentName, fragmentDef, req, ctx) {
329
347
  if (!user) {
330
348
  throw new UnauthorizedError(`Authentication required to access fragment "${fragmentName}".`);
331
349
  }
332
- const roles = Array.isArray(user.roles) ? user.roles : user.role ? [user.role] : [];
350
+ const roles = isArray(user.roles) ? user.roles : user.role ? [user.role] : [];
333
351
  if (!roles.includes(authRule)) {
334
352
  throw new ForbiddenError(`Role "${authRule}" required to access fragment "${fragmentName}".`);
335
353
  }
336
354
  return;
337
355
  }
338
356
 
339
- if (Array.isArray(authRule)) {
357
+ if (isArray(authRule)) {
340
358
  if (!user) {
341
359
  throw new UnauthorizedError(`Authentication required to access fragment "${fragmentName}".`);
342
360
  }
343
- const roles = Array.isArray(user.roles) ? user.roles : user.role ? [user.role] : [];
361
+ const roles = isArray(user.roles) ? user.roles : user.role ? [user.role] : [];
344
362
  if (!authRule.some((r) => roles.includes(r))) {
345
363
  throw new ForbiddenError(`Insufficient role to access fragment "${fragmentName}".`);
346
364
  }
@@ -363,7 +381,22 @@ async function enforceFragmentAuth(fragmentName, fragmentDef, req, ctx) {
363
381
  * Notice: ONLY validated filter/pagination `input` is serialized in `data-w-fragment-input`.
364
382
  * Server loader data (`fragmentData`) is NEVER serialized as props!
365
383
  */
366
- function wrapFragmentRootHtml({ name, instanceId, pagePath, dependsOn, validatedInput, innerHtml, tag = 'div', className = '' }) {
384
+ function wrapFragmentRootHtml({
385
+ name,
386
+ instanceId,
387
+ pagePath,
388
+ dependsOn,
389
+ validatedInput,
390
+ innerHtml,
391
+ tag = 'div',
392
+ className = '',
393
+ staleTime,
394
+ refetchOnWindowFocus,
395
+ refetchOnReconnect,
396
+ refetchOnMount,
397
+ refetchInterval,
398
+ refetchIntervalInBackground,
399
+ }) {
367
400
  const safeTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(tag || 'div') ? tag : 'div';
368
401
  const attrs = [
369
402
  `data-w-fragment="${escapeHtmlAttr(name)}"`,
@@ -371,7 +404,7 @@ function wrapFragmentRootHtml({ name, instanceId, pagePath, dependsOn, validated
371
404
  `data-w-fragment-page="${escapeHtmlAttr(pagePath || '/')}"`,
372
405
  ];
373
406
 
374
- if (Array.isArray(dependsOn) && dependsOn.length > 0) {
407
+ if (isArray(dependsOn) && dependsOn.length > 0) {
375
408
  attrs.push(`data-w-depends-on="${escapeHtmlAttr(dependsOn.join(','))}"`);
376
409
  }
377
410
 
@@ -379,6 +412,30 @@ function wrapFragmentRootHtml({ name, instanceId, pagePath, dependsOn, validated
379
412
  attrs.push(`data-w-fragment-input="${escapeHtmlAttr(JSON.stringify(validatedInput))}"`);
380
413
  }
381
414
 
415
+ if (staleTime !== undefined && staleTime !== null && staleTime !== false) {
416
+ attrs.push(`data-w-stale-time="${escapeHtmlAttr(String(staleTime))}"`);
417
+ }
418
+
419
+ if (refetchOnWindowFocus !== undefined && refetchOnWindowFocus !== null) {
420
+ attrs.push(`data-w-refetch-on-focus="${escapeHtmlAttr(String(refetchOnWindowFocus))}"`);
421
+ }
422
+
423
+ if (refetchOnReconnect !== undefined && refetchOnReconnect !== null) {
424
+ attrs.push(`data-w-refetch-on-reconnect="${escapeHtmlAttr(String(refetchOnReconnect))}"`);
425
+ }
426
+
427
+ if (refetchOnMount !== undefined && refetchOnMount !== null) {
428
+ attrs.push(`data-w-refetch-on-mount="${escapeHtmlAttr(String(refetchOnMount))}"`);
429
+ }
430
+
431
+ if (refetchInterval !== undefined && refetchInterval !== null && refetchInterval !== false) {
432
+ attrs.push(`data-w-refetch-interval="${escapeHtmlAttr(String(refetchInterval))}"`);
433
+ }
434
+
435
+ if (refetchIntervalInBackground !== undefined && refetchIntervalInBackground !== null) {
436
+ attrs.push(`data-w-refetch-in-background="${escapeHtmlAttr(String(refetchIntervalInBackground))}"`);
437
+ }
438
+
382
439
  if (className) {
383
440
  attrs.push(`class="${escapeHtmlAttr(className)}"`);
384
441
  }
@@ -432,7 +489,7 @@ async function executeAndRenderFragment({
432
489
  ensureRequestMemo(req, ctx);
433
490
 
434
491
  // 1. Run fragment-specific middlewares if any
435
- if (Array.isArray(fragmentDef.middleware) && fragmentDef.middleware.length > 0) {
492
+ if (isArray(fragmentDef.middleware) && fragmentDef.middleware.length > 0) {
436
493
  await runMiddlewares(fragmentDef.middleware, req, res);
437
494
  }
438
495
 
@@ -494,6 +551,27 @@ async function executeAndRenderFragment({
494
551
  innerHtml,
495
552
  tag: options.tag || 'div',
496
553
  className: options.class || options.className || '',
554
+ staleTime: options.staleTime !== undefined ? options.staleTime : fragmentDef.staleTime,
555
+ refetchOnWindowFocus:
556
+ options.refetchOnWindowFocus !== undefined
557
+ ? options.refetchOnWindowFocus
558
+ : options.refetchOnFocus !== undefined
559
+ ? options.refetchOnFocus
560
+ : fragmentDef.refetchOnWindowFocus,
561
+ refetchOnReconnect:
562
+ options.refetchOnReconnect !== undefined ? options.refetchOnReconnect : fragmentDef.refetchOnReconnect,
563
+ refetchOnMount:
564
+ options.refetchOnMount !== undefined
565
+ ? options.refetchOnMount
566
+ : options.refetchOnVisit !== undefined
567
+ ? options.refetchOnVisit
568
+ : fragmentDef.refetchOnMount,
569
+ refetchInterval:
570
+ options.refetchInterval !== undefined ? options.refetchInterval : fragmentDef.refetchInterval,
571
+ refetchIntervalInBackground:
572
+ options.refetchIntervalInBackground !== undefined
573
+ ? options.refetchIntervalInBackground
574
+ : fragmentDef.refetchIntervalInBackground,
497
575
  });
498
576
 
499
577
  return {
@@ -588,7 +666,7 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
588
666
  * @returns {Promise<string>}
589
667
  */
590
668
  async function resolvePendingSsrFragments(html, req) {
591
- if (typeof html !== 'string' || !req || !Array.isArray(req.__pendingSsrFragments)) {
669
+ if (typeof html !== 'string' || !req || !isArray(req.__pendingSsrFragments)) {
592
670
  return html;
593
671
  }
594
672
 
@@ -627,7 +705,7 @@ function trimTrailingSlashes(str) {
627
705
  * @returns {{ entry: object, params: Record<string, string> }|null}
628
706
  */
629
707
  function matchPageEntryByPath(pagePath, pageEntries) {
630
- if (!pagePath || !Array.isArray(pageEntries)) return null;
708
+ if (!pagePath || !isArray(pageEntries)) return null;
631
709
  const cleanPath = trimTrailingSlashes(String(pagePath).split('?')[0]) || '/';
632
710
 
633
711
  // 1. Exact static match
@@ -722,7 +800,7 @@ function createFragmentRefreshHandler(config) {
722
800
  },
723
801
  ];
724
802
  } else {
725
- if (!Array.isArray(payload.fragments) || payload.fragments.length === 0) {
803
+ if (!isArray(payload.fragments) || payload.fragments.length === 0) {
726
804
  throw new BadRequestError('Fragment refresh request requires a non-empty `fragments` array.');
727
805
  }
728
806
  requestedFragments = payload.fragments;
@@ -755,12 +833,12 @@ function createFragmentRefreshHandler(config) {
755
833
  }
756
834
 
757
835
  // 1. Execute page-level cascading middlewares (`_hooks.js`) and page route `middleware`
758
- if (Array.isArray(entry.cascadingMiddlewares) && entry.cascadingMiddlewares.length > 0) {
836
+ if (isArray(entry.cascadingMiddlewares) && entry.cascadingMiddlewares.length > 0) {
759
837
  await runMiddlewares(entry.cascadingMiddlewares, req, res);
760
838
  if (res.headersSent) return;
761
839
  }
762
840
 
763
- if (pageMod && Array.isArray(pageMod.middleware) && pageMod.middleware.length > 0) {
841
+ if (pageMod && isArray(pageMod.middleware) && pageMod.middleware.length > 0) {
764
842
  const resolvedPageMws =
765
843
  typeof resolveMiddlewareList === 'function'
766
844
  ? resolveMiddlewareList(pageMod.middleware)