@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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/AuroraManager.d.ts +44 -0
- package/dist/AuroraManager.js +47 -0
- package/dist/AuroraProvider.d.ts +52 -0
- package/dist/AuroraProvider.js +145 -0
- package/dist/Pages.d.ts +78 -0
- package/dist/Pages.js +116 -0
- package/dist/component.d.ts +55 -0
- package/dist/component.js +97 -0
- package/dist/html.d.ts +30 -0
- package/dist/html.js +246 -0
- package/dist/hydrate.d.ts +29 -0
- package/dist/hydrate.js +379 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/reactive.d.ts +83 -0
- package/dist/reactive.js +217 -0
- package/dist/relay.d.ts +43 -0
- package/dist/relay.js +144 -0
- package/dist/render.d.ts +25 -0
- package/dist/render.js +283 -0
- package/dist/route.d.ts +64 -0
- package/dist/route.js +49 -0
- package/dist/server/renderPage.d.ts +62 -0
- package/dist/server/renderPage.js +83 -0
- package/dist/server/serveAssets.d.ts +43 -0
- package/dist/server/serveAssets.js +89 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.js +31 -0
- package/dist/ssr.d.ts +22 -0
- package/dist/ssr.js +179 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +15 -0
- package/package.json +69 -0
- package/src/AuroraManager.ts +76 -0
- package/src/AuroraProvider.ts +187 -0
- package/src/Pages.ts +164 -0
- package/src/component.ts +138 -0
- package/src/html.ts +296 -0
- package/src/hydrate.ts +518 -0
- package/src/index.ts +43 -0
- package/src/reactive.ts +265 -0
- package/src/relay.ts +171 -0
- package/src/render.ts +378 -0
- package/src/route.ts +96 -0
- package/src/server/renderPage.ts +135 -0
- package/src/server/serveAssets.ts +135 -0
- package/src/services/main.ts +40 -0
- package/src/ssr.ts +179 -0
- package/src/types.ts +97 -0
package/src/reactive.ts
ADDED
|
@@ -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
|
+
}
|