@tinyfx/runtime 0.1.8 → 0.2.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.
Files changed (52) hide show
  1. package/dist/__tests__/http-errors.test.d.ts +1 -0
  2. package/dist/__tests__/http-errors.test.js +65 -0
  3. package/dist/__tests__/mount-state.test.d.ts +1 -0
  4. package/dist/__tests__/mount-state.test.js +30 -0
  5. package/dist/__tests__/page-registry.test.d.ts +1 -0
  6. package/dist/__tests__/page-registry.test.js +38 -0
  7. package/dist/__tests__/path-matcher.test.d.ts +1 -0
  8. package/dist/__tests__/path-matcher.test.js +43 -0
  9. package/dist/__tests__/registry.test.d.ts +1 -0
  10. package/dist/__tests__/registry.test.js +67 -0
  11. package/dist/__tests__/router-merged.test.d.ts +1 -0
  12. package/dist/__tests__/router-merged.test.js +33 -0
  13. package/dist/__tests__/signals.test.d.ts +1 -0
  14. package/dist/__tests__/signals.test.js +93 -0
  15. package/dist/context.d.ts +15 -0
  16. package/dist/context.js +0 -2
  17. package/dist/dom.d.ts +35 -0
  18. package/dist/dom.js +35 -0
  19. package/dist/each.d.ts +10 -0
  20. package/dist/each.js +24 -0
  21. package/dist/http/data.d.ts +84 -17
  22. package/dist/http/data.js +15 -23
  23. package/dist/http/helper.d.ts +20 -0
  24. package/dist/http/helper.js +20 -0
  25. package/dist/http/http.d.ts +13 -0
  26. package/dist/http/http.js +19 -6
  27. package/dist/index.d.ts +10 -2
  28. package/dist/index.js +9 -1
  29. package/dist/init.d.ts +12 -0
  30. package/dist/init.js +42 -0
  31. package/dist/mount-state.d.ts +17 -0
  32. package/dist/mount-state.js +27 -0
  33. package/dist/page-registry.d.ts +31 -0
  34. package/dist/page-registry.js +38 -0
  35. package/dist/registry.d.ts +31 -0
  36. package/dist/registry.js +49 -0
  37. package/dist/router/active-links.d.ts +3 -0
  38. package/dist/router/active-links.js +3 -0
  39. package/dist/router/index.d.ts +21 -22
  40. package/dist/router/index.js +12 -35
  41. package/dist/router/lifecycle.d.ts +28 -2
  42. package/dist/router/lifecycle.js +28 -2
  43. package/dist/router/navigate.d.ts +8 -0
  44. package/dist/router/navigate.js +8 -0
  45. package/dist/router/params.d.ts +15 -7
  46. package/dist/router/params.js +16 -25
  47. package/dist/router/path-matcher.d.ts +30 -0
  48. package/dist/router/path-matcher.js +45 -0
  49. package/dist/router/types.d.ts +15 -2
  50. package/dist/signals.d.ts +28 -7
  51. package/dist/signals.js +41 -21
  52. package/package.json +6 -2
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { httpError, timeoutError, parseError } from "../http/data";
3
+ describe("httpError", () => {
4
+ it("creates http error with all fields", () => {
5
+ const err = httpError(404, "Not Found", "/api/test", "GET");
6
+ expect(err.kind).toBe("http");
7
+ if (err.kind === "http") {
8
+ expect(err.status).toBe(404);
9
+ expect(err.statusText).toBe("Not Found");
10
+ expect(err.url).toBe("/api/test");
11
+ expect(err.method).toBe("GET");
12
+ }
13
+ });
14
+ it("includes optional response", () => {
15
+ const response = new Response();
16
+ const err = httpError(500, "Server Error", "/api", "POST", response);
17
+ if (err.kind === "http") {
18
+ expect(err.response).toBe(response);
19
+ }
20
+ });
21
+ });
22
+ describe("timeoutError", () => {
23
+ it("creates timeout error with url and timeout", () => {
24
+ const err = timeoutError("/api/slow", 5000);
25
+ expect(err.kind).toBe("timeout");
26
+ if (err.kind === "timeout") {
27
+ expect(err.url).toBe("/api/slow");
28
+ expect(err.timeout).toBe(5000);
29
+ }
30
+ });
31
+ });
32
+ describe("parseError", () => {
33
+ it("creates parse error with message and url", () => {
34
+ const err = parseError("Unexpected token", "/api/json");
35
+ expect(err.kind).toBe("parse");
36
+ if (err.kind === "parse") {
37
+ expect(err.message).toBe("Unexpected token");
38
+ expect(err.url).toBe("/api/json");
39
+ }
40
+ });
41
+ });
42
+ describe("error discrimination", () => {
43
+ it("allows discriminating by kind", () => {
44
+ const errors = [
45
+ httpError(400, "Bad Request", "/a", "GET"),
46
+ timeoutError("/b", 1000),
47
+ parseError("Bad JSON", "/c"),
48
+ ];
49
+ const httpErr = errors.find((e) => e.kind === "http");
50
+ expect(httpErr === null || httpErr === void 0 ? void 0 : httpErr.kind).toBe("http");
51
+ if ((httpErr === null || httpErr === void 0 ? void 0 : httpErr.kind) === "http") {
52
+ expect(httpErr.status).toBe(400);
53
+ }
54
+ const timeoutErr = errors.find((e) => e.kind === "timeout");
55
+ expect(timeoutErr === null || timeoutErr === void 0 ? void 0 : timeoutErr.kind).toBe("timeout");
56
+ if ((timeoutErr === null || timeoutErr === void 0 ? void 0 : timeoutErr.kind) === "timeout") {
57
+ expect(timeoutErr.timeout).toBe(1000);
58
+ }
59
+ const parseErr = errors.find((e) => e.kind === "parse");
60
+ expect(parseErr === null || parseErr === void 0 ? void 0 : parseErr.kind).toBe("parse");
61
+ if ((parseErr === null || parseErr === void 0 ? void 0 : parseErr.kind) === "parse") {
62
+ expect(parseErr.message).toBe("Bad JSON");
63
+ }
64
+ });
65
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { markMounted, isMounted } from "../mount-state";
3
+ describe("markMounted", () => {
4
+ it("returns true on first call", () => {
5
+ const el = document.createElement("div");
6
+ expect(markMounted(el)).toBe(true);
7
+ });
8
+ it("returns false on second call for same element", () => {
9
+ const el = document.createElement("div");
10
+ markMounted(el);
11
+ expect(markMounted(el)).toBe(false);
12
+ });
13
+ it("returns true for different elements", () => {
14
+ const el1 = document.createElement("div");
15
+ const el2 = document.createElement("div");
16
+ markMounted(el1);
17
+ expect(markMounted(el2)).toBe(true);
18
+ });
19
+ });
20
+ describe("isMounted", () => {
21
+ it("returns false for unmarked element", () => {
22
+ const el = document.createElement("div");
23
+ expect(isMounted(el)).toBe(false);
24
+ });
25
+ it("returns true after markMounted", () => {
26
+ const el = document.createElement("div");
27
+ markMounted(el);
28
+ expect(isMounted(el)).toBe(true);
29
+ });
30
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { registerPage, getPageModule, runPageInit } from "../page-registry";
3
+ describe("registerPage / getPageModule", () => {
4
+ it("returns undefined for unregistered page", () => {
5
+ expect(getPageModule("/unknown")).toBeUndefined();
6
+ });
7
+ it("returns module after registration", () => {
8
+ const mod = { init: () => { } };
9
+ registerPage("/test", mod);
10
+ expect(getPageModule("/test")).toBe(mod);
11
+ });
12
+ });
13
+ describe("runPageInit", () => {
14
+ it("calls init on page module", () => {
15
+ let called = false;
16
+ let receivedEl;
17
+ let receivedCtx;
18
+ registerPage("/init-test", {
19
+ init: (el, ctx) => {
20
+ called = true;
21
+ receivedEl = el;
22
+ receivedCtx = ctx;
23
+ },
24
+ });
25
+ const ctx = { params: { id: "1" }, navigate: () => { } };
26
+ runPageInit("/init-test", ctx);
27
+ expect(called).toBe(true);
28
+ expect(receivedEl).toBe(document.body);
29
+ expect(receivedCtx).toBe(ctx);
30
+ });
31
+ it("does nothing for unregistered page", () => {
32
+ expect(() => runPageInit("/nonexistent", { params: {}, navigate: () => { } })).not.toThrow();
33
+ });
34
+ it("does nothing for page without init", () => {
35
+ registerPage("/no-init", {});
36
+ expect(() => runPageInit("/no-init", { params: {}, navigate: () => { } })).not.toThrow();
37
+ });
38
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { matchPath, splitPath } from "../router/path-matcher";
3
+ describe("splitPath", () => {
4
+ it("splits root path", () => {
5
+ expect(splitPath("/")).toEqual([]);
6
+ });
7
+ it("splits nested path", () => {
8
+ expect(splitPath("/blog/hello-world")).toEqual(["blog", "hello-world"]);
9
+ });
10
+ it("handles trailing slash", () => {
11
+ expect(splitPath("/blog/")).toEqual(["blog"]);
12
+ });
13
+ });
14
+ describe("matchPath", () => {
15
+ it("matches static route exactly", () => {
16
+ const def = { staticSegments: ["blog"], paramNames: [] };
17
+ expect(matchPath(def, ["blog"])).toEqual({});
18
+ });
19
+ it("returns null for static mismatch", () => {
20
+ const def = { staticSegments: ["blog"], paramNames: [] };
21
+ expect(matchPath(def, ["about"])).toBeNull();
22
+ });
23
+ it("returns null for length mismatch", () => {
24
+ const def = { staticSegments: ["blog"], paramNames: [] };
25
+ expect(matchPath(def, ["blog", "extra"])).toBeNull();
26
+ });
27
+ it("extracts single param", () => {
28
+ const def = { staticSegments: ["blog", null], paramNames: ["slug"] };
29
+ expect(matchPath(def, ["blog", "hello-world"])).toEqual({ slug: "hello-world" });
30
+ });
31
+ it("extracts multiple params", () => {
32
+ const def = { staticSegments: [null, "posts", null], paramNames: ["user", "id"] };
33
+ expect(matchPath(def, ["alice", "posts", "42"])).toEqual({ user: "alice", id: "42" });
34
+ });
35
+ it("decodes URI params", () => {
36
+ const def = { staticSegments: ["blog", null], paramNames: ["slug"] };
37
+ expect(matchPath(def, ["blog", "hello%20world"])).toEqual({ slug: "hello world" });
38
+ });
39
+ it("matches root route", () => {
40
+ const def = { staticSegments: [], paramNames: [] };
41
+ expect(matchPath(def, [])).toEqual({});
42
+ });
43
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { registerComponent, getComponentFactory, mountComponents } from "../registry";
3
+ describe("registerComponent / getComponentFactory", () => {
4
+ beforeEach(() => {
5
+ registerComponent("__test_cleanup__", () => { });
6
+ });
7
+ it("returns undefined for unregistered component", () => {
8
+ expect(getComponentFactory("NonExistent")).toBeUndefined();
9
+ });
10
+ it("returns factory after registration", () => {
11
+ const factory = (el) => ({ mounted: true });
12
+ registerComponent("TestComp", factory);
13
+ expect(getComponentFactory("TestComp")).toBe(factory);
14
+ });
15
+ });
16
+ describe("mountComponents", () => {
17
+ it("mounts components found in root element", () => {
18
+ const root = document.createElement("div");
19
+ const el1 = document.createElement("div");
20
+ el1.setAttribute("data-component", "CompA");
21
+ const el2 = document.createElement("div");
22
+ el2.setAttribute("data-component", "CompB");
23
+ root.appendChild(el1);
24
+ root.appendChild(el2);
25
+ const results = [];
26
+ registerComponent("CompA", () => {
27
+ results.push("CompA");
28
+ return { name: "CompA" };
29
+ });
30
+ registerComponent("CompB", () => {
31
+ results.push("CompB");
32
+ return { name: "CompB" };
33
+ });
34
+ const ctx = { params: {}, navigate: () => { } };
35
+ const instances = mountComponents(ctx, root);
36
+ expect(results).toContain("CompA");
37
+ expect(results).toContain("CompB");
38
+ expect(instances).toHaveLength(2);
39
+ });
40
+ it("skips elements without data-component", () => {
41
+ const root = document.createElement("div");
42
+ const el = document.createElement("div");
43
+ root.appendChild(el);
44
+ const ctx = { params: {}, navigate: () => { } };
45
+ const instances = mountComponents(ctx, root);
46
+ expect(instances).toHaveLength(0);
47
+ });
48
+ it("skips unregistered component names", () => {
49
+ const root = document.createElement("div");
50
+ const el = document.createElement("div");
51
+ el.setAttribute("data-component", "Unknown");
52
+ root.appendChild(el);
53
+ const ctx = { params: {}, navigate: () => { } };
54
+ const instances = mountComponents(ctx, root);
55
+ expect(instances).toHaveLength(0);
56
+ });
57
+ it("does not include undefined returns in instances", () => {
58
+ const root = document.createElement("div");
59
+ const el = document.createElement("div");
60
+ el.setAttribute("data-component", "NoReturn");
61
+ root.appendChild(el);
62
+ registerComponent("NoReturn", () => undefined);
63
+ const ctx = { params: {}, navigate: () => { } };
64
+ const instances = mountComponents(ctx, root);
65
+ expect(instances).toHaveLength(0);
66
+ });
67
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { navigate, goBack } from "../router/index";
3
+ describe("navigate", () => {
4
+ it("is a function that accepts a path string", () => {
5
+ expect(typeof navigate).toBe("function");
6
+ expect(() => navigate("/test/path")).not.toThrow();
7
+ });
8
+ });
9
+ describe("goBack", () => {
10
+ it("calls history.go(-1)", () => {
11
+ const spy = vi.spyOn(window.history, "go");
12
+ goBack();
13
+ expect(spy).toHaveBeenCalledWith(-1);
14
+ spy.mockRestore();
15
+ });
16
+ });
17
+ describe("RouteMeta type", () => {
18
+ it("accepts valid route metadata", () => {
19
+ const meta = { page: "blog-post", path: "/blog/:slug" };
20
+ expect(meta.page).toBe("blog-post");
21
+ expect(meta.path).toBe("/blog/:slug");
22
+ });
23
+ });
24
+ describe("RouteMap type", () => {
25
+ it("accepts route map object", () => {
26
+ const routes = {
27
+ "/": { page: "index", path: "/" },
28
+ "/blog/:slug": { page: "blog-post", path: "/blog/:slug" },
29
+ };
30
+ expect(Object.keys(routes)).toHaveLength(2);
31
+ expect(routes["/blog/:slug"].page).toBe("blog-post");
32
+ });
33
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { signal, effect } from "../signals";
3
+ describe("signal (inlined)", () => {
4
+ it("returns current value when called", () => {
5
+ const count = signal(0);
6
+ expect(count()).toBe(0);
7
+ });
8
+ it("updates value via set()", () => {
9
+ const count = signal(0);
10
+ count.set(5);
11
+ expect(count()).toBe(5);
12
+ });
13
+ it("does not notify subscribers when value is the same", () => {
14
+ const count = signal(0);
15
+ let runs = 0;
16
+ effect(() => {
17
+ count();
18
+ runs++;
19
+ });
20
+ expect(runs).toBe(1);
21
+ count.set(0);
22
+ expect(runs).toBe(1);
23
+ });
24
+ it("notifies subscribers on change", () => {
25
+ const count = signal(0);
26
+ let value = 0;
27
+ effect(() => {
28
+ value = count();
29
+ });
30
+ expect(value).toBe(0);
31
+ count.set(10);
32
+ expect(value).toBe(10);
33
+ });
34
+ it("supports multiple subscribers", () => {
35
+ const count = signal(0);
36
+ let a = 0;
37
+ let b = 0;
38
+ effect(() => { a = count(); });
39
+ effect(() => { b = count() * 2; });
40
+ count.set(3);
41
+ expect(a).toBe(3);
42
+ expect(b).toBe(6);
43
+ });
44
+ it("works with objects", () => {
45
+ const user = signal({ name: "Alice" });
46
+ expect(user().name).toBe("Alice");
47
+ user.set({ name: "Bob" });
48
+ expect(user().name).toBe("Bob");
49
+ });
50
+ it("does not re-run effect when unrelated signal changes", () => {
51
+ const a = signal(0);
52
+ const b = signal(0);
53
+ let runs = 0;
54
+ effect(() => {
55
+ a();
56
+ runs++;
57
+ });
58
+ expect(runs).toBe(1);
59
+ b.set(1);
60
+ expect(runs).toBe(1);
61
+ });
62
+ });
63
+ describe("effect", () => {
64
+ it("runs immediately on creation", () => {
65
+ let ran = false;
66
+ effect(() => { ran = true; });
67
+ expect(ran).toBe(true);
68
+ });
69
+ it("re-runs when tracked signal changes", () => {
70
+ const count = signal(0);
71
+ let runs = 0;
72
+ effect(() => {
73
+ count();
74
+ runs++;
75
+ });
76
+ expect(runs).toBe(1);
77
+ count.set(1);
78
+ expect(runs).toBe(2);
79
+ });
80
+ it("supports nested signal reads", () => {
81
+ const a = signal(1);
82
+ const b = signal(2);
83
+ let sum = 0;
84
+ effect(() => {
85
+ sum = a() + b();
86
+ });
87
+ expect(sum).toBe(3);
88
+ a.set(10);
89
+ expect(sum).toBe(12);
90
+ b.set(5);
91
+ expect(sum).toBe(15);
92
+ });
93
+ });
package/dist/context.d.ts CHANGED
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Lightweight page context passed to page and component behavior.
3
+ *
4
+ * TinyFX uses this to expose route params and navigation helpers without a
5
+ * dependency-injection container.
6
+ *
7
+ * @example
8
+ * import type { TinyFxContext } from "@tinyfx/runtime";
9
+ *
10
+ * export function init(el: HTMLElement, ctx: TinyFxContext): void {
11
+ * console.log(ctx.params.slug);
12
+ * ctx.navigate("/");
13
+ * void el;
14
+ * }
15
+ */
1
16
  export interface TinyFxContext {
2
17
  params: Record<string, string>;
3
18
  navigate: (path: string) => void;
package/dist/context.js CHANGED
@@ -1,3 +1 @@
1
- // Lightweight page context passed to components by the runtime.
2
- // Not a DI container — services are created explicitly via factory functions.
3
1
  export {};
package/dist/dom.d.ts CHANGED
@@ -1,3 +1,38 @@
1
+ /**
2
+ * Binds an element's text content to a reactive getter.
3
+ *
4
+ * @param el - The DOM element whose text content should be updated
5
+ * @param get - A reactive getter that returns the next text value
6
+ * @returns Nothing
7
+ *
8
+ * @example
9
+ * const count = signal(0);
10
+ * bindText(span, () => `Count: ${count()}`);
11
+ */
1
12
  export declare function bindText(el: HTMLElement, get: () => any): void;
13
+ /**
14
+ * Binds an attribute to a reactive getter.
15
+ *
16
+ * @param el - The DOM element whose attribute should be updated
17
+ * @param attr - The attribute name to write
18
+ * @param get - A reactive getter that returns the attribute value
19
+ * @returns Nothing
20
+ *
21
+ * @example
22
+ * const isDark = signal(false);
23
+ * bindAttr(document.body, "data-theme", () => (isDark() ? "dark" : "light"));
24
+ */
2
25
  export declare function bindAttr(el: HTMLElement, attr: string, get: () => string): void;
26
+ /**
27
+ * Toggles a CSS class from a reactive boolean getter.
28
+ *
29
+ * @param el - The DOM element whose class list should be updated
30
+ * @param cls - The class name to toggle
31
+ * @param get - A reactive getter that returns whether the class should exist
32
+ * @returns Nothing
33
+ *
34
+ * @example
35
+ * const open = signal(false);
36
+ * bindClass(panel, "is-open", () => open());
37
+ */
3
38
  export declare function bindClass(el: HTMLElement, cls: string, get: () => boolean): void;
package/dist/dom.js CHANGED
@@ -1,16 +1,51 @@
1
1
  // @tinyfx/runtime — DOM helpers
2
2
  // Helpers for binding reactive state to the DOM.
3
3
  import { effect } from "./signals";
4
+ /**
5
+ * Binds an element's text content to a reactive getter.
6
+ *
7
+ * @param el - The DOM element whose text content should be updated
8
+ * @param get - A reactive getter that returns the next text value
9
+ * @returns Nothing
10
+ *
11
+ * @example
12
+ * const count = signal(0);
13
+ * bindText(span, () => `Count: ${count()}`);
14
+ */
4
15
  export function bindText(el, get) {
5
16
  effect(() => {
6
17
  el.textContent = String(get());
7
18
  });
8
19
  }
20
+ /**
21
+ * Binds an attribute to a reactive getter.
22
+ *
23
+ * @param el - The DOM element whose attribute should be updated
24
+ * @param attr - The attribute name to write
25
+ * @param get - A reactive getter that returns the attribute value
26
+ * @returns Nothing
27
+ *
28
+ * @example
29
+ * const isDark = signal(false);
30
+ * bindAttr(document.body, "data-theme", () => (isDark() ? "dark" : "light"));
31
+ */
9
32
  export function bindAttr(el, attr, get) {
10
33
  effect(() => {
11
34
  el.setAttribute(attr, get());
12
35
  });
13
36
  }
37
+ /**
38
+ * Toggles a CSS class from a reactive boolean getter.
39
+ *
40
+ * @param el - The DOM element whose class list should be updated
41
+ * @param cls - The class name to toggle
42
+ * @param get - A reactive getter that returns whether the class should exist
43
+ * @returns Nothing
44
+ *
45
+ * @example
46
+ * const open = signal(false);
47
+ * bindClass(panel, "is-open", () => open());
48
+ */
14
49
  export function bindClass(el, cls, get) {
15
50
  effect(() => {
16
51
  el.classList.toggle(cls, get());
package/dist/each.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Register a mounted component instance by root element.
3
+ */
4
+ export declare function registerInstance(el: HTMLElement, instance: {
5
+ destroy?: () => void;
6
+ }): void;
7
+ /**
8
+ * Destroy a mounted component instance on a node and nested component nodes.
9
+ */
10
+ export declare function destroyNode(el: HTMLElement): void;
package/dist/each.js ADDED
@@ -0,0 +1,24 @@
1
+ // @tinyfx/runtime — each lifecycle helpers
2
+ const __componentInstances = new WeakMap();
3
+ /**
4
+ * Register a mounted component instance by root element.
5
+ */
6
+ export function registerInstance(el, instance) {
7
+ __componentInstances.set(el, instance);
8
+ }
9
+ /**
10
+ * Destroy a mounted component instance on a node and nested component nodes.
11
+ */
12
+ export function destroyNode(el) {
13
+ var _a;
14
+ const inst = __componentInstances.get(el);
15
+ (_a = inst === null || inst === void 0 ? void 0 : inst.destroy) === null || _a === void 0 ? void 0 : _a.call(inst);
16
+ __componentInstances.delete(el);
17
+ el.querySelectorAll("[data-component]").forEach((child) => {
18
+ var _a;
19
+ const childEl = child;
20
+ const childInst = __componentInstances.get(childEl);
21
+ (_a = childInst === null || childInst === void 0 ? void 0 : childInst.destroy) === null || _a === void 0 ? void 0 : _a.call(childInst);
22
+ __componentInstances.delete(childEl);
23
+ });
24
+ }
@@ -1,20 +1,59 @@
1
- export declare class HttpError extends Error {
2
- readonly status: number;
3
- readonly statusText: string;
4
- readonly url: string;
5
- readonly method: string;
6
- readonly response?: Response | undefined;
7
- constructor(status: number, statusText: string, url: string, method: string, response?: Response | undefined);
8
- }
9
- export declare class HttpTimeoutError extends Error {
10
- readonly url: string;
11
- readonly timeout: number;
12
- constructor(url: string, timeout: number);
13
- }
14
- export declare class HttpParseError extends Error {
15
- readonly url: string;
16
- constructor(message: string, url: string);
17
- }
1
+ /**
2
+ * Discriminated union type for HTTP errors.
3
+ *
4
+ * @example
5
+ * try {
6
+ * await http.get("/posts");
7
+ * } catch (error) {
8
+ * if (error.kind === "http") {
9
+ * console.error(error.status, error.url);
10
+ * } else if (error.kind === "timeout") {
11
+ * console.error(error.timeout);
12
+ * } else if (error.kind === "parse") {
13
+ * console.error(error.url);
14
+ * }
15
+ * }
16
+ */
17
+ export type HttpError = {
18
+ kind: "http";
19
+ status: number;
20
+ statusText: string;
21
+ url: string;
22
+ method: string;
23
+ response?: Response;
24
+ } | {
25
+ kind: "timeout";
26
+ url: string;
27
+ timeout: number;
28
+ } | {
29
+ kind: "parse";
30
+ message: string;
31
+ url: string;
32
+ };
33
+ export declare function httpError(status: number, statusText: string, url: string, method: string, response?: Response): HttpError;
34
+ export declare function timeoutError(url: string, timeout: number): HttpError;
35
+ export declare function parseError(message: string, url: string): HttpError;
36
+ export declare function isHttpError(err: unknown): err is Extract<HttpError, {
37
+ kind: "http";
38
+ }>;
39
+ export declare function isTimeoutError(err: unknown): err is Extract<HttpError, {
40
+ kind: "timeout";
41
+ }>;
42
+ export declare function isParseError(err: unknown): err is Extract<HttpError, {
43
+ kind: "parse";
44
+ }>;
45
+ /**
46
+ * Hook that can rewrite an outgoing request before `fetch` runs.
47
+ *
48
+ * @example
49
+ * const addToken: RequestInterceptor = (url, options) => ({
50
+ * url,
51
+ * options: {
52
+ * ...options,
53
+ * headers: { ...options.headers, Authorization: "Bearer token" },
54
+ * },
55
+ * });
56
+ */
18
57
  export interface RequestInterceptor {
19
58
  (url: string, options: RequestInit): Promise<{
20
59
  url: string;
@@ -24,9 +63,28 @@ export interface RequestInterceptor {
24
63
  options: RequestInit;
25
64
  };
26
65
  }
66
+ /**
67
+ * Hook that can inspect or replace a response before TinyFX handles it.
68
+ *
69
+ * @example
70
+ * const logResponse: ResponseInterceptor = (response) => {
71
+ * console.log(response.status);
72
+ * return response;
73
+ * };
74
+ */
27
75
  export interface ResponseInterceptor {
28
76
  (response: Response): Promise<Response> | Response;
29
77
  }
78
+ /**
79
+ * Default options for an HTTP client created by `createHttp()`.
80
+ *
81
+ * @example
82
+ * const config: HttpConfig = {
83
+ * baseUrl: "/api",
84
+ * timeout: 5000,
85
+ * retries: 1,
86
+ * };
87
+ */
30
88
  export interface HttpConfig {
31
89
  baseUrl?: string;
32
90
  headers?: Record<string, string>;
@@ -36,6 +94,15 @@ export interface HttpConfig {
36
94
  requestInterceptors?: RequestInterceptor[];
37
95
  responseInterceptors?: ResponseInterceptor[];
38
96
  }
97
+ /**
98
+ * Per-request overrides for an HTTP call.
99
+ *
100
+ * @example
101
+ * const options: RequestOptions = {
102
+ * params: { page: 2 },
103
+ * timeout: 1000,
104
+ * };
105
+ */
39
106
  export interface RequestOptions {
40
107
  headers?: Record<string, string>;
41
108
  timeout?: number;