@tinyfx/runtime 0.1.9 → 0.2.1

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,15 +1,20 @@
1
1
  # @tinyfx/runtime
2
2
 
3
- The TinyFX browser runtime: signals, DOM helpers, typed HTTP, and router lifecycle utilities.
3
+ The TinyFX browser runtime: signals, DOM helpers, typed HTTP, router lifecycle utilities, and dynamic registration APIs.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - Signals (`signal`, `effect`)
8
8
  - DOM binding helpers (`bindText`, `bindAttr`, `bindClass`)
9
- - Typed HTTP client (`createHttp`)
10
- - Router helpers (`initRouter`, `navigate`, `goBack`, `getParam`)
9
+ - Typed HTTP client (`createHttp`) with discriminated error types
10
+ - Router helpers (`getParam`, `getParams`, `navigate`, `goBack`)
11
11
  - Lifecycle hooks (`onMount`, `onDestroy`)
12
12
  - Lightweight `TinyFxContext` (`params`, `navigate`)
13
+ - Dynamic component registration (`registerComponent`, `getComponentFactory`, `mountComponents`)
14
+ - Dynamic page registration (`registerPage`, `getPageModule`, `runPageInit`)
15
+ - Mount state tracking (`markMounted`, `isMounted`)
16
+ - Path matching (`matchPath`, `splitPath`, `RouteDef`)
17
+ - Unified initialization (`init`)
13
18
 
14
19
  ## Installation
15
20
 
