smaoog 0.0.1

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/src/index.d.ts ADDED
@@ -0,0 +1,120 @@
1
+ import * as React from "react";
2
+
3
+ export const version: string;
4
+
5
+ export type JsonPrimitive = string | number | boolean | null;
6
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
7
+ export type FormBodyValue = string | string[];
8
+ export type ParsedBody = Record<string, JsonValue | FormBodyValue>;
9
+ export type RouteParams = Record<string, string>;
10
+ export type HeadersObject = Record<string, string | number | readonly string[]>;
11
+
12
+ export interface SmaoogRequest<
13
+ Params extends RouteParams = RouteParams,
14
+ Body extends object = ParsedBody,
15
+ > {
16
+ raw: unknown;
17
+ method: string;
18
+ path: string;
19
+ params: Params;
20
+ query: URLSearchParams;
21
+ headers: Headers;
22
+ rawBody(): Promise<Uint8Array>;
23
+ body(): Promise<Body>;
24
+ }
25
+
26
+ export interface SmaoogResponse<Props = unknown> {
27
+ raw: unknown;
28
+ status(code: number): this;
29
+ headers(headers: HeadersObject): this;
30
+ text(body: string | number | boolean | null | undefined): TerminalResponse;
31
+ json(data: unknown): TerminalResponse;
32
+ redirect(location: string, code?: number): TerminalResponse;
33
+ render(props?: Props): RenderResponse<Props>;
34
+ notFound(props?: Props): NotFoundResponse<Props>;
35
+ }
36
+
37
+ export type RouteHandler<
38
+ Params extends RouteParams = RouteParams,
39
+ Body extends object = ParsedBody,
40
+ Props = unknown,
41
+ > = (
42
+ req: SmaoogRequest<Params, Body>,
43
+ res: SmaoogResponse<Props>,
44
+ ) => TerminalResponse | Promise<TerminalResponse>;
45
+
46
+ export interface TerminalResponse {
47
+ readonly kind: string;
48
+ }
49
+
50
+ export interface RenderResponse<Props = unknown> extends TerminalResponse {
51
+ readonly kind: "render";
52
+ readonly props: Props;
53
+ }
54
+
55
+ export interface NotFoundResponse<Props = unknown> extends TerminalResponse {
56
+ readonly kind: "notFound";
57
+ readonly props: Props;
58
+ }
59
+
60
+ export interface MetaDescriptor {
61
+ title?: string;
62
+ description?: string;
63
+ canonical?: string;
64
+ robots?: string;
65
+ meta?: Array<Record<string, string | number | boolean>>;
66
+ links?: Array<Record<string, string | number | boolean>>;
67
+ }
68
+
69
+ export type MetaFunction<Props = unknown> = (context: {
70
+ props: Props;
71
+ }) => MetaDescriptor;
72
+
73
+ export type Meta<Props = unknown> = MetaDescriptor | MetaFunction<Props>;
74
+
75
+ export interface DocumentProps {
76
+ children?: React.ReactNode;
77
+ head?: React.ReactNode;
78
+ }
79
+
80
+ export interface StylesheetProps {
81
+ href: string;
82
+ }
83
+
84
+ export function Stylesheet(props: StylesheetProps): React.ReactElement | null;
85
+
86
+ export interface ClientEntryProps {
87
+ src: string;
88
+ }
89
+
90
+ export function ClientEntry(props: ClientEntryProps): React.ReactElement | null;
91
+
92
+ export function ReactRefresh(): React.ReactElement | null;
93
+
94
+ export interface LinkProps
95
+ extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick"> {
96
+ href: string;
97
+ replace?: boolean;
98
+ onClick?: React.MouseEventHandler<HTMLAnchorElement>;
99
+ children?: React.ReactNode;
100
+ }
101
+
102
+ export function Link(props: LinkProps): React.ReactElement;
103
+
104
+ export interface FormState {
105
+ submitting: boolean;
106
+ }
107
+
108
+ export interface FormProps
109
+ extends Omit<
110
+ React.FormHTMLAttributes<HTMLFormElement>,
111
+ "action" | "children" | "method" | "onError" | "onSubmit"
112
+ > {
113
+ action?: string;
114
+ method?: "get" | "post" | "put" | "patch" | "delete" | (string & {});
115
+ onSuccess?: (data: unknown, response: Response) => void;
116
+ onError?: (data: unknown, response?: Response) => void;
117
+ children?: React.ReactNode | ((state: FormState) => React.ReactNode);
118
+ }
119
+
120
+ export function Form(props: FormProps): React.ReactElement;
package/src/index.js ADDED
@@ -0,0 +1,95 @@
1
+ import React from "react";
2
+ import { appPath } from "./assets.js";
3
+ import { PROPS_SCRIPT_ID, ROUTE_SCRIPT_ID, serializeJsonForScript } from "./serialize.js";
4
+
5
+ export { Link } from "./link.js";
6
+ export { Form } from "./form.js";
7
+
8
+ export const version = "0.0.1";
9
+
10
+ // Server-render context installed by the active render path (see render.js). It
11
+ // lets the Document helpers resolve dev/prod asset URLs and emit the hydration
12
+ // bootstrap.
13
+ //
14
+ // It lives on globalThis under a shared symbol, not a module variable, on
15
+ // purpose: in dev the Document renders inside Vite's SSR module graph, a
16
+ // different `smaoog` instance than the host renderer — a module-local slot would
17
+ // not be the one the renderer set. renderToString is synchronous, so a single
18
+ // global slot is safe; no other request can interleave between install/restore.
19
+ const CONTEXT_KEY = Symbol.for("smaoog.renderContext");
20
+
21
+ function currentContext() {
22
+ return globalThis[CONTEXT_KEY] ?? null;
23
+ }
24
+
25
+ export function __withSmaoogContext(context, fn) {
26
+ const previous = globalThis[CONTEXT_KEY] ?? null;
27
+ globalThis[CONTEXT_KEY] = context ?? null;
28
+ try {
29
+ return fn();
30
+ } finally {
31
+ globalThis[CONTEXT_KEY] = previous;
32
+ }
33
+ }
34
+
35
+ // Link a source stylesheet, resolved to its dev source URL or built asset.
36
+ export function Stylesheet({ href }) {
37
+ const ctx = currentContext();
38
+ const resolved = ctx ? ctx.stylesheetHref(href) : appPath(href);
39
+ return React.createElement("link", { rel: "stylesheet", href: resolved });
40
+ }
41
+
42
+ function jsonScript(key, id, value) {
43
+ return React.createElement("script", {
44
+ key,
45
+ id,
46
+ type: "application/json",
47
+ // Pre-escaped JSON: React must not touch it, and the escaping (see
48
+ // serializeJsonForScript) is what keeps props from breaking out of <script>.
49
+ dangerouslySetInnerHTML: { __html: serializeJsonForScript(value) },
50
+ });
51
+ }
52
+
53
+ // Loads the browser entry and, alongside it, the hydration bootstrap: the page's
54
+ // props and its route id/module. The client runtime (smaoog/client, pulled in by
55
+ // the entry) reads these to hydrate the server-rendered page.
56
+ export function ClientEntry({ src }) {
57
+ const ctx = currentContext();
58
+ if (!ctx) return null;
59
+
60
+ const entryHref = ctx.entryHref(src);
61
+ const page = ctx.page;
62
+ const moduleHref = page ? ctx.pageModuleHref(page.routeId) : null;
63
+
64
+ const nodes = [];
65
+ // The bootstrap is only useful when there is both a runtime entry to read it
66
+ // and a resolvable page module to hydrate (static/error pages have neither).
67
+ if (entryHref && page && moduleHref) {
68
+ nodes.push(jsonScript("props", PROPS_SCRIPT_ID, page.props ?? {}));
69
+ nodes.push(jsonScript("route", ROUTE_SCRIPT_ID, { id: page.routeId, module: moduleHref }));
70
+ }
71
+ if (entryHref) {
72
+ nodes.push(React.createElement("script", { key: "entry", type: "module", src: entryHref }));
73
+ }
74
+
75
+ if (nodes.length === 0) return null;
76
+ return React.createElement(React.Fragment, null, ...nodes);
77
+ }
78
+
79
+ // Vite's React Refresh preamble. It must run before any refreshable module, so
80
+ // it belongs in the document <head>. Dev only; nothing in production.
81
+ const REACT_REFRESH_PREAMBLE =
82
+ 'import RefreshRuntime from "/@react-refresh";\n' +
83
+ "RefreshRuntime.injectIntoGlobalHook(window);\n" +
84
+ "window.$RefreshReg$ = () => {};\n" +
85
+ "window.$RefreshSig$ = () => (type) => type;\n" +
86
+ "window.__vite_plugin_react_preamble_installed__ = true;";
87
+
88
+ export function ReactRefresh() {
89
+ const ctx = currentContext();
90
+ if (!ctx || !ctx.dev) return null;
91
+ return React.createElement("script", {
92
+ type: "module",
93
+ dangerouslySetInnerHTML: { __html: REACT_REFRESH_PREAMBLE },
94
+ });
95
+ }
package/src/link.js ADDED
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { navigate, shouldInterceptClick } from "./client/navigation.js";
3
+
4
+ // A navigational anchor. It renders a plain `<a href>` — so it works on the
5
+ // server, before hydration, and with JavaScript disabled — and, once hydrated,
6
+ // upgrades same-origin clicks to client navigation (no full reload).
7
+ //
8
+ // Modifier clicks (cmd/ctrl/shift/alt, middle-click), `target`/`download`
9
+ // anchors, and external links are left entirely to the browser. `replace`
10
+ // swaps the current history entry instead of pushing a new one.
11
+ export function Link({ href, replace = false, onClick, children, ...rest }) {
12
+ function handleClick(event) {
13
+ if (onClick) onClick(event);
14
+ // `download` is a native anchor behavior we never intercept.
15
+ if (rest.download === undefined && shouldInterceptClick(event, href, rest.target)) {
16
+ event.preventDefault();
17
+ navigate(href, { history: replace ? "replace" : "push" });
18
+ }
19
+ }
20
+
21
+ return React.createElement("a", { ...rest, href, onClick: handleClick }, children);
22
+ }
package/src/meta.js ADDED
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+
3
+ // True only for object literals (and null-prototype objects), so arrays, Dates,
4
+ // Maps, class instances, promises, etc. are rejected as meta.
5
+ function isPlainObject(value) {
6
+ if (value === null || typeof value !== "object") return false;
7
+ const proto = Object.getPrototypeOf(value);
8
+ return proto === null || proto === Object.prototype;
9
+ }
10
+
11
+ // Resolve a route module's `meta` export against the render props. `meta` may be
12
+ // a static plain object or a synchronous function ({ props }) => object. Async
13
+ // meta and non-object meta are clear errors rather than silent no-ops.
14
+ export function resolveMeta(routeModule, props) {
15
+ const meta = routeModule?.meta;
16
+ if (meta === undefined || meta === null) return {};
17
+
18
+ let resolved = meta;
19
+ if (typeof meta === "function") {
20
+ resolved = meta({ props });
21
+ if (resolved && typeof resolved.then === "function") {
22
+ throw new Error("Route meta must be synchronous; async meta is not supported.");
23
+ }
24
+ if (resolved === undefined || resolved === null) return {};
25
+ }
26
+
27
+ if (!isPlainObject(resolved)) {
28
+ throw new Error(
29
+ "Route meta must be a plain object, or a synchronous function returning one.",
30
+ );
31
+ }
32
+ return resolved;
33
+ }
34
+
35
+ // Attribute stamped on every framework-managed head element. It marks the tags
36
+ // the framework owns so client navigation can find and replace them when the
37
+ // route's metadata changes, without disturbing the user's own <head> contents.
38
+ export const HEAD_MARKER = "data-smaoog-head";
39
+
40
+ // Turn resolved meta fields into a flat, renderer-agnostic list of head tag
41
+ // descriptors. Both consumers build from this one list so the SSR head and the
42
+ // client-side head update can never drift:
43
+ // { tag: "title", text } -> <title>text</title>
44
+ // { tag: "meta" | "link", attrs } -> <tag ...attrs>
45
+ export function metaToTags(meta) {
46
+ const tags = [];
47
+ if (typeof meta.title === "string") tags.push({ tag: "title", text: meta.title });
48
+ if (typeof meta.description === "string") {
49
+ tags.push({ tag: "meta", attrs: { name: "description", content: meta.description } });
50
+ }
51
+ if (typeof meta.canonical === "string") {
52
+ tags.push({ tag: "link", attrs: { rel: "canonical", href: meta.canonical } });
53
+ }
54
+ if (typeof meta.robots === "string") {
55
+ tags.push({ tag: "meta", attrs: { name: "robots", content: meta.robots } });
56
+ }
57
+ // Custom entries are raw attribute objects (e.g. { property: "og:title", content }).
58
+ addCustom("meta", "meta", meta.meta);
59
+ addCustom("links", "link", meta.links);
60
+ return tags;
61
+
62
+ function addCustom(field, tag, entries) {
63
+ if (entries === undefined) return;
64
+ if (!Array.isArray(entries)) {
65
+ throw new Error(`Route meta.${field} must be an array.`);
66
+ }
67
+ for (const attrs of entries) {
68
+ if (!isPlainObject(attrs)) {
69
+ throw new Error(`Route meta.${field} entries must be plain attribute objects.`);
70
+ }
71
+ tags.push({ tag, attrs });
72
+ }
73
+ }
74
+ }
75
+
76
+ // Turn resolved meta fields into managed React head elements. The result is an
77
+ // array passed to Document as its `head` prop. There is no user-facing <Head>
78
+ // component: head entries are declared through the `meta` export only. Each tag
79
+ // carries HEAD_MARKER so client navigation can replace them later.
80
+ export function metaToElements(meta) {
81
+ return metaToTags(meta).map((tag, key) => {
82
+ if (tag.tag === "title") {
83
+ return React.createElement("title", { key, [HEAD_MARKER]: "" }, tag.text);
84
+ }
85
+ return React.createElement(tag.tag, { key, ...tag.attrs, [HEAD_MARKER]: "" });
86
+ });
87
+ }
@@ -0,0 +1,14 @@
1
+ // Wire protocol shared by the server and the browser runtime. Kept in its own
2
+ // dependency-free module so the client can import it without pulling in any
3
+ // server-only code.
4
+
5
+ // Request header an enhanced <Form> mutation sends. It tells the server to
6
+ // serialize render() and redirect() results as JSON envelopes (the same
7
+ // page-swap payloads client navigation uses) instead of HTML, while leaving a
8
+ // handler's own res.json()/res.text() response untouched for onSuccess/onError.
9
+ export const SUBMIT_HEADER = "x-smaoog-submit";
10
+
11
+ // Response header marking a framework envelope and its kind ("render" or
12
+ // "redirect"). It is absent on a handler's own res.json()/res.text() response,
13
+ // which is how the form client tells a page-swap apart from callback data.
14
+ export const RESPONSE_HEADER = "x-smaoog-response";
package/src/render.js ADDED
@@ -0,0 +1,55 @@
1
+ import React from "react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { __withSmaoogContext } from "./index.js";
4
+ import { metaToElements, resolveMeta } from "./meta.js";
5
+
6
+ function FallbackDocument({ children, head }) {
7
+ return React.createElement(
8
+ "html",
9
+ null,
10
+ React.createElement("head", null, React.createElement("meta", { charSet: "utf-8" }), head),
11
+ React.createElement("body", null, React.createElement("main", { id: "root" }, children)),
12
+ );
13
+ }
14
+
15
+ // Serialize a RenderIntent for a normal document request. Managed head tags are
16
+ // resolved from the route's `meta` export here — after the handler ran and right
17
+ // before HTML is flushed — and passed to Document as its `head` prop. Props
18
+ // scripts and hydration hooks are layered on this path in later phases.
19
+ export function renderIntentToHtml(intent) {
20
+ if (typeof intent.Page !== "function") {
21
+ throw new Error("Cannot render route: missing default export");
22
+ }
23
+ // Attach this render's page bootstrap (route id + props) to the client context
24
+ // so <ClientEntry> can emit the hydration scripts. No context (standalone
25
+ // render) -> the Document helpers degrade to static output.
26
+ const context = intent.client
27
+ ? { ...intent.client, page: { routeId: intent.routeId ?? null, props: intent.props ?? {} } }
28
+ : null;
29
+ return __withSmaoogContext(context, () => {
30
+ const Document = intent.Document ?? FallbackDocument;
31
+ const head = metaToElements(resolveMeta(intent.routeModule, intent.props));
32
+ const page = React.createElement(intent.Page, intent.props);
33
+ const document = React.createElement(Document, { children: page, head });
34
+ return "<!doctype html>" + renderToString(document);
35
+ });
36
+ }
37
+
38
+ // Serialize a RenderIntent for a /_smaoog/nav request as the client navigation
39
+ // payload. It is the same intent the HTML path renders, but reduced to what the
40
+ // client runtime needs to swap the page: the page module to import, the props to
41
+ // render it with, the resolved meta to update the head, and the status. There is
42
+ // no second render here — the client renders the page in the browser.
43
+ export function renderIntentToNav(intent) {
44
+ if (typeof intent.Page !== "function") {
45
+ throw new Error("Cannot render route: missing default export");
46
+ }
47
+ return {
48
+ kind: "render",
49
+ routeId: intent.routeId ?? null,
50
+ module: intent.client?.pageModuleHref?.(intent.routeId) ?? null,
51
+ props: intent.props ?? {},
52
+ meta: resolveMeta(intent.routeModule, intent.props),
53
+ status: intent.status,
54
+ };
55
+ }
package/src/request.js ADDED
@@ -0,0 +1,133 @@
1
+ import { HttpError } from "./errors.js";
2
+
3
+ // Default maximum request body size.
4
+ export const DEFAULT_BODY_LIMIT = 1024 * 1024; // 1 MB
5
+
6
+ // Read the Node request stream once into a Buffer, enforcing a size limit.
7
+ // On overflow we stop accumulating and reject with 413, but do not destroy the
8
+ // socket — the dispatcher still needs to send the 413 response.
9
+ function readStream(nodeReq, limit) {
10
+ return new Promise((resolve, reject) => {
11
+ const chunks = [];
12
+ let size = 0;
13
+ let overflowed = false;
14
+
15
+ nodeReq.on("data", (chunk) => {
16
+ if (overflowed) return;
17
+ size += chunk.length;
18
+ if (size > limit) {
19
+ overflowed = true;
20
+ reject(new HttpError(413, "Payload Too Large"));
21
+ return;
22
+ }
23
+ chunks.push(chunk);
24
+ });
25
+ nodeReq.on("end", () => resolve(Buffer.concat(chunks)));
26
+ nodeReq.on("error", reject);
27
+ });
28
+ }
29
+
30
+ // Parse a cached body Buffer according to its content type.
31
+ function parseBody(buffer, contentType) {
32
+ // Empty body short-circuits before any content-type handling.
33
+ if (buffer.length === 0) return {};
34
+
35
+ const mime = (contentType ?? "").split(";")[0].trim().toLowerCase();
36
+ if (!mime) return {};
37
+
38
+ if (mime === "application/json") {
39
+ let value;
40
+ try {
41
+ value = JSON.parse(buffer.toString("utf8"));
42
+ } catch {
43
+ throw new HttpError(400, "Bad Request");
44
+ }
45
+ // The body model is object-shaped: arrays, primitives, and null are
46
+ // rejected so handlers can always rely on body being an object.
47
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
48
+ throw new HttpError(400, "Bad Request");
49
+ }
50
+ return value;
51
+ }
52
+
53
+ if (mime === "application/x-www-form-urlencoded") {
54
+ const params = new URLSearchParams(buffer.toString("utf8"));
55
+ const result = {};
56
+ for (const key of new Set(params.keys())) {
57
+ const values = params.getAll(key);
58
+ // defineProperty (not assignment) so user-controlled keys like
59
+ // "__proto__" are stored as ordinary data, never invoking the prototype
60
+ // setter — form data can't restructure the parsed object.
61
+ Object.defineProperty(result, key, {
62
+ value: values.length > 1 ? values : values[0],
63
+ writable: true,
64
+ enumerable: true,
65
+ configurable: true,
66
+ });
67
+ }
68
+ return result;
69
+ }
70
+
71
+ // multipart/form-data and any other type: {} in v0.
72
+ return {};
73
+ }
74
+
75
+ // Wrap a Node IncomingMessage in smaoog's request surface. `params` comes from
76
+ // the route matcher; it is `{}` for routes with no dynamic segments. `url`
77
+ // overrides the request target used to derive path/query — the navigation
78
+ // endpoint sets it so a handler sees the target URL it is rendering for, not the
79
+ // internal `/_smaoog/nav` URL.
80
+ export function createRequest(
81
+ nodeReq,
82
+ { bodyLimit = DEFAULT_BODY_LIMIT, params = {}, url: target } = {},
83
+ ) {
84
+ // Dummy origin lets the URL parser handle path-only request targets.
85
+ const url = new URL(target ?? nodeReq.url, "http://localhost");
86
+
87
+ // Web Headers give a case-insensitive .get() that returns string | null.
88
+ const headers = new Headers();
89
+ for (const [key, value] of Object.entries(nodeReq.headers)) {
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) headers.append(key, item);
92
+ } else if (value != null) {
93
+ headers.set(key, value);
94
+ }
95
+ }
96
+
97
+ // The stream is read at most once; body() and rawBody() share this read.
98
+ let rawPromise;
99
+ function readRawOnce() {
100
+ if (!rawPromise) rawPromise = readStream(nodeReq, bodyLimit);
101
+ return rawPromise;
102
+ }
103
+
104
+ let parsed;
105
+ let hasParsed = false;
106
+
107
+ return {
108
+ // Escape hatch: the raw Node IncomingMessage.
109
+ raw: nodeReq,
110
+ // Uppercase HTTP method (GET, POST, ...).
111
+ method: (nodeReq.method ?? "GET").toUpperCase(),
112
+ // Path without the query string.
113
+ path: url.pathname,
114
+ // URLSearchParams: query.get(name) returns a string or null.
115
+ query: url.searchParams,
116
+ // Case-insensitive: headers.get(name) returns a string or null.
117
+ headers,
118
+ // Dynamic route params from the matcher (e.g. { id: "42" }); {} for static routes.
119
+ params,
120
+ // Exact request bytes (cached Buffer).
121
+ async rawBody() {
122
+ return readRawOnce();
123
+ },
124
+ // Parsed body, from the same cached read.
125
+ async body() {
126
+ if (hasParsed) return parsed;
127
+ const buffer = await readRawOnce();
128
+ parsed = parseBody(buffer, headers.get("content-type"));
129
+ hasParsed = true;
130
+ return parsed;
131
+ },
132
+ };
133
+ }