@igojs/signal 6.0.0-beta.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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * StateProxy - Deep reactive state with Proxy (like Vue 3)
3
+ *
4
+ * Features:
5
+ * - Deep reactivity (nested objects/arrays)
6
+ * - Array mutation detection (push, pop, splice, etc.)
7
+ * - WeakMap caching to avoid double-wrapping
8
+ */
9
+ class StateProxy {
10
+ constructor(component, namespace) {
11
+ this.component = component;
12
+ this.namespace = namespace;
13
+ this.cache = new WeakMap();
14
+ }
15
+
16
+ create(target, path = []) {
17
+ if (this.cache.has(target)) return this.cache.get(target);
18
+
19
+ const proxy = new Proxy(target, {
20
+ get: (target, property) => {
21
+ // Track dependency
22
+ if (this.component._isTracking) {
23
+ this.component._trackedDeps.push([this.namespace, ...path, property]);
24
+ }
25
+
26
+ const value = target[property];
27
+
28
+ // Don't wrap primitives, functions, Date, RegExp
29
+ if (!value || typeof value !== 'object' || value instanceof Date || value instanceof RegExp) {
30
+ return value;
31
+ }
32
+
33
+ // Recursively wrap objects/arrays
34
+ return this.create(value, [...path, property]);
35
+ },
36
+
37
+ set: (target, property, value) => {
38
+ const oldValue = target[property];
39
+ target[property] = value;
40
+
41
+ // Trigger render if changed
42
+ if (this.component._isInitialized && !Object.is(oldValue, value)) {
43
+ this.component._triggerRender();
44
+ }
45
+
46
+ return true;
47
+ }
48
+ });
49
+
50
+ // Wrap array methods
51
+ if (Array.isArray(target)) {
52
+ ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
53
+ const original = Array.prototype[method];
54
+ Object.defineProperty(target, method, {
55
+ value: (...args) => {
56
+ const result = original.apply(target, args);
57
+ if (this.component._isInitialized) this.component._triggerRender();
58
+ return result;
59
+ },
60
+ enumerable: false,
61
+ writable: true,
62
+ configurable: true
63
+ });
64
+ });
65
+ }
66
+
67
+ this.cache.set(target, proxy);
68
+ return proxy;
69
+ }
70
+
71
+
72
+ }
73
+
74
+ module.exports = StateProxy;
@@ -0,0 +1,26 @@
1
+
2
+ const _CACHE = {};
3
+
4
+ const AsyncFunction = Object.getPrototypeOf(async function() { }).constructor;
5
+
6
+ const getTemplate = (file) => {
7
+ // loadTemplate(file);
8
+ return _CACHE[file];
9
+ };
10
+
11
+ const loadTemplate = async (file) => {
12
+ if (!_CACHE[file]) {
13
+ const response = await fetch(`/__signal/templates?file=${file}`);
14
+ const json = await response.json();
15
+ // console.dir(json);
16
+ const fn = new AsyncFunction('l', 'u', 'c', 's', json.source);
17
+ _CACHE[file] = fn;
18
+ }
19
+ return _CACHE[file];
20
+ };
21
+
22
+ module.exports = {
23
+ getTemplate,
24
+ loadTemplate,
25
+ _CACHE
26
+ };
@@ -0,0 +1,124 @@
1
+ const Templates = require('./Templates');
2
+ const { uneval } = require('devalue'); // Bundler handles ES modules correctly
3
+ const igoDustHelpers = require('@igojs/dust/src/render/Helpers');
4
+ const { createSerializeHelper, htmlencode } = require('../../shared/serialize');
5
+
6
+ // Special characters
7
+ const BS = /\\/g,
8
+ LS = /\u2028/g,
9
+ PS = /\u2029/g,
10
+ LT = /</g,
11
+ FS = /\//g,
12
+ CR = /\r/g,
13
+ NL = /\n/g,
14
+ LF = /\f/g,
15
+ SQ = /'/g,
16
+ DQ = /"/g,
17
+ TB = /\t/g;
18
+
19
+ const escapeJs = (s) => {
20
+ if (typeof s === 'string') {
21
+ return s
22
+ .replace(BS, '\\\\')
23
+ .replace(FS, '\\/')
24
+ .replace(DQ, '\\"')
25
+ .replace(SQ, '\\\'')
26
+ .replace(CR, '\\r')
27
+ .replace(LS, '\\u2028')
28
+ .replace(PS, '\\u2029')
29
+ .replace(NL, '\\n')
30
+ .replace(LF, '\\f')
31
+ .replace(TB, '\\t');
32
+ }
33
+ return s;
34
+ };
35
+
36
+ const stringifyJson = (o) => {
37
+ return o && JSON.stringify(o)
38
+ .replace(LS, '\\u2028')
39
+ .replace(PS, '\\u2029')
40
+ .replace(LT, '\\u003c');
41
+ };
42
+
43
+ // Filters
44
+ const f = {
45
+ h: htmlencode,
46
+ j: escapeJs,
47
+ u: encodeURI,
48
+ uc: encodeURIComponent,
49
+ js: stringifyJson,
50
+ jp: JSON.parse,
51
+ uppercase: s => s.toUpperCase(),
52
+ lowercase: s => s.toLowerCase(),
53
+ };
54
+
55
+
56
+ // return value to be displayed
57
+ const d = (s, t, l) => {
58
+ if (typeof s === 'function') {
59
+ return s.call(t, l);
60
+ }
61
+ if (s === null || s === undefined) {
62
+ return '';
63
+ }
64
+ return s;
65
+ };
66
+
67
+ // return value (if it's a function, invoke it with locals)
68
+ const v = (s, t, l) => {
69
+ if (typeof s === 'function') {
70
+ return s.call(t, l);
71
+ }
72
+ return s;
73
+ };
74
+
75
+ // return boolean
76
+ const b = (v) => {
77
+ if (!v) {
78
+ return false;
79
+ }
80
+ if (v.length === 0) {
81
+ return false;
82
+ }
83
+ return true;
84
+ };
85
+
86
+ // return array
87
+ const a = (v) => {
88
+ if (Array.isArray(v)) {
89
+ if (v.length === 0) {
90
+ return null;
91
+ }
92
+ return v;
93
+ }
94
+ if (v) {
95
+ return [v];
96
+ }
97
+ return null;
98
+ };
99
+
100
+ // helpers
101
+ const h = (t, p, l) => {
102
+ if (!h.helpers || !h.helpers[t]) {
103
+ throw new Error(`Error: helper @${t} not found!`);
104
+ }
105
+ return h.helpers[t](p, l);
106
+ };
107
+
108
+ // Initialize with igo-dust base helpers
109
+ h.helpers = {
110
+ ...igoDustHelpers,
111
+ serialize: createSerializeHelper(uneval)
112
+ };
113
+
114
+ // Register application helpers (called by signal.start())
115
+ const setHelpers = (appHelpers) => {
116
+ Object.assign(h.helpers, appHelpers);
117
+ };
118
+
119
+ // include file
120
+ const i = async (file) => {
121
+ return await Templates.loadTemplate(file);
122
+ };
123
+
124
+ module.exports = { a, b, v, d, h, f, i, setHelpers };
@@ -0,0 +1,22 @@
1
+ const i18next = require('i18next');
2
+
3
+ // Get language from <html lang="..."> attribute (set by server)
4
+ const lang = document.documentElement.lang || 'en';
5
+
6
+ // Initialize i18next with translations injected from the server
7
+ i18next.init({
8
+ lng: lang,
9
+ fallbackLng: 'en',
10
+ resources: {
11
+ [lang]: {
12
+ translation: window.__signal_translations || {}
13
+ }
14
+ },
15
+ interpolation: {
16
+ escapeValue: false
17
+ }
18
+ });
19
+
20
+ // Expose globally for use in components
21
+ window.i18next = i18next;
22
+
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Signal - Zero-boilerplate reactive framework (browser entry point)
3
+ *
4
+ * Usage:
5
+ * require('@igojs/signal/src/client').start({
6
+ * components: { 'Counter': Counter },
7
+ * helpers: require('./helpers')
8
+ * });
9
+ */
10
+
11
+ require('./dust/i18n');
12
+
13
+ const SignalComponent = require('./SignalComponent');
14
+ const Utils = require('./dust/Utils');
15
+
16
+ window.__signal = {
17
+ IgoDustUtils: Utils
18
+ };
19
+
20
+ let registry = {};
21
+
22
+ function mountElement(el) {
23
+ if (el.__igoInstance) {
24
+ return;
25
+ }
26
+ const Comp = registry[el.dataset.component];
27
+ if (Comp) {
28
+ new Comp(el);
29
+ } else {
30
+ console.error(`[signal] Component "${el.dataset.component}" not found`);
31
+ }
32
+ }
33
+
34
+ function mountAll(root = document) {
35
+ root.querySelectorAll('[data-component]').forEach(mountElement);
36
+ }
37
+
38
+ function start(config = {}) {
39
+ const { components, helpers } = config;
40
+
41
+ // Register application helpers
42
+ if (helpers) {
43
+ Utils.setHelpers(helpers);
44
+ }
45
+
46
+ // Build component registry
47
+ if (components) {
48
+ // Support webpack require.context
49
+ if (typeof components.keys === 'function') {
50
+ components.keys().forEach(key => {
51
+ const mod = components(key);
52
+ if (mod?.prototype instanceof SignalComponent) {
53
+ registry[key.replace(/^\.\//, '').replace(/\.js$/, '')] = mod;
54
+ }
55
+ });
56
+ } else {
57
+ // Support plain object { 'path': ComponentClass }
58
+ Object.assign(registry, components);
59
+ }
60
+ }
61
+
62
+ // Mount components
63
+ if (document.readyState === 'loading') {
64
+ document.addEventListener('DOMContentLoaded', () => mountAll());
65
+ } else {
66
+ mountAll();
67
+ }
68
+ }
69
+
70
+ window.__signal.mountElement = mountElement;
71
+
72
+ module.exports = { SignalComponent, start, mountAll, mountElement };
@@ -0,0 +1,70 @@
1
+
2
+ const _ = require('lodash');
3
+
4
+ /**
5
+ * Serialize data for client-side hydration
6
+ *
7
+ * This function prepares data for devalue by converting Model instances
8
+ * to plain objects using their serialize() method.
9
+ *
10
+ * IMPORTANT: For deduplication to work, pass the same object references
11
+ * multiple times. Do NOT create separate serialize() calls before passing
12
+ * to this function - let it handle the serialization.
13
+ *
14
+ * @param {any} data - Data to serialize
15
+ * @param {WeakMap} [seen] - Internal map for tracking already-serialized objects
16
+ * @returns {any} - Serialized data ready for devalue
17
+ */
18
+ module.exports.serialize = (data, seen = new WeakMap()) => {
19
+ if (data === null || data === undefined) {
20
+ return null;
21
+ }
22
+ // Skip functions
23
+ if (_.isFunction(data)) {
24
+ return undefined;
25
+ }
26
+ // Keep Date objects as-is (devalue handles them natively)
27
+ if (_.isDate(data)) {
28
+ return data;
29
+ }
30
+ if (_.isArray(data)) {
31
+ return _.map(data, item => module.exports.serialize(item, seen));
32
+ }
33
+ // Model instances with serialize method
34
+ if (_.isFunction(data?.serialize)) {
35
+ // Check if already serialized (deduplication)
36
+ if (seen.has(data)) {
37
+ return seen.get(data);
38
+ }
39
+ // Create placeholder to handle circular refs
40
+ const serialized = {};
41
+ seen.set(data, serialized);
42
+ // Serialize and merge into placeholder
43
+ const result = data.serialize();
44
+ Object.assign(serialized, module.exports.serialize(result, seen));
45
+ return serialized;
46
+ }
47
+ // Form instances with getValues method (Igo Form)
48
+ if (_.isFunction(data?.getValues) && data.constructor?.schema?.attributes) {
49
+ return module.exports.serialize(data.getValues(), seen);
50
+ }
51
+ // Plain objects only - recursively serialize values
52
+ if (_.isPlainObject(data)) {
53
+ // Check if already processed (for circular plain objects)
54
+ if (seen.has(data)) {
55
+ return seen.get(data);
56
+ }
57
+ const serialized = {};
58
+ seen.set(data, serialized);
59
+ for (const [key, value] of Object.entries(data)) {
60
+ serialized[key] = module.exports.serialize(value, seen);
61
+ }
62
+ return serialized;
63
+ }
64
+ // Primitives (string, number, boolean)
65
+ if (!_.isObject(data)) {
66
+ return data;
67
+ }
68
+ // Skip non-POJO class instances that we don't know how to serialize
69
+ return undefined;
70
+ };
@@ -0,0 +1,94 @@
1
+
2
+ const { dust: IgoDust } = require('@igojs/server');
3
+
4
+ const SerializeUtils = require('./SerializeUtils');
5
+ const { createSerializeHelper } = require('../shared/serialize');
6
+
7
+ // Load devalue dynamically (ES module)
8
+ let uneval;
9
+ let helperRegistered = false;
10
+ const devaluePromise = import('devalue').then(mod => {
11
+ uneval = mod.uneval;
12
+ });
13
+
14
+ // Translations are loaded from user's project (configured via signal.configure())
15
+ let translations = {};
16
+
17
+ // Register @serialize helper (called once after devalue is loaded)
18
+ const registerHelper = () => {
19
+ if (helperRegistered) return;
20
+ helperRegistered = true;
21
+ IgoDust.helpers.serialize = createSerializeHelper(uneval);
22
+ };
23
+
24
+
25
+ /**
26
+ * Signal middleware - Handles SSR for SignalComponents
27
+ *
28
+ * Usage in controllers:
29
+ * res.locals.signal_props = { session, products, form, ... };
30
+ * res.locals.signal_components = [NewRegistrationForm]; // optional: for SSR derived values
31
+ * res.render('view');
32
+ *
33
+ * The middleware will:
34
+ * 1. Serialize signal_props with deduplication (Model.serialize() called once per instance)
35
+ * 2. Compute SSR derived values from registered signal_components
36
+ * 3. Merge everything into res.locals for template rendering
37
+ */
38
+ module.exports.middleware = async (req, res, next) => {
39
+
40
+ // Wait for devalue to be loaded, then register helper
41
+ await devaluePromise;
42
+ registerHelper();
43
+
44
+ // Inject translations for frontend i18next
45
+ res.locals.__signal_translations = uneval(translations);
46
+
47
+ const originalRender = res.render.bind(res);
48
+
49
+ res.render = (view, locals, callback) => {
50
+ const props = locals?.signal_props || res.locals.signal_props || {};
51
+ const components = res.locals.signal_components || [];
52
+
53
+ try {
54
+ // Serialize props with deduplication
55
+ const serializedProps = SerializeUtils.serialize(props);
56
+
57
+ // Store serialized props for client hydration (devalue for XSS safety)
58
+ res.locals.__signal_props = uneval(serializedProps);
59
+
60
+ // Merge raw props into res.locals for template access
61
+ Object.assign(res.locals, props);
62
+
63
+ // Compute and merge SSR derived values from each component
64
+ for (const ComponentClass of components) {
65
+ if (ComponentClass?.ssr) {
66
+ const derived = ComponentClass.ssr(props);
67
+ Object.assign(res.locals, derived);
68
+ }
69
+ }
70
+
71
+ } catch (error) {
72
+ console.error('[Signal] Serialization error:', error);
73
+ res.locals.__signal_props = '{}';
74
+ }
75
+
76
+ return originalRender(view, locals, callback);
77
+ };
78
+
79
+ next();
80
+ };
81
+
82
+ //
83
+ module.exports.templates = async (req, res) => {
84
+ const file = req.query.file;
85
+ const source = await IgoDust.getSource(`${file}.dust`);
86
+ res.json({ file, source });
87
+ };
88
+
89
+ // Configure Signal (called from user's app)
90
+ module.exports.configure = (options) => {
91
+ if (options.translations) {
92
+ translations = options.translations;
93
+ }
94
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Signal - Server-side SSR support for SignalComponents
3
+ *
4
+ * This module provides:
5
+ * - SignalController: Express middleware for SSR and template serving
6
+ * - SerializeUtils: Model serialization with deduplication
7
+ */
8
+
9
+ const SignalController = require('./SignalController');
10
+ const SerializeUtils = require('./SerializeUtils');
11
+
12
+ module.exports = {
13
+ middleware: SignalController.middleware,
14
+ templates: SignalController.templates,
15
+ configure: SignalController.configure,
16
+ serialize: SerializeUtils.serialize
17
+ };
@@ -0,0 +1,35 @@
1
+
2
+ // Shared serialize helper for @igojs/signal
3
+ // Used both server-side (SignalController) and client-side (Utils)
4
+
5
+ const HCHARS = /[&<>"']/;
6
+
7
+ const htmlencode = (s) => {
8
+ if (!s || typeof s !== 'string' || !HCHARS.test(s)) {
9
+ return s;
10
+ }
11
+ return s
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#39;');
17
+ };
18
+
19
+ /**
20
+ * Create serialize helper function with the provided uneval function
21
+ * @param {Function} uneval - The uneval function from devalue
22
+ * @returns {Function} The serialize helper
23
+ */
24
+ const createSerializeHelper = (uneval) => {
25
+ return (params, locals) => {
26
+ const data = {};
27
+ const keys = (params.props || '').split(',').map(p => p.trim()).filter(Boolean);
28
+ keys.forEach(key => {
29
+ data[key] = locals[key];
30
+ });
31
+ return htmlencode(uneval(data));
32
+ };
33
+ };
34
+
35
+ module.exports = { createSerializeHelper, htmlencode };
@@ -0,0 +1,44 @@
1
+ const assert = require('assert');
2
+ const DerivedCache = require('../../src/client/DerivedCache');
3
+
4
+ describe('DerivedCache', () => {
5
+
6
+ it('should cache computed values', () => {
7
+ const cache = new DerivedCache();
8
+ const context = { props: { value: 1 }, state: {} };
9
+ let callCount = 0;
10
+
11
+ const fn = () => ++callCount;
12
+
13
+ cache.memoize('test', fn, [], context, fn());
14
+ cache.memoize('test', fn, [], context);
15
+
16
+ assert.strictEqual(callCount, 1);
17
+ });
18
+
19
+ it('should recompute when deps change', () => {
20
+ const cache = new DerivedCache();
21
+ const context = { props: { count: 1 }, state: {} };
22
+
23
+ const fn = () => context.props.count * 2;
24
+
25
+ cache.memoize('double', fn, [['props', 'count']], context, fn());
26
+ context.props.count = 5;
27
+ const result = cache.memoize('double', fn, [['props', 'count']], context);
28
+
29
+ assert.strictEqual(result, 10);
30
+ });
31
+
32
+ it('should clear all cached values', () => {
33
+ const cache = new DerivedCache();
34
+ let count = 0;
35
+ const fn = () => ++count;
36
+
37
+ cache.memoize('a', fn, [], {}, fn());
38
+ cache.clear();
39
+ cache.memoize('a', fn, [], {});
40
+
41
+ assert.strictEqual(count, 2);
42
+ });
43
+
44
+ });
@@ -0,0 +1,59 @@
1
+ const assert = require('assert');
2
+ const SignalComponent = require('../../src/client/SignalComponent');
3
+
4
+ describe('SignalComponent', () => {
5
+
6
+ it('should initialize with props in SSR mode', () => {
7
+ const props = { products: [{ id: 1 }] };
8
+ const component = new SignalComponent(null, 'test', props);
9
+
10
+ assert.deepStrictEqual(component.props, props);
11
+ });
12
+
13
+ it('should initialize form state from props.form', () => {
14
+ const props = { form: { search: 'test' } };
15
+ const component = new SignalComponent(null, 'test', props);
16
+
17
+ assert.deepStrictEqual(component._state.form, props.form);
18
+ });
19
+
20
+ it('should compute derived values via ssr()', () => {
21
+ class TestComponent extends SignalComponent {
22
+ get doubled() {
23
+ return (this.props.value || 0) * 2;
24
+ }
25
+ }
26
+
27
+ const derived = TestComponent.ssr({ value: 5 });
28
+
29
+ assert.strictEqual(derived.doubled, 10);
30
+ });
31
+
32
+ it('should skip private and reserved getters in ssr()', () => {
33
+ class TestComponent extends SignalComponent {
34
+ get _private() { return 'hidden'; }
35
+ get public() { return 'visible'; }
36
+ }
37
+
38
+ const derived = TestComponent.ssr({});
39
+
40
+ assert.strictEqual(derived._private, undefined);
41
+ assert.strictEqual(derived.public, 'visible');
42
+ });
43
+
44
+ it('should handle getter errors gracefully in ssr()', () => {
45
+ class TestComponent extends SignalComponent {
46
+ get failing() { throw new Error('DOM error'); }
47
+ get working() { return 'ok'; }
48
+ }
49
+
50
+ const originalError = console.error;
51
+ console.error = () => {};
52
+ const derived = TestComponent.ssr({});
53
+ console.error = originalError;
54
+
55
+ assert.strictEqual(derived.failing, undefined);
56
+ assert.strictEqual(derived.working, 'ok');
57
+ });
58
+
59
+ });
@@ -0,0 +1,59 @@
1
+ const assert = require('assert');
2
+ const StateProxy = require('../../src/client/StateProxy');
3
+
4
+ describe('StateProxy', () => {
5
+
6
+ function createMockComponent() {
7
+ return {
8
+ _isTracking: false,
9
+ _trackedDeps: [],
10
+ _isInitialized: true,
11
+ _renderCount: 0,
12
+ _triggerRender() { this._renderCount++; }
13
+ };
14
+ }
15
+
16
+ it('should trigger render on property change', () => {
17
+ const component = createMockComponent();
18
+ const state = new StateProxy(component, 'state').create({ count: 0 });
19
+
20
+ state.count = 5;
21
+
22
+ assert.strictEqual(state.count, 5);
23
+ assert.strictEqual(component._renderCount, 1);
24
+ });
25
+
26
+ it('should handle nested objects', () => {
27
+ const component = createMockComponent();
28
+ const state = new StateProxy(component, 'state').create({
29
+ user: { name: 'John' }
30
+ });
31
+
32
+ state.user.name = 'Jane';
33
+
34
+ assert.strictEqual(state.user.name, 'Jane');
35
+ assert.strictEqual(component._renderCount, 1);
36
+ });
37
+
38
+ it('should trigger render on array methods', () => {
39
+ const component = createMockComponent();
40
+ const state = new StateProxy(component, 'state').create({ items: [] });
41
+
42
+ state.items.push({ id: 1 });
43
+
44
+ assert.strictEqual(state.items.length, 1);
45
+ assert.strictEqual(component._renderCount, 1);
46
+ });
47
+
48
+ it('should track dependencies when tracking is enabled', () => {
49
+ const component = createMockComponent();
50
+ const state = new StateProxy(component, 'state').create({ count: 0 });
51
+
52
+ component._isTracking = true;
53
+ const _ = state.count;
54
+ component._isTracking = false;
55
+
56
+ assert.deepStrictEqual(component._trackedDeps[0], ['state', 'count']);
57
+ });
58
+
59
+ });