@hypen-space/core 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 (49) hide show
  1. package/dist/chunk-5va59f7m.js +22 -0
  2. package/dist/chunk-5va59f7m.js.map +9 -0
  3. package/dist/engine.d.ts +101 -0
  4. package/dist/events.d.ts +78 -0
  5. package/dist/index.browser.d.ts +13 -0
  6. package/dist/index.d.ts +33 -0
  7. package/dist/remote/index.d.ts +6 -0
  8. package/dist/router.d.ts +93 -0
  9. package/dist/src/app.js +160 -0
  10. package/dist/src/app.js.map +10 -0
  11. package/dist/src/context.js +114 -0
  12. package/dist/src/context.js.map +10 -0
  13. package/dist/src/engine.browser.js +130 -0
  14. package/dist/src/engine.browser.js.map +10 -0
  15. package/dist/src/engine.js +101 -0
  16. package/dist/src/engine.js.map +10 -0
  17. package/dist/src/events.js +72 -0
  18. package/dist/src/events.js.map +10 -0
  19. package/dist/src/index.browser.js +51 -0
  20. package/dist/src/index.browser.js.map +9 -0
  21. package/dist/src/index.js +55 -0
  22. package/dist/src/index.js.map +9 -0
  23. package/dist/src/remote/client.js +176 -0
  24. package/dist/src/remote/client.js.map +10 -0
  25. package/dist/src/remote/index.js +9 -0
  26. package/dist/src/remote/index.js.map +9 -0
  27. package/dist/src/remote/types.js +2 -0
  28. package/dist/src/remote/types.js.map +9 -0
  29. package/dist/src/renderer.js +58 -0
  30. package/dist/src/renderer.js.map +10 -0
  31. package/dist/src/router.js +189 -0
  32. package/dist/src/router.js.map +10 -0
  33. package/dist/src/state.js +226 -0
  34. package/dist/src/state.js.map +10 -0
  35. package/dist/state.d.ts +30 -0
  36. package/package.json +124 -0
  37. package/src/app.ts +330 -0
  38. package/src/context.ts +201 -0
  39. package/src/engine.browser.ts +245 -0
  40. package/src/engine.ts +208 -0
  41. package/src/events.ts +126 -0
  42. package/src/index.browser.ts +104 -0
  43. package/src/index.ts +126 -0
  44. package/src/remote/client.ts +274 -0
  45. package/src/remote/index.ts +17 -0
  46. package/src/remote/types.ts +51 -0
  47. package/src/renderer.ts +102 -0
  48. package/src/router.ts +311 -0
  49. package/src/state.ts +363 -0
