zero-query 0.9.6 → 0.9.8

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.
Files changed (59) hide show
  1. package/README.md +36 -8
  2. package/cli/commands/build.js +50 -3
  3. package/cli/commands/create.js +22 -9
  4. package/cli/help.js +2 -0
  5. package/cli/scaffold/default/app/app.js +211 -0
  6. package/cli/scaffold/default/app/components/about.js +201 -0
  7. package/cli/scaffold/default/app/components/api-demo.js +143 -0
  8. package/cli/scaffold/default/app/components/contact-card.js +231 -0
  9. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -0
  10. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -0
  11. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -0
  12. package/cli/scaffold/default/app/components/counter.js +127 -0
  13. package/cli/scaffold/default/app/components/home.js +249 -0
  14. package/cli/scaffold/{app → default/app}/components/not-found.js +2 -2
  15. package/cli/scaffold/default/app/components/playground/playground.css +116 -0
  16. package/cli/scaffold/default/app/components/playground/playground.html +162 -0
  17. package/cli/scaffold/default/app/components/playground/playground.js +117 -0
  18. package/cli/scaffold/default/app/components/todos.js +225 -0
  19. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -0
  20. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -0
  21. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -0
  22. package/cli/scaffold/default/app/routes.js +15 -0
  23. package/cli/scaffold/{app → default/app}/store.js +15 -10
  24. package/cli/scaffold/{global.css → default/global.css} +238 -252
  25. package/cli/scaffold/{index.html → default/index.html} +35 -0
  26. package/cli/scaffold/{app → minimal/app}/app.js +37 -39
  27. package/cli/scaffold/minimal/app/components/about.js +68 -0
  28. package/cli/scaffold/minimal/app/components/counter.js +122 -0
  29. package/cli/scaffold/minimal/app/components/home.js +68 -0
  30. package/cli/scaffold/minimal/app/components/not-found.js +16 -0
  31. package/cli/scaffold/minimal/app/routes.js +9 -0
  32. package/cli/scaffold/minimal/app/store.js +36 -0
  33. package/cli/scaffold/minimal/assets/.gitkeep +0 -0
  34. package/cli/scaffold/minimal/global.css +291 -0
  35. package/cli/scaffold/minimal/index.html +44 -0
  36. package/dist/zquery.dist.zip +0 -0
  37. package/dist/zquery.js +1949 -1894
  38. package/dist/zquery.min.js +2 -2
  39. package/index.d.ts +10 -1
  40. package/index.js +5 -3
  41. package/package.json +1 -1
  42. package/src/component.js +6 -3
  43. package/src/diff.js +15 -2
  44. package/src/http.js +37 -0
  45. package/tests/cli.test.js +304 -0
  46. package/tests/http.test.js +200 -0
  47. package/types/http.d.ts +15 -4
  48. package/cli/scaffold/app/components/about.js +0 -131
  49. package/cli/scaffold/app/components/api-demo.js +0 -103
  50. package/cli/scaffold/app/components/contacts/contacts.css +0 -246
  51. package/cli/scaffold/app/components/contacts/contacts.html +0 -140
  52. package/cli/scaffold/app/components/contacts/contacts.js +0 -153
  53. package/cli/scaffold/app/components/counter.js +0 -85
  54. package/cli/scaffold/app/components/home.js +0 -137
  55. package/cli/scaffold/app/components/todos.js +0 -131
  56. package/cli/scaffold/app/routes.js +0 -13
  57. /package/cli/scaffold/{LICENSE → default/LICENSE} +0 -0
  58. /package/cli/scaffold/{assets → default/assets}/.gitkeep +0 -0
  59. /package/cli/scaffold/{favicon.ico → default/favicon.ico} +0 -0
