@vyckr/tachyon 1.1.11 → 1.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/.env.example +7 -4
  2. package/LICENSE +21 -0
  3. package/README.md +210 -90
  4. package/package.json +50 -33
  5. package/src/cli/bundle.ts +37 -0
  6. package/src/cli/serve.ts +100 -0
  7. package/src/{client/template.js → compiler/render-template.js} +10 -17
  8. package/src/compiler/template-compiler.ts +419 -0
  9. package/src/runtime/hot-reload-client.ts +15 -0
  10. package/src/{client/dev.html → runtime/shells/development.html} +2 -2
  11. package/src/runtime/shells/not-found.html +73 -0
  12. package/src/{client/prod.html → runtime/shells/production.html} +1 -1
  13. package/src/runtime/spa-renderer.ts +439 -0
  14. package/src/server/console-logger.ts +39 -0
  15. package/src/server/process-executor.ts +287 -0
  16. package/src/server/process-pool.ts +80 -0
  17. package/src/server/route-handler.ts +229 -0
  18. package/src/server/schema-validator.ts +161 -0
  19. package/bun.lock +0 -127
  20. package/components/clicker.html +0 -30
  21. package/deno.lock +0 -19
  22. package/go.mod +0 -3
  23. package/lib/gson-2.3.jar +0 -0
  24. package/main.js +0 -13
  25. package/routes/DELETE +0 -18
  26. package/routes/GET +0 -17
  27. package/routes/HTML +0 -135
  28. package/routes/POST +0 -32
  29. package/routes/SOCKET +0 -26
  30. package/routes/api/:version/DELETE +0 -10
  31. package/routes/api/:version/GET +0 -29
  32. package/routes/api/:version/PATCH +0 -24
  33. package/routes/api/GET +0 -29
  34. package/routes/api/POST +0 -16
  35. package/routes/api/PUT +0 -21
  36. package/src/client/404.html +0 -7
  37. package/src/client/dist.ts +0 -20
  38. package/src/client/hmr.ts +0 -12
  39. package/src/client/render.ts +0 -417
  40. package/src/client/routes.json +0 -1
  41. package/src/client/yon.ts +0 -364
  42. package/src/router.ts +0 -186
  43. package/src/serve.ts +0 -147
  44. package/src/server/logger.ts +0 -31
  45. package/src/server/tach.ts +0 -238
  46. package/tests/index.test.ts +0 -110
  47. package/tests/stream.ts +0 -24
  48. package/tests/worker.ts +0 -7
  49. package/tsconfig.json +0 -17