package/src/router.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Hypen Router - Declarative routing system
3
+ * Integrated with Hypen's reactive state management
4
+ */
5
+
6
+ import { createObservableState, getStateSnapshot } from "./state.js";
7
+
8
+ export type RouteMatch = {
9
+ params: Record<string, string>;
10
+ query: Record<string, string>;
11
+ path: string;
12
+ };
13
+
14
+ export type RouteState = {
15
+ currentPath: string;
16
+ params: Record<string, string>;
17
+ query: Record<string, string>;
18
+ previousPath: string | null;
19
+ };
20
+
21
+ export type RouteChangeCallback = (route: RouteState) => void;
22
+
23
+ /**
24
+ * Hypen Router - Manages application routing with pattern matching
25
+ */
26
+ export class HypenRouter {
27
+ private state: RouteState;
28
+ private subscribers = new Set<RouteChangeCallback>();
29
+ private isInitialized = false;
30
+
31
+ constructor() {
32
+ // Create observable state for reactivity
33
+ this.state = createObservableState<RouteState>(
34
+ {
35
+ currentPath: "/",
36
+ params: {},
37
+ query: {},
38
+ previousPath: null,
39
+ },
40
+ {
41
+ onChange: () => {
42
+ this.notifySubscribers();
43
+ },
44
+ }
45
+ );
46
+
47
+ // Initialize from browser if available
48
+ if (typeof window !== "undefined") {
49
+ this.initializeBrowserSync();
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Initialize browser history sync
55
+ */
56
+ private initializeBrowserSync() {
57
+ // Get initial path from hash or pathname
58
+ const initialPath = this.getPathFromBrowser();
59
+ this.state.currentPath = initialPath;
60
+ this.state.params = {};
61
+ this.state.query = this.parseQuery();
62
+
63
+ // Listen for browser back/forward
64
+ window.addEventListener("popstate", () => {
65
+ const newPath = this.getPathFromBrowser();
66
+ this.updatePath(newPath, false); // Don't push to history again
67
+ });
68
+
69
+ // Listen for hash changes
70
+ window.addEventListener("hashchange", () => {
71
+ const newPath = this.getPathFromBrowser();
72
+ this.updatePath(newPath, false);
73
+ });
74
+
75
+ this.isInitialized = true;
76
+ console.log("Router initialized at:", initialPath);
77
+ }
78
+
79
+ /**
80
+ * Get path from browser URL (supports both hash and pathname)
81
+ */
82
+ private getPathFromBrowser(): string {
83
+ if (typeof window === "undefined") return "/";
84
+
85
+ // Prefer hash-based routing for simplicity
86
+ const hash = window.location.hash.slice(1);
87
+ if (hash) return hash;
88
+
89
+ // Fallback to pathname
90
+ return window.location.pathname;
91
+ }
92
+
93
+ /**
94
+ * Parse query string from URL
95
+ */
96
+ private parseQuery(): Record<string, string> {
97
+ if (typeof window === "undefined") return {};
98
+
99
+ const query: Record<string, string> = {};
100
+ const searchParams = new URLSearchParams(window.location.search);
101
+
102
+ searchParams.forEach((value, key) => {
103
+ query[key] = value;
104
+ });
105
+
106
+ return query;
107
+ }
108
+
109
+ /**
110
+ * Navigate to a new path
111
+ */
112
+ push(path: string) {
113
+ console.log("Router.push:", path);
114
+ this.updatePath(path, true);
115
+ }
116
+
117
+ /**
118
+ * Replace current path without adding to history
119
+ */
120
+ replace(path: string) {
121
+ console.log("Router.replace:", path);
122
+ this.updatePath(path, true, true);
123
+ }
124
+
125
+ /**
126
+ * Go back in history
127
+ */
128
+ back() {
129
+ console.log("Router.back");
130
+ if (typeof window !== "undefined") {
131
+ window.history.back();
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Go forward in history
137
+ */
138
+ forward() {
139
+ console.log("Router.forward");
140
+ if (typeof window !== "undefined") {
141
+ window.history.forward();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Update the current path
147
+ */
148
+ private updatePath(
149
+ path: string,
150
+ updateBrowser: boolean,
151
+ replace: boolean = false
152
+ ) {
153
+ const oldPath = this.state.currentPath;
154
+ this.state.previousPath = oldPath;
155
+ this.state.currentPath = path;
156
+ this.state.query = this.parseQuery();
157
+
158
+ // Update browser URL if needed
159
+ if (updateBrowser && typeof window !== "undefined") {
160
+ const url = "#" + path;
161
+ if (replace) {
162
+ window.history.replaceState(null, "", url);
163
+ } else {
164
+ window.history.pushState(null, "", url);
165
+ }
166
+
167
+ // Manually trigger hashchange event
168
+ const hashChangeEvent = new HashChangeEvent("hashchange", {
169
+ oldURL: window.location.href.replace(window.location.hash, "#" + oldPath),
170
+ newURL: window.location.href,
171
+ });
172
+ window.dispatchEvent(hashChangeEvent);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Get current path
178
+ */
179
+ getCurrentPath(): string {
180
+ return this.state.currentPath;
181
+ }
182
+
183
+ /**
184
+ * Get current route params
185
+ */
186
+ getParams(): Record<string, string> {
187
+ return { ...this.state.params };
188
+ }
189
+
190
+ /**
191
+ * Get current query params
192
+ */
193
+ getQuery(): Record<string, string> {
194
+ return { ...this.state.query };
195
+ }
196
+
197
+ /**
198
+ * Get full route state snapshot
199
+ */
200
+ getState(): RouteState {
201
+ return getStateSnapshot(this.state);
202
+ }
203
+
204
+ /**
205
+ * Match a pattern against a path
206
+ */
207
+ matchPath(pattern: string, path: string): RouteMatch | null {
208
+ // Handle invalid inputs gracefully
209
+ if (!pattern || typeof pattern !== 'string') {
210
+ return null;
211
+ }
212
+ if (!path || typeof path !== 'string') {
213
+ return null;
214
+ }
215
+
216
+ // Exact match
217
+ if (pattern === path) {
218
+ return {
219
+ params: {},
220
+ query: this.state.query,
221
+ path,
222
+ };
223
+ }
224
+
225
+ // Wildcard match: /dashboard/* matches /dashboard/anything
226
+ if (pattern.endsWith("/*")) {
227
+ const prefix = pattern.slice(0, -2);
228
+ if (path === prefix || path.startsWith(prefix + "/")) {
229
+ return {
230
+ params: {},
231
+ query: this.state.query,
232
+ path,
233
+ };
234
+ }
235
+ return null;
236
+ }
237
+
238
+ // Parameter match: /users/:id matches /users/123
239
+ const paramNames: string[] = [];
240
+ const regexPattern = pattern
241
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
242
+ paramNames.push(name);
243
+ return "([^/]+)";
244
+ })
245
+ .replace(/\*/g, ".*");
246
+
247
+ const regex = new RegExp(`^${regexPattern}$`);
248
+ const match = path.match(regex);
249
+
250
+ if (!match) return null;
251
+
252
+ // Extract params
253
+ const params: Record<string, string> = {};
254
+ paramNames.forEach((name, i) => {
255
+ const value = match[i + 1];
256
+ if (value !== undefined) {
257
+ params[name] = decodeURIComponent(value);
258
+ }
259
+ });
260
+
261
+ return {
262
+ params,
263
+ query: this.state.query,
264
+ path,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Subscribe to route changes
270
+ */
271
+ onNavigate(callback: RouteChangeCallback): () => void {
272
+ this.subscribers.add(callback);
273
+
274
+ // Call immediately with current state
275
+ callback(this.getState());
276
+
277
+ // Return unsubscribe function
278
+ return () => {
279
+ this.subscribers.delete(callback);
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Notify all subscribers of route change
285
+ */
286
+ private notifySubscribers() {
287
+ const routeState = this.getState();
288
+ this.subscribers.forEach((callback) => {
289
+ callback(routeState);
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Check if a path matches the current route
295
+ */
296
+ isActive(pattern: string): boolean {
297
+ return this.matchPath(pattern, this.state.currentPath) !== null;
298
+ }
299
+
300
+ /**
301
+ * Get a URL with query params
302
+ */
303
+ buildUrl(path: string, query?: Record<string, string>): string {
304
+ if (!query || Object.keys(query).length === 0) {
305
+ return path;
306
+ }
307
+
308
+ const queryString = new URLSearchParams(query).toString();
309
+ return `${path}?${queryString}`;
310
+ }
311
+ }
package/src/state.ts ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * State management with observer pattern and diffing
3
+ */
4
+
5
+ export type StatePath = string; // e.g., "user.name", "items.0.title"
6
+
7
+ /**
8
+ * Represents a change in state with full path information
9
+ */
10
+ export interface StateChange {
11
+ paths: StatePath[];
12
+ newValues: Record<StatePath, any>;
13
+ }
14
+
15
+ /**
16
+ * Options for state observer
17
+ */
18
+ export interface StateObserverOptions {
19
+ onChange: (change: StateChange) => void;
20
+ pathPrefix?: string; // For nested observers
21
+ }
22
+
23
+ /**
24
+ * Deep clone an object, handling circular references
25
+ */
26
+ function deepClone<T>(obj: T): T {
27
+ // Handle primitives and null
28
+ if (obj === null || typeof obj !== 'object') {
29
+ return obj;
30
+ }
31
+
32
+ // Use a WeakMap to track visited objects and handle circular references
33
+ const visited = new WeakMap();
34
+
35
+ function cloneInternal(value: any): any {
36
+ // Handle primitives and null
37
+ if (value === null || typeof value !== 'object') {
38
+ return value;
39
+ }
40
+
41
+ // Check if we've already cloned this object (circular reference)
42
+ if (visited.has(value)) {
43
+ return visited.get(value);
44
+ }
45
+
46
+ // Handle Date
47
+ if (value instanceof Date) {
48
+ return new Date(value.getTime());
49
+ }
50
+
51
+ // Handle RegExp
52
+ if (value instanceof RegExp) {
53
+ return new RegExp(value.source, value.flags);
54
+ }
55
+
56
+ // Handle Map
57
+ if (value instanceof Map) {
58
+ const mapClone = new Map();
59
+ visited.set(value, mapClone);
60
+ for (const [k, v] of value.entries()) {
61
+ mapClone.set(cloneInternal(k), cloneInternal(v));
62
+ }
63
+ return mapClone;
64
+ }
65
+
66
+ // Handle Set
67
+ if (value instanceof Set) {
68
+ const setClone = new Set();
69
+ visited.set(value, setClone);
70
+ for (const item of value.values()) {
71
+ setClone.add(cloneInternal(item));
72
+ }
73
+ return setClone;
74
+ }
75
+
76
+ // Handle WeakMap/WeakSet - cannot be cloned, return as-is
77
+ if (value instanceof WeakMap || value instanceof WeakSet) {
78
+ return value;
79
+ }
80
+
81
+ // Handle Array
82
+ if (Array.isArray(value)) {
83
+ const arrClone: any[] = [];
84
+ visited.set(value, arrClone);
85
+ for (let i = 0; i < value.length; i++) {
86
+ arrClone[i] = cloneInternal(value[i]);
87
+ }
88
+ return arrClone;
89
+ }
90
+
91
+ // Handle plain objects
92
+ const objClone: any = {};
93
+ visited.set(value, objClone);
94
+ for (const key in value) {
95
+ if (value.hasOwnProperty(key)) {
96
+ objClone[key] = cloneInternal(value[key]);
97
+ }
98
+ }
99
+ return objClone;
100
+ }
101
+
102
+ return cloneInternal(obj);
103
+ }
104
+
105
+ /**
106
+ * Compare two values and detect changes with full paths
107
+ */
108
+ function diffState(
109
+ oldState: any,
110
+ newState: any,
111
+ basePath: string = ""
112
+ ): StateChange {
113
+ const paths: StatePath[] = [];
114
+ const newValues: Record<StatePath, any> = {};
115
+
116
+ function diff(oldVal: any, newVal: any, path: string) {
117
+ // Handle null/undefined
118
+ if (oldVal === newVal) return;
119
+
120
+ // Different types or primitives
121
+ if (
122
+ typeof oldVal !== "object" ||
123
+ typeof newVal !== "object" ||
124
+ oldVal === null ||
125
+ newVal === null
126
+ ) {
127
+ if (oldVal !== newVal) {
128
+ paths.push(path);
129
+ newValues[path] = newVal;
130
+ }
131
+ return;
132
+ }
133
+
134
+ // Arrays
135
+ if (Array.isArray(oldVal) || Array.isArray(newVal)) {
136
+ if (
137
+ !Array.isArray(oldVal) ||
138
+ !Array.isArray(newVal) ||
139
+ oldVal.length !== newVal.length
140
+ ) {
141
+ paths.push(path);
142
+ newValues[path] = newVal;
143
+ return;
144
+ }
145
+
146
+ for (let i = 0; i < newVal.length; i++) {
147
+ const itemPath = path ? `${path}.${i}` : `${i}`;
148
+ diff(oldVal[i], newVal[i], itemPath);
149
+ }
150
+ return;
151
+ }
152
+
153
+ // Objects
154
+ const oldKeys = new Set(Object.keys(oldVal));
155
+ const newKeys = new Set(Object.keys(newVal));
156
+
157
+ // Check for added or changed keys
158
+ for (const key of newKeys) {
159
+ const propPath = path ? `${path}.${key}` : key;
160
+ if (!oldKeys.has(key)) {
161
+ // New property
162
+ paths.push(propPath);
163
+ newValues[propPath] = newVal[key];
164
+ } else {
165
+ // Existing property, recurse
166
+ diff(oldVal[key], newVal[key], propPath);
167
+ }
168
+ }
169
+
170
+ // Check for removed keys
171
+ for (const key of oldKeys) {
172
+ if (!newKeys.has(key)) {
173
+ const propPath = path ? `${path}.${key}` : key;
174
+ paths.push(propPath);
175
+ newValues[propPath] = undefined;
176
+ }
177
+ }
178
+ }
179
+
180
+ diff(oldState, newState, basePath);
181
+ return { paths, newValues };
182
+ }
183
+
184
+ /**
185
+ * Create an observable state object that tracks changes
186
+ */
187
+ export function createObservableState<T extends object>(
188
+ initialState: T,
189
+ options?: StateObserverOptions
190
+ ): T {
191
+ // Use default options if not provided
192
+ const opts: StateObserverOptions = options || { onChange: () => {} };
193
+
194
+ // Detect and reject primitive wrapper objects (Number, String, Boolean)
195
+ if (
196
+ initialState instanceof Number ||
197
+ initialState instanceof String ||
198
+ initialState instanceof Boolean
199
+ ) {
200
+ throw new TypeError(
201
+ "Cannot create observable state from primitive wrapper objects (Number, String, Boolean). " +
202
+ "Use plain primitives or regular objects instead."
203
+ );
204
+ }
205
+
206
+ // Keep a snapshot of the last known state
207
+ let lastSnapshot = deepClone(initialState);
208
+ const pathPrefix = opts.pathPrefix || "";
209
+
210
+ // Track if we're in a batch update
211
+ let batchDepth = 0;
212
+ let pendingChange: StateChange | null = null;
213
+
214
+ function notifyChange() {
215
+ if (batchDepth > 0) return;
216
+
217
+ // Compare current state with last snapshot
218
+ const change = diffState(lastSnapshot, state, pathPrefix);
219
+
220
+ if (change.paths.length > 0) {
221
+ // Update snapshot
222
+ lastSnapshot = deepClone(state);
223
+
224
+ // Merge with pending changes if any
225
+ if (pendingChange) {
226
+ change.paths.push(...pendingChange.paths);
227
+ Object.assign(change.newValues, pendingChange.newValues);
228
+ pendingChange = null;
229
+ }
230
+
231
+ // Notify
232
+ opts.onChange(change);
233
+ }
234
+ }
235
+
236
+ // Track if we have a pending microtask notification
237
+ let notificationPending = false;
238
+
239
+ function scheduleBatch() {
240
+ if (batchDepth === 0) {
241
+ // If not in a batch, schedule notification in next microtask to coalesce rapid changes
242
+ if (!notificationPending) {
243
+ notificationPending = true;
244
+ queueMicrotask(() => {
245
+ notificationPending = false;
246
+ if (batchDepth === 0) {
247
+ notifyChange();
248
+ }
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ // Cache proxies to handle circular references
255
+ const proxyCache = new WeakMap<any, any>();
256
+
257
+ function createProxy(target: any, basePath: string): any {
258
+ // Return cached proxy if it exists (handles circular references)
259
+ if (proxyCache.has(target)) {
260
+ return proxyCache.get(target);
261
+ }
262
+
263
+ const proxy = new Proxy(target, {
264
+ get(obj, prop) {
265
+ const value = obj[prop];
266
+
267
+ // Expose batch control methods
268
+ if (prop === "__beginBatch") {
269
+ return () => {
270
+ batchDepth++;
271
+ };
272
+ }
273
+ if (prop === "__endBatch") {
274
+ return () => {
275
+ batchDepth--;
276
+ if (batchDepth === 0) {
277
+ notifyChange();
278
+ }
279
+ };
280
+ }
281
+ if (prop === "__getSnapshot") {
282
+ return () => deepClone(obj);
283
+ }
284
+
285
+ // Return proxied nested objects/arrays, but NOT special types
286
+ if (value && typeof value === "object") {
287
+ // Check for special object types that should not be proxied
288
+ if (
289
+ value instanceof Date ||
290
+ value instanceof RegExp ||
291
+ value instanceof Map ||
292
+ value instanceof Set ||
293
+ value instanceof WeakMap ||
294
+ value instanceof WeakSet
295
+ ) {
296
+ return value;
297
+ }
298
+
299
+ // Proxy regular objects and arrays
300
+ return createProxy(value, basePath ? `${basePath}.${String(prop)}` : String(prop));
301
+ }
302
+
303
+ return value;
304
+ },
305
+
306
+ set(obj, prop, value) {
307
+ const oldValue = obj[prop];
308
+
309
+ // Set the new value
310
+ obj[prop] = value;
311
+
312
+ if (oldValue !== value) {
313
+ scheduleBatch();
314
+ }
315
+
316
+ return true;
317
+ },
318
+
319
+ deleteProperty(obj, prop) {
320
+ if (prop in obj) {
321
+ delete obj[prop];
322
+ scheduleBatch();
323
+ }
324
+ return true;
325
+ },
326
+ });
327
+
328
+ // Cache the proxy before returning
329
+ proxyCache.set(target, proxy);
330
+ return proxy;
331
+ }
332
+
333
+ const state = createProxy(initialState, pathPrefix);
334
+ return state as T;
335
+ }
336
+
337
+ /**
338
+ * Helper to batch multiple state updates
339
+ */
340
+ export function batchStateUpdates<T>(state: T, fn: () => void): void {
341
+ const s = state as any;
342
+ if (s.__beginBatch && s.__endBatch) {
343
+ s.__beginBatch();
344
+ try {
345
+ fn();
346
+ } finally {
347
+ s.__endBatch();
348
+ }
349
+ } else {
350
+ fn();
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Get a snapshot of the current state
356
+ */
357
+ export function getStateSnapshot<T>(state: T): T {
358
+ const s = state as any;
359
+ if (s.__getSnapshot) {
360
+ return s.__getSnapshot();
361
+ }
362
+ return deepClone(state);
363
+ }