package/src/component.js CHANGED
@@ -502,11 +502,14 @@ class Component {
502
502
  this._bindRefs();
503
503
  this._bindModels();
504
504
 
505
- // Restore focus if the morph replaced the focused element
505
+ // Restore focus if the morph replaced the focused element.
506
+ // Always restore selectionRange — even when the element is still
507
+ // the activeElement — because _bindModels or morph attribute syncing
508
+ // can alter the value and move the cursor.
506
509
  if (_focusInfo) {
507
510
  const el = this._el.querySelector(_focusInfo.selector);
508
- if (el && el !== document.activeElement) {
509
- el.focus();
511
+ if (el) {
512
+ if (el !== document.activeElement) el.focus();
510
513
  try {
511
514
  if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
512
515
  el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
package/src/diff.js CHANGED
@@ -89,6 +89,11 @@ export function morphElement(oldEl, newHTML) {
89
89
  return clone;
90
90
  }
91
91
 
92
+ // Aliases for the concat build — core.js imports these as _morph / _morphElement,
93
+ // but the build strips `import … as` lines, so the aliases must exist at runtime.
94
+ const _morph = morph;
95
+ const _morphElement = morphElement;
96
+
92
97
  /**
93
98
  * Reconcile children of `oldParent` to match `newParent`.
94
99
  *
@@ -434,6 +439,13 @@ function _morphAttributes(oldEl, newEl) {
434
439
 
435
440
  /**
436
441
  * Sync input element value, checked, disabled states.
442
+ *
443
+ * Only updates the value when the new HTML explicitly carries a `value`
444
+ * attribute. Templates that use z-model manage values through reactive
445
+ * state + _bindModels — the morph engine should not interfere by wiping
446
+ * a live input's content to '' just because the template has no `value`
447
+ * attr. This prevents the wipe-then-restore cycle that resets cursor
448
+ * position on every keystroke.
437
449
  */
438
450
  function _syncInputValue(oldEl, newEl) {
439
451
  const type = (oldEl.type || '').toLowerCase();
@@ -441,8 +453,9 @@ function _syncInputValue(oldEl, newEl) {
441
453
  if (type === 'checkbox' || type === 'radio') {
442
454
  if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
443
455
  } else {
444
- if (oldEl.value !== (newEl.getAttribute('value') || '')) {
445
- oldEl.value = newEl.getAttribute('value') || '';
456
+ const newVal = newEl.getAttribute('value');
457
+ if (newVal !== null && oldEl.value !== newVal) {
458
+ oldEl.value = newVal;
446
459
  }
447
460
  }
448
461
 
package/src/http.js CHANGED
@@ -165,6 +165,7 @@ export const http = {
165
165
  put: (url, data, opts) => request('PUT', url, data, opts),
166
166
  patch: (url, data, opts) => request('PATCH', url, data, opts),
167
167
  delete: (url, data, opts) => request('DELETE', url, data, opts),
168
+ head: (url, opts) => request('HEAD', url, undefined, opts),
168
169
 
169
170
  /**
170
171
  * Configure defaults
@@ -175,20 +176,56 @@ export const http = {
175
176
  if (opts.timeout !== undefined) _config.timeout = opts.timeout;
176
177
  },
177
178
 
179
+ /**
180
+ * Read-only snapshot of current configuration
181
+ */
182
+ getConfig() {
183
+ return {
184
+ baseURL: _config.baseURL,
185
+ headers: { ..._config.headers },
186
+ timeout: _config.timeout,
187
+ };
188
+ },
189
+
178
190
  /**
179
191
  * Add request interceptor
180
192
  * @param {Function} fn — (fetchOpts, url) → void | false | { url, options }
193
+ * @returns {Function} unsubscribe function
181
194
  */
182
195
  onRequest(fn) {
183
196
  _interceptors.request.push(fn);
197
+ return () => {
198
+ const idx = _interceptors.request.indexOf(fn);
199
+ if (idx !== -1) _interceptors.request.splice(idx, 1);
200
+ };
184
201
  },
185
202
 
186
203
  /**
187
204
  * Add response interceptor
188
205
  * @param {Function} fn — (result) → void
206
+ * @returns {Function} unsubscribe function
189
207
  */
190
208
  onResponse(fn) {
191
209
  _interceptors.response.push(fn);
210
+ return () => {
211
+ const idx = _interceptors.response.indexOf(fn);
212
+ if (idx !== -1) _interceptors.response.splice(idx, 1);
213
+ };
214
+ },
215
+
216
+ /**
217
+ * Clear interceptors — all, or just 'request' / 'response'
218
+ */
219
+ clearInterceptors(type) {
220
+ if (!type || type === 'request') _interceptors.request.length = 0;
221
+ if (!type || type === 'response') _interceptors.response.length = 0;
222
+ },
223
+
224
+ /**
225
+ * Run multiple requests in parallel
226
+ */
227
+ all(requests) {
228
+ return Promise.all(requests);
192
229
  },
193
230
 
194
231
  /**
package/tests/cli.test.js CHANGED
@@ -454,3 +454,307 @@ describe('CLI — flag/option extra', () => {
454
454
  expect(flag('watch', 'w')).toBe(true);
455
455
  });
456
456
  });
457
+
458
+
459
+ // ===========================================================================
460
+ // createProject — scaffold command
461
+ // ===========================================================================
462
+
463
+ describe('CLI — createProject', () => {
464
+ const fs = require('fs');
465
+ const path = require('path');
466
+ const os = require('os');
467
+
468
+ let createProject;
469
+
470
+ beforeEach(async () => {
471
+ vi.resetModules();
472
+ const mod = await import('../cli/commands/create.js');
473
+ createProject = mod.default || mod;
474
+ });
475
+
476
+ function tmpDir() {
477
+ const dir = path.join(os.tmpdir(), 'zq-create-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8));
478
+ fs.mkdirSync(dir, { recursive: true });
479
+ return dir;
480
+ }
481
+
482
+ function cleanup(dir) {
483
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
484
+ }
485
+
486
+ // -- scaffold variant directories exist --
487
+
488
+ it('default scaffold directory exists', () => {
489
+ const dir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default');
490
+ expect(fs.existsSync(dir)).toBe(true);
491
+ });
492
+
493
+ it('minimal scaffold directory exists', () => {
494
+ const dir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
495
+ expect(fs.existsSync(dir)).toBe(true);
496
+ });
497
+
498
+ // -- default scaffold contains expected files --
499
+
500
+ it('default scaffold has index.html', () => {
501
+ const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'index.html');
502
+ expect(fs.existsSync(f)).toBe(true);
503
+ });
504
+
505
+ it('default scaffold has app/app.js', () => {
506
+ const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'app', 'app.js');
507
+ expect(fs.existsSync(f)).toBe(true);
508
+ });
509
+
510
+ it('default scaffold has app/routes.js', () => {
511
+ const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'app', 'routes.js');
512
+ expect(fs.existsSync(f)).toBe(true);
513
+ });
514
+
515
+ it('default scaffold has app/store.js', () => {
516
+ const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'app', 'store.js');
517
+ expect(fs.existsSync(f)).toBe(true);
518
+ });
519
+
520
+ // -- minimal scaffold contains expected files --
521
+
522
+ it('minimal scaffold has index.html', () => {
523
+ const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'index.html');
524
+ expect(fs.existsSync(f)).toBe(true);
525
+ });
526
+
527
+ it('minimal scaffold has app/app.js', () => {
528
+ const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'app', 'app.js');
529
+ expect(fs.existsSync(f)).toBe(true);
530
+ });
531
+
532
+ it('minimal scaffold has 4 components', () => {
533
+ const dir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'app', 'components');
534
+ const files = fs.readdirSync(dir).sort();
535
+ expect(files).toEqual(['about.js', 'counter.js', 'home.js', 'not-found.js']);
536
+ });
537
+
538
+ // -- minimal scaffold is a subset of default --
539
+
540
+ it('minimal scaffold has fewer files than default', () => {
541
+ function countFiles(dir) {
542
+ let count = 0;
543
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
544
+ if (entry.isDirectory()) count += countFiles(path.join(dir, entry.name));
545
+ else count++;
546
+ }
547
+ return count;
548
+ }
549
+ const defDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default');
550
+ const minDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
551
+ expect(countFiles(minDir)).toBeLessThan(countFiles(defDir));
552
+ });
553
+
554
+ // -- {{NAME}} replacement --
555
+
556
+ it('scaffold templates contain {{NAME}} placeholder', () => {
557
+ const html = fs.readFileSync(
558
+ path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'index.html'), 'utf-8'
559
+ );
560
+ expect(html).toContain('{{NAME}}');
561
+ });
562
+
563
+ it('minimal scaffold templates contain {{NAME}} placeholder', () => {
564
+ const html = fs.readFileSync(
565
+ path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'index.html'), 'utf-8'
566
+ );
567
+ expect(html).toContain('{{NAME}}');
568
+ });
569
+
570
+ // -- walkDir functionality (tested via module internals) --
571
+
572
+ it('scaffolds default project into a target directory', () => {
573
+ const target = tmpDir();
574
+ const projDir = path.join(target, 'test-app');
575
+
576
+ // Simulate: process.argv for default (no --minimal)
577
+ process.argv = ['node', 'zquery', 'create', 'test-app'];
578
+ vi.resetModules();
579
+
580
+ // We can't easily call createProject because it uses process.exit.
581
+ // Instead, test the walkDir + copy logic directly.
582
+ const scaffoldDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default');
583
+
584
+ function walkDir(dir, prefix = '') {
585
+ const entries = [];
586
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
587
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
588
+ if (entry.isDirectory()) entries.push(...walkDir(path.join(dir, entry.name), rel));
589
+ else entries.push(rel);
590
+ }
591
+ return entries;
592
+ }
593
+
594
+ const files = walkDir(scaffoldDir);
595
+ expect(files.length).toBeGreaterThan(5);
596
+ expect(files).toContain('index.html');
597
+ expect(files).toContain('global.css');
598
+ expect(files).toContain('app/app.js');
599
+ expect(files).toContain('app/routes.js');
600
+ expect(files).toContain('app/store.js');
601
+
602
+ cleanup(target);
603
+ });
604
+
605
+ it('walkDir lists minimal scaffold files correctly', () => {
606
+ const scaffoldDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
607
+
608
+ function walkDir(dir, prefix = '') {
609
+ const entries = [];
610
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
611
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
612
+ if (entry.isDirectory()) entries.push(...walkDir(path.join(dir, entry.name), rel));
613
+ else entries.push(rel);
614
+ }
615
+ return entries;
616
+ }
617
+
618
+ const files = walkDir(scaffoldDir);
619
+ expect(files).toContain('index.html');
620
+ expect(files).toContain('global.css');
621
+ expect(files).toContain('app/app.js');
622
+ expect(files).toContain('app/routes.js');
623
+ expect(files).toContain('app/store.js');
624
+ expect(files).toContain('app/components/home.js');
625
+ expect(files).toContain('app/components/counter.js');
626
+ expect(files).toContain('app/components/about.js');
627
+ expect(files).toContain('app/components/not-found.js');
628
+ expect(files).toContain('assets/.gitkeep');
629
+ });
630
+
631
+ // -- {{NAME}} replacement in copied files --
632
+
633
+ it('replaces {{NAME}} in copied scaffold files', () => {
634
+ const target = tmpDir();
635
+ const scaffoldDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
636
+ const projectName = 'my-cool-app';
637
+
638
+ function walkDir(dir, prefix = '') {
639
+ const entries = [];
640
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
641
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
642
+ if (entry.isDirectory()) entries.push(...walkDir(path.join(dir, entry.name), rel));
643
+ else entries.push(rel);
644
+ }
645
+ return entries;
646
+ }
647
+
648
+ const files = walkDir(scaffoldDir);
649
+
650
+ for (const rel of files) {
651
+ let content = fs.readFileSync(path.join(scaffoldDir, rel), 'utf-8');
652
+ content = content.replace(/\{\{NAME\}\}/g, projectName);
653
+ const dest = path.join(target, rel);
654
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
655
+ fs.writeFileSync(dest, content, 'utf-8');
656
+ }
657
+
658
+ const html = fs.readFileSync(path.join(target, 'index.html'), 'utf-8');
659
+ expect(html).toContain(projectName);
660
+ expect(html).not.toContain('{{NAME}}');
661
+
662
+ const appJs = fs.readFileSync(path.join(target, 'app', 'app.js'), 'utf-8');
663
+ expect(appJs).toContain(projectName);
664
+ expect(appJs).not.toContain('{{NAME}}');
665
+
666
+ cleanup(target);
667
+ });
668
+
669
+ // -- conflict detection --
670
+
671
+ it('conflicts array detects existing files', () => {
672
+ const target = tmpDir();
673
+ fs.writeFileSync(path.join(target, 'index.html'), 'existing');
674
+ fs.mkdirSync(path.join(target, 'app'), { recursive: true });
675
+
676
+ const conflicts = ['index.html', 'global.css', 'app', 'assets'].filter(f =>
677
+ fs.existsSync(path.join(target, f))
678
+ );
679
+
680
+ expect(conflicts).toContain('index.html');
681
+ expect(conflicts).toContain('app');
682
+ expect(conflicts).not.toContain('global.css');
683
+ expect(conflicts).not.toContain('assets');
684
+
685
+ cleanup(target);
686
+ });
687
+
688
+ it('no conflicts in empty directory', () => {
689
+ const target = tmpDir();
690
+
691
+ const conflicts = ['index.html', 'global.css', 'app', 'assets'].filter(f =>
692
+ fs.existsSync(path.join(target, f))
693
+ );
694
+
695
+ expect(conflicts).toHaveLength(0);
696
+
697
+ cleanup(target);
698
+ });
699
+
700
+ // -- flag parsing for --minimal --
701
+
702
+ it('--minimal flag resolves to minimal variant', async () => {
703
+ process.argv = ['node', 'zquery', 'create', '--minimal', 'my-app'];
704
+ vi.resetModules();
705
+ const { flag } = await import('../cli/args.js');
706
+ expect(flag('minimal', 'm')).toBe(true);
707
+ });
708
+
709
+ it('-m short flag resolves to minimal variant', async () => {
710
+ process.argv = ['node', 'zquery', 'create', '-m', 'my-app'];
711
+ vi.resetModules();
712
+ const { flag } = await import('../cli/args.js');
713
+ expect(flag('minimal', 'm')).toBe(true);
714
+ });
715
+
716
+ it('no flag defaults to default variant', async () => {
717
+ process.argv = ['node', 'zquery', 'create', 'my-app'];
718
+ vi.resetModules();
719
+ const { flag } = await import('../cli/args.js');
720
+ expect(flag('minimal', 'm')).toBe(false);
721
+ });
722
+
723
+ // -- dirArg parsing skips flags --
724
+
725
+ it('dirArg parsing skips flag args', () => {
726
+ const args = ['create', '--minimal', 'my-app'];
727
+ const dirArg = args.slice(1).find(a => !a.startsWith('-'));
728
+ expect(dirArg).toBe('my-app');
729
+ });
730
+
731
+ it('dirArg parsing returns first positional', () => {
732
+ const args = ['create', 'my-app'];
733
+ const dirArg = args.slice(1).find(a => !a.startsWith('-'));
734
+ expect(dirArg).toBe('my-app');
735
+ });
736
+
737
+ it('dirArg is undefined when no positional given', () => {
738
+ const args = ['create', '--minimal'];
739
+ const dirArg = args.slice(1).find(a => !a.startsWith('-'));
740
+ expect(dirArg).toBeUndefined();
741
+ });
742
+
743
+ it('dirArg with flag after dir name', () => {
744
+ const args = ['create', 'my-app', '--minimal'];
745
+ const dirArg = args.slice(1).find(a => !a.startsWith('-'));
746
+ expect(dirArg).toBe('my-app');
747
+ });
748
+
749
+ // -- help text mentions --minimal --
750
+
751
+ it('help text includes --minimal flag', async () => {
752
+ vi.resetModules();
753
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
754
+ const showHelp = (await import('../cli/help.js')).default;
755
+ showHelp();
756
+ const text = spy.mock.calls.map(c => c.join(' ')).join('\n');
757
+ expect(text).toContain('--minimal');
758
+ spy.mockRestore();
759
+ });
760
+ });
@@ -446,3 +446,203 @@ describe('http — AbortController integration', () => {
446
446
  expect(fetchSpy).toHaveBeenCalled();
447
447
  });
448
448
  });
449
+
450
+
451
+ // ===========================================================================
452
+ // HEAD requests
453
+ // ===========================================================================
454
+
455
+ describe('http.head', () => {
456
+ it('sends a HEAD request', async () => {
457
+ mockFetch({});
458
+ const result = await http.head('https://api.test.com/resource');
459
+ expect(fetchSpy.mock.calls[0][1].method).toBe('HEAD');
460
+ expect(result.ok).toBe(true);
461
+ expect(result.status).toBe(200);
462
+ });
463
+
464
+ it('does not send a body', async () => {
465
+ mockFetch({});
466
+ await http.head('https://api.test.com/resource');
467
+ const opts = fetchSpy.mock.calls[0][1];
468
+ expect(opts.body).toBeUndefined();
469
+ });
470
+
471
+ it('accepts per-request options', async () => {
472
+ mockFetch({});
473
+ await http.head('https://api.test.com/resource', {
474
+ headers: { 'X-Check': 'exists' },
475
+ });
476
+ const opts = fetchSpy.mock.calls[0][1];
477
+ expect(opts.headers['X-Check']).toBe('exists');
478
+ });
479
+
480
+ it('returns response headers', async () => {
481
+ mockFetch({});
482
+ const result = await http.head('https://api.test.com/resource');
483
+ expect(result.headers).toBeDefined();
484
+ expect(typeof result.headers).toBe('object');
485
+ });
486
+ });
487
+
488
+
489
+ // ===========================================================================
490
+ // Interceptor unsubscribe
491
+ // ===========================================================================
492
+
493
+ describe('http — interceptor unsubscribe', () => {
494
+ it('onRequest returns an unsubscribe function', async () => {
495
+ http.clearInterceptors();
496
+ const spy = vi.fn();
497
+ const unsub = http.onRequest(spy);
498
+ expect(typeof unsub).toBe('function');
499
+
500
+ mockFetch({});
501
+ await http.get('https://api.test.com/a');
502
+ expect(spy).toHaveBeenCalledTimes(1);
503
+
504
+ unsub();
505
+ await http.get('https://api.test.com/b');
506
+ expect(spy).toHaveBeenCalledTimes(1); // not called again
507
+ });
508
+
509
+ it('onResponse returns an unsubscribe function', async () => {
510
+ http.clearInterceptors();
511
+ const spy = vi.fn();
512
+ const unsub = http.onResponse(spy);
513
+ expect(typeof unsub).toBe('function');
514
+
515
+ mockFetch({});
516
+ await http.get('https://api.test.com/a');
517
+ expect(spy).toHaveBeenCalledTimes(1);
518
+
519
+ unsub();
520
+ await http.get('https://api.test.com/b');
521
+ expect(spy).toHaveBeenCalledTimes(1);
522
+ });
523
+
524
+ it('double-unsubscribe is safe', async () => {
525
+ http.clearInterceptors();
526
+ const spy = vi.fn();
527
+ const unsub = http.onRequest(spy);
528
+ unsub();
529
+ unsub(); // should not throw
530
+ mockFetch({});
531
+ await http.get('https://api.test.com/data');
532
+ expect(spy).not.toHaveBeenCalled();
533
+ });
534
+ });
535
+
536
+
537
+ // ===========================================================================
538
+ // clearInterceptors
539
+ // ===========================================================================
540
+
541
+ describe('http.clearInterceptors', () => {
542
+ it('clears all interceptors when called with no args', async () => {
543
+ http.clearInterceptors();
544
+ const reqSpy = vi.fn();
545
+ const resSpy = vi.fn();
546
+ http.onRequest(reqSpy);
547
+ http.onResponse(resSpy);
548
+
549
+ http.clearInterceptors();
550
+ mockFetch({});
551
+ await http.get('https://api.test.com/data');
552
+ expect(reqSpy).not.toHaveBeenCalled();
553
+ expect(resSpy).not.toHaveBeenCalled();
554
+ });
555
+
556
+ it('clears only request interceptors with "request"', async () => {
557
+ http.clearInterceptors();
558
+ const reqSpy = vi.fn();
559
+ const resSpy = vi.fn();
560
+ http.onRequest(reqSpy);
561
+ http.onResponse(resSpy);
562
+
563
+ http.clearInterceptors('request');
564
+ mockFetch({});
565
+ await http.get('https://api.test.com/data');
566
+ expect(reqSpy).not.toHaveBeenCalled();
567
+ expect(resSpy).toHaveBeenCalledTimes(1);
568
+ });
569
+
570
+ it('clears only response interceptors with "response"', async () => {
571
+ http.clearInterceptors();
572
+ const reqSpy = vi.fn();
573
+ const resSpy = vi.fn();
574
+ http.onRequest(reqSpy);
575
+ http.onResponse(resSpy);
576
+
577
+ http.clearInterceptors('response');
578
+ mockFetch({});
579
+ await http.get('https://api.test.com/data');
580
+ expect(reqSpy).toHaveBeenCalledTimes(1);
581
+ expect(resSpy).not.toHaveBeenCalled();
582
+ });
583
+ });
584
+
585
+
586
+ // ===========================================================================
587
+ // http.all — parallel requests
588
+ // ===========================================================================
589
+
590
+ describe('http.all', () => {
591
+ it('resolves all parallel requests', async () => {
592
+ mockFetch({ ok: true });
593
+ const results = await http.all([
594
+ http.get('https://api.test.com/a'),
595
+ http.get('https://api.test.com/b'),
596
+ http.get('https://api.test.com/c'),
597
+ ]);
598
+ expect(results).toHaveLength(3);
599
+ expect(results.every(r => r.ok)).toBe(true);
600
+ });
601
+
602
+ it('rejects if any request fails', async () => {
603
+ http.clearInterceptors();
604
+ mockFetch({ error: 'fail' }, false, 500);
605
+ await expect(
606
+ http.all([
607
+ http.get('https://api.test.com/a'),
608
+ http.get('https://api.test.com/b'),
609
+ ])
610
+ ).rejects.toThrow();
611
+ });
612
+
613
+ it('handles empty array', async () => {
614
+ const results = await http.all([]);
615
+ expect(results).toEqual([]);
616
+ });
617
+ });
618
+
619
+
620
+ // ===========================================================================
621
+ // http.getConfig
622
+ // ===========================================================================
623
+
624
+ describe('http.getConfig', () => {
625
+ it('returns current config', () => {
626
+ http.configure({ baseURL: 'https://myapi.com', timeout: 5000 });
627
+ const config = http.getConfig();
628
+ expect(config.baseURL).toBe('https://myapi.com');
629
+ expect(config.timeout).toBe(5000);
630
+ expect(config.headers).toBeDefined();
631
+ });
632
+
633
+ it('returns a copy (not the internal reference)', () => {
634
+ const config = http.getConfig();
635
+ config.baseURL = 'https://mutated.com';
636
+ config.headers['X-Evil'] = 'injected';
637
+ const fresh = http.getConfig();
638
+ expect(fresh.baseURL).not.toBe('https://mutated.com');
639
+ expect(fresh.headers['X-Evil']).toBeUndefined();
640
+ });
641
+
642
+ it('reflects updates after configure', () => {
643
+ http.configure({ baseURL: '' });
644
+ expect(http.getConfig().baseURL).toBe('');
645
+ http.configure({ baseURL: 'https://updated.com' });
646
+ expect(http.getConfig().baseURL).toBe('https://updated.com');
647
+ });
648
+ });
package/types/http.d.ts CHANGED
@@ -61,15 +61,26 @@ export interface HttpClient {
61
61
  patch<T = any>(url: string, data?: any, opts?: HttpRequestOptions): Promise<HttpResponse<T>>;
62
62
  /** DELETE request. */
63
63
  delete<T = any>(url: string, data?: any, opts?: HttpRequestOptions): Promise<HttpResponse<T>>;
64
+ /** HEAD request — no body, useful for checking resource existence or headers. */
65
+ head<T = any>(url: string, opts?: HttpRequestOptions): Promise<HttpResponse<T>>;
64
66
 
65
67
  /** Update default configuration for all subsequent requests. */
66
68
  configure(options: HttpConfigureOptions): void;
67
69
 
68
- /** Add a request interceptor (called before every request). */
69
- onRequest(fn: HttpRequestInterceptor): void;
70
+ /** Read-only snapshot of the current configuration. Returns a shallow copy. */
71
+ getConfig(): { baseURL: string; headers: Record<string, string>; timeout: number };
70
72
 
71
- /** Add a response interceptor (called after every response, before error check). */
72
- onResponse(fn: HttpResponseInterceptor): void;
73
+ /** Add a request interceptor (called before every request). Returns an unsubscribe function. */
74
+ onRequest(fn: HttpRequestInterceptor): () => void;
75
+
76
+ /** Add a response interceptor (called after every response, before error check). Returns an unsubscribe function. */
77
+ onResponse(fn: HttpResponseInterceptor): () => void;
78
+
79
+ /** Clear interceptors. No args = all; `'request'` or `'response'` for one type. */
80
+ clearInterceptors(type?: 'request' | 'response'): void;
81
+
82
+ /** Run multiple request promises in parallel via `Promise.all`. */
83
+ all<T extends readonly Promise<HttpResponse<any>>[]>(requests: T): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }>;
73
84
 
74
85
  /** Create a new `AbortController` for manual request cancellation. */
75
86
  createAbort(): AbortController;