@c9up/aurora 0.1.3

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/AuroraManager.d.ts +44 -0
  4. package/dist/AuroraManager.js +47 -0
  5. package/dist/AuroraProvider.d.ts +52 -0
  6. package/dist/AuroraProvider.js +145 -0
  7. package/dist/Pages.d.ts +78 -0
  8. package/dist/Pages.js +116 -0
  9. package/dist/component.d.ts +55 -0
  10. package/dist/component.js +97 -0
  11. package/dist/html.d.ts +30 -0
  12. package/dist/html.js +246 -0
  13. package/dist/hydrate.d.ts +29 -0
  14. package/dist/hydrate.js +379 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +12 -0
  17. package/dist/reactive.d.ts +83 -0
  18. package/dist/reactive.js +217 -0
  19. package/dist/relay.d.ts +43 -0
  20. package/dist/relay.js +144 -0
  21. package/dist/render.d.ts +25 -0
  22. package/dist/render.js +283 -0
  23. package/dist/route.d.ts +64 -0
  24. package/dist/route.js +49 -0
  25. package/dist/server/renderPage.d.ts +62 -0
  26. package/dist/server/renderPage.js +83 -0
  27. package/dist/server/serveAssets.d.ts +43 -0
  28. package/dist/server/serveAssets.js +89 -0
  29. package/dist/services/main.d.ts +18 -0
  30. package/dist/services/main.js +31 -0
  31. package/dist/ssr.d.ts +22 -0
  32. package/dist/ssr.js +179 -0
  33. package/dist/types.d.ts +78 -0
  34. package/dist/types.js +15 -0
  35. package/package.json +69 -0
  36. package/src/AuroraManager.ts +76 -0
  37. package/src/AuroraProvider.ts +187 -0
  38. package/src/Pages.ts +164 -0
  39. package/src/component.ts +138 -0
  40. package/src/html.ts +296 -0
  41. package/src/hydrate.ts +518 -0
  42. package/src/index.ts +43 -0
  43. package/src/reactive.ts +265 -0
  44. package/src/relay.ts +171 -0
  45. package/src/render.ts +378 -0
  46. package/src/route.ts +96 -0
  47. package/src/server/renderPage.ts +135 -0
  48. package/src/server/serveAssets.ts +135 -0
  49. package/src/services/main.ts +40 -0
  50. package/src/ssr.ts +179 -0
  51. package/src/types.ts +97 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `serveAssets` — generic static-file handler exposed by aurora so an