@@ -33,7 +38,7 @@ effect(() => {
33
38
  count.set(count() + 1);
34
39
  ```
35
40
 
36
- ### DOM bindings
41
+ ### DOM Bindings
37
42
 
38
43
  ```ts
39
44
  import { signal, bindText, bindClass, bindAttr } from "@tinyfx/runtime";
@@ -46,12 +51,12 @@ const btn = document.querySelector("button");
46
51
 
47
52
  if (el && btn) {
48
53
  bindText(el, () => `Count: ${count()}`);
49
- bindClass(btn, "active", isActive);
54
+ bindClass(btn, "active", () => isActive());
50
55
  bindAttr(btn, "disabled", () => count() > 10);
51
56
  }
52
57
  ```
53
58
 
54
- ### HTTP client
59
+ ### HTTP Client
55
60
 
56
61
  ```ts
57
62
  import { createHttp } from "@tinyfx/runtime";
@@ -65,20 +70,16 @@ const users = await http.get<{ id: number; name: string }[]>("/users");
65
70
  ```ts
66
71
  import type { TinyFxContext } from "@tinyfx/runtime";
67
72
 
68
- export function init(el: HTMLElement, ctx: TinyFxContext) {
73
+ export function init(_el: HTMLElement, ctx: TinyFxContext) {
69
74
  console.log(ctx.params);
70
75
  ctx.navigate("/about");
71
76
  }
72
77
  ```
73
78
 
74
- ### Router + lifecycle hooks
79
+ ### Router + Lifecycle Hooks
75
80
 
76
81
  ```ts
77
- import { initRouter, onMount, onDestroy } from "@tinyfx/runtime";
78
-
79
- initRouter({
80
- "/": { page: "index", path: "/" },
81
- });
82
+ import { onMount, onDestroy } from "@tinyfx/runtime";
82
83
 
83
84
  onMount(() => {
84
85
  console.log("mounted");
@@ -89,21 +90,66 @@ onDestroy(() => {
89
90
  });
90
91
  ```
91
92
 
92
- ## API overview
93
-
94
- - `signal<T>(value: T)`
95
- - `effect(fn)`
96
- - `bindText(el, source)`
97
- - `bindAttr(el, attr, source)`
98
- - `bindClass(el, className, source)`
99
- - `createHttp(config?)`
100
- - `initRouter(routes)`
101
- - `getParam(name)`
102
- - `navigate(path)`
103
- - `goBack()`
104
- - `onMount(fn)`
105
- - `onDestroy(fn)`
106
- - `TinyFxContext`
93
+ ### Component Registration
94
+
95
+ ```ts
96
+ import { registerComponent } from "@tinyfx/runtime";
97
+
98
+ registerComponent("MyButton", (el, ctx) => {
99
+ el.addEventListener("click", () => console.log("clicked"));
100
+ return { el };
101
+ });
102
+ ```
103
+
104
+ ### Page Registration
105
+
106
+ ```ts
107
+ import { registerPage } from "@tinyfx/runtime";
108
+
109
+ registerPage("/", {
110
+ init(_el, _ctx) {
111
+ console.log("home page loaded");
112
+ },
113
+ });
114
+ ```
115
+
116
+ ### Initialization
117
+
118
+ The compiler-generated `tinyfx.gen.ts` calls `init()` automatically. You typically do not need to call it yourself.
119
+
120
+ ```ts
121
+ import { init } from "@tinyfx/runtime";
122
+
123
+ init({ routes, setupDirectives });
124
+ ```
125
+
126
+ ## API Overview
127
+
128
+ - `signal<T>(value: T)` — creates a reactive signal
129
+ - `effect(fn)` — registers a reactive effect
130
+ - `bindText(el, source)` — binds text content reactively
131
+ - `bindAttr(el, attr, source)` — binds an attribute reactively
132
+ - `bindClass(el, className, source)` — binds a class toggle reactively
133
+ - `createHttp(config?)` — creates a typed HTTP client
134
+ - `getParam(name)` — gets a single route parameter
135
+ - `getParams()` — gets all route parameters
136
+ - `navigate(path)` — triggers full browser navigation
137
+ - `goBack()` — goes back in browser history
138
+ - `onMount(fn)` — registers a mount callback
139
+ - `onDestroy(fn)` — registers a destroy callback
140
+ - `registerComponent(name, factory)` — registers a dynamic component
141
+ - `getComponentFactory(name)` — retrieves a registered component factory
142
+ - `mountComponents(ctx, root?)` — mounts all components in a root element
143
+ - `registerPage(route, module)` — registers a page module
144
+ - `getPageModule(route)` — retrieves a registered page module
145
+ - `runPageInit(route, ctx)` — runs page init if registered
146
+ - `markMounted(el)` — marks an element as mounted (returns true on first call)
147
+ - `isMounted(el)` — checks if an element has been mounted
148
+ - `matchPath(def, segments)` — matches a RouteDef against path segments
149
+ - `splitPath(pathname)` — splits a pathname into segments
150
+ - `init(config)` — unified bootstrap entry point
151
+ - `TinyFxContext` — type for page/component context
152
+ - `RouteMap`, `RouteMeta`, `RouteDef` — routing types
107
153
 
108
154
  ## License
109
155
 
@@ -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,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
+ });
@@ -1,56 +1,47 @@
1
1
  /**
2
- * Error thrown for non-success HTTP responses.
2
+ * Discriminated union type for HTTP errors.
3
3
  *
4
4
  * @example
5
5
  * try {
6
6
  * await http.get("/posts");
7
7
  * } catch (error) {
8
- * if (error instanceof HttpError) {
8
+ * if (error.kind === "http") {
9
9
  * console.error(error.status, error.url);
10
- * }
11
- * }
12
- */
13
- export declare class HttpError extends Error {
14
- readonly status: number;
15
- readonly statusText: string;
16
- readonly url: string;
17
- readonly method: string;
18
- readonly response?: Response | undefined;
19
- constructor(status: number, statusText: string, url: string, method: string, response?: Response | undefined);
20
- }
21
- /**
22
- * Error thrown when a request times out.
23
- *
24
- * @example
25
- * try {
26
- * await http.get("/slow", { timeout: 100 });
27
- * } catch (error) {
28
- * if (error instanceof HttpTimeoutError) {
10
+ * } else if (error.kind === "timeout") {
29
11
  * console.error(error.timeout);
30
- * }
31
- * }
32
- */
33
- export declare class HttpTimeoutError extends Error {
34
- readonly url: string;
35
- readonly timeout: number;
36
- constructor(url: string, timeout: number);
37
- }
38
- /**
39
- * Error thrown when a JSON response cannot be parsed.
40
- *
41
- * @example
42
- * try {
43
- * await http.get("/broken-json");
44
- * } catch (error) {
45
- * if (error instanceof HttpParseError) {
12
+ * } else if (error.kind === "parse") {
46
13
  * console.error(error.url);
47
14
  * }
48
15
  * }
49
16
  */
