@tinyfx/runtime 0.1.0 → 0.1.3

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
@@ -9,6 +9,7 @@ A lightweight collection of helpers for reactivity, DOM manipulation, and typed
9
9
  - **Signals** — Explicit reactivity without Proxies.
10
10
  - **DOM Helpers** — One-way bindings for text, attributes, and classes.
11
11
  - **Typed HTTP** — Minimal wrapper over `fetch` with response typing.
12
+ - **DI Container** — Simple `provide()`/`inject()` registry for app services.
12
13
  - **Tiny** — No dependencies, tree-shakeable, and extremely fast.
13
14
 
14
15
  ## Installation
@@ -76,6 +77,82 @@ const users = await api.get<User[]>("/users");
76
77
  await api.post("/users", { name: "Alice" });
77
78
  ```
78
79
 
80
+ ### DI Container (provide / inject)
81
+
82
+ TinyFX includes a small dependency injection container to avoid importing singleton instances everywhere.
83
+
84
+ Import it from `@tinyfx/runtime/di`:
85
+
86
+ ```ts
87
+ import { Container } from "@tinyfx/runtime/di";
88
+
89
+ class Config {
90
+ constructor(public baseUrl: string) {}
91
+ }
92
+
93
+ const container = new Container();
94
+ container.provide(Config, new Config("/api"));
95
+
96
+ const cfg = container.inject(Config);
97
+ console.log(cfg.baseUrl);
98
+ ```
99
+
100
+ #### Using DI with tinyfx components
101
+
102
+ When you use the tinyfx compiler, the generated glue calls your component behavior as:
103
+
104
+ ```ts
105
+ init(el, { container })
106
+ ```
107
+
108
+ So your component can inject services without importing global singletons:
109
+
110
+ ```ts
111
+ import type { TinyFxContext } from "@tinyfx/runtime/di";
112
+ import { HttpService } from "../lib/http.service";
113
+
114
+ export function init(el: HTMLElement, ctx: TinyFxContext) {
115
+ const http = ctx.container.inject(HttpService);
116
+ http.getWeather().then((text) => {
117
+ el.textContent = text;
118
+ });
119
+ }
120
+ ```
121
+
122
+ #### Example: service that uses the HTTP helper
123
+
124
+ ```ts
125
+ // src/lib/http.service.ts
126
+ import { createHttp } from "@tinyfx/runtime";
127
+
128
+ export class HttpService {
129
+ private readonly http;
130
+
131
+ constructor(baseUrl: string) {
132
+ this.http = createHttp({ baseUrl });
133
+ }
134
+
135
+ getWeather() {
136
+ // wttr.in can return plain text; your wrapper returns text when not JSON.
137
+ return this.http.get<string>("");
138
+ }
139
+ }
140
+ ```
141
+
142
+ ```ts
143
+ // src/main.ts
144
+ import { Container } from "@tinyfx/runtime/di";
145
+ import { setupComponents } from "./tinyfx.gen";
146
+ import { HttpService } from "./lib/http.service";
147
+
148
+ const container = new Container();
149
+ container.provide(HttpService, new HttpService("https://wttr.in/tunisa"));
150
+
151
+ document.addEventListener("DOMContentLoaded", () => {
152
+ setupComponents(container);
153
+ });
154
+ ```
155
+
79
156
  ## API Overview
80
157
 
81
158
  ### Signals
@@ -94,6 +171,13 @@ await api.post("/users", { name: "Alice" });
94
171
  - `client.put<T>(url: string, data?: any): Promise<T>`
95
172
  - `client.del<T>(url: string): Promise<T>`
96
173
 
174
+ ### DI
175
+ - `new Container()`
176
+ - `container.provide(token, instance)`
177
+ - `container.inject(token)`
178
+ - `createToken<T>(description: string): symbol`
179
+ - `TinyFxContext` (passed to component init via tinyfx glue)
180
+
97
181
  ## License
98
182
 
99
183
  MIT
package/dist/di.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /** A token used to register and retrieve a service from the container. */
2
+ export type Token<T> = (new (...args: any[]) => T) | symbol;
3
+ /** Create a unique symbol token with a debug description. */
4
+ export declare function createToken<T>(description: string): Token<T>;
5
+ /** Context passed to every component's init() function. */
6
+ export interface TinyFxContext {
7
+ container: Container;
8
+ }
9
+ /**
10
+ * Lightweight DI container.
11
+ *
12
+ * Register services at bootstrap time with `provide()`,
13
+ * then retrieve them inside components via `inject()`.
14
+ */
15
+ export declare class Container {
16
+ private readonly services;
17
+ /** Register a service instance under the given token. */
18
+ provide<T>(token: Token<T>, instance: T): void;
19
+ /** Retrieve a service instance. Throws if not registered. */
20
+ inject<T>(token: Token<T>): T;
21
+ /** Check whether a token has been provided. */
22
+ has<T>(token: Token<T>): boolean;
23
+ }
package/dist/di.js ADDED
@@ -0,0 +1,32 @@
1
+ // @tinyfx/runtime — Dependency Injection container
2
+ /** Create a unique symbol token with a debug description. */
3
+ export function createToken(description) {
4
+ return Symbol(description);
5
+ }
6
+ /**
7
+ * Lightweight DI container.
8
+ *
9
+ * Register services at bootstrap time with `provide()`,
10
+ * then retrieve them inside components via `inject()`.
11
+ */
12
+ export class Container {
13
+ constructor() {
14
+ this.services = new Map();
15
+ }
16
+ /** Register a service instance under the given token. */
17
+ provide(token, instance) {
18
+ this.services.set(token, instance);
19
+ }
20
+ /** Retrieve a service instance. Throws if not registered. */
21
+ inject(token) {
22
+ var _a;
23
+ if (!this.services.has(token)) {
24
+ throw new Error(`TinyFX: No provider for ${typeof token === "symbol" ? token.toString() : (_a = token.name) !== null && _a !== void 0 ? _a : token}`);
25
+ }
26
+ return this.services.get(token);
27
+ }
28
+ /** Check whether a token has been provided. */
29
+ has(token) {
30
+ return this.services.has(token);
31
+ }
32
+ }
@@ -0,0 +1,44 @@
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
+ }
18
+ export interface RequestInterceptor {
19
+ (url: string, options: RequestInit): Promise<{
20
+ url: string;
21
+ options: RequestInit;
22
+ }> | {
23
+ url: string;
24
+ options: RequestInit;
25
+ };
26
+ }
27
+ export interface ResponseInterceptor {
28
+ (response: Response): Promise<Response> | Response;
29
+ }
30
+ export interface HttpConfig {
31
+ baseUrl?: string;
32
+ headers?: Record<string, string>;
33
+ timeout?: number;
34
+ retries?: number;
35
+ retryDelay?: number;
36
+ requestInterceptors?: RequestInterceptor[];
37
+ responseInterceptors?: ResponseInterceptor[];
38
+ }
39
+ export interface RequestOptions {
40
+ headers?: Record<string, string>;
41
+ timeout?: number;
42
+ signal?: AbortSignal;
43
+ params?: Record<string, string | number | boolean>;
44
+ }
@@ -0,0 +1,26 @@
1
+ export class HttpError extends Error {
2
+ constructor(status, statusText, url, method, response) {
3
+ super(`HTTP ${status} ${statusText}: ${method} ${url}`);
4
+ this.status = status;
5
+ this.statusText = statusText;
6
+ this.url = url;
7
+ this.method = method;
8
+ this.response = response;
9
+ this.name = "HttpError";
10
+ }
11
+ }
12
+ export class HttpTimeoutError extends Error {
13
+ constructor(url, timeout) {
14
+ super(`Request timeout after ${timeout}ms: ${url}`);
15
+ this.url = url;
16
+ this.timeout = timeout;
17
+ this.name = "HttpTimeoutError";
18
+ }
19
+ }
20
+ export class HttpParseError extends Error {
21
+ constructor(message, url) {
22
+ super(message);
23
+ this.url = url;
24
+ this.name = "HttpParseError";
25
+ }
26
+ }
@@ -0,0 +1,2 @@
1
+ export declare function buildUrl(url: string, base: string, params?: Record<string, string | number | boolean>): string;
2
+ export declare function sleep(ms: number): Promise<void>;
@@ -0,0 +1,13 @@
1
+ export function buildUrl(url, base, params) {
2
+ const fullUrl = base + url;
3
+ if (!params || Object.keys(params).length === 0)
4
+ return fullUrl;
5
+ const urlObj = new URL(fullUrl, typeof window !== 'undefined' ? window.location.href : 'http://localhost');
6
+ Object.entries(params).forEach(([key, value]) => {
7
+ urlObj.searchParams.append(key, String(value));
8
+ });
9
+ return urlObj.toString().replace(urlObj.origin, '');
10
+ }
11
+ export async function sleep(ms) {
12
+ return new Promise(resolve => setTimeout(resolve, ms));
13
+ }
@@ -0,0 +1,9 @@
1
+ import { HttpConfig, RequestOptions } from "./data";
2
+ export declare function createHttp(config?: HttpConfig): {
3
+ get: <T>(url: string, options?: RequestOptions) => Promise<T>;
4
+ post: <T>(url: string, body?: unknown, options?: RequestOptions) => Promise<T>;
5
+ put: <T>(url: string, body?: unknown, options?: RequestOptions) => Promise<T>;
6
+ patch: <T>(url: string, body?: unknown, options?: RequestOptions) => Promise<T>;
7
+ del: <T>(url: string, options?: RequestOptions) => Promise<T>;
8
+ delete: <T>(url: string, options?: RequestOptions) => Promise<T>;
9
+ };
@@ -0,0 +1,124 @@
1
+ // @tinyfx/runtime — HTTP client
2
+ // Thin, typed wrapper over fetch.
3
+ import { HttpError, HttpParseError, HttpTimeoutError } from "./data";
4
+ import { buildUrl, sleep } from "./helper";
5
+ export function createHttp(config = {}) {
6
+ var _a, _b, _c, _d, _e, _f, _g;
7
+ const base = (_a = config.baseUrl) !== null && _a !== void 0 ? _a : "";
8
+ const defaultHeaders = (_b = config.headers) !== null && _b !== void 0 ? _b : {};
9
+ const defaultTimeout = (_c = config.timeout) !== null && _c !== void 0 ? _c : 30000;
10
+ const maxRetries = (_d = config.retries) !== null && _d !== void 0 ? _d : 0;
11
+ const retryDelay = (_e = config.retryDelay) !== null && _e !== void 0 ? _e : 1000;
12
+ const requestInterceptors = (_f = config.requestInterceptors) !== null && _f !== void 0 ? _f : [];
13
+ const responseInterceptors = (_g = config.responseInterceptors) !== null && _g !== void 0 ? _g : [];
14
+ async function request(method, url, body, options = {}) {
15
+ var _a, _b;
16
+ const fullUrl = buildUrl(url, base, options.params);
17
+ const timeoutMs = (_a = options.timeout) !== null && _a !== void 0 ? _a : defaultTimeout;
18
+ let attempts = 0;
19
+ const maxAttempts = maxRetries + 1;
20
+ while (attempts < maxAttempts) {
21
+ attempts++;
22
+ try {
23
+ const headers = Object.assign(Object.assign({}, defaultHeaders), options.headers);
24
+ if (body !== undefined && !headers["Content-Type"]) {
25
+ headers["Content-Type"] = "application/json";
26
+ }
27
+ let fetchOptions = {
28
+ method,
29
+ headers,
30
+ body: body !== undefined ? JSON.stringify(body) : undefined,
31
+ signal: options.signal,
32
+ };
33
+ let requestUrl = fullUrl;
34
+ // Apply request interceptors
35
+ for (const interceptor of requestInterceptors) {
36
+ const result = await interceptor(requestUrl, fetchOptions);
37
+ requestUrl = result.url;
38
+ fetchOptions = result.options;
39
+ }
40
+ // Create timeout controller
41
+ const timeoutController = new AbortController();
42
+ const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
43
+ // Combine signals if user provided one
44
+ let combinedSignal = timeoutController.signal;
45
+ if (options.signal) {
46
+ const combinedController = new AbortController();
47
+ const abortHandler = () => combinedController.abort();
48
+ options.signal.addEventListener("abort", abortHandler, { once: true });
49
+ timeoutController.signal.addEventListener("abort", abortHandler, { once: true });
50
+ combinedSignal = combinedController.signal;
51
+ }
52
+ fetchOptions.signal = combinedSignal;
53
+ let res;
54
+ try {
55
+ res = await fetch(base + requestUrl, fetchOptions);
56
+ }
57
+ catch (err) {
58
+ clearTimeout(timeoutId);
59
+ if (err instanceof Error && err.name === "AbortError") {
60
+ if ((_b = options.signal) === null || _b === void 0 ? void 0 : _b.aborted) {
61
+ throw err; // User cancelled
62
+ }
63
+ throw new HttpTimeoutError(requestUrl, timeoutMs);
64
+ }
65
+ throw err;
66
+ }
67
+ finally {
68
+ clearTimeout(timeoutId);
69
+ }
70
+ // Apply response interceptors
71
+ for (const interceptor of responseInterceptors) {
72
+ res = await interceptor(res);
73
+ }
74
+ if (!res.ok) {
75
+ throw new HttpError(res.status, res.statusText, requestUrl, method, res);
76
+ }
77
+ // Parse response based on content type
78
+ const contentType = res.headers.get("content-type") || "";
79
+ if (contentType.includes("application/json")) {
80
+ try {
81
+ const text = await res.text();
82
+ if (!text || text.trim().length === 0) {
83
+ return undefined;
84
+ }
85
+ return JSON.parse(text);
86
+ }
87
+ catch (err) {
88
+ throw new HttpParseError(`Failed to parse JSON response: ${err instanceof Error ? err.message : "Unknown error"}`, requestUrl);
89
+ }
90
+ }
91
+ if (contentType.includes("text/")) {
92
+ return (await res.text());
93
+ }
94
+ // For other content types, try to read as text
95
+ try {
96
+ const text = await res.text();
97
+ return (text || undefined);
98
+ }
99
+ catch (_c) {
100
+ return undefined;
101
+ }
102
+ }
103
+ catch (err) {
104
+ // Retry logic for network errors or 5xx errors
105
+ const shouldRetry = attempts < maxAttempts &&
106
+ (!(err instanceof HttpError) || (err.status >= 500 && err.status < 600));
107
+ if (shouldRetry) {
108
+ await sleep(retryDelay * attempts);
109
+ continue;
110
+ }
111
+ throw err;
112
+ }
113
+ }
114
+ throw new Error("Max retries exceeded");
115
+ }
116
+ return {
117
+ get: (url, options) => request("GET", url, undefined, options),
118
+ post: (url, body, options) => request("POST", url, body, options),
119
+ put: (url, body, options) => request("PUT", url, body, options),
120
+ patch: (url, body, options) => request("PATCH", url, body, options),
121
+ del: (url, options) => request("DELETE", url, undefined, options),
122
+ delete: (url, options) => request("DELETE", url, undefined, options),
123
+ };
124
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { signal, effect, Signal } from "./signals";
2
2
  export { bindText, bindAttr, bindClass } from "./dom";
3
- export { createHttp } from "./http";
4
- export type { HttpConfig } from "./http";
3
+ export { createHttp } from "./http/http";
4
+ export type { HttpConfig } from "./http/data";
5
+ export { Container, createToken } from "./di";
6
+ export type { Token, TinyFxContext } from "./di";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // @tinyfx/runtime — barrel export
2
2
  export { signal, effect, Signal } from "./signals";
3
3
  export { bindText, bindAttr, bindClass } from "./dom";
4
- export { createHttp } from "./http";
4
+ export { createHttp } from "./http/http";
5
+ export { Container, createToken } from "./di";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyfx/runtime",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Minimal frontend runtime — signals, DOM helpers, typed HTTP, DTO mapping",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,7 +9,8 @@
9
9
  ".": "./dist/index.js",
10
10
  "./signals": "./dist/signals.js",
11
11
  "./dom": "./dist/dom.js",
12
- "./http": "./dist/http.js"
12
+ "./http": "./dist/http.js",
13
+ "./di": "./dist/di.js"
13
14
  },
14
15
  "files": [
15
16
  "dist"