@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,265 @@
1
+ /**
2
+ * Reactive core — signals + effects with auto-tracking.
3
+ *
4
+ * No proxies, no VDOM. A `signal<T>()` is a single read/write function that
5
+ * registers itself in the currently-running observer's dependency set when
6
+ * read, and notifies every dependent observer when written.
7
+ *
8
+ * Effects run their callback once eagerly, capture the signals they read,
9
+ * and re-run whenever any of those signals fires. Effects can return a
10
+ * cleanup function that runs before the next re-execution and at disposal.
11
+ */
12
+
13
+ import type { EffectCallback } from "./types.js";
14
+
15
+ /** Reader-only view of a signal. Returned by `memo()`. */
16
+ export interface ReadSignal<T> {
17
+ (): T;
18
+ readonly [SIGNAL_BRAND]: true;
19
+ }
20
+
21
+ /** Read-write signal — call with no args to read, with one arg to write. */
22
+ export interface Signal<T> {
23
+ (): T;
24
+ (next: T): void;
25
+ (updater: (prev: T) => T): void;
26
+ readonly [SIGNAL_BRAND]: true;
27
+ }
28
+
29
+ /**
30
+ * Brand symbol so consumers can distinguish a signal from a plain function
31
+ * without instanceof. Exposed only via the `isSignal` guard — never call
32
+ * sites need to import this directly.
33
+ */
34
+ export const SIGNAL_BRAND: unique symbol = Symbol.for("aurora:signal");
35
+
36
+ /** An effect computation that re-runs when any tracked signal changes. */
37
+ interface Effect {
38
+ run(): void;
39
+ dispose(): void;
40
+ readonly dependencies: Set<SignalNode<unknown>>;
41
+ readonly cleanups: Array<() => void>;
42
+ disposed: boolean;
43
+ }
44
+
45
+ /** Internal node shared by every signal — the dependency-tracking primitive. */
46
+ interface SignalNode<T> {
47
+ value: T;
48
+ observers: Set<Effect>;
49
+ /**
50
+ * Custom equality. `Object.is` is the default — passing a custom
51
+ * comparator lets callers store reference-equal values (arrays, maps)
52
+ * without spurious recomputation.
53
+ */
54
+ equals: (a: T, b: T) => boolean;
55
+ }
56
+
57
+ // `undefined` entries are untrack() sentinels — a read while one is on
58
+ // top sees "no active observer" and registers no dependency.
59
+ const observerStack: Array<Effect | undefined> = [];
60
+ let batchDepth = 0;
61
+ const pendingNotifications = new Set<Effect>();
62
+
63
+ function activeObserver(): Effect | undefined {
64
+ return observerStack[observerStack.length - 1];
65
+ }
66
+
67
+ /**
68
+ * Create a writable signal seeded with `initial`. Reads register the
69
+ * current observer; writes notify every observer that previously read.
70
+ *
71
+ * Optional `{ equals }` swaps the default `Object.is` check — return
72
+ * `true` to skip notifying observers (the new value is "the same").
73
+ */
74
+ export function signal<T>(
75
+ initial: T,
76
+ options?: { equals?: (a: T, b: T) => boolean },
77
+ ): Signal<T> {
78
+ const node: SignalNode<T> = {
79
+ value: initial,
80
+ observers: new Set(),
81
+ equals: options?.equals ?? Object.is,
82
+ };
83
+
84
+ function accessor(...args: [] | [T] | [(prev: T) => T]): T | undefined {
85
+ if (args.length === 0) {
86
+ const obs = activeObserver();
87
+ if (obs) {
88
+ node.observers.add(obs);
89
+ obs.dependencies.add(node as SignalNode<unknown>);
90
+ }
91
+ return node.value;
92
+ }
93
+ const arg = args[0];
94
+ const next =
95
+ typeof arg === "function"
96
+ ? (arg as (prev: T) => T)(node.value)
97
+ : (arg as T);
98
+ if (node.equals(node.value, next)) return;
99
+ node.value = next;
100
+ // Snapshot observers before iteration — an effect's run() may
101
+ // dispose itself (or peers) and mutate the live Set during the
102
+ // loop, which would skip notifications under for…of semantics.
103
+ const toNotify = [...node.observers];
104
+ if (batchDepth > 0) {
105
+ for (const eff of toNotify) pendingNotifications.add(eff);
106
+ return;
107
+ }
108
+ for (const eff of toNotify) {
109
+ if (!eff.disposed) eff.run();
110
+ }
111
+ }
112
+
113
+ (accessor as unknown as { [SIGNAL_BRAND]: true })[SIGNAL_BRAND] = true;
114
+ signalNodes.set(accessor, node);
115
+ return accessor as Signal<T>;
116
+ }
117
+
118
+ /**
119
+ * Test-only registry mapping a signal accessor to its backing node, so
120
+ * `observerCount` can assert the observer Set doesn't leak. A WeakMap
121
+ * keeps it off the public accessor surface and never retains a disposed
122
+ * signal.
123
+ */
124
+ const signalNodes = new WeakMap<
125
+ object,
126
+ { observers: { readonly size: number } }
127
+ >();
128
+
129
+ /**
130
+ * @internal Observer-count test seam for the untrack-leak invariant: a
131
+ * read inside `untrack()` must NOT add an entry to a signal's observer
132
+ * Set. Returns -1 for a value that isn't a tracked signal.
133
+ */
134
+ export function observerCount(sig: object): number {
135
+ const node = signalNodes.get(sig);
136
+ return node ? node.observers.size : -1;
137
+ }
138
+
139
+ /** Runtime guard — distinguishes a signal accessor from any other callable. */
140
+ export function isSignal<T = unknown>(value: unknown): value is Signal<T> {
141
+ return (
142
+ typeof value === "function" &&
143
+ (value as { [SIGNAL_BRAND]?: boolean })[SIGNAL_BRAND] === true
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Run `fn` immediately and every time a signal it reads changes. Returns a
149
+ * dispose function — call it to stop the effect and run any pending
150
+ * cleanup.
151
+ *
152
+ * Inside `fn`, return another function to register cleanup that runs
153
+ * before the next execution AND at disposal. Multiple cleanups can also
154
+ * be registered via `onCleanup()`.
155
+ */
156
+ export function effect(fn: EffectCallback): () => void {
157
+ const eff: Effect = {
158
+ dependencies: new Set(),
159
+ cleanups: [],
160
+ disposed: false,
161
+ run() {
162
+ if (this.disposed) return;
163
+ runCleanups(this);
164
+ detach(this);
165
+ observerStack.push(this);
166
+ try {
167
+ const teardown = fn();
168
+ if (typeof teardown === "function") this.cleanups.push(teardown);
169
+ } finally {
170
+ observerStack.pop();
171
+ }
172
+ },
173
+ dispose() {
174
+ if (this.disposed) return;
175
+ this.disposed = true;
176
+ runCleanups(this);
177
+ detach(this);
178
+ },
179
+ };
180
+ eff.run();
181
+ return () => eff.dispose();
182
+ }
183
+
184
+ /**
185
+ * Register a cleanup callback against the currently-running effect.
186
+ * No-op when called outside an effect — same contract as Solid's
187
+ * `onCleanup`, more permissive than React's hook-only access.
188
+ */
189
+ export function onCleanup(fn: () => void): void {
190
+ const obs = activeObserver();
191
+ if (obs) obs.cleanups.push(fn);
192
+ }
193
+
194
+ /**
195
+ * Defer notifications until `fn` returns. Multiple writes to the same
196
+ * signal coalesce into a single observer re-run, and writes across
197
+ * signals re-run each affected observer at most once.
198
+ */
199
+ export function batch<T>(fn: () => T): T {
200
+ batchDepth++;
201
+ try {
202
+ return fn();
203
+ } finally {
204
+ batchDepth--;
205
+ if (batchDepth === 0) {
206
+ const toRun = [...pendingNotifications];
207
+ pendingNotifications.clear();
208
+ for (const eff of toRun) {
209
+ if (!eff.disposed) eff.run();
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Read signals inside `fn` without registering them as dependencies of
217
+ * the current observer. Useful when an effect needs the current value of
218
+ * a signal but should not re-run when it changes.
219
+ */
220
+ export function untrack<T>(fn: () => T): T {
221
+ // Push an `undefined` sentinel rather than a dummy Effect. A dummy
222
+ // gets `add()`-ed into every signal's `observers` Set on read and is
223
+ // never detached, leaking dead entries that grow each write. With
224
+ // `undefined` on top, `activeObserver()` returns undefined and reads
225
+ // register nothing — the actual "untracked" semantics.
226
+ observerStack.push(undefined);
227
+ try {
228
+ return fn();
229
+ } finally {
230
+ observerStack.pop();
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Derived read-only signal — `fn` re-runs when any signal it reads
236
+ * changes, and the latest return value is cached + handed out via the
237
+ * returned accessor. Cleanups inside `fn` (via `onCleanup`) run on every
238
+ * recomputation.
239
+ */
240
+ export function memo<T>(fn: () => T): ReadSignal<T> {
241
+ const internal = signal<T | undefined>(undefined);
242
+ effect(() => {
243
+ internal(fn());
244
+ });
245
+ const reader = (() => internal() as T) as ReadSignal<T>;
246
+ (reader as { [SIGNAL_BRAND]: true })[SIGNAL_BRAND] = true;
247
+ return reader;
248
+ }
249
+
250
+ function runCleanups(eff: Effect): void {
251
+ if (eff.cleanups.length === 0) return;
252
+ const queued = eff.cleanups.splice(0, eff.cleanups.length);
253
+ for (const cleanup of queued) {
254
+ try {
255
+ cleanup();
256
+ } catch {
257
+ /* swallow — cleanup errors must not block sibling cleanups */
258
+ }
259
+ }
260
+ }
261
+
262
+ function detach(eff: Effect): void {
263
+ for (const dep of eff.dependencies) dep.observers.delete(eff);
264
+ eff.dependencies.clear();
265
+ }
package/src/relay.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Browser-side relay helper — hides the EventSource + POST handshake
3
+ * Ream's `@c9up/relay` package expects.
4
+ *
5
+ * import { relay } from '@c9up/aurora/relay'
6
+ *
7
+ * relay().subscribe(`project/${id}`, (ev) => {
8
+ * console.log('received', ev)
9
+ * })
10
+ *
11
+ * One EventSource per page (a singleton inside this module). All
12
+ * subscribe calls fan out to its uid. The connection re-opens after
13
+ * the browser auto-reconnect; subscriptions are re-applied.
14
+ *
15
+ * This module is browser-only. It's shipped via aurora's pre-built
16
+ * `dist/` and imported through the same importmap that maps
17
+ * `@c9up/aurora`. Node-side code that pulls it will trip on
18
+ * `EventSource` being undefined.
19
+ */
20
+
21
+ export interface RelayClient {
22
+ subscribe<E>(channel: string, handler: (event: E) => void): () => void;
23
+ close(): void;
24
+ }
25
+
26
+ interface RelayState {
27
+ sse: EventSource | null;
28
+ uid: string | null;
29
+ channels: Map<string, Set<(event: unknown) => void>>;
30
+ pending: Array<() => void>;
31
+ }
32
+
33
+ const STATE: RelayState = {
34
+ sse: null,
35
+ uid: null,
36
+ channels: new Map(),
37
+ pending: [],
38
+ };
39
+
40
+ export interface RelayOptions {
41
+ /** SSE endpoint. Defaults to `/__relay/events`. */
42
+ sseUrl?: string;
43
+ /** Subscribe POST endpoint. Defaults to `/__relay/subscribe`. */
44
+ subscribeUrl?: string;
45
+ /** Optional bearer token (for guarded relay routes). */
46
+ bearer?: string;
47
+ }
48
+
49
+ let CONFIG: Required<RelayOptions> = {
50
+ sseUrl: "/__relay/events",
51
+ subscribeUrl: "/__relay/subscribe",
52
+ bearer: "",
53
+ };
54
+
55
+ /**
56
+ * Configure the relay endpoints + bearer. Call once at boot if you
57
+ * need to override the defaults. Multiple calls overwrite — last call
58
+ * wins.
59
+ */
60
+ export function configureRelay(options: RelayOptions): void {
61
+ CONFIG = {
62
+ sseUrl: options.sseUrl ?? CONFIG.sseUrl,
63
+ subscribeUrl: options.subscribeUrl ?? CONFIG.subscribeUrl,
64
+ bearer: options.bearer ?? CONFIG.bearer,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Lazily-opened EventSource bound to a single page lifetime. Returns
70
+ * the same client across calls — duplicate `relay()` calls share the
71
+ * underlying connection.
72
+ */
73
+ export function relay(): RelayClient {
74
+ if (!STATE.sse) {
75
+ open();
76
+ }
77
+ return CLIENT;
78
+ }
79
+
80
+ const CLIENT: RelayClient = {
81
+ subscribe(channel, handler) {
82
+ let handlers = STATE.channels.get(channel);
83
+ if (!handlers) {
84
+ handlers = new Set();
85
+ STATE.channels.set(channel, handlers);
86
+ }
87
+ const adapted = handler as (event: unknown) => void;
88
+ handlers.add(adapted);
89
+
90
+ // Subscribe over POST as soon as we have a uid. If the SSE is
91
+ // still mid-handshake, queue the call and flush on `connected`.
92
+ const doSubscribe = () => {
93
+ postSubscribe(channel).catch((err: unknown) => {
94
+ console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
95
+ });
96
+ };
97
+ if (STATE.uid) doSubscribe();
98
+ else STATE.pending.push(doSubscribe);
99
+
100
+ // Detacher — only removes the local listener. The server-side
101
+ // subscription stays open; closing it would interrupt other
102
+ // listeners on the same channel.
103
+ return () => {
104
+ handlers?.delete(adapted);
105
+ };
106
+ },
107
+
108
+ close() {
109
+ if (STATE.sse) {
110
+ STATE.sse.close();
111
+ STATE.sse = null;
112
+ }
113
+ STATE.uid = null;
114
+ STATE.channels.clear();
115
+ STATE.pending.length = 0;
116
+ },
117
+ };
118
+
119
+ function open(): void {
120
+ const sse = new EventSource(CONFIG.sseUrl);
121
+ STATE.sse = sse;
122
+
123
+ sse.addEventListener("connected", (ev) => {
124
+ const data = safeJson<{ uid?: string }>((ev as MessageEvent).data);
125
+ if (data && typeof data.uid === "string") {
126
+ STATE.uid = data.uid;
127
+ const queue = STATE.pending.splice(0);
128
+ for (const fn of queue) fn();
129
+ }
130
+ });
131
+
132
+ sse.onmessage = (ev) => {
133
+ const data = safeJson<{ channel?: string; [key: string]: unknown }>(
134
+ ev.data,
135
+ );
136
+ if (!data || typeof data.channel !== "string") return;
137
+ const handlers = STATE.channels.get(data.channel);
138
+ if (!handlers) return;
139
+ for (const handler of handlers) {
140
+ try {
141
+ handler(data);
142
+ } catch (err) {
143
+ console.warn(`[aurora/relay] listener for ${data.channel} threw:`, err);
144
+ }
145
+ }
146
+ };
147
+ }
148
+
149
+ async function postSubscribe(channel: string): Promise<void> {
150
+ const headers: Record<string, string> = {
151
+ "content-type": "application/json",
152
+ };
153
+ if (CONFIG.bearer) headers.authorization = `Bearer ${CONFIG.bearer}`;
154
+ const res = await fetch(CONFIG.subscribeUrl, {
155
+ method: "POST",
156
+ headers,
157
+ body: JSON.stringify({ uid: STATE.uid, channel }),
158
+ });
159
+ if (!res.ok) {
160
+ throw new Error(`HTTP ${res.status}`);
161
+ }
162
+ }
163
+
164
+ function safeJson<T>(raw: unknown): T | null {
165
+ if (typeof raw !== "string") return null;
166
+ try {
167
+ return JSON.parse(raw) as T;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }