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/LICENSE +21 -0
- package/README.md +102 -0
- package/package.json +50 -0
- package/src/assets.js +118 -0
- package/src/build.js +216 -0
- package/src/cli.js +58 -0
- package/src/client/index.d.ts +6 -0
- package/src/client/index.js +12 -0
- package/src/client/navigation.js +209 -0
- package/src/client/runtime.js +47 -0
- package/src/client/store.js +31 -0
- package/src/conventions.js +47 -0
- package/src/dev.js +84 -0
- package/src/dispatch.js +75 -0
- package/src/errors.js +11 -0
- package/src/form.js +121 -0
- package/src/index.d.ts +120 -0
- package/src/index.js +95 -0
- package/src/link.js +22 -0
- package/src/meta.js +87 -0
- package/src/protocol.js +14 -0
- package/src/render.js +55 -0
- package/src/request.js +133 -0
- package/src/response.js +256 -0
- package/src/router.js +497 -0
- package/src/routes.js +175 -0
- package/src/serialize.js +17 -0
- package/src/server-only.js +122 -0
- package/src/server.js +42 -0
- package/src/start.js +60 -0
- package/src/tailwind.js +20 -0
- package/src/vite.js +69 -0
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
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -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
|
+
}
|