@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.
@@ -0,0 +1,244 @@
1
+ // @magnetic/server JSX runtime
2
+ // Transforms TSX → DomNode (JSON DOM descriptors for Magnetic)
3
+
4
+ export interface DomNode {
5
+ tag: string;
6
+ key?: string;
7
+ attrs?: Record<string, string>;
8
+ events?: Record<string, string>;
9
+ text?: string;
10
+ children?: DomNode[];
11
+ }
12
+
13
+ type Child = DomNode | string | number | boolean | null | undefined | Child[];
14
+
15
+ interface Props {
16
+ key?: string;
17
+ children?: Child | Child[];
18
+ [prop: string]: unknown;
19
+ }
20
+
21
+ type Component = (props: any) => DomNode;
22
+
23
+ // ── Built-in components ─────────────────────────────────────────────
24
+
25
+ /**
26
+ * Client-side navigation link. Renders an <a> that magnetic.js intercepts
27
+ * to do pushState + send navigate action (no full page reload).
28
+ */
29
+ export function Link(props: { href: string; children?: Child | Child[]; class?: string; [k: string]: unknown }): DomNode {
30
+ const { href, children, ...rest } = props;
31
+ return jsx('a', { ...rest, href, onClick: `navigate:${href}`, children }, undefined);
32
+ }
33
+
34
+ /**
35
+ * Declares <head> elements (title, meta, link, etc.) from within a page component.
36
+ * During SSR, these are extracted and placed into the document <head>.
37
+ * During live updates, they are ignored (head is static after SSR).
38
+ *
39
+ * Usage:
40
+ * <Head><title>My Page</title><meta name="description" content="..." /></Head>
41
+ */
42
+ export function Head({ children }: { children?: Child | Child[] }): DomNode {
43
+ const flat = flattenChildren(children);
44
+ return { tag: 'magnetic:head', children: flat };
45
+ }
46
+
47
+ // Event prop prefix → event name mapping
48
+ const EVENT_MAP: Record<string, string> = {
49
+ onClick: 'click',
50
+ onSubmit: 'submit',
51
+ onInput: 'input',
52
+ onChange: 'change',
53
+ onFocus: 'focus',
54
+ onBlur: 'blur',
55
+ onKeyDown: 'keydown',
56
+ onKeyUp: 'keyup',
57
+ onScroll: 'scroll',
58
+ };
59
+
60
+ function flattenChildren(raw: Child | Child[]): DomNode[] {
61
+ if (raw == null || raw === false || raw === true) return [];
62
+ if (typeof raw === 'string' || typeof raw === 'number') {
63
+ return [{ tag: 'span', text: String(raw) }];
64
+ }
65
+ if (Array.isArray(raw)) {
66
+ const out: DomNode[] = [];
67
+ for (const c of raw) {
68
+ out.push(...flattenChildren(c));
69
+ }
70
+ return out;
71
+ }
72
+ return [raw as DomNode];
73
+ }
74
+
75
+ export function jsx(tag: string | Component, props: Props, key?: string | number): DomNode {
76
+ const { children, ...rest } = props;
77
+
78
+ // Component function — call it with props (including children)
79
+ if (typeof tag === 'function') {
80
+ return tag({ ...rest, key, children });
81
+ }
82
+
83
+ // HTML element
84
+ const node: DomNode = { tag };
85
+ if (key != null) node.key = String(key);
86
+
87
+ const attrs: Record<string, string> = {};
88
+ const events: Record<string, string> = {};
89
+
90
+ for (const [k, v] of Object.entries(rest)) {
91
+ if (v == null || v === false) continue;
92
+
93
+ // Event props
94
+ if (EVENT_MAP[k]) {
95
+ events[EVENT_MAP[k]] = String(v);
96
+ continue;
97
+ }
98
+
99
+ // class prop → attrs.class
100
+ if (k === 'class' || k === 'className') {
101
+ const cls = String(v).trim();
102
+ if (cls) attrs['class'] = cls;
103
+ continue;
104
+ }
105
+
106
+ // Boolean attributes
107
+ if (v === true) {
108
+ attrs[k] = '';
109
+ continue;
110
+ }
111
+
112
+ attrs[k] = String(v);
113
+ }
114
+
115
+ if (Object.keys(attrs).length) node.attrs = attrs;
116
+ if (Object.keys(events).length) node.events = events;
117
+
118
+ // Children
119
+ if (children != null) {
120
+ // Single string/number child → text property (no wrapper span)
121
+ if (typeof children === 'string' || typeof children === 'number') {
122
+ node.text = String(children);
123
+ } else {
124
+ const flat = flattenChildren(children);
125
+ // If all children are text spans, merge into single text
126
+ if (flat.length === 1 && flat[0].tag === 'span' && flat[0].text != null && !flat[0].key) {
127
+ node.text = flat[0].text;
128
+ } else if (flat.length > 0) {
129
+ node.children = flat;
130
+ }
131
+ }
132
+ }
133
+
134
+ return node;
135
+ }
136
+
137
+ // jsxs = jsx with static children array (same implementation, key is 3rd arg)
138
+ export const jsxs = jsx;
139
+
140
+ // Fragment — returns children as-is (for use inside other elements)
141
+ export function Fragment({ children }: { children?: Child | Child[] }): DomNode {
142
+ const flat = flattenChildren(children);
143
+ if (flat.length === 1) return flat[0];
144
+ return { tag: 'div', children: flat };
145
+ }
146
+
147
+ // ── JSX namespace for TypeScript ────────────────────────────────────
148
+
149
+ type Booleanish = boolean | 'true' | 'false';
150
+
151
+ interface HtmlAttributes {
152
+ key?: string | number;
153
+ class?: string;
154
+ className?: string;
155
+ id?: string;
156
+ style?: string;
157
+ tabIndex?: number;
158
+ role?: string;
159
+ title?: string;
160
+ hidden?: Booleanish;
161
+ 'data-key'?: string;
162
+
163
+ // Events (action names, not callbacks)
164
+ onClick?: string;
165
+ onSubmit?: string;
166
+ onInput?: string;
167
+ onChange?: string;
168
+ onFocus?: string;
169
+ onBlur?: string;
170
+ onKeyDown?: string;
171
+ onKeyUp?: string;
172
+ onScroll?: string;
173
+
174
+ children?: Child | Child[];
175
+ [attr: string]: unknown;
176
+ }
177
+
178
+ interface InputAttributes extends HtmlAttributes {
179
+ type?: string;
180
+ name?: string;
181
+ value?: string;
182
+ placeholder?: string;
183
+ disabled?: boolean;
184
+ readonly?: boolean;
185
+ required?: boolean;
186
+ autocomplete?: string;
187
+ autofocus?: boolean;
188
+ checked?: boolean;
189
+ }
190
+
191
+ interface FormAttributes extends HtmlAttributes {
192
+ action?: string;
193
+ method?: string;
194
+ novalidate?: boolean;
195
+ }
196
+
197
+ export declare namespace JSX {
198
+ type Element = DomNode;
199
+ interface IntrinsicElements {
200
+ div: HtmlAttributes;
201
+ span: HtmlAttributes;
202
+ p: HtmlAttributes;
203
+ h1: HtmlAttributes;
204
+ h2: HtmlAttributes;
205
+ h3: HtmlAttributes;
206
+ h4: HtmlAttributes;
207
+ h5: HtmlAttributes;
208
+ h6: HtmlAttributes;
209
+ a: HtmlAttributes & { href?: string; target?: string; rel?: string };
210
+ button: HtmlAttributes & { type?: string; disabled?: boolean };
211
+ form: FormAttributes;
212
+ input: InputAttributes;
213
+ textarea: HtmlAttributes & { name?: string; placeholder?: string; rows?: number };
214
+ select: HtmlAttributes & { name?: string };
215
+ option: HtmlAttributes & { value?: string; selected?: boolean };
216
+ label: HtmlAttributes & { for?: string };
217
+ img: HtmlAttributes & { src?: string; alt?: string; width?: number; height?: number; loading?: string };
218
+ meta: HtmlAttributes & { name?: string; content?: string; property?: string; charset?: string; 'http-equiv'?: string };
219
+ title: HtmlAttributes;
220
+ link: HtmlAttributes & { rel?: string; href?: string; type?: string; sizes?: string; media?: string };
221
+ ul: HtmlAttributes;
222
+ ol: HtmlAttributes;
223
+ li: HtmlAttributes;
224
+ nav: HtmlAttributes;
225
+ header: HtmlAttributes;
226
+ footer: HtmlAttributes;
227
+ main: HtmlAttributes;
228
+ section: HtmlAttributes;
229
+ article: HtmlAttributes;
230
+ aside: HtmlAttributes;
231
+ strong: HtmlAttributes;
232
+ em: HtmlAttributes;
233
+ code: HtmlAttributes;
234
+ pre: HtmlAttributes;
235
+ hr: HtmlAttributes;
236
+ br: HtmlAttributes;
237
+ table: HtmlAttributes;
238
+ thead: HtmlAttributes;
239
+ tbody: HtmlAttributes;
240
+ tr: HtmlAttributes;
241
+ th: HtmlAttributes;
242
+ td: HtmlAttributes;
243
+ }
244
+ }
@@ -0,0 +1,174 @@
1
+ // @magnetic/server — Middleware
2
+ // Express-style use() chain with next() pattern for request processing
3
+
4
+ import type { DomNode } from './jsx-runtime.ts';
5
+
6
+ // ── Context ─────────────────────────────────────────────────────────
7
+
8
+ export interface MagneticContext {
9
+ /** HTTP method */
10
+ method: string;
11
+ /** Request URL path */
12
+ path: string;
13
+ /** Parsed query params */
14
+ query: Record<string, string>;
15
+ /** Request headers */
16
+ headers: Record<string, string>;
17
+ /** Action name (for POST /actions/:action) */
18
+ action?: string;
19
+ /** Action payload */
20
+ payload?: Record<string, any>;
21
+ /** Attached user/session data (set by auth middleware) */
22
+ user?: { id: string; [key: string]: unknown };
23
+ /** Response status code (middleware can set this) */
24
+ status: number;
25
+ /** Response headers to add */
26
+ responseHeaders: Record<string, string>;
27
+ /** If set, short-circuit with this response body */
28
+ body?: string;
29
+ /** Arbitrary storage for middleware to share data */
30
+ state: Record<string, unknown>;
31
+ }
32
+
33
+ // ── Middleware types ─────────────────────────────────────────────────
34
+
35
+ export type NextFn = () => void | Promise<void>;
36
+
37
+ export type MiddlewareFn = (
38
+ ctx: MagneticContext,
39
+ next: NextFn,
40
+ ) => void | Promise<void>;
41
+
42
+ // ── Middleware chain ─────────────────────────────────────────────────
43
+
44
+ export interface MiddlewareStack {
45
+ /** Add a middleware function */
46
+ use(fn: MiddlewareFn): void;
47
+ /** Run the middleware chain for a given context */
48
+ run(ctx: MagneticContext): Promise<MagneticContext>;
49
+ }
50
+
51
+ /**
52
+ * Creates a middleware stack.
53
+ *
54
+ * Usage:
55
+ * ```ts
56
+ * const mw = createMiddleware();
57
+ * mw.use(logger);
58
+ * mw.use(cors);
59
+ * mw.use(auth);
60
+ * const ctx = await mw.run(context);
61
+ * ```
62
+ */
63
+ export function createMiddleware(): MiddlewareStack {
64
+ const fns: MiddlewareFn[] = [];
65
+
66
+ return {
67
+ use(fn: MiddlewareFn) {
68
+ fns.push(fn);
69
+ },
70
+
71
+ async run(ctx: MagneticContext): Promise<MagneticContext> {
72
+ let index = 0;
73
+
74
+ async function next(): Promise<void> {
75
+ if (index >= fns.length) return;
76
+ // Short-circuit if body was set (middleware wants to respond early)
77
+ if (ctx.body != null) return;
78
+ const fn = fns[index++];
79
+ await fn(ctx, next);
80
+ }
81
+
82
+ await next();
83
+ return ctx;
84
+ },
85
+ };
86
+ }
87
+
88
+ // ── Helper: create context from raw request ─────────────────────────
89
+
90
+ export function createContext(opts: {
91
+ method: string;
92
+ url: string;
93
+ headers?: Record<string, string>;
94
+ action?: string;
95
+ payload?: Record<string, any>;
96
+ }): MagneticContext {
97
+ const [path, qs] = (opts.url || '/').split('?');
98
+ const query: Record<string, string> = {};
99
+ if (qs) {
100
+ for (const pair of qs.split('&')) {
101
+ const [k, v] = pair.split('=');
102
+ if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
103
+ }
104
+ }
105
+
106
+ return {
107
+ method: opts.method,
108
+ path,
109
+ query,
110
+ headers: opts.headers || {},
111
+ action: opts.action,
112
+ payload: opts.payload,
113
+ status: 200,
114
+ responseHeaders: {},
115
+ state: {},
116
+ };
117
+ }
118
+
119
+ // ── Built-in middleware ──────────────────────────────────────────────
120
+
121
+ /** Logs request method + path + timing */
122
+ export const loggerMiddleware: MiddlewareFn = async (ctx, next) => {
123
+ const start = Date.now();
124
+ await next();
125
+ const ms = Date.now() - start;
126
+ console.log(`[magnetic] ${ctx.method} ${ctx.path} → ${ctx.status} (${ms}ms)`);
127
+ };
128
+
129
+ /** CORS headers */
130
+ export function corsMiddleware(origins: string | string[] = '*'): MiddlewareFn {
131
+ const origin = Array.isArray(origins) ? origins.join(', ') : origins;
132
+ return async (ctx, next) => {
133
+ ctx.responseHeaders['Access-Control-Allow-Origin'] = origin;
134
+ ctx.responseHeaders['Access-Control-Allow-Headers'] = 'Content-Type';
135
+ ctx.responseHeaders['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
136
+ if (ctx.method === 'OPTIONS') {
137
+ ctx.status = 204;
138
+ ctx.body = '';
139
+ return;
140
+ }
141
+ await next();
142
+ };
143
+ }
144
+
145
+ /** Rate limiter (per-IP, sliding window) */
146
+ export function rateLimitMiddleware(opts: {
147
+ windowMs?: number;
148
+ max?: number;
149
+ } = {}): MiddlewareFn {
150
+ const windowMs = opts.windowMs || 60_000;
151
+ const max = opts.max || 100;
152
+ const hits = new Map<string, { count: number; resetAt: number }>();
153
+
154
+ return async (ctx, next) => {
155
+ const ip = ctx.headers['x-forwarded-for'] || ctx.headers['x-real-ip'] || 'unknown';
156
+ const now = Date.now();
157
+ let entry = hits.get(ip);
158
+
159
+ if (!entry || now > entry.resetAt) {
160
+ entry = { count: 0, resetAt: now + windowMs };
161
+ hits.set(ip, entry);
162
+ }
163
+
164
+ entry.count++;
165
+
166
+ if (entry.count > max) {
167
+ ctx.status = 429;
168
+ ctx.body = JSON.stringify({ error: 'Too many requests' });
169
+ return;
170
+ }
171
+
172
+ await next();
173
+ };
174
+ }
package/src/router.ts ADDED
@@ -0,0 +1,250 @@
1
+ // @magnetic/server — Router
2
+ // Nested routes, dynamic params, layouts, guards, redirects, file-based conventions
3
+
4
+ import type { DomNode } from './jsx-runtime.ts';
5
+
6
+ // ── Types ───────────────────────────────────────────────────────────
7
+
8
+ export type PageComponent = (props: {
9
+ params: Record<string, string>;
10
+ children?: DomNode;
11
+ [key: string]: unknown;
12
+ }) => DomNode;
13
+
14
+ export type LayoutComponent = (props: {
15
+ children: DomNode;
16
+ params: Record<string, string>;
17
+ path: string;
18
+ }) => DomNode;
19
+
20
+ /** Return true to allow, a string to redirect, or { redirect } */
21
+ export type RouteGuard = (ctx: {
22
+ path: string;
23
+ params: Record<string, string>;
24
+ }) => true | string | { redirect: string };
25
+
26
+ export interface RouteDefinition {
27
+ /** Path pattern: "/tasks/:id", "/about", "*" */
28
+ path: string;
29
+ /** Page component (leaf) */
30
+ page?: PageComponent;
31
+ /** Layout wrapping this route and its children */
32
+ layout?: LayoutComponent;
33
+ /** Guard — runs before render. Return true to allow, string to redirect */
34
+ guard?: RouteGuard;
35
+ /** Redirect target — if set, this route always redirects */
36
+ redirect?: string;
37
+ /** Nested child routes */
38
+ children?: RouteDefinition[];
39
+ }
40
+
41
+ export interface RouteMatch {
42
+ /** Matched page component */
43
+ page: PageComponent;
44
+ /** Extracted URL params */
45
+ params: Record<string, string>;
46
+ /** Layout chain from outermost to innermost */
47
+ layouts: LayoutComponent[];
48
+ /** Guards to run in order (outermost first) */
49
+ guards: RouteGuard[];
50
+ /** If set, this route should redirect instead of render */
51
+ redirect?: string;
52
+ }
53
+
54
+ /** Result of resolving a route — either a DomNode or a redirect */
55
+ export type RouteResult =
56
+ | { kind: 'render'; dom: DomNode }
57
+ | { kind: 'redirect'; to: string };
58
+
59
+ // ── Compiled route node ─────────────────────────────────────────────
60
+
61
+ interface CompiledNode {
62
+ def: RouteDefinition;
63
+ regex: RegExp;
64
+ paramNames: string[];
65
+ children: CompiledNode[];
66
+ }
67
+
68
+ function compileNode(def: RouteDefinition, prefix: string): CompiledNode {
69
+ let pattern: string;
70
+ const paramNames: string[] = [];
71
+
72
+ if (def.path === '*') {
73
+ pattern = '.*';
74
+ } else {
75
+ const parts = def.path.split('/').filter(Boolean);
76
+ const regParts = parts.map((p) => {
77
+ if (p.startsWith(':')) {
78
+ paramNames.push(p.slice(1));
79
+ return '([^/]+)';
80
+ }
81
+ return p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ });
83
+ pattern = regParts.length ? regParts.join('/') : '';
84
+ }
85
+
86
+ const fullPattern = prefix + (pattern ? '/' + pattern : '');
87
+ const hasChildren = def.children && def.children.length > 0;
88
+
89
+ // If has children, match prefix (allow more segments after)
90
+ // If leaf, match exactly
91
+ const regexStr = hasChildren
92
+ ? `^${fullPattern || ''}(?:/|$)`
93
+ : `^${fullPattern || '/'}$`;
94
+
95
+ const children = (def.children || []).map((c) =>
96
+ compileNode(c, fullPattern)
97
+ );
98
+
99
+ return {
100
+ def,
101
+ regex: new RegExp(regexStr),
102
+ paramNames,
103
+ children,
104
+ };
105
+ }
106
+
107
+ // ── Router ──────────────────────────────────────────────────────────
108
+
109
+ export interface Router {
110
+ /** Match a URL path. Returns matched page + layout chain + guards */
111
+ match(path: string): RouteMatch | null;
112
+ /** Resolve: run guards, apply redirects, nest layouts → DomNode or redirect */
113
+ resolve(path: string, appProps?: Record<string, unknown>): RouteResult | null;
114
+ /** All top-level route definitions */
115
+ routes: RouteDefinition[];
116
+ }
117
+
118
+ /**
119
+ * Creates a router from (potentially nested) route definitions.
120
+ *
121
+ * Supports:
122
+ * - Static paths: `/about`
123
+ * - Dynamic params: `/tasks/:id`
124
+ * - Wildcard: `*`
125
+ * - Nested routes with `children`
126
+ * - Layouts at any level
127
+ * - Guards at any level (run outermost first)
128
+ * - Redirects (route-level or guard-returned)
129
+ * - First match wins (definition order)
130
+ */
131
+ export function createRouter(routes: RouteDefinition[]): Router {
132
+ // Compile the root as a virtual node with empty path
133
+ const compiled: CompiledNode[] = routes.map((r) => compileNode(r, ''));
134
+
135
+ function matchPath(
136
+ nodes: CompiledNode[],
137
+ path: string,
138
+ params: Record<string, string>,
139
+ layouts: LayoutComponent[],
140
+ guards: RouteGuard[],
141
+ ): RouteMatch | null {
142
+ const normalized = path === '/' ? '/' : path.replace(/\/+$/, '');
143
+
144
+ for (const node of nodes) {
145
+ const m = normalized.match(node.regex);
146
+ if (!m) continue;
147
+
148
+ // Extract params from this level
149
+ const levelParams = { ...params };
150
+ for (let i = 0; i < node.paramNames.length; i++) {
151
+ levelParams[node.paramNames[i]] = decodeURIComponent(m[i + 1]);
152
+ }
153
+
154
+ // Collect layout + guard from this level
155
+ const levelLayouts = node.def.layout ? [...layouts, node.def.layout] : [...layouts];
156
+ const levelGuards = node.def.guard ? [...guards, node.def.guard] : [...guards];
157
+
158
+ // Redirect at route level
159
+ if (node.def.redirect) {
160
+ return {
161
+ page: () => ({ tag: 'div' }),
162
+ params: levelParams,
163
+ layouts: levelLayouts,
164
+ guards: levelGuards,
165
+ redirect: node.def.redirect,
166
+ };
167
+ }
168
+
169
+ // Try children first (depth-first)
170
+ if (node.children.length > 0) {
171
+ const childMatch = matchPath(
172
+ node.children, normalized, levelParams, levelLayouts, levelGuards,
173
+ );
174
+ if (childMatch) return childMatch;
175
+ }
176
+
177
+ // Leaf match — must have a page component
178
+ if (node.def.page) {
179
+ return {
180
+ page: node.def.page,
181
+ params: levelParams,
182
+ layouts: levelLayouts,
183
+ guards: levelGuards,
184
+ };
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ return {
191
+ routes,
192
+
193
+ match(path: string): RouteMatch | null {
194
+ return matchPath(compiled, path, {}, [], []);
195
+ },
196
+
197
+ resolve(path: string, appProps?: Record<string, unknown>): RouteResult | null {
198
+ const match = this.match(path);
199
+ if (!match) return null;
200
+
201
+ // Run guards in order
202
+ for (const guard of match.guards) {
203
+ const result = guard({ path, params: match.params });
204
+ if (result === true) continue;
205
+ const to = typeof result === 'string' ? result : result.redirect;
206
+ return { kind: 'redirect', to };
207
+ }
208
+
209
+ // Route-level redirect
210
+ if (match.redirect) {
211
+ return { kind: 'redirect', to: match.redirect };
212
+ }
213
+
214
+ // Render page
215
+ const pageProps = { params: match.params, ...appProps };
216
+ let dom = match.page(pageProps);
217
+
218
+ // Wrap in layouts (innermost first → outermost wraps)
219
+ for (let i = match.layouts.length - 1; i >= 0; i--) {
220
+ dom = match.layouts[i]({ children: dom, params: match.params, path });
221
+ }
222
+
223
+ return { kind: 'render', dom };
224
+ },
225
+ };
226
+ }
227
+
228
+ // ── Convenience: renderRoute (backward compat) ─────────────────────
229
+
230
+ export function renderRoute(
231
+ router: Router,
232
+ path: string,
233
+ appProps?: Record<string, unknown>,
234
+ ): DomNode | null {
235
+ const result = router.resolve(path, appProps);
236
+ if (!result) return null;
237
+ if (result.kind === 'redirect') {
238
+ // For backward compat, follow one redirect level
239
+ const r2 = router.resolve(result.to, appProps);
240
+ if (r2 && r2.kind === 'render') return r2.dom;
241
+ return null;
242
+ }
243
+ return result.dom;
244
+ }
245
+
246
+ // ── Navigate action string ──────────────────────────────────────────
247
+
248
+ export function navigateAction(path: string): string {
249
+ return `navigate:${path}`;
250
+ }