3
+ * app can mount the runtime + the pages dist with a couple of routes:
4
+ *
5
+ * router.get('/_assets/aurora/*', serveAssets({ root: auroraDistPath }))
6
+ * router.get('/_assets/pages/*', serveAssets({ root: pagesPath }))
7
+ *
8
+ * The handler is framework-agnostic: it reads `ctx.request.param('*')`
9
+ * and writes to `ctx.response`. Any context that satisfies
10
+ * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
+ */
12
+
13
+ import { readFile, realpath } from "node:fs/promises";
14
+ import { extname, join, resolve as resolvePath, sep } from "node:path";
15
+
16
+ const CONTENT_TYPES: Record<string, string> = {
17
+ ".js": "text/javascript; charset=utf-8",
18
+ ".mjs": "text/javascript; charset=utf-8",
19
+ ".map": "application/json; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".json": "application/json; charset=utf-8",
22
+ };
23
+
24
+ export interface AssetsRequest {
25
+ /**
26
+ * Read the wildcard `*` segment of the matched route. Most routers
27
+ * (Ream, AdonisJS, fastify with params) expose this as
28
+ * `params['*']` — the duck-typed helper below accepts either
29
+ * convention.
30
+ */
31
+ param(name: string): unknown;
32
+ }
33
+ export interface AssetsResponse {
34
+ status(code: number): AssetsResponse;
35
+ header(name: string, value: string): AssetsResponse;
36
+ send(body: string | Buffer): void;
37
+ }
38
+ export interface AssetsHttpContext {
39
+ request: AssetsRequest;
40
+ response: AssetsResponse;
41
+ }
42
+
43
+ export interface ServeAssetsOptions {
44
+ /**
45
+ * Absolute filesystem root the handler is allowed to serve from.
46
+ * Requests resolving outside this root return 403.
47
+ */
48
+ root: string;
49
+ /**
50
+ * `Cache-Control` value to emit. Defaults to a dev-friendly
51
+ * 60-second TTL. Production deployments should hash the asset
52
+ * name and switch to `public, max-age=31536000, immutable`.
53
+ */
54
+ cacheControl?: string;
55
+ }
56
+
57
+ export function serveAssets(
58
+ options: ServeAssetsOptions,
59
+ ): (ctx: AssetsHttpContext) => Promise<void> {
60
+ const root = options.root;
61
+ const cacheControl = options.cacheControl ?? "public, max-age=60";
62
+ // Canonicalize the root ONCE at handler creation. The realpath check
63
+ // below compares against this canonical form so a symlinked root
64
+ // (e.g. `/var/www/current → /var/www/release-42`) still resolves
65
+ // requests correctly. `realpath` failure at construction means the
66
+ // configured root doesn't exist yet — we fall back to the lexical
67
+ // resolve so the first request emits a clean 404 instead of a boot
68
+ // crash. The realpath re-check at request time handles that case.
69
+ let canonicalRoot: string | undefined;
70
+ realpath(root).then(
71
+ (p) => {
72
+ canonicalRoot = p;
73
+ },
74
+ () => {
75
+ /* root not yet on disk — request-time realpath will surface it */
76
+ },
77
+ );
78
+
79
+ return async (ctx) => {
80
+ const rest = ctx.request.param("*");
81
+ if (typeof rest !== "string" || rest.length === 0) {
82
+ ctx.response.status(400).send("missing asset path");
83
+ return;
84
+ }
85
+ // First gate: lexical containment check. `resolve()` collapses
86
+ // `../` segments; we assert the resolved path still starts with
87
+ // `root + sep`. This blocks the "../../../etc/passwd" class of
88
+ // requests before we ever touch the filesystem.
89
+ const absolute = resolvePath(join(root, rest));
90
+ if (!absolute.startsWith(root + sep) && absolute !== root) {
91
+ ctx.response.status(403).send("forbidden");
92
+ return;
93
+ }
94
+
95
+ // Second gate: dereference any symlinks under the root and
96
+ // re-check containment against the canonical root. Without this
97
+ // step a symlink planted at `<root>/legit → /etc/secrets` would
98
+ // pass the lexical check above and be served. We re-canonicalize
99
+ // the root each request when the constructor-time realpath
100
+ // hadn't resolved yet (root mounted after boot).
101
+ let canonicalAbsolute: string;
102
+ let canonicalRootNow: string;
103
+ try {
104
+ canonicalRootNow = canonicalRoot ?? (await realpath(root));
105
+ canonicalAbsolute = await realpath(absolute);
106
+ } catch {
107
+ // realpath fails if the target doesn't exist — emit a normal
108
+ // 404 here so symlink-escape probes can't be distinguished
109
+ // from genuine misses via response timing or status.
110
+ ctx.response.status(404).send("asset not found");
111
+ return;
112
+ }
113
+ if (
114
+ !canonicalAbsolute.startsWith(canonicalRootNow + sep) &&
115
+ canonicalAbsolute !== canonicalRootNow
116
+ ) {
117
+ ctx.response.status(403).send("forbidden");
118
+ return;
119
+ }
120
+
121
+ let body: Buffer;
122
+ try {
123
+ body = await readFile(canonicalAbsolute);
124
+ } catch {
125
+ ctx.response.status(404).send("asset not found");
126
+ return;
127
+ }
128
+
129
+ const type =
130
+ CONTENT_TYPES[extname(canonicalAbsolute)] ?? "application/octet-stream";
131
+ ctx.response.header("content-type", type);
132
+ ctx.response.header("cache-control", cacheControl);
133
+ ctx.response.send(body);
134
+ };
135
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Default `AuroraManager` singleton — Adonis-style:
3
+ *
4
+ * import aurora from '@c9up/aurora/services/main'
5
+ *
6
+ * await aurora.render(ctx, 'ProjectPage', { project, tasks })
7
+ *
8
+ * Populated either by `AuroraProvider.boot()` (when the app uses
9
+ * `() => import('@c9up/aurora/provider')`) or by the app itself via
10
+ * `setAurora(myManager)`.
11
+ */
12
+
13
+ import type { AuroraManager } from "../AuroraManager.js";
14
+
15
+ let instance: AuroraManager | undefined;
16
+
17
+ /** @internal Bind the singleton (called by AuroraProvider or by the app). */
18
+ export function setAurora(value: AuroraManager): void {
19
+ instance = value;
20
+ }
21
+
22
+ /** @internal Read the singleton (or `undefined` pre-boot). */
23
+ export function getAurora(): AuroraManager | undefined {
24
+ return instance;
25
+ }
26
+
27
+ const aurora: AuroraManager = new Proxy({} as AuroraManager, {
28
+ get(_target, prop) {
29
+ if (!instance) {
30
+ throw new Error(
31
+ "[aurora] AuroraManager singleton accessed before AuroraProvider.boot() ran " +
32
+ "or `setAurora(myManager)` was called. Wire one of them first.",
33
+ );
34
+ }
35
+ const value = Reflect.get(instance, prop, instance);
36
+ return typeof value === "function" ? value.bind(instance) : value;
37
+ },
38
+ });
39
+
40
+ export default aurora;
package/src/ssr.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Server-side rendering — produces an HTML string from a `TemplateResult`
3
+ * without ever touching the DOM.
4
+ *
5
+ * Reads each slot's value eagerly (signals get a one-shot snapshot,
6
+ * functions are invoked, nested TemplateResults recurse). Event handlers
7
+ * are dropped server-side; hydration re-binds them once the markup
8
+ * lands in the browser.
9
+ */
10
+
11
+ import { isSignal } from "./reactive.js";
12
+ import { isTemplateResult, type TemplateResult } from "./types.js";
13
+
14
+ const VOID_ELEMENTS = new Set([
15
+ "area",
16
+ "base",
17
+ "br",
18
+ "col",
19
+ "embed",
20
+ "hr",
21
+ "img",
22
+ "input",
23
+ "keygen",
24
+ "link",
25
+ "meta",
26
+ "source",
27
+ "track",
28
+ "wbr",
29
+ ]);
30
+
31
+ /**
32
+ * Stringify a TemplateResult into HTML. Returns the markup ready to be
33
+ * shipped over the wire — no surrounding `<html>`/`<head>`/`<body>`
34
+ * unless the template includes them.
35
+ *
36
+ * The function walks the `strings` array directly; it does NOT depend
37
+ * on the DOM-side template cache, so it works in any JS runtime (Node,
38
+ * Cloudflare Workers, Bun, Deno).
39
+ */
40
+ export function renderToString(result: TemplateResult): string {
41
+ return stringifyTemplateResult(result);
42
+ }
43
+
44
+ function stringifyTemplateResult(result: TemplateResult): string {
45
+ const { strings, values } = result;
46
+ let out = "";
47
+ // When a segment ends with a directive (` @click="`, ` ?disabled="`,
48
+ // ` .value="`), we drop the directive prefix from that segment, skip
49
+ // the matching value, and consume the closing `"` from the next
50
+ // segment. This three-step coordination is why the loop holds a
51
+ // `pendingClosingQuote` flag.
52
+ let pendingClosingQuote = false;
53
+ for (let i = 0; i < strings.length; i++) {
54
+ let segment = strings[i];
55
+ if (pendingClosingQuote) {
56
+ segment = segment.replace(/^"/, "");
57
+ pendingClosingQuote = false;
58
+ }
59
+ const directiveMatch = segment.match(/\s([@?.][\w-]+)="$/);
60
+ const skipValue = directiveMatch !== null;
61
+ if (directiveMatch) {
62
+ segment = segment.slice(0, segment.length - directiveMatch[0].length);
63
+ pendingClosingQuote = true;
64
+ }
65
+ out += segment;
66
+ if (i < values.length && !skipValue) {
67
+ const value = values[i];
68
+ const inAttr = isInsideAttribute(out);
69
+ if (!inAttr && isReactiveStructuredSlot(value)) {
70
+ // Reactive text slot whose value is a nested template /
71
+ // array — wrap the rendered content in boundary markers so
72
+ // hydration can locate the exact node range and SWAP it when
73
+ // the signal changes client-side. Without these markers a
74
+ // nested-template slot hydrates once and then goes stale
75
+ // (no way to find where the subtree starts/ends). Scalar
76
+ // reactive slots (`${signal}` → text) are NOT wrapped: their
77
+ // hydration updates the text node in place, no range needed.
78
+ out += `<!--${SLOT_START}-->`;
79
+ out += stringifyValue(value, false);
80
+ out += `<!--${SLOT_END}-->`;
81
+ } else {
82
+ out += stringifyValue(value, inAttr);
83
+ }
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Boundary-marker comment payloads (kept in sync with hydrate.ts). */
90
+ const SLOT_START = "$";
91
+ const SLOT_END = "/$";
92
+
93
+ /**
94
+ * True when `value` is a reactive expression (signal / function) whose
95
+ * current evaluation is a structured node payload (a nested
96
+ * TemplateResult, or an array). These are the slots that can SWAP their
97
+ * subtree on a client-side change and therefore need boundary markers
98
+ * for hydration to find the range. A reactive slot resolving to a
99
+ * scalar (string / number) is updated in place and needs no markers.
100
+ */
101
+ function isReactiveStructuredSlot(value: unknown): boolean {
102
+ if (!isSignal(value) && typeof value !== "function") return false;
103
+ let evaluated: unknown;
104
+ try {
105
+ evaluated = isSignal(value)
106
+ ? (value as () => unknown)()
107
+ : (value as () => unknown)();
108
+ } catch {
109
+ return false;
110
+ }
111
+ return isTemplateResult(evaluated) || Array.isArray(evaluated);
112
+ }
113
+
114
+ /**
115
+ * Returns true if the position at the end of `htmlSoFar` lives inside
116
+ * the value region of an HTML tag (between `<` and `>`). The check
117
+ * walks backwards from the end, which is the smallest hint we need to
118
+ * decide between text-region and attribute-region escaping.
119
+ */
120
+ function isInsideAttribute(htmlSoFar: string): boolean {
121
+ for (let i = htmlSoFar.length - 1; i >= 0; i--) {
122
+ const c = htmlSoFar.charCodeAt(i);
123
+ if (c === 60 /* '<' */) return true;
124
+ if (c === 62 /* '>' */) return false;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ function stringifyValue(value: unknown, inAttribute: boolean): string {
130
+ if (value === null || value === undefined || value === false) return "";
131
+ if (value === true) return inAttribute ? "" : "true";
132
+ if (isSignal(value)) return stringifyValue(value(), inAttribute);
133
+ if (typeof value === "function") {
134
+ // In attribute position: directive handlers (`@click`, `?disabled`,
135
+ // `.prop`) have already been stripped by `stripDirectiveBefore`.
136
+ // A function reaching this point is a reactive-expression text
137
+ // slot (`${() => ...}`), which we evaluate eagerly server-side.
138
+ try {
139
+ return stringifyValue((value as () => unknown)(), inAttribute);
140
+ } catch {
141
+ return "";
142
+ }
143
+ }
144
+ if (Array.isArray(value)) {
145
+ let out = "";
146
+ for (const item of value) out += stringifyValue(item, inAttribute);
147
+ return out;
148
+ }
149
+ if (isTemplateResult(value)) return stringifyTemplateResult(value);
150
+ // Plain value — escape HTML entities (text) or attribute special
151
+ // characters (attribute value).
152
+ return inAttribute ? escapeAttr(String(value)) : escapeText(String(value));
153
+ }
154
+
155
+ function escapeText(s: string): string {
156
+ return s
157
+ .replaceAll("&", "&amp;")
158
+ .replaceAll("<", "&lt;")
159
+ .replaceAll(">", "&gt;");
160
+ }
161
+
162
+ function escapeAttr(s: string): string {
163
+ // Escape BOTH quote styles: the engine doesn't force double-quoted
164
+ // attributes (the classifier only tracks `<`/`>`), so a template author
165
+ // writing `id='${x}'` must still be safe — without escaping `'` a value
166
+ // like `' onmouseover='alert(1)` would break out of a single-quoted
167
+ // attribute. `>` isn't strictly required inside a quoted value but is
168
+ // escaped to stay safe under stray scanners that hunt tag boundaries
169
+ // before resolving the quote context.
170
+ return s
171
+ .replaceAll("&", "&amp;")
172
+ .replaceAll('"', "&quot;")
173
+ .replaceAll("'", "&#39;")
174
+ .replaceAll("<", "&lt;")
175
+ .replaceAll(">", "&gt;");
176
+ }
177
+
178
+ // VOID_ELEMENTS exported for downstream tooling (hydration heuristics).
179
+ export { VOID_ELEMENTS };
package/src/types.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared types for aurora templates.
3
+ *
4
+ * A `TemplateResult` is what `html\`...\`` returns. It carries the raw
5
+ * pieces (strings, values) and a stable reference to the parsed
6
+ * `Template` (memoised by `strings`). Templates are framework-internal —
7
+ * apps only ever see `TemplateResult`.
8
+ */
9
+
10
+ /**
11
+ * A lifecycle/effect callback that runs and OPTIONALLY returns a cleanup
12
+ * function — exactly React's `EffectCallback = () => void | (() => void)`.
13
+ *
14
+ * The `void` member of the return union is load-bearing: it is what makes a
15
+ * plain `() => {}` (whose return type is `void`) assignable here. Replacing it
16
+ * with `(() => void) | undefined` makes every void-returning callback a type
17
+ * error (a `() => void` is NOT assignable to `() => (() => void) | undefined`).
18
+ * So `noConfusingVoidType` is suppressed once, at this single definition,
19
+ * instead of being worked around at ~15 call sites with broken types.
20
+ */
21
+ // biome-ignore lint/suspicious/noConfusingVoidType: `void` is required so plain void-returning callbacks stay assignable (React EffectCallback pattern; dropping it breaks ~10 call sites)
22
+ export type EffectCallback = () => void | (() => void);
23
+
24
+ /** Slot descriptor — where a `${value}` lives inside a parsed template. */
25
+ export type SlotKind = "text" | "attr" | "event" | "boolean-attr" | "prop";
26
+
27
+ /**
28
+ * Path to the binding point inside the cloned template. Each step is a
29
+ * child index. We never walk by query selectors because attribute and text
30
+ * placements would need synthetic markers in markup an app provided.
31
+ */
32
+ export type NodePath = readonly number[];
33
+
34
+ export interface TextSlot {
35
+ kind: "text";
36
+ path: NodePath;
37
+ }
38
+ export interface AttrSlot {
39
+ kind: "attr";
40
+ path: NodePath;
41
+ name: string;
42
+ /**
43
+ * When the attribute interpolates more than one `${...}` slot, every
44
+ * slot shares the same `name` and the static segments are stored under
45
+ * `staticParts`. The render step joins them back together each tick.
46
+ */
47
+ staticParts?: readonly string[];
48
+ staticPartIndex?: number;
49
+ }
50
+ export interface EventSlot {
51
+ kind: "event";
52
+ path: NodePath;
53
+ event: string;
54
+ }
55
+ export interface BooleanAttrSlot {
56
+ kind: "boolean-attr";
57
+ path: NodePath;
58
+ name: string;
59
+ }
60
+ export interface PropSlot {
61
+ kind: "prop";
62
+ path: NodePath;
63
+ name: string;
64
+ }
65
+
66
+ export type Slot = TextSlot | AttrSlot | EventSlot | BooleanAttrSlot | PropSlot;
67
+
68
+ /** Compiled artefact — produced once per unique `strings` array. */
69
+ export interface Template {
70
+ /**
71
+ * A `<template>` element whose `content` fragment is cloned on every
72
+ * render. Cloning is much cheaper than re-parsing the HTML string.
73
+ */
74
+ readonly element: HTMLTemplateElement;
75
+ /** Slot descriptors in source order (same order as the `${...}` values). */
76
+ readonly slots: readonly Slot[];
77
+ }
78
+
79
+ /** Tagged-template return value — what `html\`\`` produces. */
80
+ export interface TemplateResult {
81
+ readonly strings: TemplateStringsArray;
82
+ readonly values: readonly unknown[];
83
+ readonly [TEMPLATE_RESULT_BRAND]: true;
84
+ }
85
+
86
+ export const TEMPLATE_RESULT_BRAND: unique symbol = Symbol.for(
87
+ "aurora:template-result",
88
+ );
89
+
90
+ export function isTemplateResult(value: unknown): value is TemplateResult {
91
+ return (
92
+ typeof value === "object" &&
93
+ value !== null &&
94
+ (value as { [TEMPLATE_RESULT_BRAND]?: boolean })[TEMPLATE_RESULT_BRAND] ===
95
+ true
96
+ );
97
+ }