50
- export declare class HttpParseError extends Error {
51
- readonly url: string;
52
- constructor(message: string, url: string);
53
- }
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
+ }>;
54
45
  /**
55
46
  * Hook that can rewrite an outgoing request before `fetch` runs.
56
47
  *
@@ -117,4 +108,5 @@ export interface RequestOptions {
117
108
  timeout?: number;
118
109
  signal?: AbortSignal;
119
110
  params?: Record<string, string | number | boolean>;
111
+ json?: boolean;
120
112
  }
package/dist/http/data.js CHANGED
@@ -1,62 +1,18 @@
1
- /**
2
- * Error thrown for non-success HTTP responses.
3
- *
4
- * @example
5
- * try {
6
- * await http.get("/posts");
7
- * } catch (error) {
8
- * if (error instanceof HttpError) {
9
- * console.error(error.status, error.url);
10
- * }
11
- * }
12
- */
13
- export class HttpError extends Error {
14
- constructor(status, statusText, url, method, response) {
15
- super(`HTTP ${status} ${statusText}: ${method} ${url}`);
16
- this.status = status;
17
- this.statusText = statusText;
18
- this.url = url;
19
- this.method = method;
20
- this.response = response;
21
- this.name = "HttpError";
22
- }
1
+ export function httpError(status, statusText, url, method, response) {
2
+ return { kind: "http", status, statusText, url, method, response };
23
3
  }
24
- /**
25
- * Error thrown when a request times out.
26
- *
27
- * @example
28
- * try {
29
- * await http.get("/slow", { timeout: 100 });
30
- * } catch (error) {
31
- * if (error instanceof HttpTimeoutError) {
32
- * console.error(error.timeout);
33
- * }
34
- * }
35
- */
36
- export class HttpTimeoutError extends Error {
37
- constructor(url, timeout) {
38
- super(`Request timeout after ${timeout}ms: ${url}`);
39
- this.url = url;
40
- this.timeout = timeout;
41
- this.name = "HttpTimeoutError";
42
- }
4
+ export function timeoutError(url, timeout) {
5
+ return { kind: "timeout", url, timeout };
43
6
  }
44
- /**
45
- * Error thrown when a JSON response cannot be parsed.
46
- *
47
- * @example
48
- * try {
49
- * await http.get("/broken-json");
50
- * } catch (error) {
51
- * if (error instanceof HttpParseError) {
52
- * console.error(error.url);
53
- * }
54
- * }
55
- */
56
- export class HttpParseError extends Error {
57
- constructor(message, url) {
58
- super(message);
59
- this.url = url;
60
- this.name = "HttpParseError";
61
- }
7
+ export function parseError(message, url) {
8
+ return { kind: "parse", message, url };
9
+ }
10
+ export function isHttpError(err) {
11
+ return typeof err === "object" && err !== null && "kind" in err && err.kind === "http";
12
+ }
13
+ export function isTimeoutError(err) {
14
+ return typeof err === "object" && err !== null && "kind" in err && err.kind === "timeout";
15
+ }
16
+ export function isParseError(err) {
17
+ return typeof err === "object" && err !== null && "kind" in err && err.kind === "parse";
62
18
  }
package/dist/http/http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @tinyfx/runtime — HTTP client
2
2
  // Thin, typed wrapper over fetch.
3
- import { HttpError, HttpParseError, HttpTimeoutError } from "./data";
3
+ import { httpError, timeoutError, parseError, isHttpError } from "./data";
4
4
  import { buildUrl, sleep } from "./helper";
5
5
  /**
6
6
  * Creates a typed HTTP client around `fetch`.
@@ -25,22 +25,26 @@ export function createHttp(config = {}) {
25
25
  const requestInterceptors = (_f = config.requestInterceptors) !== null && _f !== void 0 ? _f : [];
26
26
  const responseInterceptors = (_g = config.responseInterceptors) !== null && _g !== void 0 ? _g : [];
27
27
  async function request(method, url, body, options = {}) {
28
- var _a, _b;
28
+ var _a, _b, _c;
29
29
  const fullUrl = buildUrl(url, base, options.params);
30
30
  const timeoutMs = (_a = options.timeout) !== null && _a !== void 0 ? _a : defaultTimeout;
31
+ const useJson = (_b = options.json) !== null && _b !== void 0 ? _b : true;
31
32
  let attempts = 0;
32
33
  const maxAttempts = maxRetries + 1;
33
34
  while (attempts < maxAttempts) {
34
35
  attempts++;
35
36
  try {
36
37
  const headers = Object.assign(Object.assign({}, defaultHeaders), options.headers);
37
- if (body !== undefined && !headers["Content-Type"]) {
38
+ if (body !== undefined && useJson && !headers["Content-Type"]) {
38
39
  headers["Content-Type"] = "application/json";
39
40
  }
41
+ const serializedBody = body !== undefined
42
+ ? (useJson ? JSON.stringify(body) : body)
43
+ : undefined;
40
44
  let fetchOptions = {
41
45
  method,
42
46
  headers,
43
- body: body !== undefined ? JSON.stringify(body) : undefined,
47
+ body: serializedBody,
44
48
  signal: options.signal,
45
49
  };
46
50
  let requestUrl = fullUrl;
@@ -70,10 +74,10 @@ export function createHttp(config = {}) {
70
74
  catch (err) {
71
75
  clearTimeout(timeoutId);
72
76
  if (err instanceof Error && err.name === "AbortError") {
73
- if ((_b = options.signal) === null || _b === void 0 ? void 0 : _b.aborted) {
77
+ if ((_c = options.signal) === null || _c === void 0 ? void 0 : _c.aborted) {
74
78
  throw err; // User cancelled
75
79
  }
76
- throw new HttpTimeoutError(requestUrl, timeoutMs);
80
+ throw timeoutError(requestUrl, timeoutMs);
77
81
  }
78
82
  throw err;
79
83
  }
@@ -85,7 +89,7 @@ export function createHttp(config = {}) {
85
89
  res = await interceptor(res);
86
90
  }
87
91
  if (!res.ok) {
88
- throw new HttpError(res.status, res.statusText, requestUrl, method, res);
92
+ throw httpError(res.status, res.statusText, requestUrl, method, res);
89
93
  }
90
94
  // Parse response based on content type
91
95
  const contentType = res.headers.get("content-type") || "";
@@ -98,7 +102,7 @@ export function createHttp(config = {}) {
98
102
  return JSON.parse(text);
99
103
  }
100
104
  catch (err) {
101
- throw new HttpParseError(`Failed to parse JSON response: ${err instanceof Error ? err.message : "Unknown error"}`, requestUrl);
105
+ throw parseError(`Failed to parse JSON response: ${err instanceof Error ? err.message : "Unknown error"}`, requestUrl);
102
106
  }
103
107
  }
104
108
  if (contentType.includes("text/")) {
@@ -109,14 +113,14 @@ export function createHttp(config = {}) {
109
113
  const text = await res.text();
110
114
  return (text || undefined);
111
115
  }
112
- catch (_c) {
116
+ catch (_d) {
113
117
  return undefined;
114
118
  }
115
119
  }
116
120
  catch (err) {
117
121
  // Retry logic for network errors or 5xx errors
118
- const shouldRetry = attempts < maxAttempts &&
119
- (!(err instanceof HttpError) || (err.status >= 500 && err.status < 600));
122
+ const is5xx = isHttpError(err) && err.status >= 500 && err.status < 600;
123
+ const shouldRetry = attempts < maxAttempts && (!isHttpError(err) || is5xx);
120
124
  if (shouldRetry) {
121
125
  await sleep(retryDelay * attempts);
122
126
  continue;
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { matchPath, splitPath } from "./router/path-matcher";
9
9
  export type { RouteDef } from "./router/path-matcher";
10
10
  export { init } from "./init";
11
11
  export type { InitConfig } from "./init";
12
- export { initRouter, getParam, navigate, goBack } from "./router/index";
12
+ export { getParam, getParams, navigate, goBack } from "./router/index";
13
13
  export { onMount, onDestroy } from "./router/lifecycle";
14
14
  export type { TinyFxContext } from "./context";
15
- export type { RouteMap, RouteMeta } from "./router/types";
15
+ export type { RouteMap, RouteMeta } from "./router/index";
package/dist/index.js CHANGED
@@ -10,5 +10,5 @@ export { markMounted, isMounted } from "./mount-state";
10
10
  export { matchPath, splitPath } from "./router/path-matcher";
11
11
  export { init } from "./init";
12
12
  // Keep existing router exports
13
- export { initRouter, getParam, navigate, goBack } from "./router/index";
13
+ export { getParam, getParams, navigate, goBack } from "./router/index";
14
14
  export { onMount, onDestroy } from "./router/lifecycle";
package/dist/init.js CHANGED
@@ -3,7 +3,8 @@
3
3
  import { matchPath, splitPath } from "./router/path-matcher";
4
4
  import { initLifecycle } from "./router/lifecycle";
5
5
  import { highlightActiveLinks } from "./router/active-links";
6
- import { navigate } from "./router/navigate";
6
+ import { navigate } from "./router/index";
7
+ import { setParams } from "./router/params";
7
8
  import { mountComponents } from "./registry";
8
9
  import { runPageInit } from "./page-registry";
9
10
  let initialized = false;
@@ -24,12 +25,15 @@ export function init(config) {
24
25
  }
25
26
  }
26
27
  if (!matchedPath) {
27
- console.warn(`[tinyfx] No route matched for pathname: "${pathname}". ` +
28
- "Check that a page file exists for this URL in src/pages/.");
28
+ if (__DEV__) {
29
+ console.warn(`[tinyfx] No route matched for pathname: "${pathname}". ` +
30
+ "Check that a page file exists for this URL in src/pages/.");
31
+ }
29
32
  return null;
30
33
  }
31
34
  initLifecycle();
32
35
  highlightActiveLinks(pathname);
36
+ setParams(params);
33
37
  const ctx = { params, navigate };
34
38
  const instances = mountComponents(ctx);
35
39
  if (config.setupDirectives) {
@@ -1,25 +1,23 @@
1
- import type { RouteMap } from "./types";
2
- import { navigate, goBack } from "./navigate";
3
- import { getParam } from "./params";
4
- export type { RouteMap };
5
- export { navigate, goBack, getParam };
6
1
  /**
7
- * Initialise the TinyFX router for the current page.
8
- *
9
- * Called automatically from the compiler-generated `tinyfx.gen.ts` on every
10
- * page load. Matches `window.location.pathname` against the compiled route
11
- * map, resolves URL params, starts lifecycle hooks, and highlights active
12
- * navigation links.
13
- *
14
- * @param routes - The route map emitted by `tinyfx build`.
15
- * @returns Nothing
16
- *
17
- * @example
18
- * // tinyfx.gen.ts (auto-generated — do not edit)
19
- * initRouter({
20
- * "/": { page: "home", path: "/" },
21
- * "/blog": { page: "blog", path: "/blog" },
22
- * "/blog/:slug":{ page: "blog-post", path: "/blog/:slug" },
23
- * });
2
+ * Metadata for a single route produced by the compiler.
24
3
  */
25
- export declare function initRouter(routes: RouteMap): void;
4
+ export interface RouteMeta {
5
+ /** The page name, derived from the source filename. */
6
+ page: string;
7
+ /** The declared URL pattern, e.g. "/blog/:slug". */
8
+ path: string;
9
+ }
10
+ /**
11
+ * The full route map emitted by `tinyfx build` into `tinyfx.gen.ts`.
12
+ */
13
+ export type RouteMap = Record<string, RouteMeta>;
14
+ /**
15
+ * Navigate to the given path.
16
+ * This is a full browser navigation — not a DOM swap.
17
+ */
18
+ export declare function navigate(path: string): void;
19
+ /**
20
+ * Go back one step in the browser history.
21
+ */
22
+ export declare function goBack(): void;
23
+ export { getParam, getParams } from "./params";
@@ -1,39 +1,15 @@
1
- // @tinyfx/runtime — router entry point
2
- import { navigate, goBack } from "./navigate";
3
- import { getParam, resolveParams } from "./params";
4
- import { initLifecycle } from "./lifecycle";
5
- import { highlightActiveLinks } from "./active-links";
6
- export { navigate, goBack, getParam };
1
+ // @tinyfx/runtime — router utilities
7
2
  /**
8
- * Initialise the TinyFX router for the current page.
9
- *
10
- * Called automatically from the compiler-generated `tinyfx.gen.ts` on every
11
- * page load. Matches `window.location.pathname` against the compiled route
12
- * map, resolves URL params, starts lifecycle hooks, and highlights active
13
- * navigation links.
14
- *
15
- * @param routes - The route map emitted by `tinyfx build`.
16
- * @returns Nothing
17
- *
18
- * @example
19
- * // tinyfx.gen.ts (auto-generated — do not edit)
20
- * initRouter({
21
- * "/": { page: "home", path: "/" },
22
- * "/blog": { page: "blog", path: "/blog" },
23
- * "/blog/:slug":{ page: "blog-post", path: "/blog/:slug" },
24
- * });
3
+ * Navigate to the given path.
4
+ * This is a full browser navigation — not a DOM swap.
25
5
  */
26
- export function initRouter(routes) {
27
- const pathname = window.location.pathname;
28
- for (const pattern of Object.keys(routes)) {
29
- if (resolveParams(pattern, pathname)) {
30
- // Match found
31
- initLifecycle();
32
- highlightActiveLinks(pathname);
33
- return;
34
- }
35
- }
36
- // No route matched — log a warning and do nothing else.
37
- console.warn(`[tinyfx] No route matched for pathname: "${pathname}". ` +
38
- "Check that a page file exists for this URL in src/pages/.");
6
+ export function navigate(path) {
7
+ window.location.href = path;
39
8
  }
9
+ /**
10
+ * Go back one step in the browser history.
11
+ */
12
+ export function goBack() {
13
+ window.history.go(-1);
14
+ }
15
+ export { getParam, getParams } from "./params";
@@ -29,7 +29,7 @@ export declare function onMount(fn: LifecycleCallback): void;
29
29
  export declare function onDestroy(fn: LifecycleCallback): void;
30
30
  /**
31
31
  * Trigger all registered `onMount` callbacks.
32
- * Called by `initRouter` after matching the current route.
32
+ * Called by `init()` after matching the current route.
33
33
  *
34
34
  * @returns Nothing
35
35
  *
@@ -39,7 +39,7 @@ export declare function onDestroy(fn: LifecycleCallback): void;
39
39
  export declare function flushMount(): void;
40
40
  /**
41
41
  * Wire up registered `onDestroy` callbacks to the `pagehide` event.
42
- * Called by `initRouter` after matching the current route.
42
+ * Called by `init()` after matching the current route.
43
43
  *
44
44
  * @returns Nothing
45
45
  *
@@ -39,7 +39,7 @@ export function onDestroy(fn) {
39
39
  }
40
40
  /**
41
41
  * Trigger all registered `onMount` callbacks.
42
- * Called by `initRouter` after matching the current route.
42
+ * Called by `init()` after matching the current route.
43
43
  *
44
44
  * @returns Nothing
45
45
  *
@@ -57,7 +57,7 @@ export function flushMount() {
57
57
  }
58
58
  /**
59
59
  * Wire up registered `onDestroy` callbacks to the `pagehide` event.
60
- * Called by `initRouter` after matching the current route.
60
+ * Called by `init()` after matching the current route.
61
61
  *
62
62
  * @returns Nothing
63
63
  *
@@ -1,20 +1,9 @@
1
1
  /**
2
- * Match `pattern` (e.g. "/blog/:slug") against `pathname` (e.g. "/blog/hello").
2
+ * Set resolved route params (called internally by init()).
3
3
  *
4
- * Returns true if they match, and stores any `:param` captures in the
5
- * module-level store so `getParam()` can retrieve them.
6
- *
7
- * Static segments must match exactly; `:param` segments match any non-empty
8
- * path segment.
9
- *
10
- * @param pattern - The route pattern produced by the compiler
11
- * @param pathname - The current browser pathname
12
- * @returns `true` when the pathname matches the pattern, otherwise `false`
13
- *
14
- * @example
15
- * resolveParams("/blog/:slug", "/blog/hello-world");
4
+ * @param params - The resolved URL parameters
16
5
  */
17
- export declare function resolveParams(pattern: string, pathname: string): boolean;
6
+ export declare function setParams(params: Record<string, string>): void;
18
7
  /**
19
8
  * Retrieve a URL parameter resolved for the current page.
20
9
  *
@@ -2,40 +2,12 @@
2
2
  /** Module-level store for the current page's resolved params. */
3
3
  let currentParams = {};
4
4
  /**
5
- * Match `pattern` (e.g. "/blog/:slug") against `pathname` (e.g. "/blog/hello").
5
+ * Set resolved route params (called internally by init()).
6
6
  *
7
- * Returns true if they match, and stores any `:param` captures in the
8
- * module-level store so `getParam()` can retrieve them.
9
- *
10
- * Static segments must match exactly; `:param` segments match any non-empty
11
- * path segment.
12
- *
13
- * @param pattern - The route pattern produced by the compiler
14
- * @param pathname - The current browser pathname
15
- * @returns `true` when the pathname matches the pattern, otherwise `false`
16
- *
17
- * @example
18
- * resolveParams("/blog/:slug", "/blog/hello-world");
7
+ * @param params - The resolved URL parameters
19
8
  */
20
- export function resolveParams(pattern, pathname) {
21
- const patternParts = pattern.split("/").filter(Boolean);
22
- const pathParts = pathname.split("/").filter(Boolean);
23
- if (patternParts.length !== pathParts.length)
24
- return false;
25
- const captured = {};
26
- for (let i = 0; i < patternParts.length; i++) {
27
- const seg = patternParts[i];
28
- if (seg.startsWith(":")) {
29
- // Dynamic segment — capture the value
30
- captured[seg.slice(1)] = decodeURIComponent(pathParts[i]);
31
- }
32
- else if (seg !== pathParts[i]) {
33
- // Static segment mismatch
34
- return false;
35
- }
36
- }
37
- currentParams = captured;
38
- return true;
9
+ export function setParams(params) {
10
+ currentParams = params;
39
11
  }
40
12
  /**
41
13
  * Retrieve a URL parameter resolved for the current page.
package/dist/signals.d.ts CHANGED
@@ -1,23 +1,3 @@
1
- /**
2
- * Reactive value container used by TinyFX signals.
3
- *
4
- * Reading the value through {@link Signal.get} during an active effect records
5
- * that effect as a subscriber. Writing through {@link Signal.set} re-runs the
6
- * affected subscribers.
7
- *
8
- * @example
9
- * const count = new Signal(0);
10
- * console.log(count.get());
11
- * count.set(1);
12
- * console.log(count.get());
13
- */
14
- export declare class Signal<T> {
15
- private value;
16
- private subs;
17
- constructor(v: T);
18
- get(): T;
19
- set(v: T): void;
20
- }
21
1
  /**
22
2
  * Creates a reactive signal function.
23
3
  *
package/dist/signals.js CHANGED
@@ -1,38 +1,6 @@
1
1
  // @tinyfx/runtime — signals
2
2
  // Minimal, explicit reactivity — no proxies, no magic.
3
3
  const effectStack = [];
4
- /**
5
- * Reactive value container used by TinyFX signals.
6
- *
7
- * Reading the value through {@link Signal.get} during an active effect records
8
- * that effect as a subscriber. Writing through {@link Signal.set} re-runs the
9
- * affected subscribers.
10
- *
11
- * @example
12
- * const count = new Signal(0);
13
- * console.log(count.get());
14
- * count.set(1);
15
- * console.log(count.get());
16
- */
17
- export class Signal {
18
- constructor(v) {
19
- this.subs = new Set();
20
- this.value = v;
21
- }
22
- get() {
23
- const running = effectStack[effectStack.length - 1];
24
- if (running) {
25
- this.subs.add(running);
26
- }
27
- return this.value;
28
- }
29
- set(v) {
30
- if (Object.is(v, this.value))
31
- return;
32
- this.value = v;
33
- this.subs.forEach((fn) => fn());
34
- }
35
- }
36
4
  /**
37
5
  * Creates a reactive signal function.
38
6
  *
@@ -46,9 +14,20 @@ export class Signal {
46
14
  * count.set(1);
47
15
  */
48
16
  export function signal(v) {
49
- const s = new Signal(v);
50
- const fn = () => s.get();
51
- fn.set = (v) => s.set(v);
17
+ let value = v;
18
+ const subs = new Set();
19
+ const fn = () => {
20
+ const running = effectStack[effectStack.length - 1];
21
+ if (running)
22
+ subs.add(running);
23
+ return value;
24
+ };
25
+ fn.set = (next) => {
26
+ if (Object.is(next, value))
27
+ return;
28
+ value = next;
29
+ subs.forEach((s) => s());
30
+ };
52
31
  return fn;
53
32
  }
54
33
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyfx/runtime",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Minimal frontend runtime — signals, DOM helpers, typed HTTP, DTO mapping",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",