@magneticjs/server 0.1.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.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @magneticjs/server
2
+
3
+ Server-driven UI framework for Magnetic. Provides the JSX runtime that transforms TSX into JSON DOM descriptors, plus routing and SSR utilities.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @magneticjs/server
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### JSX Runtime (pages & components)
14
+
15
+ ```tsx
16
+ // tsconfig.json: { "jsx": "react-jsx", "jsxImportSource": "@magneticjs/server" }
17
+ import { Head, Link } from '@magneticjs/server/jsx-runtime';
18
+
19
+ export function IndexPage(props: any) {
20
+ return (
21
+ <div key="app">
22
+ <Head><title>My App</title></Head>
23
+ <h1>{props.title}</h1>
24
+ <Link href="/about">About</Link>
25
+ <button onClick="do_something">Click me</button>
26
+ </div>
27
+ );
28
+ }
29
+ ```
30
+
31
+ ### Router
32
+
33
+ ```ts
34
+ import { createRouter } from '@magneticjs/server/router';
35
+
36
+ const router = createRouter([
37
+ { path: '/', page: IndexPage },
38
+ { path: '/about', page: AboutPage },
39
+ { path: '*', page: NotFoundPage },
40
+ ]);
41
+
42
+ const result = router.resolve('/about', viewModel);
43
+ // → { kind: 'render', dom: DomNode }
44
+ ```
45
+
46
+ ## Key Concepts
47
+
48
+ - **DomNode**: JSON DOM descriptor `{ tag, key?, attrs?, events?, text?, children? }`
49
+ - **Events**: `onClick`, `onSubmit`, `onInput` → action names (strings, not callbacks)
50
+ - **Head**: Declares `<title>` and `<meta>` tags for SSR
51
+ - **Link**: Client-side navigation without page reload
52
+ - **Fragment**: Groups children without a wrapper element
53
+
54
+ ## Exports
55
+
56
+ | Path | Description |
57
+ |------|-------------|
58
+ | `@magneticjs/server` | Core index |
59
+ | `@magneticjs/server/jsx-runtime` | JSX factory, Head, Link, Fragment, DomNode |
60
+ | `@magneticjs/server/router` | createRouter, route matching |
61
+ | `@magneticjs/server/ssr` | render_page, PageOptions |
62
+
63
+ ## License
64
+
65
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@magneticjs/server",
3
+ "version": "0.1.0",
4
+ "description": "Magnetic server-driven UI framework — JSX runtime, router, SSR",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./jsx-runtime": "./src/jsx-runtime.ts",
9
+ "./router": "./src/router.ts",
10
+ "./ssr": "./src/ssr.ts"
11
+ },
12
+ "files": ["src"],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": { "type": "git", "url": "https://github.com/inventhq/magnetic.git", "directory": "js/packages/magnetic-server" },
17
+ "homepage": "https://github.com/inventhq/magnetic#readme",
18
+ "keywords": ["magnetic", "server-driven-ui", "jsx", "ssr", "dom-descriptors"],
19
+ "license": "MIT"
20
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,190 @@
1
+ // @magnetic/server — Static Asset Pipeline
2
+ // Content-hashed filenames, cache headers, asset manifest
3
+
4
+ import { createHash } from 'node:crypto';
5
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
6
+ import { join, basename, extname } from 'node:path';
7
+
8
+ // ── Asset manifest ──────────────────────────────────────────────────
9
+
10
+ export interface AssetManifest {
11
+ /** Maps original filename → hashed filename */
12
+ files: Record<string, string>;
13
+ /** Reverse map: hashed filename → original filename */
14
+ reverse: Record<string, string>;
15
+ }
16
+
17
+ /**
18
+ * Builds a content-hashed asset manifest from a source directory.
19
+ * Copies files to outDir with hashed names.
20
+ *
21
+ * e.g. style.css → style.a1b2c3d4.css
22
+ */
23
+ export function buildAssets(opts: {
24
+ /** Source directory with original files */
25
+ srcDir: string;
26
+ /** Output directory for hashed files */
27
+ outDir: string;
28
+ /** File extensions to hash (default: css, js, wasm) */
29
+ extensions?: string[];
30
+ /** Files to skip hashing (served as-is) */
31
+ passthrough?: string[];
32
+ }): AssetManifest {
33
+ const {
34
+ srcDir,
35
+ outDir,
36
+ extensions = ['.css', '.js', '.wasm'],
37
+ passthrough = [],
38
+ } = opts;
39
+
40
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
41
+
42
+ const manifest: AssetManifest = { files: {}, reverse: {} };
43
+
44
+ if (!existsSync(srcDir)) return manifest;
45
+
46
+ const entries = readdirSync(srcDir);
47
+
48
+ for (const entry of entries) {
49
+ const ext = extname(entry);
50
+ const srcPath = join(srcDir, entry);
51
+
52
+ // Passthrough files — copy without hashing
53
+ if (passthrough.includes(entry)) {
54
+ copyFileSync(srcPath, join(outDir, entry));
55
+ manifest.files[entry] = entry;
56
+ manifest.reverse[entry] = entry;
57
+ continue;
58
+ }
59
+
60
+ // Only hash configured extensions
61
+ if (!extensions.includes(ext)) {
62
+ copyFileSync(srcPath, join(outDir, entry));
63
+ manifest.files[entry] = entry;
64
+ manifest.reverse[entry] = entry;
65
+ continue;
66
+ }
67
+
68
+ // Read file and compute content hash
69
+ const content = readFileSync(srcPath);
70
+ const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
71
+ const name = basename(entry, ext);
72
+ const hashedName = `${name}.${hash}${ext}`;
73
+
74
+ copyFileSync(srcPath, join(outDir, hashedName));
75
+ manifest.files[entry] = hashedName;
76
+ manifest.reverse[hashedName] = entry;
77
+ }
78
+
79
+ return manifest;
80
+ }
81
+
82
+ /**
83
+ * Saves manifest to a JSON file for use at runtime.
84
+ */
85
+ export function saveManifest(manifest: AssetManifest, filePath: string): void {
86
+ writeFileSync(filePath, JSON.stringify(manifest, null, 2));
87
+ }
88
+
89
+ /**
90
+ * Loads manifest from a JSON file.
91
+ */
92
+ export function loadManifest(filePath: string): AssetManifest {
93
+ if (!existsSync(filePath)) return { files: {}, reverse: {} };
94
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
95
+ }
96
+
97
+ // ── Asset URL resolver ──────────────────────────────────────────────
98
+
99
+ /**
100
+ * Creates an asset resolver that maps original filenames to hashed URLs.
101
+ *
102
+ * Usage:
103
+ * ```ts
104
+ * const asset = createAssetResolver(manifest, '/static');
105
+ * asset('style.css') // → '/static/style.a1b2c3d4.css'
106
+ * ```
107
+ */
108
+ export function createAssetResolver(
109
+ manifest: AssetManifest,
110
+ prefix: string = '',
111
+ ): (filename: string) => string {
112
+ return (filename: string) => {
113
+ const hashed = manifest.files[filename];
114
+ return prefix + '/' + (hashed || filename);
115
+ };
116
+ }
117
+
118
+ // ── Serve static with cache headers ─────────────────────────────────
119
+
120
+ export interface StaticFileResult {
121
+ found: boolean;
122
+ content?: Buffer;
123
+ contentType?: string;
124
+ headers?: Record<string, string>;
125
+ }
126
+
127
+ const MIME: Record<string, string> = {
128
+ '.html': 'text/html; charset=utf-8',
129
+ '.js': 'application/javascript',
130
+ '.css': 'text/css',
131
+ '.json': 'application/json',
132
+ '.wasm': 'application/wasm',
133
+ '.png': 'image/png',
134
+ '.jpg': 'image/jpeg',
135
+ '.jpeg': 'image/jpeg',
136
+ '.gif': 'image/gif',
137
+ '.svg': 'image/svg+xml',
138
+ '.ico': 'image/x-icon',
139
+ '.webp': 'image/webp',
140
+ '.woff': 'font/woff',
141
+ '.woff2': 'font/woff2',
142
+ '.ttf': 'font/ttf',
143
+ };
144
+
145
+ /**
146
+ * Serves a static file with appropriate cache headers.
147
+ * Content-hashed files get immutable cache (1 year).
148
+ * Non-hashed files get short cache (5 min) with revalidation.
149
+ */
150
+ export function serveStatic(
151
+ dir: string,
152
+ urlPath: string,
153
+ manifest?: AssetManifest,
154
+ ): StaticFileResult {
155
+ // Strip leading slash
156
+ const filename = urlPath.replace(/^\//, '');
157
+ const filePath = join(dir, filename);
158
+
159
+ // Security: prevent path traversal
160
+ if (!filePath.startsWith(dir)) {
161
+ return { found: false };
162
+ }
163
+
164
+ if (!existsSync(filePath)) {
165
+ return { found: false };
166
+ }
167
+
168
+ const ext = extname(filename);
169
+ const contentType = MIME[ext] || 'application/octet-stream';
170
+ const content = readFileSync(filePath);
171
+
172
+ // Determine cache strategy
173
+ const isHashed = manifest?.reverse[filename] != null
174
+ && manifest.reverse[filename] !== filename;
175
+
176
+ const headers: Record<string, string> = {
177
+ 'Content-Type': contentType,
178
+ 'Content-Length': String(content.length),
179
+ };
180
+
181
+ if (isHashed) {
182
+ // Immutable — content-hashed, safe to cache forever
183
+ headers['Cache-Control'] = 'public, max-age=31536000, immutable';
184
+ } else {
185
+ // Short cache with revalidation
186
+ headers['Cache-Control'] = 'public, max-age=300, must-revalidate';
187
+ }
188
+
189
+ return { found: true, content, contentType, headers };
190
+ }
@@ -0,0 +1,63 @@
1
+ // @magnetic/server — Error Boundaries
2
+ // Wraps render/reduce in try-catch with fallback UI
3
+
4
+ import type { DomNode } from './jsx-runtime.ts';
5
+
6
+ export interface ErrorFallbackProps {
7
+ error: Error;
8
+ path?: string;
9
+ action?: string;
10
+ }
11
+
12
+ export type ErrorFallback = (props: ErrorFallbackProps) => DomNode;
13
+
14
+ /** Default fallback — minimal error display */
15
+ export const defaultFallback: ErrorFallback = ({ error, action }) => ({
16
+ tag: 'div',
17
+ key: 'error-boundary',
18
+ attrs: { class: 'magnetic-error' },
19
+ children: [
20
+ { tag: 'h2', key: 'err-h', text: 'Something went wrong' },
21
+ { tag: 'p', key: 'err-msg', text: error.message },
22
+ ...(action ? [{ tag: 'p', key: 'err-act', text: `Action: ${action}` }] : []),
23
+ ],
24
+ });
25
+
26
+ /**
27
+ * Wraps a render function with error handling.
28
+ * If render throws, the fallback component is returned instead.
29
+ */
30
+ export function withErrorBoundary<T extends (...args: any[]) => DomNode>(
31
+ renderFn: T,
32
+ fallback: ErrorFallback = defaultFallback,
33
+ ): T {
34
+ return ((...args: any[]) => {
35
+ try {
36
+ return renderFn(...args);
37
+ } catch (err) {
38
+ const error = err instanceof Error ? err : new Error(String(err));
39
+ console.error('[magnetic] render error:', error.message);
40
+ return fallback({ error });
41
+ }
42
+ }) as T;
43
+ }
44
+
45
+ /**
46
+ * Wraps a reducer with error handling.
47
+ * If reduce throws, the original state is returned unchanged.
48
+ */
49
+ export function safeReduce<S>(
50
+ reduceFn: (state: S, action: string, payload: any) => S,
51
+ onError?: (error: Error, action: string) => void,
52
+ ): (state: S, action: string, payload: any) => S {
53
+ return (state, action, payload) => {
54
+ try {
55
+ return reduceFn(state, action, payload);
56
+ } catch (err) {
57
+ const error = err instanceof Error ? err : new Error(String(err));
58
+ console.error(`[magnetic] reduce error on "${action}":`, error.message);
59
+ if (onError) onError(error, action);
60
+ return state;
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,196 @@
1
+ // @magnetic/server — File-based Router
2
+ // Scans a pages/ directory and generates route definitions from file conventions.
3
+ //
4
+ // Convention:
5
+ // pages/
6
+ // index.tsx → /
7
+ // about.tsx → /about
8
+ // layout.tsx → layout wrapping all routes at this level
9
+ // _guard.ts → guard for all routes at this level
10
+ // tasks/
11
+ // index.tsx → /tasks
12
+ // [id].tsx → /tasks/:id
13
+ // layout.tsx → layout wrapping /tasks/* routes
14
+ // _guard.ts → guard for /tasks/* routes
15
+ //
16
+ // Exports `scanPages()` which returns a RouteDefinition[] tree.
17
+ // This runs at build time or server startup — not at request time.
18
+
19
+ import { readdirSync, statSync, existsSync } from 'node:fs';
20
+ import { join, basename, extname } from 'node:path';
21
+ import type { RouteDefinition, PageComponent, LayoutComponent, RouteGuard } from './router.ts';
22
+
23
+ export interface FileRouterOptions {
24
+ /** Absolute path to the pages/ directory */
25
+ pagesDir: string;
26
+ /** Function to import a module given its file path. Default: dynamic import */
27
+ importFn?: (filePath: string) => any;
28
+ }
29
+
30
+ interface ScannedModule {
31
+ default?: PageComponent | LayoutComponent | RouteGuard;
32
+ guard?: RouteGuard;
33
+ layout?: LayoutComponent;
34
+ redirect?: string;
35
+ }
36
+
37
+ /**
38
+ * Scans a pages/ directory and returns route definitions.
39
+ *
40
+ * Each .tsx file becomes a route. The default export is the page component.
41
+ * Special files:
42
+ * - `layout.tsx` → layout component (default export)
43
+ * - `_guard.ts` → route guard (default or named `guard` export)
44
+ *
45
+ * Dynamic params use bracket syntax: `[id].tsx` → `:id`
46
+ */
47
+ export function scanPages(
48
+ pagesDir: string,
49
+ modules: Record<string, ScannedModule>,
50
+ ): RouteDefinition[] {
51
+ if (!existsSync(pagesDir)) return [];
52
+ return scanDir(pagesDir, '', modules);
53
+ }
54
+
55
+ function scanDir(
56
+ dir: string,
57
+ pathPrefix: string,
58
+ modules: Record<string, ScannedModule>,
59
+ ): RouteDefinition[] {
60
+ const entries = readdirSync(dir).sort();
61
+ const routes: RouteDefinition[] = [];
62
+
63
+ // Check for layout and guard at this level
64
+ let layout: LayoutComponent | undefined;
65
+ let guard: RouteGuard | undefined;
66
+
67
+ const layoutFile = findFile(dir, 'layout');
68
+ if (layoutFile) {
69
+ const mod = modules[layoutFile];
70
+ if (mod) layout = (mod.default || mod.layout) as LayoutComponent;
71
+ }
72
+
73
+ const guardFile = findFile(dir, '_guard');
74
+ if (guardFile) {
75
+ const mod = modules[guardFile];
76
+ if (mod) guard = (mod.default || mod.guard) as RouteGuard;
77
+ }
78
+
79
+ // Scan files and subdirectories
80
+ const files: string[] = [];
81
+ const dirs: string[] = [];
82
+
83
+ for (const entry of entries) {
84
+ const full = join(dir, entry);
85
+ const stat = statSync(full);
86
+ if (stat.isDirectory()) {
87
+ dirs.push(entry);
88
+ } else if (/\.(tsx?|jsx?)$/.test(entry)) {
89
+ const name = basename(entry, extname(entry));
90
+ // Skip special files
91
+ if (name === 'layout' || name === '_guard') continue;
92
+ files.push(entry);
93
+ }
94
+ }
95
+
96
+ // Process page files
97
+ for (const file of files) {
98
+ const name = basename(file, extname(file));
99
+ const filePath = join(dir, file);
100
+ const mod = modules[filePath];
101
+ if (!mod || !mod.default) continue;
102
+
103
+ let segment: string;
104
+ if (name === 'index') {
105
+ segment = pathPrefix || '/';
106
+ } else if (name.startsWith('[') && name.endsWith(']')) {
107
+ // Dynamic param: [id].tsx → :id
108
+ const param = name.slice(1, -1);
109
+ segment = pathPrefix + '/:' + param;
110
+ } else {
111
+ segment = pathPrefix + '/' + name;
112
+ }
113
+
114
+ const route: RouteDefinition = {
115
+ path: segment,
116
+ page: mod.default as PageComponent,
117
+ };
118
+
119
+ if (mod.redirect) route.redirect = mod.redirect;
120
+
121
+ routes.push(route);
122
+ }
123
+
124
+ // Process subdirectories (nested routes)
125
+ for (const dirName of dirs) {
126
+ if (dirName.startsWith('.') || dirName === 'node_modules') continue;
127
+
128
+ const subDir = join(dir, dirName);
129
+ let segment: string;
130
+ if (dirName.startsWith('[') && dirName.endsWith(']')) {
131
+ const param = dirName.slice(1, -1);
132
+ segment = pathPrefix + '/:' + param;
133
+ } else {
134
+ segment = pathPrefix + '/' + dirName;
135
+ }
136
+
137
+ const children = scanDir(subDir, segment, modules);
138
+
139
+ if (children.length > 0) {
140
+ // Check if any child is the index for this path
141
+ const indexIdx = children.findIndex((c) => c.path === segment);
142
+
143
+ if (indexIdx >= 0 || layout || guard) {
144
+ // Create a parent route node with children
145
+ const parentRoute: RouteDefinition = {
146
+ path: segment,
147
+ children,
148
+ };
149
+ if (layout) parentRoute.layout = layout;
150
+ if (guard) parentRoute.guard = guard;
151
+
152
+ // If there's an index page, lift it to be the parent's page
153
+ if (indexIdx >= 0) {
154
+ parentRoute.page = children[indexIdx].page;
155
+ children.splice(indexIdx, 1);
156
+ }
157
+
158
+ routes.push(parentRoute);
159
+ } else {
160
+ // No layout/guard — flatten children into current level
161
+ routes.push(...children);
162
+ }
163
+ }
164
+ }
165
+
166
+ // If this is the root level and we have a layout/guard, wrap everything
167
+ if (pathPrefix === '' && (layout || guard)) {
168
+ return [{
169
+ path: '/',
170
+ layout,
171
+ guard,
172
+ children: routes,
173
+ }];
174
+ }
175
+
176
+ return routes;
177
+ }
178
+
179
+ function findFile(dir: string, name: string): string | null {
180
+ for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {
181
+ const full = join(dir, name + ext);
182
+ if (existsSync(full)) return full;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Helper: converts a pages/ directory path to a route path segment.
189
+ * e.g. "[id]" → ":id", "about" → "about"
190
+ */
191
+ export function fileNameToSegment(name: string): string {
192
+ if (name.startsWith('[') && name.endsWith(']')) {
193
+ return ':' + name.slice(1, -1);
194
+ }
195
+ return name;
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ // @magnetic/server — Magnetic Server Components
2
+ // Re-exports for developer use
3
+
4
+ export type { DomNode } from './jsx-runtime.ts';
5
+ export { Link, Head } from './jsx-runtime.ts';
6
+
7
+ export { createRouter, renderRoute, navigateAction } from './router.ts';
8
+ export type { Router, RouteDefinition, RouteMatch, RouteResult, RouteGuard, PageComponent, LayoutComponent } from './router.ts';
9
+
10
+ export { scanPages, fileNameToSegment } from './file-router.ts';
11
+ export type { FileRouterOptions } from './file-router.ts';
12
+
13
+ export { renderToHTML, renderPage, extractHead } from './ssr.ts';
14
+ export type { PageOptions, ExtractedHead } from './ssr.ts';
15
+
16
+ export { withErrorBoundary, safeReduce, defaultFallback } from './error-boundary.ts';
17
+ export type { ErrorFallback, ErrorFallbackProps } from './error-boundary.ts';
18
+
19
+ export { createMiddleware, createContext, loggerMiddleware, corsMiddleware, rateLimitMiddleware } from './middleware.ts';
20
+ export type { MiddlewareStack, MiddlewareFn, MagneticContext, NextFn } from './middleware.ts';
21
+
22
+ export { buildAssets, saveManifest, loadManifest, createAssetResolver, serveStatic } from './assets.ts';
23
+ export type { AssetManifest, StaticFileResult } from './assets.ts';