@hypen-space/core 0.2.11 → 0.3.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 (48) hide show
  1. package/README.md +182 -11
  2. package/dist/src/app.js +470 -44
  3. package/dist/src/app.js.map +7 -5
  4. package/dist/src/components/builtin.js +470 -44
  5. package/dist/src/components/builtin.js.map +7 -5
  6. package/dist/src/discovery.js +559 -65
  7. package/dist/src/discovery.js.map +8 -6
  8. package/dist/src/engine.browser.js +2 -2
  9. package/dist/src/engine.browser.js.map +2 -2
  10. package/dist/src/engine.js +18 -9
  11. package/dist/src/engine.js.map +3 -3
  12. package/dist/src/index.browser.js +863 -82
  13. package/dist/src/index.browser.js.map +11 -7
  14. package/dist/src/index.js +1591 -125
  15. package/dist/src/index.js.map +17 -10
  16. package/dist/src/remote/client.js +525 -35
  17. package/dist/src/remote/client.js.map +7 -4
  18. package/dist/src/remote/index.js +1796 -35
  19. package/dist/src/remote/index.js.map +13 -4
  20. package/dist/src/router.js +55 -29
  21. package/dist/src/router.js.map +3 -3
  22. package/dist/src/state.js +57 -29
  23. package/dist/src/state.js.map +3 -3
  24. package/package.json +8 -2
  25. package/src/app.ts +292 -13
  26. package/src/discovery.ts +123 -18
  27. package/src/disposable.ts +281 -0
  28. package/src/engine.browser.ts +1 -1
  29. package/src/engine.ts +29 -10
  30. package/src/hypen.ts +209 -0
  31. package/src/index.ts +147 -11
  32. package/src/logger.ts +338 -0
  33. package/src/remote/client.ts +263 -56
  34. package/src/remote/index.ts +25 -1
  35. package/src/remote/server.ts +652 -0
  36. package/src/remote/session.ts +256 -0
  37. package/src/remote/types.ts +68 -1
  38. package/src/result.ts +260 -0
  39. package/src/retry.ts +306 -0
  40. package/src/state.ts +103 -45
  41. package/wasm-browser/README.md +4 -0
  42. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  43. package/wasm-browser/package.json +1 -1
  44. package/wasm-node/README.md +4 -0
  45. package/wasm-node/hypen_engine_bg.wasm +0 -0
  46. package/wasm-node/package.json +1 -1
  47. package/wasm-browser/hypen_engine_bg.js +0 -736
  48. package/wasm-node/hypen_engine_bg.js +0 -736
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Disposable Pattern for Resource Management
3
+ *
4
+ * Provides a consistent way to manage and clean up resources like
5
+ * event listeners, timers, WebSocket connections, etc.
6
+ */
7
+
8
+ /**
9
+ * Interface for objects that can be disposed
10
+ */
11
+ export interface Disposable {
12
+ dispose(): void;
13
+ }
14
+
15
+ /**
16
+ * Check if an object is Disposable
17
+ */
18
+ export function isDisposable(obj: unknown): obj is Disposable {
19
+ return (
20
+ obj !== null &&
21
+ typeof obj === 'object' &&
22
+ 'dispose' in obj &&
23
+ typeof (obj as Disposable).dispose === 'function'
24
+ );
25
+ }
26
+
27
+ /**
28
+ * A stack of disposables that are disposed in LIFO order
29
+ */
30
+ export class DisposableStack implements Disposable {
31
+ private stack: Disposable[] = [];
32
+ private disposed = false;
33
+
34
+ /**
35
+ * Add a disposable to the stack and return it
36
+ */
37
+ add<T extends Disposable>(disposable: T): T {
38
+ if (this.disposed) {
39
+ // If already disposed, immediately dispose the new item
40
+ disposable.dispose();
41
+ return disposable;
42
+ }
43
+ this.stack.push(disposable);
44
+ return disposable;
45
+ }
46
+
47
+ /**
48
+ * Add a cleanup callback to the stack
49
+ */
50
+ addCallback(callback: () => void): void {
51
+ this.add({ dispose: callback });
52
+ }
53
+
54
+ /**
55
+ * Add a value with a custom dispose function
56
+ */
57
+ addValue<T>(value: T, dispose: (value: T) => void): T {
58
+ this.add({ dispose: () => dispose(value) });
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Dispose all items in reverse order (LIFO)
64
+ */
65
+ dispose(): void {
66
+ if (this.disposed) return;
67
+ this.disposed = true;
68
+
69
+ while (this.stack.length > 0) {
70
+ const item = this.stack.pop()!;
71
+ try {
72
+ item.dispose();
73
+ } catch (error) {
74
+ // Log but continue disposing other items
75
+ console.error('[DisposableStack] Error during dispose:', error);
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if this stack has been disposed
82
+ */
83
+ get isDisposed(): boolean {
84
+ return this.disposed;
85
+ }
86
+
87
+ /**
88
+ * Get the number of items in the stack
89
+ */
90
+ get size(): number {
91
+ return this.stack.length;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Create a disposable from an event listener
97
+ */
98
+ export function disposableListener(
99
+ target: EventTarget,
100
+ event: string,
101
+ handler: EventListenerOrEventListenerObject,
102
+ options?: AddEventListenerOptions
103
+ ): Disposable {
104
+ target.addEventListener(event, handler, options);
105
+ return {
106
+ dispose: () => target.removeEventListener(event, handler, options),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Create a disposable from a timeout
112
+ */
113
+ export function disposableTimeout(
114
+ callback: () => void,
115
+ ms: number
116
+ ): Disposable & { id: ReturnType<typeof setTimeout> } {
117
+ const id = setTimeout(callback, ms);
118
+ return {
119
+ id,
120
+ dispose: () => clearTimeout(id),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Create a disposable from an interval
126
+ */
127
+ export function disposableInterval(
128
+ callback: () => void,
129
+ ms: number
130
+ ): Disposable & { id: ReturnType<typeof setInterval> } {
131
+ const id = setInterval(callback, ms);
132
+ return {
133
+ id,
134
+ dispose: () => clearInterval(id),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Create a disposable from a WebSocket
140
+ */
141
+ export function disposableWebSocket(ws: WebSocket): Disposable {
142
+ return {
143
+ dispose: () => {
144
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
145
+ ws.close();
146
+ }
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Create a disposable from an AbortController
153
+ */
154
+ export function disposableAbortController(): Disposable & { controller: AbortController; signal: AbortSignal } {
155
+ const controller = new AbortController();
156
+ return {
157
+ controller,
158
+ signal: controller.signal,
159
+ dispose: () => controller.abort(),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Create a disposable subscription (for event emitters, observables, etc.)
165
+ */
166
+ export function disposableSubscription(unsubscribe: () => void): Disposable {
167
+ return { dispose: unsubscribe };
168
+ }
169
+
170
+ /**
171
+ * Symbol used to store disposables on DOM elements
172
+ */
173
+ const ELEMENT_DISPOSABLES = Symbol('hypen.disposables');
174
+
175
+ /**
176
+ * Get or create a DisposableStack for an HTML element
177
+ */
178
+ export function getElementDisposables(element: HTMLElement): DisposableStack {
179
+ const existing = (element as any)[ELEMENT_DISPOSABLES];
180
+ if (existing instanceof DisposableStack) {
181
+ return existing;
182
+ }
183
+ const stack = new DisposableStack();
184
+ (element as any)[ELEMENT_DISPOSABLES] = stack;
185
+ return stack;
186
+ }
187
+
188
+ /**
189
+ * Dispose all disposables attached to an element
190
+ */
191
+ export function disposeElement(element: HTMLElement): void {
192
+ const stack = (element as any)[ELEMENT_DISPOSABLES];
193
+ if (stack instanceof DisposableStack) {
194
+ stack.dispose();
195
+ delete (element as any)[ELEMENT_DISPOSABLES];
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Check if an element has disposables
201
+ */
202
+ export function hasElementDisposables(element: HTMLElement): boolean {
203
+ return (element as any)[ELEMENT_DISPOSABLES] instanceof DisposableStack;
204
+ }
205
+
206
+ /**
207
+ * Decorator/helper to make a class disposable
208
+ * Tracks all resources and disposes them when dispose() is called
209
+ */
210
+ export class DisposableMixin {
211
+ protected disposables = new DisposableStack();
212
+
213
+ /**
214
+ * Register a disposable to be cleaned up
215
+ */
216
+ protected track<T extends Disposable>(disposable: T): T {
217
+ return this.disposables.add(disposable);
218
+ }
219
+
220
+ /**
221
+ * Register a cleanup callback
222
+ */
223
+ protected onDispose(callback: () => void): void {
224
+ this.disposables.addCallback(callback);
225
+ }
226
+
227
+ /**
228
+ * Dispose all tracked resources
229
+ */
230
+ dispose(): void {
231
+ this.disposables.dispose();
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Create a composite disposable that disposes multiple items together
237
+ */
238
+ export function compositeDisposable(...disposables: Disposable[]): Disposable {
239
+ return {
240
+ dispose: () => {
241
+ for (const d of disposables) {
242
+ try {
243
+ d.dispose();
244
+ } catch (error) {
245
+ console.error('[compositeDisposable] Error during dispose:', error);
246
+ }
247
+ }
248
+ },
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Run a function with automatic cleanup on exit
254
+ * Similar to Python's context managers or C#'s using statement
255
+ */
256
+ export async function using<T extends Disposable, R>(
257
+ resource: T | (() => T),
258
+ fn: (resource: T) => R | Promise<R>
259
+ ): Promise<R> {
260
+ const r = typeof resource === 'function' ? resource() : resource;
261
+ try {
262
+ return await fn(r);
263
+ } finally {
264
+ r.dispose();
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Synchronous version of using()
270
+ */
271
+ export function usingSync<T extends Disposable, R>(
272
+ resource: T | (() => T),
273
+ fn: (resource: T) => R
274
+ ): R {
275
+ const r = typeof resource === 'function' ? resource() : resource;
276
+ try {
277
+ return fn(r);
278
+ } finally {
279
+ r.dispose();
280
+ }
281
+ }
@@ -94,7 +94,7 @@ export class Engine {
94
94
  if (this.initialized) return;
95
95
 
96
96
  // Default to CDN for zero-config experience
97
- const cdnBase = "https://unpkg.com/@hypen-space/core@0.2.11/wasm-browser";
97
+ const cdnBase = "https://unpkg.com/@hypen-space/core@0.2.12/wasm-browser";
98
98
  const jsUrl = options.jsUrl ?? `${cdnBase}/hypen_engine.js`;
99
99
  const wasmUrl = options.wasmUrl ?? `${cdnBase}/hypen_engine_bg.wasm`;
100
100
 
package/src/engine.ts CHANGED
@@ -6,6 +6,31 @@
6
6
  // WASM module types
7
7
  import { WasmEngine } from "../wasm-node/hypen_engine.js";
8
8
 
9
+ /**
10
+ * Unwrap proxy objects to plain values for WASM serialization.
11
+ * Uses structuredClone when available (Node 17+, Bun, modern browsers),
12
+ * falls back to JSON round-trip for proxy objects or older environments.
13
+ */
14
+ function unwrapForWasm<T>(value: T): T {
15
+ // Fast path: primitives don't need cloning
16
+ if (value === null || typeof value !== 'object') {
17
+ return value;
18
+ }
19
+
20
+ // Check if the object has a snapshot method (our proxy convention)
21
+ if (typeof (value as any).__getSnapshot === 'function') {
22
+ return (value as any).__getSnapshot() as T;
23
+ }
24
+
25
+ // Try structuredClone first (fastest for plain objects)
26
+ try {
27
+ return structuredClone(value);
28
+ } catch {
29
+ // Fallback for proxy objects or unsupported types
30
+ return JSON.parse(JSON.stringify(value));
31
+ }
32
+ }
33
+
9
34
  export type Patch = {
10
35
  type: "create" | "setProp" | "setText" | "insert" | "move" | "remove" | "attachEvent" | "detachEvent";
11
36
  id?: string;
@@ -108,8 +133,7 @@ export class Engine {
108
133
  */
109
134
  renderInto(source: string, parentNodeId: string, state: Record<string, any>): void {
110
135
  const engine = this.ensureInitialized();
111
- const safeState = JSON.parse(JSON.stringify(state));
112
- engine.renderInto(source, parentNodeId, safeState);
136
+ engine.renderInto(source, parentNodeId, unwrapForWasm(state));
113
137
  }
114
138
 
115
139
  /**
@@ -122,9 +146,7 @@ export class Engine {
122
146
  return;
123
147
  }
124
148
 
125
- // Clone values to prevent proxy objects from causing issues
126
- const plainValues = JSON.parse(JSON.stringify(values));
127
- engine.updateStateSparse(paths, plainValues);
149
+ engine.updateStateSparse(paths, unwrapForWasm(values));
128
150
  console.debug("[Hypen] State changed (sparse):", paths);
129
151
  }
130
152
 
@@ -134,9 +156,7 @@ export class Engine {
134
156
  */
135
157
  notifyStateChangeFull(paths: string[], currentState: Record<string, any>): void {
136
158
  const engine = this.ensureInitialized();
137
-
138
- const plainObject = JSON.parse(JSON.stringify(currentState));
139
- engine.updateState(plainObject);
159
+ engine.updateState(unwrapForWasm(currentState));
140
160
 
141
161
  if (paths.length > 0) {
142
162
  console.debug("[Hypen] State changed (full):", paths);
@@ -149,8 +169,7 @@ export class Engine {
149
169
  */
150
170
  updateState(statePatch: Record<string, any>): void {
151
171
  const engine = this.ensureInitialized();
152
- const plainObject = JSON.parse(JSON.stringify(statePatch));
153
- engine.updateState(plainObject);
172
+ engine.updateState(unwrapForWasm(statePatch));
154
173
  }
155
174
 
156
175
  /**
package/src/hypen.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Hypen Tagged Template Literal
3
+ *
4
+ * Enables single-file components with inline UI templates.
5
+ *
6
+ * The `hypen` tagged template preserves ${state.x} and ${item.x} bindings
7
+ * instead of interpolating them, allowing you to write:
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { app, hypen, state } from "@hypen-space/core";
12
+ *
13
+ * export default app
14
+ * .defineState({ count: 0 })
15
+ * .onAction("increment", ({ state }) => {
16
+ * state.count += 1;
17
+ * })
18
+ * .ui(hypen`
19
+ * Column {
20
+ * Text("Count: ${state.count}")
21
+ * Button { Text("+") }
22
+ * .onClick("@actions.increment")
23
+ * }
24
+ * `);
25
+ * ```
26
+ */
27
+
28
+ /**
29
+ * Creates a proxy that captures property access as a binding path string.
30
+ *
31
+ * When used in a template literal, the proxy's toString() returns the
32
+ * full binding expression (e.g., "${state.user.name}").
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const state = createBindingProxy('state');
37
+ * `${state.user.name}` // Returns: "${state.user.name}"
38
+ * ```
39
+ */
40
+ function createBindingProxy(root: string): any {
41
+ const handler: ProxyHandler<object> = {
42
+ get(_, prop: string | symbol): any {
43
+ // Handle Symbol.toPrimitive, toString, and valueOf for string conversion
44
+ if (
45
+ prop === Symbol.toPrimitive ||
46
+ prop === "toString" ||
47
+ prop === "valueOf"
48
+ ) {
49
+ return () => `\${${root}}`;
50
+ }
51
+
52
+ // Handle other Symbol properties (e.g., Symbol.toStringTag)
53
+ if (typeof prop === "symbol") {
54
+ return undefined;
55
+ }
56
+
57
+ // Handle JSON.stringify
58
+ if (prop === "toJSON") {
59
+ return () => `\${${root}}`;
60
+ }
61
+
62
+ // Chain to nested property: state.user -> state.user.name
63
+ return createBindingProxy(`${root}.${prop}`);
64
+ },
65
+
66
+ // Support for `in` operator
67
+ has() {
68
+ return true;
69
+ },
70
+
71
+ // Support for Object.keys() - return empty to avoid enumeration issues
72
+ ownKeys() {
73
+ return [];
74
+ },
75
+
76
+ getOwnPropertyDescriptor() {
77
+ return {
78
+ configurable: true,
79
+ enumerable: true,
80
+ };
81
+ },
82
+ };
83
+
84
+ return new Proxy({} as any, handler);
85
+ }
86
+
87
+ /**
88
+ * Proxy for state bindings.
89
+ *
90
+ * Use in hypen templates to create reactive state bindings:
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * hypen`Text("Hello, ${state.user.name}")`
95
+ * // Produces: Text("Hello, ${state.user.name}")
96
+ * ```
97
+ */
98
+ export const state: any = createBindingProxy("state");
99
+
100
+ /**
101
+ * Proxy for item bindings in list iteration.
102
+ *
103
+ * Use inside List components to reference the current item:
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * hypen`
108
+ * List(@state.items) {
109
+ * Text("${item.name}: ${item.price}")
110
+ * }
111
+ * `
112
+ * ```
113
+ */
114
+ export const item: any = createBindingProxy("item");
115
+
116
+ /**
117
+ * Proxy for index in list iteration.
118
+ *
119
+ * Use inside List components to reference the current index:
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * hypen`
124
+ * List(@state.items) {
125
+ * Text("Item #${index}: ${item.name}")
126
+ * }
127
+ * `
128
+ * ```
129
+ */
130
+ export const index: any = {
131
+ [Symbol.toPrimitive]: () => "${index}",
132
+ toString: () => "${index}",
133
+ valueOf: () => "${index}",
134
+ toJSON: () => "${index}",
135
+ };
136
+
137
+ /**
138
+ * Tagged template literal for Hypen DSL templates.
139
+ *
140
+ * Works with the `state`, `item`, and `index` binding proxies to preserve
141
+ * binding syntax in the output string.
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * import { hypen, state, item } from "@hypen-space/core";
146
+ *
147
+ * // Simple state binding
148
+ * const t1 = hypen`Text("Count: ${state.count}")`;
149
+ * // Result: 'Text("Count: ${state.count}")'
150
+ *
151
+ * // Nested state binding
152
+ * const t2 = hypen`Text("Hello, ${state.user.profile.name}")`;
153
+ * // Result: 'Text("Hello, ${state.user.profile.name}")'
154
+ *
155
+ * // List with item binding
156
+ * const t3 = hypen`
157
+ * List(@state.products) {
158
+ * Text("${item.name} - $${item.price}")
159
+ * }
160
+ * `;
161
+ *
162
+ * // Complex expressions (use regular JS interpolation for static values)
163
+ * const title = "My App";
164
+ * const t4 = hypen`Text("${title}: ${state.count}")`;
165
+ * // Result: 'Text("My App: ${state.count}")'
166
+ * ```
167
+ *
168
+ * @param strings - Template literal string parts
169
+ * @param expressions - Interpolated expressions (proxies return binding strings)
170
+ * @returns The template string with bindings preserved
171
+ */
172
+ export function hypen(
173
+ strings: TemplateStringsArray,
174
+ ...expressions: unknown[]
175
+ ): string {
176
+ let result = strings[0];
177
+
178
+ for (let i = 0; i < expressions.length; i++) {
179
+ const expr = expressions[i];
180
+
181
+ // Convert expression to string
182
+ // Binding proxies will return "${state.x}" via their toString()
183
+ result += String(expr);
184
+ result += strings[i + 1]!;
185
+ }
186
+
187
+ return result!.trim();
188
+ }
189
+
190
+ /**
191
+ * Type helper for defining state shape.
192
+ * Use with state proxy for better IDE support in complex scenarios.
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * type MyState = { user: { name: string; age: number } };
197
+ * const typedState = state as StateProxy<MyState>;
198
+ * ```
199
+ */
200
+ export type StateProxy<T> = {
201
+ [K in keyof T]: T[K] extends object
202
+ ? StateProxy<T[K]> & { toString(): string }
203
+ : { toString(): string };
204
+ } & { toString(): string };
205
+
206
+ /**
207
+ * Type helper for item proxy in lists.
208
+ */
209
+ export type ItemProxy<T> = StateProxy<T>;