@@ -0,0 +1,439 @@
1
+ type RenderFn = (elementId?: string | null, eventDetail?: unknown) => Promise<string>;
2
+
3
+ // ── State ──────────────────────────────────────────────────────────────────────
4
+ let pageRender: RenderFn;
5
+ let layoutRender: RenderFn | null = null;
6
+ let currentLayoutPath: string | null = null;
7
+ let previousHTML = '';
8
+ let focusTarget: string | null = null;
9
+ let freshNavigation = false;
10
+
11
+ const routes = new Map<string, Record<string, number>>();
12
+ const layouts: Record<string, string> = {};
13
+ const slugs: Record<string, string> = {};
14
+ let params: (string | number | boolean | null | undefined)[] = [];
15
+
16
+ const parser = new DOMParser();
17
+
18
+ // ── Bootstrap ──────────────────────────────────────────────────────────────────
19
+ Promise.all([
20
+ fetch('/routes.json').then(r => r.json()),
21
+ fetch('/layouts.json').then(r => r.json()),
22
+ ]).then(([routeData, layoutData]) => {
23
+ for (const [path, s] of Object.entries(routeData))
24
+ routes.set(path, s as Record<string, number>);
25
+ Object.assign(layouts, layoutData);
26
+ navigate(location.pathname);
27
+ });
28
+
29
+ // ── Event Delegation ───────────────────────────────────────────────────────────
30
+ // Single delegated listener at the document level instead of per-element binding.
31
+ // Handles both `@event` attribute actions and `:value` two-way binding.
32
+ document.addEventListener('click', (ev: MouseEvent) => {
33
+ // SPA link interception
34
+ const anchor = (ev.target as Element)?.closest('a[href]') as HTMLAnchorElement | null;
35
+ if (anchor) {
36
+ const url = new URL(anchor.href, location.origin);
37
+ if (url.origin === location.origin) {
38
+ ev.preventDefault();
39
+ navigate(url.pathname);
40
+ return;
41
+ }
42
+ }
43
+
44
+ // Delegated @click
45
+ const target = findEventTarget(ev.target as Element, 'click');
46
+ if (target) {
47
+ ev.preventDefault();
48
+ dispatchAction(target);
49
+ }
50
+ });
51
+
52
+ // Value-change events (input, change, sl-input, sl-change)
53
+ for (const eventName of ['input', 'change', 'sl-input', 'sl-change'] as const) {
54
+ document.addEventListener(eventName, (ev: Event) => {
55
+ const el = ev.target as HTMLElement;
56
+ if (!el?.id || !el.hasAttribute('value')) return;
57
+ const value = (el as HTMLInputElement).value;
58
+ rerender(el.id, { value });
59
+ });
60
+ }
61
+
62
+ window.addEventListener('popstate', () => navigate(location.pathname));
63
+
64
+ // ── Event helpers ──────────────────────────────────────────────────────────────
65
+ function findEventTarget(el: Element | null, eventName: string): Element | null {
66
+ while (el && el !== document.body) {
67
+ if (el.hasAttribute(`@${eventName}`)) return el;
68
+ el = el.parentElement;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function dispatchAction(el: Element) {
74
+ rerender(el.id);
75
+ }
76
+
77
+ function findLazyAncestor(elementId: string): HTMLElement | null {
78
+ let el = document.getElementById(elementId);
79
+ while (el) {
80
+ if (lazyRenders.has(el.id)) return el;
81
+ el = el.parentElement;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ async function rerender(triggerId: string, eventDetail?: unknown) {
87
+ focusTarget = triggerId;
88
+
89
+ // Check if the event is inside a lazy-loaded component
90
+ const lazyContainer = findLazyAncestor(triggerId);
91
+ if (lazyContainer) {
92
+ const render = lazyRenders.get(lazyContainer.id) as RenderFn;
93
+ await render(triggerId, eventDetail);
94
+ const html = await render();
95
+ morphChildren(lazyContainer, parseFragment(html));
96
+ postPatch();
97
+ return;
98
+ }
99
+
100
+ const inSlot = layoutRender ? isInsideSlot(triggerId) : false;
101
+
102
+ if (layoutRender && inSlot) {
103
+ // First call executes the matched action (mutates state), second builds clean HTML
104
+ await pageRender(triggerId, eventDetail);
105
+ const html = await pageRender();
106
+ patchSlot(html);
107
+ } else if (layoutRender) {
108
+ await rerenderLayout(triggerId, eventDetail);
109
+ } else {
110
+ await pageRender(triggerId, eventDetail);
111
+ patchBody(await pageRender());
112
+ }
113
+ }
114
+
115
+ function isInsideSlot(elementId: string): boolean {
116
+ const el = document.getElementById(elementId);
117
+ if (!el) return false;
118
+ let node: Element | null = el;
119
+ while (node) {
120
+ if (node.id === 'ty-layout-slot') return true;
121
+ node = node.parentElement;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ // ── Layout ─────────────────────────────────────────────────────────────────────
127
+ async function rerenderLayout(triggerId?: string | null, eventDetail?: unknown) {
128
+ if (!layoutRender) return;
129
+ const slotHTML = document.getElementById('ty-layout-slot')?.innerHTML ?? '';
130
+ // First call executes the matched action, second builds clean HTML
131
+ if (triggerId) await layoutRender(triggerId, eventDetail);
132
+ const html = await layoutRender();
133
+ document.body.innerHTML = html;
134
+ const slot = document.getElementById('ty-layout-slot');
135
+ if (slot) slot.innerHTML = slotHTML;
136
+ postPatch();
137
+ }
138
+
139
+ function resolveLayout(pathname: string): string | null {
140
+ if (pathname !== '/') {
141
+ const segs = pathname.split('/');
142
+ for (let i = segs.length; i >= 1; i--) {
143
+ const prefix = segs.slice(0, i).join('/') || '/';
144
+ if (layouts[prefix]) return layouts[prefix];
145
+ }
146
+ }
147
+ return layouts['/'] ?? null;
148
+ }
149
+
150
+ // ── DOM Patching ───────────────────────────────────────────────────────────────
151
+ function patchSlot(html: string) {
152
+ const slot = document.getElementById('ty-layout-slot');
153
+ if (!slot || (!html && !previousHTML)) return;
154
+ if (html === previousHTML) return;
155
+ previousHTML = html;
156
+
157
+ if (freshNavigation) {
158
+ slot.innerHTML = html;
159
+ } else {
160
+ morphChildren(slot, parseFragment(html));
161
+ }
162
+ postPatch();
163
+ }
164
+
165
+ function patchBody(html: string) {
166
+ if (!html || html === previousHTML) return;
167
+ previousHTML = html;
168
+
169
+ if (freshNavigation) {
170
+ document.body.innerHTML = html;
171
+ } else {
172
+ morphChildren(document.body, parseFragment(html));
173
+ }
174
+ postPatch();
175
+ }
176
+
177
+ function parseFragment(html: string): DocumentFragment {
178
+ const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
179
+ const frag = document.createDocumentFragment();
180
+ while (doc.body.firstChild) frag.appendChild(doc.body.firstChild);
181
+ return frag;
182
+ }
183
+
184
+ // ── Lazy Component Loading ─────────────────────────────────────────────────────
185
+ const lazyLoaded = new Set<string>();
186
+ const lazyRenders = new Map<string, Function>();
187
+
188
+ const lazyObserver = new IntersectionObserver((entries) => {
189
+ for (const entry of entries) {
190
+ if (!entry.isIntersecting) continue;
191
+ const el = entry.target as HTMLElement;
192
+ const id = el.id;
193
+ if (lazyLoaded.has(id)) continue;
194
+ lazyLoaded.add(id);
195
+ lazyObserver.unobserve(el);
196
+ loadLazyComponent(el);
197
+ }
198
+ }, { rootMargin: '100px' });
199
+
200
+ async function loadLazyComponent(el: HTMLElement) {
201
+ const path = el.dataset.lazyComponent!;
202
+ const modulePath = el.dataset.lazyPath!;
203
+ const props = el.dataset.lazyProps || '';
204
+
205
+ try {
206
+ const mod = await import(modulePath);
207
+ const render = await mod.default(props);
208
+ lazyRenders.set(el.id, render);
209
+ el.innerHTML = await render();
210
+ el.removeAttribute('data-lazy-component');
211
+ el.removeAttribute('data-lazy-path');
212
+ el.removeAttribute('data-lazy-props');
213
+
214
+ // Wire up event delegation for lazy-loaded content
215
+ const eventEls = el.querySelectorAll('[\\@click]');
216
+ // Events are already handled by delegation — no extra wiring needed
217
+ } catch (e) {
218
+ console.error(`[tachyon] Failed to load lazy component "${path}":`, e);
219
+ }
220
+ }
221
+
222
+ function observeLazyComponents() {
223
+ const placeholders = document.querySelectorAll('[data-lazy-component]');
224
+ for (const el of placeholders) {
225
+ if (!lazyLoaded.has(el.id)) {
226
+ lazyObserver.observe(el);
227
+ }
228
+ }
229
+ }
230
+
231
+ function postPatch() {
232
+ cleanBooleanAttrs();
233
+ observeLazyComponents();
234
+ if (focusTarget) {
235
+ const el = document.getElementById(focusTarget);
236
+ if (el) try { el.focus(); } catch {}
237
+ focusTarget = null;
238
+ }
239
+ freshNavigation = false;
240
+ }
241
+
242
+ /** Remove boolean HTML attributes that are explicitly "false" */
243
+ function cleanBooleanAttrs() {
244
+ const all = document.body.querySelectorAll('*');
245
+ for (const el of all) {
246
+ for (const attr of Array.from(el.attributes)) {
247
+ if (attr.name.endsWith('ed') && attr.value === 'false')
248
+ el.removeAttribute(attr.name);
249
+ }
250
+ }
251
+ }
252
+
253
+ // ── DOM Morphing ───────────────────────────────────────────────────────────────
254
+ // Efficient keyed reconciliation: matches nodes by id, then by tag+position.
255
+ function morphChildren(parent: Element | DocumentFragment, desired: DocumentFragment) {
256
+ const oldNodes = Array.from(parent.childNodes);
257
+ const newNodes = Array.from(desired.childNodes);
258
+
259
+ const maxLen = Math.max(oldNodes.length, newNodes.length);
260
+
261
+ for (let i = 0; i < maxLen; i++) {
262
+ const oldChild = oldNodes[i];
263
+ const newChild = newNodes[i];
264
+
265
+ if (!oldChild && newChild) {
266
+ parent.appendChild(newChild.cloneNode(true));
267
+ continue;
268
+ }
269
+ if (oldChild && !newChild) {
270
+ parent.removeChild(oldChild);
271
+ continue;
272
+ }
273
+ if (!oldChild || !newChild) continue;
274
+
275
+ if (!isSameNode(oldChild, newChild)) {
276
+ parent.replaceChild(newChild.cloneNode(true), oldChild);
277
+ continue;
278
+ }
279
+
280
+ // Text nodes
281
+ if (oldChild.nodeType === Node.TEXT_NODE) {
282
+ if (oldChild.textContent !== newChild.textContent)
283
+ oldChild.textContent = newChild.textContent;
284
+ continue;
285
+ }
286
+
287
+ // Element nodes — sync attributes then recurse
288
+ if (oldChild.nodeType === Node.ELEMENT_NODE) {
289
+ // Preserve lazy-loaded components — the page render outputs an empty placeholder
290
+ // but the live DOM has the loaded component content
291
+ if (lazyRenders.has((oldChild as Element).id)) continue;
292
+
293
+ syncAttributes(oldChild as Element, newChild as Element);
294
+ // Convert newChild children to a fragment for recursion
295
+ const childFrag = document.createDocumentFragment();
296
+ while (newChild.firstChild) childFrag.appendChild(newChild.firstChild);
297
+ morphChildren(oldChild as Element, childFrag);
298
+ }
299
+ }
300
+
301
+ // Trim excess old nodes
302
+ while (parent.childNodes.length > newNodes.length) {
303
+ parent.removeChild(parent.lastChild!);
304
+ }
305
+ }
306
+
307
+ function isSameNode(a: Node, b: Node): boolean {
308
+ if (a.nodeType !== b.nodeType) return false;
309
+ if (a.nodeType === Node.ELEMENT_NODE) {
310
+ const ae = a as Element, be = b as Element;
311
+ if (ae.tagName !== be.tagName) return false;
312
+ // Prefer matching by id for keyed reconciliation
313
+ if (ae.id && be.id) return ae.id === be.id;
314
+ }
315
+ return true;
316
+ }
317
+
318
+ function syncAttributes(oldEl: Element, newEl: Element) {
319
+ // Remove stale attributes (skip event attributes — they're declarative)
320
+ for (const attr of Array.from(oldEl.attributes)) {
321
+ if (!attr.name.startsWith('@') && !newEl.hasAttribute(attr.name))
322
+ oldEl.removeAttribute(attr.name);
323
+ }
324
+ // Add/update attributes
325
+ for (const attr of Array.from(newEl.attributes)) {
326
+ if (!attr.name.startsWith('@') && oldEl.getAttribute(attr.name) !== attr.value)
327
+ oldEl.setAttribute(attr.name, attr.value);
328
+ }
329
+ }
330
+
331
+ // ── Navigation / Routing ───────────────────────────────────────────────────────
332
+ function navigate(pathname: string) {
333
+ let handler: string;
334
+ let pageURL: string;
335
+
336
+ try {
337
+ handler = resolveHandler(pathname);
338
+ pageURL = `/pages${handler === '/' ? '' : handler}/HTML.js`;
339
+ } catch {
340
+ pageURL = '/pages/404.js';
341
+ handler = '';
342
+ }
343
+
344
+ const layoutPath = resolveLayout(pathname);
345
+ const layoutChanged = layoutPath !== currentLayoutPath;
346
+
347
+ const loadPage = async () => {
348
+ const mod = await import(pageURL);
349
+ if (location.pathname !== pathname) history.pushState({}, '', pathname);
350
+ else history.replaceState({}, '', pathname);
351
+ pageRender = await mod.default();
352
+ freshNavigation = true;
353
+ previousHTML = '';
354
+ lazyLoaded.clear();
355
+ lazyRenders.clear();
356
+ try {
357
+ if (layoutRender) {
358
+ patchSlot(await pageRender());
359
+ } else {
360
+ patchBody(await pageRender());
361
+ }
362
+ } finally {
363
+ freshNavigation = false;
364
+ }
365
+ };
366
+
367
+ if (layoutPath && layoutChanged) {
368
+ import(layoutPath).then(async (mod) => {
369
+ currentLayoutPath = layoutPath;
370
+ layoutRender = await mod.default();
371
+ freshNavigation = true;
372
+ previousHTML = '';
373
+ try {
374
+ document.body.innerHTML = await layoutRender!();
375
+ } finally {
376
+ freshNavigation = false;
377
+ }
378
+ await loadPage();
379
+ });
380
+ } else if (!layoutPath && currentLayoutPath) {
381
+ // Leaving a layout
382
+ currentLayoutPath = null;
383
+ layoutRender = null;
384
+ loadPage();
385
+ } else {
386
+ loadPage();
387
+ }
388
+ }
389
+
390
+ function resolveHandler(pathname: string): string {
391
+ if (pathname === '/') return pathname;
392
+
393
+ const segments = pathname.split('/').slice(1);
394
+ let bestKey = '';
395
+ let bestLen = -1;
396
+
397
+ for (const [routeKey] of routes) {
398
+ const routeSegs = routeKey.split('/');
399
+ if (routeSegs.length > segments.length) continue;
400
+
401
+ const slugMap = routes.get(routeKey) ?? {};
402
+ let match = true;
403
+
404
+ for (let i = 0; i < routeSegs.length; i++) {
405
+ if (!slugMap[routeSegs[i]] && routeSegs[i] !== segments[i]) {
406
+ match = false;
407
+ break;
408
+ }
409
+ }
410
+
411
+ if (match && routeSegs.length > bestLen) {
412
+ bestKey = routeKey;
413
+ bestLen = routeSegs.length;
414
+ }
415
+ }
416
+
417
+ if (!bestKey) throw new Error(`Route ${pathname} not found`);
418
+
419
+ // Set slugs and params
420
+ const slugMap = routes.get(bestKey) ?? {};
421
+ for (const [key, idx] of Object.entries(slugMap)) {
422
+ slugs[key.replace(':', '')] = segments[idx];
423
+ }
424
+ params = parseParams(segments.slice(bestLen));
425
+
426
+ return bestKey;
427
+ }
428
+
429
+ function parseParams(input: string[]) {
430
+ return input.map(p => {
431
+ const n = Number(p);
432
+ if (!Number.isNaN(n)) return n;
433
+ if (p === 'true') return true;
434
+ if (p === 'false') return false;
435
+ if (p === 'null') return null;
436
+ if (p === 'undefined') return undefined;
437
+ return p;
438
+ });
439
+ }
@@ -0,0 +1,39 @@
1
+ /** ANSI reset escape code */
2
+ const reset = '\x1b[0m'
3
+
4
+ /** Returns a formatted UTC timestamp string */
5
+ const formatDate = (): string => new Date().toISOString().replace('T', ' ').replace('Z', '')
6
+
7
+ const LOG_LEVEL_COLORS = {
8
+ INFO: '\x1b[32m',
9
+ ERROR: '\x1b[31m',
10
+ DEBUG: '\x1b[36m',
11
+ WARN: '\x1b[33m',
12
+ TRACE: '\x1b[35m',
13
+ } as const
14
+
15
+ type LogLevel = keyof typeof LOG_LEVEL_COLORS
16
+
17
+ /**
18
+ * Creates a leveled console logger that prepends a timestamp and log level prefix.
19
+ * The last argument is treated as the context identifier (e.g. process PID).
20
+ * @param level - The log level label
21
+ * @returns A console-compatible logger function
22
+ */
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ function createLeveledLogger(level: LogLevel): (...args: any[]) => void {
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ return (...args: any[]) => {
27
+ const context = args.length > 1 ? args.pop() : process.pid
28
+ const color = process.stdout.isTTY ? LOG_LEVEL_COLORS[level] : ''
29
+ const resetCode = process.stdout.isTTY ? reset : ''
30
+ const prefix = `[${formatDate()}]${color} ${level}${resetCode} (${context})`
31
+ console.log(prefix, ...args)
32
+ }
33
+ }
34
+
35
+ console.info = createLeveledLogger('INFO')
36
+ console.error = createLeveledLogger('ERROR')
37
+ console.debug = createLeveledLogger('DEBUG')
38
+ console.warn = createLeveledLogger('WARN')
39
+ console.trace = createLeveledLogger('TRACE')