diff --git a/CHANGELOG.md b/CHANGELOG.md index e046d55..0095b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.1.0] — 2026-04-06 + +### Added + +- **`Accessor` brand type** — All reactive getters returned by `signal()`, `derived()`, `memo()`, `memoFn()`, `writable()`, `array()`, and `reactiveArray()` are now typed as `Accessor` instead of the plain `() => T`. The brand is purely a compile-time phantom (zero runtime cost) and makes signal getters clearly distinguishable from regular functions in IDE hover tooltips and type signatures. `NodeChildren` and `NodeChild` have been updated to explicitly list `Accessor` alongside the plain arrow-function form. + +### Fixed + +- **`isDev()` unsafe default** — The fallback when neither `globalThis.__SIBU_DEV__` nor the compile-time `__SIBU_DEV__` constant is set now evaluates `process.env.NODE_ENV !== "production"` instead of hard-coding `true`. In a browser environment without a Vite build (where `process` is undefined), this resolves to `false`, preventing DevTools from being silently active in production. +- **Prototype pollution in `globalStore`** — The `dispatch()` function now strips `__proto__`, `constructor`, and `prototype` keys from the action patch before spreading it into state. Previously a malicious or malformed action could pollute `Object.prototype` via `{ "__proto__": { isAdmin: true } }`. +- **`workerFn` / `worker()` CSP documentation** — Added a prominent JSDoc warning documenting that the inline worker pattern serializes functions via `.toString()` into a `blob:` URL (equivalent to `eval()`), is incompatible with strict `worker-src 'self'` CSP directives, and must never receive user-controlled or dynamically constructed function arguments. + +--- + ## [1.0.9] — 2026-04-03 ### Fixed diff --git a/package.json b/package.json index 29c4439..2ee80ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.0.9", + "version": "1.1.0", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", diff --git a/src/core/dev.ts b/src/core/dev.ts index a823458..dc42ed3 100644 --- a/src/core/dev.ts +++ b/src/core/dev.ts @@ -19,7 +19,7 @@ export function isDev(): boolean { ? !!(globalThis as any).__SIBU_DEV__ : typeof __SIBU_DEV__ !== "undefined" ? __SIBU_DEV__ - : true; // default to dev mode when no flag is set + : typeof process !== "undefined" && process.env?.NODE_ENV !== "production"; // safe default: off in browser, on in test/dev Node } // Cache dev mode at module load — avoids 3 typeof checks per call diff --git a/src/core/rendering/types.ts b/src/core/rendering/types.ts index c0cc83f..413f494 100644 --- a/src/core/rendering/types.ts +++ b/src/core/rendering/types.ts @@ -6,6 +6,8 @@ export type NodeChild = | string | number | boolean + // Reactive: pass an Accessor directly or wrap in an arrow function. + // Accessor extends () => T so both forms are covered by this union member. | (() => NodeChild) | null | undefined; diff --git a/src/core/signals/array.ts b/src/core/signals/array.ts index eb00353..d27a768 100644 --- a/src/core/signals/array.ts +++ b/src/core/signals/array.ts @@ -1,6 +1,7 @@ import { enqueueBatchedSignal } from "../../reactivity/batch"; import type { ReactiveSignal } from "../../reactivity/signal"; import { notifySubscribers, recordDependency } from "../../reactivity/track"; +import type { Accessor } from "./signal"; import { signal } from "./signal"; /** @@ -51,7 +52,7 @@ export interface ArrayActions { clear(): void; } -export function array(initial: T[] = []): [() => T[], ArrayActions] { +export function array(initial: T[] = []): [Accessor, ArrayActions] { const [arr, setArr] = signal([...initial]); const actions: ArrayActions = { @@ -163,7 +164,7 @@ export function array(initial: T[] = []): [() => T[], ArrayActions] { * clear(); // [] * ``` */ -export function reactiveArray(initial: T[] = []): [() => readonly T[], ArrayActions] { +export function reactiveArray(initial: T[] = []): [Accessor, ArrayActions] { // Mutable internal storage — never exposed directly let data: T[] = [...initial]; diff --git a/src/core/signals/derived.ts b/src/core/signals/derived.ts index 98e44ca..7199a56 100644 --- a/src/core/signals/derived.ts +++ b/src/core/signals/derived.ts @@ -1,6 +1,7 @@ import type { ReactiveSignal } from "../../reactivity/signal"; import { recordDependency, track, trackingSuspended } from "../../reactivity/track"; import { devAssert } from "../dev"; +import type { Accessor } from "./signal"; /** * derived creates a derived reactive signal whose value updates when dependencies change. @@ -12,7 +13,7 @@ import { devAssert } from "../dev"; * - On re-evaluation, dependencies are re-tracked via track() so that * derived-of-derived chains propagate correctly. */ -export function derived(getter: () => T, options?: { name?: string }): () => T { +export function derived(getter: () => T, options?: { name?: string }): Accessor { devAssert(typeof getter === "function", "derived: argument must be a getter function."); const debugName = options?.name; const cs: any = {}; @@ -78,5 +79,5 @@ export function derived(getter: () => T, options?: { name?: string }): () => if (hook) hook.emit("computed:create", { signal: cs, name: debugName, getter: computedGetter }); - return computedGetter; + return computedGetter as Accessor; } diff --git a/src/core/signals/memo.ts b/src/core/signals/memo.ts index 09d6d78..099d373 100644 --- a/src/core/signals/memo.ts +++ b/src/core/signals/memo.ts @@ -1,4 +1,5 @@ import { derived } from "./derived"; +import type { Accessor } from "./signal"; /** * memo returns a memoized value that only recomputes when its @@ -10,6 +11,6 @@ import { derived } from "./derived"; * @param factory Function that computes the memoized value * @returns Getter function that returns the memoized value */ -export function memo(factory: () => T): () => T { +export function memo(factory: () => T): Accessor { return derived(factory); } diff --git a/src/core/signals/memoFn.ts b/src/core/signals/memoFn.ts index 1e885d6..3995a7d 100644 --- a/src/core/signals/memoFn.ts +++ b/src/core/signals/memoFn.ts @@ -1,4 +1,5 @@ import { derived } from "./derived"; +import type { Accessor } from "./signal"; /** * memoFn returns a memoized callback function that only updates @@ -8,6 +9,6 @@ import { derived } from "./derived"; * @param callback The callback function to memoize * @returns Getter that returns the current memoized callback */ -export function memoFn any>(callback: () => T): () => T { +export function memoFn any>(callback: () => T): Accessor { return derived(callback); } diff --git a/src/core/signals/signal.ts b/src/core/signals/signal.ts index dc942eb..1b11ef7 100644 --- a/src/core/signals/signal.ts +++ b/src/core/signals/signal.ts @@ -3,8 +3,25 @@ import type { ReactiveSignal } from "../../reactivity/signal"; import { notifySubscribers, recordDependency } from "../../reactivity/track"; import { isDev } from "../dev"; +// Phantom brand symbol — exists only in the type system, never at runtime. +declare const __accessor: unique symbol; + +/** + * A reactive signal getter returned by signal(), derived(), memo(), and similar primitives. + * + * Pass an Accessor directly into reactive prop positions — never call it there: + * ```ts + * const [count, setCount] = signal(0); + * + * div({ nodes: count }) // ✓ reactive — Accessor passed directly + * div({ nodes: () => count() }) // ✓ reactive — explicit arrow wrapper + * div({ nodes: count() }) // ✗ static — evaluated once, not reactive + * ``` + */ +export type Accessor = (() => T) & { readonly [__accessor]?: never }; + type SetState = (next: T | ((prev: T) => T)) => void; -type StateTuple = [() => T, SetState]; +type StateTuple = [Accessor, SetState]; /** Options for signal */ export interface SignalOptions { @@ -70,5 +87,5 @@ export function signal(initial: T, options?: SignalOptions): StateTuple if (hook) hook.emit("signal:create", { signal: state, name: debugName, getter: get, initial }); } - return [get, set]; + return [get as Accessor, set]; } diff --git a/src/core/signals/writable.ts b/src/core/signals/writable.ts index c164429..743a43a 100644 --- a/src/core/signals/writable.ts +++ b/src/core/signals/writable.ts @@ -1,5 +1,6 @@ import { batch } from "../../reactivity/batch"; import { derived } from "./derived"; +import type { Accessor } from "./signal"; /** * Creates a writable computed value — a derived getter paired with @@ -38,7 +39,7 @@ export function writable( get: () => T, set: (value: T) => void, options?: { name?: string }, -): [() => T, (value: T) => void] { +): [Accessor, (value: T) => void] { const getter = derived(get, options); const setter = (value: T): void => { diff --git a/src/patterns/globalStore.ts b/src/patterns/globalStore.ts index 8870957..c9f312f 100644 --- a/src/patterns/globalStore.ts +++ b/src/patterns/globalStore.ts @@ -39,7 +39,14 @@ export function globalStore< const execute = () => { const current = getState(); - const patch = actionFn(current, payload); + const rawPatch = actionFn(current, payload); + // Strip prototype-pollution keys before merging to prevent __proto__ / constructor attacks + const patch: Partial = {}; + for (const key of Object.keys(rawPatch)) { + if (key !== "__proto__" && key !== "constructor" && key !== "prototype") { + (patch as Record)[key] = (rawPatch as Record)[key]; + } + } setState({ ...current, ...patch } as S); // Notify listeners const newState = getState(); diff --git a/src/platform/worker.ts b/src/platform/worker.ts index 9980c9d..f304caa 100644 --- a/src/platform/worker.ts +++ b/src/platform/worker.ts @@ -20,6 +20,15 @@ export interface UseWorkerReturn { * and should call `postMessage` to send results back. It is serialized * into a Blob URL, so it must be self-contained (no closures). * + * **CSP Warning:** This function serializes the provided function via `.toString()` + * and executes it inside a `blob:` URL Worker. This is equivalent to `eval()` and + * is incompatible with strict Content Security Policies that restrict + * `worker-src 'self'` or block `blob:` URLs. Additionally: + * - Minifiers may break captured variable references (closures silently fail). + * - Module-level imports are NOT accessible inside the worker. + * - Never pass user-controlled or dynamically constructed functions — this + * would be equivalent to `eval()` on untrusted input. + * * @param workerFn The function body to run inside the worker. * It receives `self` as the worker global scope. * @returns An object with post, result, error, loading, and terminate.