rosefc 0.0.0-stage → 0.2.0
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/deploy/Dockerfile.node +25 -0
- package/deploy/Dockerfile.static +34 -0
- package/deploy/api/rosefd.ts +19 -0
- package/deploy/cloudflare/functions/[[path]].ts +59 -0
- package/deploy/cloudflare/wrangler.toml +14 -0
- package/deploy/docker-compose.yml +45 -0
- package/deploy/fly.toml +28 -0
- package/deploy/k8s/rosefd.yaml +128 -0
- package/deploy/native/RosefdApp.xcscheme +87 -0
- package/deploy/native/android.yml +62 -0
- package/deploy/native/flutter.yml +96 -0
- package/deploy/native/ios.yml +67 -0
- package/deploy/netlify/functions/rosefd.ts +36 -0
- package/deploy/netlify.toml +17 -0
- package/deploy/server.cjs +4 -0
- package/deploy/vercel.json +13 -0
- package/dist-cli/compiler.mjs +1693 -0
- package/dist-cli/edge.mjs +199 -0
- package/dist-cli/export.mjs +12287 -0
- package/dist-cli/rosefd.mjs +12783 -0
- package/dist-cli/runtime.mjs +6 -0
- package/dist-cli/test.mjs +1885 -0
- package/package.json +110 -4
- package/src/FRAMEWORK_VERSION +1 -0
- package/src/cli/content.ts +188 -0
- package/src/cli/edge.ts +298 -0
- package/src/cli/hot-vary.ts +27 -0
- package/src/cli/origin.ts +10 -0
- package/src/cli/prod-server.ts +817 -0
- package/src/cli/shell.ts +610 -0
- package/src/cli/shutdown.ts +56 -0
- package/src/cli/validator.ts +53 -0
- package/src/hash.ts +9 -0
- package/src/runtime/db.ts +166 -0
- package/src/runtime/index.ts +2992 -0
- package/src/runtime/lorawan.ts +149 -0
- package/src/types/rosefd.d.ts +327 -0
- package/README.md +0 -3
|
@@ -0,0 +1,2992 @@
|
|
|
1
|
+
|
|
2
|
+
// The LoRaWAN parser lives in its own module (it is pure and large enough to
|
|
3
|
+
// deserve one) but reaches pages through this module's surface.
|
|
4
|
+
export { parseLoRaWAN } from './lorawan.js';
|
|
5
|
+
export type { LoRaWANFrame, LoRaWANDataFrame, LoRaWANJoinRequest, LoRaWANJoinAccept, LoRaWANMessageType } from './lorawan.js';
|
|
6
|
+
|
|
7
|
+
// === Reactive Primitives ===
|
|
8
|
+
type Subscriber = () => void;
|
|
9
|
+
type Cleanup = () => void;
|
|
10
|
+
type SignalEntry = { value: unknown; subs: Set<Subscriber> };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* State that must be ONE table even when this module has several copies.
|
|
14
|
+
*
|
|
15
|
+
* Every separately-bundled runtime copy (a per-page inline bundle, a lazily
|
|
16
|
+
* loaded route chunk) imports its own copy of this file, so a bare `let` is
|
|
17
|
+
* per-copy - and the things these tables are keyed on are NOT per-copy: the
|
|
18
|
+
* document, the signal keys, the store names, the element identities. Two
|
|
19
|
+
* copies that each keep their own `directBound` double-bind one element's
|
|
20
|
+
* handlers; two copies that each keep their own signal map lose every signal
|
|
21
|
+
* the other one wrote. So the shared tables live on globalThis, first copy in
|
|
22
|
+
* wins, and the rest adopt the winner's.
|
|
23
|
+
*
|
|
24
|
+
* `shared` is for tables that are only ever MUTATED in place (add/delete/set),
|
|
25
|
+
* never re-bound. For a binding that gets re-assigned use `sharedCell` - a
|
|
26
|
+
* plain assignment to a `shared` let would update the local binding only and
|
|
27
|
+
* the other copies would keep reading the stale table.
|
|
28
|
+
*/
|
|
29
|
+
function shared<T>(key: string, init: () => T): T {
|
|
30
|
+
const g = globalThis as any;
|
|
31
|
+
return (g[key] ??= init()) as T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A shared slot whose .value is re-assigned over the module's life. */
|
|
35
|
+
function sharedCell<T>(key: string, initial: T): { value: T } {
|
|
36
|
+
const g = globalThis as any;
|
|
37
|
+
return (g[key] ??= { value: initial }) as { value: T };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let currentEffect: (() => void) | null = null;
|
|
41
|
+
|
|
42
|
+
// F43: while an $effect body runs, every signal read lands here (the entry OBJECT).
|
|
43
|
+
let trackingDeps: Set<SignalEntry> | null = null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run fn with dependency tracking off. A block's BODY render reads the very
|
|
47
|
+
* signals its own children subscribe to: running it inside the block effect's
|
|
48
|
+
* tracking window subscribes the BLOCK to every inner signal too, so one inner
|
|
49
|
+
* write rebuilds the whole {#if}/slot body (node identity lost, work
|
|
50
|
+
* multiplied) on top of the child's own fine-grained patch. The gating read
|
|
51
|
+
* (the condition, the items array) stays tracked; the body render does not.
|
|
52
|
+
*/
|
|
53
|
+
function untrack<T>(fn: () => T): T {
|
|
54
|
+
const prevEffect = currentEffect;
|
|
55
|
+
const prevTracking = trackingDeps;
|
|
56
|
+
currentEffect = null;
|
|
57
|
+
trackingDeps = null;
|
|
58
|
+
try {
|
|
59
|
+
return fn();
|
|
60
|
+
} finally {
|
|
61
|
+
currentEffect = prevEffect;
|
|
62
|
+
trackingDeps = prevTracking;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Trade-off: global signal map, shared across all inlined modules
|
|
67
|
+
const signalMap: Map<string, SignalEntry> = shared('__rosefd_signalMap', () => new Map());
|
|
68
|
+
|
|
69
|
+
// One get-or-create helper shared by state / setState / resumeState.
|
|
70
|
+
function entry(key: string, initial: unknown): { value: unknown; subs: Set<Subscriber> } {
|
|
71
|
+
const map = renderMaps().signals;
|
|
72
|
+
const e = map.get(key) ?? { value: initial, subs: new Set<Subscriber>() };
|
|
73
|
+
map.set(key, e);
|
|
74
|
+
return e;
|
|
75
|
+
// F43: $effect dep capture
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function state<T>(key: string, initial: T): [() => T, (v: T) => void] {
|
|
79
|
+
// The key belongs to whoever is rendering, so a mount's navigation
|
|
80
|
+
ownerKeys()?.add(key);
|
|
81
|
+
const e = entry(key, initial);
|
|
82
|
+
|
|
83
|
+
const getter = () => {
|
|
84
|
+
if (currentEffect) {
|
|
85
|
+
e.subs.add(currentEffect);
|
|
86
|
+
if (trackingDeps) trackingDeps.add(e);
|
|
87
|
+
/* no storage / private mode / quota: persistence is best-effort */
|
|
88
|
+
}
|
|
89
|
+
return e.value as T;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const setter = (v: T) => {
|
|
93
|
+
// F36: a function value is a functional update - it receives the current value and returns the next one ($setState('n', (x) => x + 1)).
|
|
94
|
+
const next = (typeof v === 'function' ? (v as (cur: unknown) => T)(e.value) : v) as T;
|
|
95
|
+
// A write of the value the signal already holds changes nothing: skip the
|
|
96
|
+
// fan-out. Object.is, not ==, so NaN writes are (correctly) still news and
|
|
97
|
+
// -0/+0 writes stay distinguishable. Skipping is what keeps {#key}'s
|
|
98
|
+
// promise - a re-render carrying the same key must leave the body's nodes
|
|
99
|
+
// alone, and a same-value write that ran the effect would rebuild them.
|
|
100
|
+
if (Object.is(next, e.value)) return;
|
|
101
|
+
e.value = next;
|
|
102
|
+
// F43: notify UNTRACKED. Subscribers were subscribed by whoever owns them; a write's fan-out must not hand out new subscriptions.
|
|
103
|
+
const prevEffect = currentEffect;
|
|
104
|
+
const prevTracking = trackingDeps;
|
|
105
|
+
currentEffect = null;
|
|
106
|
+
trackingDeps = null;
|
|
107
|
+
// A snapshot, not the live Set: an effect that runs here may subscribe
|
|
108
|
+
// another effect (a nested block's first run), and a live Set's iterator
|
|
109
|
+
// would visit that newcomer in the SAME pass - the nested effect runs
|
|
110
|
+
// twice, and its second run rebuilds nodes the first run just placed.
|
|
111
|
+
for (const fn of [...e.subs] as Subscriber[]) fn();
|
|
112
|
+
/* a throwing effect must not kill the route */
|
|
113
|
+
currentEffect = prevEffect;
|
|
114
|
+
trackingDeps = prevTracking;
|
|
115
|
+
if (persistedKeys.has(key)) writeStorage(key, next);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return [getter, setter];
|
|
119
|
+
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function hasState(key: string): boolean {
|
|
123
|
+
return renderMaps().signals.has(key);
|
|
124
|
+
/* a stale cleanup must not kill the route */
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// === Per-RENDER $data seeding === The compiled `$data(() => loader())` prologue is const __seeded = isSeeded("k"); const [get.
|
|
128
|
+
const seededKeys: Set<string> = shared('__rosefd_seededKeys', () => new Set());
|
|
129
|
+
|
|
130
|
+
export function isSeeded(key: string): boolean {
|
|
131
|
+
return renderMaps().seeded.has(key);
|
|
132
|
+
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function markSeeded(key: string): void {
|
|
136
|
+
renderMaps().seeded.add(key);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function setState<T>(key: string, value: T): void {
|
|
140
|
+
const e = entry(key, value);
|
|
141
|
+
// Same two rules as the signal's own setter: a write of the value already
|
|
142
|
+
// held changes nothing (Object.is, so NaN and -0/+0 stay honest), and the
|
|
143
|
+
// fan-out walks a snapshot - an effect running here may subscribe another
|
|
144
|
+
// one, and a live Set's iterator would visit that newcomer in the same pass.
|
|
145
|
+
if (Object.is(value, e.value)) return;
|
|
146
|
+
e.value = value;
|
|
147
|
+
// F43: notify untracked (same reason as the per-render setter above).
|
|
148
|
+
const prevEffect = currentEffect;
|
|
149
|
+
const prevTracking = trackingDeps;
|
|
150
|
+
currentEffect = null;
|
|
151
|
+
trackingDeps = null;
|
|
152
|
+
for (const fn of [...e.subs] as Subscriber[]) fn();
|
|
153
|
+
currentEffect = prevEffect;
|
|
154
|
+
trackingDeps = prevTracking;
|
|
155
|
+
if (persistedKeys.has(key)) writeStorage(key, value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// F9 ($persist): keys declared with $persist survive reloads — every write lands in localStorage (JSON-encoded under a rosefd: prefix) and every client.
|
|
159
|
+
const persistedKeys: Set<string> = shared('__rosefd_persistedKeys', () => new Set());
|
|
160
|
+
|
|
161
|
+
function writeStorage(key: string, v: unknown): void {
|
|
162
|
+
try {
|
|
163
|
+
(globalThis as { localStorage?: Storage }).localStorage?.setItem('rosefd:' + key, JSON.stringify(v));
|
|
164
|
+
} catch { }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// F9 ($persist): `let theme = $persist('dark')` — like $state, but the value survives reloads.
|
|
168
|
+
export function persistState<T>(key: string, initial: T): [() => T, (v: T) => void] {
|
|
169
|
+
persistedKeys.add(key);
|
|
170
|
+
let seeded = initial;
|
|
171
|
+
let hasStored = false;
|
|
172
|
+
try {
|
|
173
|
+
const raw = (globalThis as { localStorage?: Storage }).localStorage?.getItem('rosefd:' + key);
|
|
174
|
+
if (raw != null) {
|
|
175
|
+
seeded = JSON.parse(raw) as T;
|
|
176
|
+
hasStored = true;
|
|
177
|
+
}
|
|
178
|
+
} catch { }
|
|
179
|
+
|
|
180
|
+
const [get, set] = state(key, seeded);
|
|
181
|
+
if (hasStored) set(seeded);
|
|
182
|
+
// the stored value wins over the resumed server default
|
|
183
|
+
return [get, set];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// F12: the error a {#boundary} just caught — a custom {:fallback} reads it ({$boundaryError()?.message}) to render its own failure UX.
|
|
187
|
+
const lastBoundaryError = sharedCell<Error | null>('__rosefd_lastBoundaryError', null);
|
|
188
|
+
|
|
189
|
+
export function $setBoundaryError(e: unknown): void {
|
|
190
|
+
lastBoundaryError.value = e instanceof Error ? e : new Error(String(e));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function $boundaryError(): Error | null {
|
|
194
|
+
return lastBoundaryError.value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
const cleanups: Set<Cleanup> = shared('__rosefd_cleanups', () => new Set());
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
let fxOwner: object | null = null;
|
|
202
|
+
const fxByOwner: WeakMap<object, Set<Cleanup>> = shared('__rosefd_fxByOwner', () => new WeakMap());
|
|
203
|
+
const keysByOwner: WeakMap<object, Set<string>> = shared('__rosefd_keysByOwner', () => new WeakMap());
|
|
204
|
+
const fxSet = (): Set<Cleanup> => {
|
|
205
|
+
if (!fxOwner) return cleanups;
|
|
206
|
+
let s = fxByOwner.get(fxOwner);
|
|
207
|
+
if (!s) { s = new Set(); fxByOwner.set(fxOwner, s); }
|
|
208
|
+
return s;
|
|
209
|
+
};
|
|
210
|
+
const ownerKeys = (): Set<string> | null => {
|
|
211
|
+
if (!fxOwner) return null;
|
|
212
|
+
let s = keysByOwner.get(fxOwner);
|
|
213
|
+
if (!s) { s = new Set(); keysByOwner.set(fxOwner, s); }
|
|
214
|
+
return s;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Attribute everything the current render creates to `container` (null = the document's global bookkeeping).
|
|
218
|
+
export function setFxOwner(container: object | null): void {
|
|
219
|
+
fxOwner = container;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Dispose everything ONE container created - its effects, its onCleanups, the state keys its render seeded.
|
|
223
|
+
export function resetContainerFx(container: object): void {
|
|
224
|
+
const set = fxByOwner.get(container);
|
|
225
|
+
if (set) {
|
|
226
|
+
set.forEach((fn: Cleanup) => fn());
|
|
227
|
+
fxByOwner.delete(container);
|
|
228
|
+
}
|
|
229
|
+
const keys = keysByOwner.get(container);
|
|
230
|
+
if (keys) {
|
|
231
|
+
keys.forEach((k: string) => renderMaps().signals.delete(k));
|
|
232
|
+
keysByOwner.delete(container);
|
|
233
|
+
}
|
|
234
|
+
renderMaps().data.clear();
|
|
235
|
+
pendingDeltas.clear();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function effect(fn: () => void): Cleanup {
|
|
239
|
+
const wrapped: Subscriber = () => {
|
|
240
|
+
try {
|
|
241
|
+
fn();
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const prev = currentEffect;
|
|
246
|
+
currentEffect = wrapped;
|
|
247
|
+
fn();
|
|
248
|
+
currentEffect = prev;
|
|
249
|
+
const cleanup: Cleanup = () => {
|
|
250
|
+
renderMaps().signals.forEach((e: { subs: Set<Subscriber> }) => e.subs.delete(wrapped));
|
|
251
|
+
// $store() boxes live in a separate map: detach them too, or a live store
|
|
252
|
+
// patch keeps re-running an unmounted effect (stale render + leak).
|
|
253
|
+
STORES.forEach((e: { subs: Set<Subscriber> }) => e.subs.delete(wrapped));
|
|
254
|
+
fxSet().delete(cleanup);
|
|
255
|
+
};
|
|
256
|
+
fxSet().add(cleanup);
|
|
257
|
+
return cleanup;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// F43: `$effect(fn)` - the reactive effect rune. Client-only (SSR: a no-op, fn never fires).
|
|
261
|
+
export function $effect(fn: () => void | (() => void)): Cleanup {
|
|
262
|
+
if (typeof document === 'undefined') return () => {};
|
|
263
|
+
// SSR: no DOM, nothing changes
|
|
264
|
+
const deps = new Set<SignalEntry>();
|
|
265
|
+
let cleanup: (() => void) | undefined;
|
|
266
|
+
let scheduled = false;
|
|
267
|
+
let disposed = false;
|
|
268
|
+
const detach = () => {
|
|
269
|
+
deps.forEach((e: SignalEntry) => e.subs.delete(wrapped));
|
|
270
|
+
deps.clear();
|
|
271
|
+
};
|
|
272
|
+
const run = () => {
|
|
273
|
+
if (disposed) return;
|
|
274
|
+
// F43: stay "scheduled" for the whole run.
|
|
275
|
+
scheduled = true;
|
|
276
|
+
detach();
|
|
277
|
+
if (typeof cleanup === 'function') {
|
|
278
|
+
try { (cleanup as () => void)(); } catch { }
|
|
279
|
+
}
|
|
280
|
+
const prevEffect = currentEffect;
|
|
281
|
+
const prevTracking = trackingDeps;
|
|
282
|
+
currentEffect = wrapped;
|
|
283
|
+
trackingDeps = deps;
|
|
284
|
+
try {
|
|
285
|
+
const ret = fn();
|
|
286
|
+
cleanup = typeof ret === 'function' ? (ret as () => void) : undefined;
|
|
287
|
+
} catch { }
|
|
288
|
+
currentEffect = prevEffect;
|
|
289
|
+
trackingDeps = prevTracking;
|
|
290
|
+
scheduled = false;
|
|
291
|
+
};
|
|
292
|
+
const wrapped: Subscriber = () => {
|
|
293
|
+
if (scheduled || disposed) return;
|
|
294
|
+
scheduled = true;
|
|
295
|
+
queueMicrotask(run);
|
|
296
|
+
};
|
|
297
|
+
const owner = fxSet();
|
|
298
|
+
|
|
299
|
+
const dispose: Cleanup = () => {
|
|
300
|
+
disposed = true;
|
|
301
|
+
detach();
|
|
302
|
+
if (typeof cleanup === 'function') {
|
|
303
|
+
try { (cleanup as () => void)(); } catch { }
|
|
304
|
+
}
|
|
305
|
+
owner.delete(dispose);
|
|
306
|
+
};
|
|
307
|
+
owner.add(dispose);
|
|
308
|
+
queueMicrotask(run);
|
|
309
|
+
// first run: after the synchronous render that created us
|
|
310
|
+
return dispose;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// === HTML escaping (server-side interpolation safety) ===
|
|
314
|
+
const ESC_MAP: Record<string, string> = {
|
|
315
|
+
'&': '&',
|
|
316
|
+
'<': '<',
|
|
317
|
+
'>': '>',
|
|
318
|
+
'"': '"',
|
|
319
|
+
"'": ''',
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export function esc(v: unknown): string {
|
|
323
|
+
return String(v).replace(/[&<>"']/g, (c) => ESC_MAP[c]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// === Generated-code marker helpers === Every reactive marker in every generated module is emitted through these functions.
|
|
327
|
+
type Close = (s?: unknown) => unknown;
|
|
328
|
+
|
|
329
|
+
export function textMark(closes: Close[], fn: Close, pair?: boolean, arg?: unknown): string {
|
|
330
|
+
const i = closes.push(fn) - 1;
|
|
331
|
+
return `<!--\u27e6m:${i}\u27e7-->` + esc(fn(arg)) + (pair ? `<!--\u27e6/m:${i}\u27e7-->` : '');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function attrValueString(name: string, v: unknown): string {
|
|
335
|
+
if (v == null) return '';
|
|
336
|
+
if (typeof v === 'object') {
|
|
337
|
+
if (name === 'class') {
|
|
338
|
+
return Object.entries(v).filter(([, x]) => x).map(([k]) => k).join(' ');
|
|
339
|
+
}
|
|
340
|
+
if (name === 'style') {
|
|
341
|
+
return Object.entries(v)
|
|
342
|
+
.filter(([, x]) => x != null && x !== false && x !== '')
|
|
343
|
+
.map(([k, x]) => `${k}: ${x}`)
|
|
344
|
+
.join('; ');
|
|
345
|
+
}
|
|
346
|
+
return JSON.stringify(v);
|
|
347
|
+
}
|
|
348
|
+
return String(v);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function attrMark(closes: Close[], fn: Close, name: string, arg?: unknown): string {
|
|
352
|
+
const i = closes.push(fn) - 1;
|
|
353
|
+
return `${name}="${esc(attrValueString(name, fn(arg)))}" data-b${i}="${name}"`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Boolean attributes (F1): presence IS the value - `checked="false"` still
|
|
358
|
+
* checks the box, so a reactive boolean attr can never ride attrMark. The
|
|
359
|
+
* SSR side emits the bare attribute only when truthy, plus an index-suffixed
|
|
360
|
+
* marker either way; wire()'s boolean branch flips the PROPERTY (the one
|
|
361
|
+
* thing a live DOM obeys after user interaction) and mirrors the attribute
|
|
362
|
+
* for no-JS parity.
|
|
363
|
+
*/
|
|
364
|
+
export function boolAttrMark(closes: Close[], fn: Close, name: string, arg?: unknown): string {
|
|
365
|
+
const i = closes.push(fn) - 1;
|
|
366
|
+
return (fn(arg) ? ` ${name}=""` : '') + ` data-b${i}="!${name}"`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function ifMark(
|
|
370
|
+
closes: Close[],
|
|
371
|
+
cond: Close,
|
|
372
|
+
fn: (s: unknown, c: Close[]) => unknown,
|
|
373
|
+
scope?: unknown
|
|
374
|
+
): string {
|
|
375
|
+
let snap: { html: string; closes: Close[] } | null = null;
|
|
376
|
+
const i = closes.push(() => {
|
|
377
|
+
if (!cond()) {
|
|
378
|
+
snap = null;
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
// The snapshot is only sound for a body with no reactive children. A body
|
|
382
|
+
// that carries markers (a nested {#if}/{#each}/text) re-renders differently
|
|
383
|
+
// every time its inner state moves: caching its html would replay a stale
|
|
384
|
+
// snapshot whose marker ids point at the wrong closes - the enclosing
|
|
385
|
+
// block's adopt would insert nodes wired to the wrong effects.
|
|
386
|
+
if (!snap) {
|
|
387
|
+
const html = String(untrack(() => fn(scope, closes)));
|
|
388
|
+
snap = html.includes('\u27e6') ? null : { html, closes };
|
|
389
|
+
return { html, closes };
|
|
390
|
+
}
|
|
391
|
+
return snap;
|
|
392
|
+
}) - 1;
|
|
393
|
+
const r = closes[i]() as { html: string } | null;
|
|
394
|
+
return `<!--\u27e6i:${i}\u27e7-->` + (r ? r.html : '') + `<!--\u27e6/i:${i}\u27e7-->`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface EachRow {
|
|
398
|
+
html: string;
|
|
399
|
+
closes: unknown[];
|
|
400
|
+
scope?: unknown;
|
|
401
|
+
keyed?: boolean;
|
|
402
|
+
key?: string;
|
|
403
|
+
nodes?: Node[];
|
|
404
|
+
disp?: Cleanup;
|
|
405
|
+
norm?: string;
|
|
406
|
+
}
|
|
407
|
+
// A row may only be reused when its rendered html is byte-identical - and even
|
|
408
|
+
// then, a row carrying wiring that cannot survive node reuse must be rebuilt.
|
|
409
|
+
// `data-on-*` (event handlers) is the dangerous one: an inline arrow captures
|
|
410
|
+
// the row's scope, so a reused node would keep calling the PREVIOUS item's
|
|
411
|
+
// closure. The trailing hyphen matters: a bare `data-on` is an ordinary state
|
|
412
|
+
// attribute (apps use it for active-row styling), its value is part of the html
|
|
413
|
+
// the reuse check compares, and it carries no closure - forbidding it forced a
|
|
414
|
+
// full rebuild of every such list on any selection change.
|
|
415
|
+
const EACH_REUSE_FORBID = /data-on-|data-use|data-bind-this|data-transition|data-animate|data-rosefd-c|data-rv-island/;
|
|
416
|
+
const EACH_HTML_NORM = (h: string): string => h.replace(/\u27e6\/?[a-z]+:\d+\u27e7/g, '').replace(/data-b\d+/g, 'data-b');
|
|
417
|
+
const normHtml = (row: EachRow): string => (row.norm ??= EACH_HTML_NORM(row.html));
|
|
418
|
+
|
|
419
|
+
export function eachMark(closes: Close[], items: Close, fn: (s: unknown, c: Close[], vi: number) => unknown, key?: (s: unknown, vi: number) => unknown): string {
|
|
420
|
+
const i = closes.push(() => {
|
|
421
|
+
const list = items() as unknown[];
|
|
422
|
+
return untrack(() => list.map((v, vi) => {
|
|
423
|
+
return { html: String(fn(v, closes, vi)), closes, scope: v, keyed: !!key, key: key ? String(key(v, vi)) : undefined };
|
|
424
|
+
}));
|
|
425
|
+
}) - 1;
|
|
426
|
+
let h = `<!--\u27e6l:${i}\u27e7-->`;
|
|
427
|
+
for (const r of closes[i]() as Array<{ html: string }>) h += r.html;
|
|
428
|
+
return h + `<!--\u27e6/l:${i}\u27e7-->`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function headMark(closes: Close[], fn: Close): string {
|
|
432
|
+
const i = closes.push(fn) - 1;
|
|
433
|
+
return `<!--\u27e6h:${i}\u27e7-->` + fn() + `<!--\u27e6/h:${i}\u27e7-->`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* P1-2 (bug report): TRUSTED HTML - the explicit reverse of esc(). `{@html x}`
|
|
438
|
+
* emits the value verbatim, so a CMS's rich text renders as markup instead of
|
|
439
|
+
* as escaped source.
|
|
440
|
+
*
|
|
441
|
+
* The markers are an {#if} block's on purpose: wire() already knows how to
|
|
442
|
+
* adopt the server's nodes in place (identity preserved - an embedded iframe
|
|
443
|
+
* does not reload) and to swap the range for a fresh parse when the value
|
|
444
|
+
* changes. Reusing that machinery costs this function and nothing else: no
|
|
445
|
+
* new marker kind, no new wire branch, no bytes in a document that never
|
|
446
|
+
* uses it. The close returns the same {html, closes} shape an {#if} block
|
|
447
|
+
* does, with the RENDER's array - trusted html is usually opaque markup
|
|
448
|
+
* with no markers inside, but when a loader's value was itself rendered
|
|
449
|
+
* inside this same pass (a theme skin built from another composable) the
|
|
450
|
+
* markers it carries belong to this array, and a nested wire() pass must be
|
|
451
|
+
* able to resolve them. `arg` is the enclosing block's scope value, exactly
|
|
452
|
+
* as textMark takes it: a `{@html row.body}` inside an {#each} resolves
|
|
453
|
+
* against the row.
|
|
454
|
+
*/
|
|
455
|
+
export function rawMark(closes: Close[], fn: Close, arg?: unknown): string {
|
|
456
|
+
const i = closes.push((s?: unknown) => ({ html: String((fn as (a?: unknown) => unknown)(s) ?? ''), closes })) - 1;
|
|
457
|
+
const r = closes[i](arg) as { html: string };
|
|
458
|
+
return `<!--\u27e6i:${i}\u27e7-->` + r.html + `<!--\u27e6/i:${i}\u27e7-->`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A named slot's block. The child rendered its slot content into the
|
|
463
|
+
* render's shared array scoped to the slot object; the block wraps it in
|
|
464
|
+
* markers from the SAME index space, so the ids stay unique across the whole
|
|
465
|
+
* composition (a plain <slot/> inlines the children html directly and needs
|
|
466
|
+
* none of this). Lives here rather than in the compiler's emitted helper
|
|
467
|
+
* string so the array is the one the rest of the render uses.
|
|
468
|
+
*/
|
|
469
|
+
export function slotBlockMark(
|
|
470
|
+
closes: Close[],
|
|
471
|
+
hit: { o: Record<string, unknown>; fn: (o: unknown, c: Close[]) => unknown } | null
|
|
472
|
+
): string {
|
|
473
|
+
if (!hit) return '';
|
|
474
|
+
const i = closes.push(() => {
|
|
475
|
+
if (!hit) return null;
|
|
476
|
+
return { html: String(untrack(() => hit.fn(hit.o, closes))), closes, scope: hit.o };
|
|
477
|
+
}) - 1;
|
|
478
|
+
return `<!--\u27e6i:${i}\u27e7-->` + String(untrack(() => hit.fn(hit.o, closes))) + `<!--\u27e6/i:${i}\u27e7-->`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const MARK_RE = /^\u27e6([milh]):(\d+)\u27e7$/;
|
|
482
|
+
const END_RE = /^\u27e6\/([milh]):(\d+)\u27e7$/;
|
|
483
|
+
|
|
484
|
+
const wired: WeakSet<Node> = shared('__rosefd_wired', () => new WeakSet());
|
|
485
|
+
|
|
486
|
+
const pendingIslandObservers: Set<IntersectionObserver> = shared('__rosefd_pendingIslandObservers', () => new Set());
|
|
487
|
+
|
|
488
|
+
const TPL_CACHE_MAX = 512;
|
|
489
|
+
const tplCache: Map<string, HTMLTemplateElement> = shared('__rosefd_tplCache', () => new Map());
|
|
490
|
+
|
|
491
|
+
/** Parse an html string into a DocumentFragment. Parsed templates are cached per string and cloned per call, so repeated renders (keyed each rows, if-branch re-entry) skip the innerHTML parse. */
|
|
492
|
+
export function tpl(html: string): DocumentFragment {
|
|
493
|
+
let t = tplCache.get(html);
|
|
494
|
+
if (!t) {
|
|
495
|
+
t = document.createElement('template');
|
|
496
|
+
t.innerHTML = html;
|
|
497
|
+
if (tplCache.size >= TPL_CACHE_MAX) {
|
|
498
|
+
const oldest = tplCache.keys().next().value;
|
|
499
|
+
if (oldest !== undefined) tplCache.delete(oldest);
|
|
500
|
+
}
|
|
501
|
+
tplCache.set(html, t);
|
|
502
|
+
}
|
|
503
|
+
return t.content.cloneNode(true) as DocumentFragment;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const HEAD_ATTR = 'data-rosefd-head';
|
|
507
|
+
|
|
508
|
+
export const handlers: Record<string, (e: Event) => void> =
|
|
509
|
+
(globalThis as { __rosefd_handlers?: Record<string, (e: Event) => void> }).__rosefd_handlers ??= {};
|
|
510
|
+
|
|
511
|
+
// The action dispatcher the client entry installs. Shared, not per-copy: the
|
|
512
|
+
// elements it dispatches for are in the one document every copy wires, so the
|
|
513
|
+
// last entry to install wins for all of them - the same rule `handlers` and
|
|
514
|
+
// `directives` already follow.
|
|
515
|
+
const actionDispatchCell = sharedCell<((name: string, e: Event) => void) | null>('__rosefd_actionDispatch', null);
|
|
516
|
+
export function setActionDispatcher(fn: (name: string, e: Event) => void): void {
|
|
517
|
+
actionDispatchCell.value = fn;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const directBound: WeakMap<Element, Set<string>> = shared('__rosefd_directBound', () => new WeakMap());
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* F5: `use:NAME` element actions - the plugin point for DOM behaviors
|
|
524
|
+
* (tooltips, dropdowns, canvas glue) that themes and plugins register once
|
|
525
|
+
* per module: directives.tooltip = (el) => { ...; return cleanup? }. wire()
|
|
526
|
+
* invokes each action for its element when the route adopts; the returned
|
|
527
|
+
* cleanup (if any) rides the same disposal set as every other effect.
|
|
528
|
+
* Parameters travel as data-* attributes the action reads - keeping them out
|
|
529
|
+
* of the reactive graph keeps the action contract one argument.
|
|
530
|
+
*/
|
|
531
|
+
export const directives: Record<string, (el: Element, param?: unknown) => void | (() => void) | { update?: (p: unknown) => void; destroy?: () => void }> =
|
|
532
|
+
(globalThis as { __rosefd_directives?: Record<string, (el: Element, param?: unknown) => void | (() => void) | { update?: (p: unknown) => void; destroy?: () => void }> })
|
|
533
|
+
.__rosefd_directives ??= {};
|
|
534
|
+
|
|
535
|
+
const actionRecords: WeakMap<Element, { update?: (p: unknown) => void; destroy?: () => void }> = shared('__rosefd_actionRecords', () => new WeakMap());
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Apply one route's whole <head> (every block of its chain, page first) to the
|
|
539
|
+
* document: <title> replaces the title, <meta>/<link>/<base> are matched by
|
|
540
|
+
* name/property, a rel-only <link> by its rel plus target, everything else by
|
|
541
|
+
* id then tag+position. First occurrence of a key wins, so a page
|
|
542
|
+
* overrides its layouts - the same rule the server's mergeHeadBlocks applies,
|
|
543
|
+
* which is why the two halves agree without talking to each other. (A link
|
|
544
|
+
* keyed by its bare rel used to swallow the second member of every same-rel
|
|
545
|
+
* group - hreflang alternates, font preloads, multi-size icons, several
|
|
546
|
+
* feeds; the target now joins the key. `rel="canonical"` is the one spec
|
|
547
|
+
* singleton and keeps the bare-rel key, so a page's canonical still overrides
|
|
548
|
+
* its layout's.)
|
|
549
|
+
*
|
|
550
|
+
* The managed set is REBUILT from the block rather than patched element by
|
|
551
|
+
* element: a route's head is the whole truth, so anything the previous route
|
|
552
|
+
* provided and this one does not is dropped, and a key that moved tags is
|
|
553
|
+
* replaced instead of duplicated. It is also a third of the code.
|
|
554
|
+
*/
|
|
555
|
+
export function applyHead(html: string): void {
|
|
556
|
+
const seen = new Set<string>();
|
|
557
|
+
const positional = new Map<string, number>();
|
|
558
|
+
const frag = tpl(html);
|
|
559
|
+
const title = frag.querySelector('title');
|
|
560
|
+
if (title) {
|
|
561
|
+
let cur = document.head.querySelector('title');
|
|
562
|
+
if (!cur) {
|
|
563
|
+
cur = document.createElement('title');
|
|
564
|
+
document.head.appendChild(cur);
|
|
565
|
+
}
|
|
566
|
+
cur.textContent = title.textContent ?? '';
|
|
567
|
+
}
|
|
568
|
+
for (const el of Array.from(document.head.querySelectorAll(`[${HEAD_ATTR}]`))) el.remove();
|
|
569
|
+
for (const el of Array.from(frag.children)) {
|
|
570
|
+
if (el.tagName === 'TITLE') continue;
|
|
571
|
+
const tag = el.tagName.toLowerCase();
|
|
572
|
+
const name = el.getAttribute('name');
|
|
573
|
+
const property = el.getAttribute('property');
|
|
574
|
+
const rel = el.getAttribute('rel');
|
|
575
|
+
const charset = el.getAttribute('charset');
|
|
576
|
+
let identity: string | null;
|
|
577
|
+
if (name !== null) identity = name;
|
|
578
|
+
else if (property !== null) identity = property;
|
|
579
|
+
else if (rel !== null) identity = rel + linkIdentitySuffix(rel, el);
|
|
580
|
+
else identity = charset;
|
|
581
|
+
let managed: string;
|
|
582
|
+
if (identity) managed = `${tag}:${identity}`.replace(/"/g, '');
|
|
583
|
+
else if (el.id) managed = `${tag}:${el.id}`;
|
|
584
|
+
else {
|
|
585
|
+
const n = positional.get(tag) ?? 0;
|
|
586
|
+
positional.set(tag, n + 1);
|
|
587
|
+
managed = `${tag}:#${n}`;
|
|
588
|
+
}
|
|
589
|
+
if (seen.has(managed)) continue;
|
|
590
|
+
seen.add(managed);
|
|
591
|
+
el.setAttribute(HEAD_ATTR, managed);
|
|
592
|
+
document.head.appendChild(el);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The suffix that joins a rel-only link's identity key - the client half of
|
|
598
|
+
* the server's linkIdentitySuffix, same rule: `canonical` is a singleton
|
|
599
|
+
* (first wins, page over layout), every other rel carries its target.
|
|
600
|
+
*/
|
|
601
|
+
function linkIdentitySuffix(rel: string, el: Element): string {
|
|
602
|
+
if (/(^|\s)canonical(\s|$)/i.test(rel)) return '';
|
|
603
|
+
return `@${el.getAttribute('href') ?? el.getAttribute('type') ?? el.getAttribute('as') ?? ''}`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Drop every Rosefd-managed head element (routes without <head> blocks). */
|
|
607
|
+
export function clearHead(): void {
|
|
608
|
+
document.head.querySelectorAll(`[${HEAD_ATTR}]`).forEach((el) => el.remove());
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Wire reactive markers inside `root` to the closures in `closes`.
|
|
613
|
+
* Works on the live SSR DOM (adoption) and on freshly parsed fragments.
|
|
614
|
+
* Returns a disposer for every effect it created.
|
|
615
|
+
*/
|
|
616
|
+
const TRANSITION_POSES: Record<string, { transform?: string; filter?: string; opacity: string }> = {
|
|
617
|
+
fade: { opacity: '0' },
|
|
618
|
+
slide: { transform: 'translateY(20px)', opacity: '0' },
|
|
619
|
+
fly: { transform: 'translateX(-30px) rotate(-5deg)', opacity: '0' },
|
|
620
|
+
zoom: { transform: 'scale(0.8)', opacity: '0' },
|
|
621
|
+
'slide-up': { transform: 'translateY(30px)', opacity: '0' },
|
|
622
|
+
'slide-down': { transform: 'translateY(-30px)', opacity: '0' },
|
|
623
|
+
'slide-left': { transform: 'translateX(30px)', opacity: '0' },
|
|
624
|
+
'slide-right': { transform: 'translateX(-30px)', opacity: '0' },
|
|
625
|
+
scale: { transform: 'scale(0.7)', opacity: '0' },
|
|
626
|
+
spin: { transform: 'rotate(-90deg)', opacity: '0' },
|
|
627
|
+
blur: { filter: 'blur(8px)', opacity: '0' },
|
|
628
|
+
stagger: { transform: 'translateY(10px)', opacity: '0' },
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
function parseTransitionParams(pstr: string): { duration?: number; delay?: number; easing?: string } {
|
|
632
|
+
const out: { duration?: number; delay?: number; easing?: string } = {};
|
|
633
|
+
if (!pstr) return out;
|
|
634
|
+
const trimmed = pstr.trim();
|
|
635
|
+
if (/^\d+$/.test(trimmed)) { out.duration = Number(trimmed); return out; }
|
|
636
|
+
const dM = /duration\s*:\s*(\d+)/.exec(pstr);
|
|
637
|
+
if (dM) out.duration = Number(dM[1]);
|
|
638
|
+
const dlM = /delay\s*:\s*(\d+)/.exec(pstr);
|
|
639
|
+
if (dlM) out.delay = Number(dlM[1]);
|
|
640
|
+
const eM = /easing\s*:\s*(['"])(.*?)\1/.exec(pstr);
|
|
641
|
+
if (eM) out.easing = eM[2];
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function wire(root: Node, closes: Array<unknown>, scope?: unknown): Cleanup {
|
|
646
|
+
let holder: Node = root;
|
|
647
|
+
if (root.nodeType === 11) {
|
|
648
|
+
const div = document.createElement('div');
|
|
649
|
+
div.appendChild(root);
|
|
650
|
+
holder = div;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const created: Cleanup[] = [];
|
|
654
|
+
const weff = (fn: () => void) => created.push(effect(fn));
|
|
655
|
+
|
|
656
|
+
const comments: Comment[] = [];
|
|
657
|
+
const walker = document.createTreeWalker(holder, NodeFilter.SHOW_COMMENT);
|
|
658
|
+
while (walker.nextNode()) comments.push(walker.currentNode as Comment);
|
|
659
|
+
|
|
660
|
+
const nested = new Set<Comment>();
|
|
661
|
+
const headPair = new Set<Comment>();
|
|
662
|
+
const open: number[] = [];
|
|
663
|
+
for (let i = 0; i < comments.length; i++) {
|
|
664
|
+
const m = (comments[i].nodeValue ?? '').match(MARK_RE);
|
|
665
|
+
if (m && (m[1] === 'i' || m[1] === 'l' || m[1] === 'h')) {
|
|
666
|
+
open.push(i);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const em = (comments[i].nodeValue ?? '').match(END_RE);
|
|
670
|
+
if (!em) continue;
|
|
671
|
+
const key = em[1] + em[2];
|
|
672
|
+
let at = -1;
|
|
673
|
+
for (let k = open.length - 1; k >= 0; k--) {
|
|
674
|
+
const om = (comments[open[k]].nodeValue ?? '').match(MARK_RE)!;
|
|
675
|
+
if (om[1] + om[2] === key) { at = k; break; }
|
|
676
|
+
}
|
|
677
|
+
if (at < 0) continue;
|
|
678
|
+
const om = (comments[open[at]].nodeValue ?? '').match(MARK_RE)!;
|
|
679
|
+
if (om[1] === 'h') {
|
|
680
|
+
headPair.add(comments[open[at]]);
|
|
681
|
+
headPair.add(comments[i]);
|
|
682
|
+
}
|
|
683
|
+
for (let k = open[at] + 1; k < i; k++) nested.add(comments[k]);
|
|
684
|
+
open.length = at;
|
|
685
|
+
}
|
|
686
|
+
for (const c of headPair) nested.delete(c);
|
|
687
|
+
|
|
688
|
+
const ends = new Map<number, Comment>();
|
|
689
|
+
for (const c of comments) {
|
|
690
|
+
if (nested.has(c)) continue;
|
|
691
|
+
const m = (c.nodeValue ?? '').match(END_RE);
|
|
692
|
+
if (m) ends.set(+m[2], c);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const headBlocks: Array<{ idx: number; start: Comment; end: Comment; nodes: ChildNode[] }> = [];
|
|
696
|
+
|
|
697
|
+
const ownsHead = root === document.getElementById('app');
|
|
698
|
+
|
|
699
|
+
let islandSkip = false;
|
|
700
|
+
if (ownsHead) {
|
|
701
|
+
const islandEls = Array.from(
|
|
702
|
+
(holder as Element).querySelectorAll('[data-rv-island]')
|
|
703
|
+
).filter((el) => !(el.parentElement && el.parentElement.closest('[data-rv-island]')));
|
|
704
|
+
islandSkip = islandEls.length > 0;
|
|
705
|
+
for (const isl of islandEls) {
|
|
706
|
+
const fire = () => wire(isl, closes, scope);
|
|
707
|
+
const mode = isl.getAttribute('data-rv-island-hydrate');
|
|
708
|
+
if (mode === 'visible' && typeof IntersectionObserver !== 'undefined') {
|
|
709
|
+
const io = new IntersectionObserver((entries) => {
|
|
710
|
+
if (entries.some((en) => en.isIntersecting)) {
|
|
711
|
+
io.disconnect();
|
|
712
|
+
pendingIslandObservers.delete(io);
|
|
713
|
+
fire();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
io.observe(isl);
|
|
717
|
+
pendingIslandObservers.add(io);
|
|
718
|
+
} else if (mode === 'idle' && typeof (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback === 'function') {
|
|
719
|
+
(globalThis as { requestIdleCallback: (cb: () => void) => void }).requestIdleCallback(() => fire());
|
|
720
|
+
} else {
|
|
721
|
+
fire();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
for (const c of comments) {
|
|
727
|
+
if (nested.has(c) || wired.has(c) || !c.parentNode) continue;
|
|
728
|
+
const m = (c.nodeValue ?? '').match(MARK_RE);
|
|
729
|
+
if (!m) continue;
|
|
730
|
+
const kind = m[1];
|
|
731
|
+
const idx = +m[2];
|
|
732
|
+
|
|
733
|
+
if (islandSkip && kind !== 'h' && (c.parentNode as Element).closest?.('[data-rv-island]')) continue;
|
|
734
|
+
|
|
735
|
+
if (kind === 'h' && !ownsHead) continue;
|
|
736
|
+
wired.add(c);
|
|
737
|
+
|
|
738
|
+
if (kind === 'h') {
|
|
739
|
+
const end = ends.get(idx);
|
|
740
|
+
if (!end) continue;
|
|
741
|
+
const nodes: ChildNode[] = [];
|
|
742
|
+
let n: ChildNode | null = c.nextSibling;
|
|
743
|
+
while (n && n !== end) {
|
|
744
|
+
const nx = n.nextSibling;
|
|
745
|
+
nodes.push(n);
|
|
746
|
+
n = nx;
|
|
747
|
+
}
|
|
748
|
+
headBlocks.push({ idx, start: c, end, nodes });
|
|
749
|
+
} else if (kind === 'm') {
|
|
750
|
+
let target = c.nextSibling;
|
|
751
|
+
if (!target || target.nodeType !== 3) {
|
|
752
|
+
target = document.createTextNode('');
|
|
753
|
+
c.parentNode!.insertBefore(target, c.nextSibling);
|
|
754
|
+
}
|
|
755
|
+
const tn = target as Text;
|
|
756
|
+
const end = ends.get(idx);
|
|
757
|
+
if (end) {
|
|
758
|
+
let n: ChildNode | null = tn.nextSibling;
|
|
759
|
+
while (n && n !== end) {
|
|
760
|
+
const nx = n.nextSibling;
|
|
761
|
+
n.remove();
|
|
762
|
+
n = nx;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
weff(() => {
|
|
766
|
+
tn.nodeValue = String((closes[idx] as (s?: unknown) => unknown)(scope));
|
|
767
|
+
});
|
|
768
|
+
} else if (kind === 'i') {
|
|
769
|
+
const anchor = c;
|
|
770
|
+
const end = ends.get(idx);
|
|
771
|
+
if (!end) continue;
|
|
772
|
+
const st = anchor as unknown as { __st?: { nodes: Node[]; disp: Cleanup }; __ever?: boolean };
|
|
773
|
+
weff(() => {
|
|
774
|
+
if (st.__st) {
|
|
775
|
+
// Range delete, not the recorded node list: an inner block's effect
|
|
776
|
+
// may have inserted rows between these markers after the last run,
|
|
777
|
+
// and those nodes belong to no outer snapshot - a list-based remove
|
|
778
|
+
// strands them in the document (the branch's old content lives on
|
|
779
|
+
// next to the new).
|
|
780
|
+
let n = anchor.nextSibling;
|
|
781
|
+
while (n && n !== end) {
|
|
782
|
+
const nx = n.nextSibling;
|
|
783
|
+
n.remove();
|
|
784
|
+
n = nx;
|
|
785
|
+
}
|
|
786
|
+
st.__st.disp();
|
|
787
|
+
st.__st = undefined;
|
|
788
|
+
}
|
|
789
|
+
const r = (closes[idx] as (s?: unknown) => unknown)(scope) as { html: string; closes: unknown[] } | null;
|
|
790
|
+
if (!r) {
|
|
791
|
+
// The condition went false. The first run must still clear whatever
|
|
792
|
+
// the SSR flush (or an enclosing block's re-render) left between the
|
|
793
|
+
// markers: an early return here strands those nodes in the document
|
|
794
|
+
// forever - they belong to a branch that no longer renders.
|
|
795
|
+
if (!st.__ever) {
|
|
796
|
+
st.__ever = true;
|
|
797
|
+
let n = anchor.nextSibling;
|
|
798
|
+
while (n && n !== end) {
|
|
799
|
+
const nx = n.nextSibling;
|
|
800
|
+
n.remove();
|
|
801
|
+
n = nx;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
let frag: DocumentFragment;
|
|
807
|
+
if (st.__ever) {
|
|
808
|
+
frag = tpl(r.html);
|
|
809
|
+
} else {
|
|
810
|
+
st.__ever = true;
|
|
811
|
+
frag = document.createDocumentFragment();
|
|
812
|
+
let n = anchor.nextSibling;
|
|
813
|
+
while (n && n !== end) {
|
|
814
|
+
const nx = n.nextSibling;
|
|
815
|
+
frag.appendChild(n);
|
|
816
|
+
n = nx;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const disp = wire(frag, r.closes, (r as { scope?: unknown }).scope ?? scope);
|
|
820
|
+
const nodes = Array.from(frag.childNodes);
|
|
821
|
+
const parent = anchor.parentNode!;
|
|
822
|
+
for (const nd of nodes) parent.insertBefore(nd, end);
|
|
823
|
+
st.__st = { nodes, disp };
|
|
824
|
+
});
|
|
825
|
+
} else {
|
|
826
|
+
const start = c;
|
|
827
|
+
const end = ends.get(idx);
|
|
828
|
+
if (!end) continue;
|
|
829
|
+
const st = start as unknown as { __disp?: Cleanup; __kmap?: Map<string, EachRow> };
|
|
830
|
+
weff(() => {
|
|
831
|
+
if (st.__disp) {
|
|
832
|
+
st.__disp();
|
|
833
|
+
st.__disp = undefined;
|
|
834
|
+
}
|
|
835
|
+
const items = (closes[idx] as (s?: unknown) => unknown)(scope) as EachRow[];
|
|
836
|
+
const parent = start.parentNode!;
|
|
837
|
+
const keyed = items.length > 0 && items.every((r) => r.keyed);
|
|
838
|
+
if (keyed) {
|
|
839
|
+
const prev = st.__kmap ?? new Map<string, EachRow>();
|
|
840
|
+
const counts = new Map<string, number>();
|
|
841
|
+
for (const r of items) counts.set(r.key!, (counts.get(r.key!) ?? 0) + 1);
|
|
842
|
+
const keep = new Set<Node>();
|
|
843
|
+
const nextMap = new Map<string, EachRow>();
|
|
844
|
+
const used = new Set<string>();
|
|
845
|
+
const decisions: Array<{ row: EachRow; keepRow?: EachRow }> = [];
|
|
846
|
+
for (const r of items) {
|
|
847
|
+
const k = r.key!;
|
|
848
|
+
let keepRow: EachRow | undefined;
|
|
849
|
+
if (counts.get(k) === 1 && !used.has(k)) {
|
|
850
|
+
const cand = prev.get(k);
|
|
851
|
+
if (cand && normHtml(cand) === normHtml(r) && !EACH_REUSE_FORBID.test(r.html)) {
|
|
852
|
+
keepRow = cand;
|
|
853
|
+
used.add(k);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
decisions.push({ row: r, keepRow });
|
|
857
|
+
if (keepRow) for (const nd of keepRow.nodes ?? []) keep.add(nd);
|
|
858
|
+
}
|
|
859
|
+
let n = start.nextSibling;
|
|
860
|
+
while (n && n !== end) {
|
|
861
|
+
const nx = n.nextSibling;
|
|
862
|
+
if (!keep.has(n)) n.remove();
|
|
863
|
+
n = nx;
|
|
864
|
+
}
|
|
865
|
+
const disps: Cleanup[] = [];
|
|
866
|
+
for (const d of decisions) {
|
|
867
|
+
if (d.keepRow) {
|
|
868
|
+
disps.push(d.keepRow.disp!);
|
|
869
|
+
nextMap.set(d.row.key!, d.keepRow);
|
|
870
|
+
for (const nd of d.keepRow.nodes ?? []) parent.insertBefore(nd, end);
|
|
871
|
+
} else {
|
|
872
|
+
const frag = tpl(d.row.html);
|
|
873
|
+
const disp = wire(frag, d.row.closes, d.row.scope);
|
|
874
|
+
disps.push(disp);
|
|
875
|
+
const nodes = Array.from(frag.childNodes);
|
|
876
|
+
for (const nd of nodes) parent.insertBefore(nd, end);
|
|
877
|
+
if (counts.get(d.row.key!) === 1) nextMap.set(d.row.key!, { ...d.row, nodes, disp });
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
st.__kmap = nextMap;
|
|
881
|
+
st.__disp = () => disps.forEach((d) => d());
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
st.__kmap = undefined;
|
|
885
|
+
let n = start.nextSibling;
|
|
886
|
+
while (n && n !== end) {
|
|
887
|
+
const nx = n.nextSibling;
|
|
888
|
+
n.remove();
|
|
889
|
+
n = nx;
|
|
890
|
+
}
|
|
891
|
+
const disps: Cleanup[] = [];
|
|
892
|
+
for (const r of items) {
|
|
893
|
+
const frag = tpl(r.html);
|
|
894
|
+
disps.push(wire(frag, r.closes, r.scope));
|
|
895
|
+
for (const nd of Array.from(frag.childNodes)) parent.insertBefore(nd, end);
|
|
896
|
+
}
|
|
897
|
+
st.__disp = () => disps.forEach((d) => d());
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (root === document.getElementById('app')) {
|
|
903
|
+
if (headBlocks.length > 0) {
|
|
904
|
+
const blocks = headBlocks;
|
|
905
|
+
weff(() => {
|
|
906
|
+
let html = '';
|
|
907
|
+
for (const b of blocks) html += String((closes[b.idx] as (s?: unknown) => unknown)(scope));
|
|
908
|
+
applyHead(html);
|
|
909
|
+
});
|
|
910
|
+
for (const b of blocks) {
|
|
911
|
+
b.nodes.forEach((nd) => nd.remove());
|
|
912
|
+
b.start.remove();
|
|
913
|
+
b.end.remove();
|
|
914
|
+
}
|
|
915
|
+
} else if (root.nodeType !== 11) clearHead();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const all = (holder as Element).querySelectorAll('*');
|
|
919
|
+
all.forEach((el) => {
|
|
920
|
+
if (islandSkip && el.closest('[data-rv-island]')) return;
|
|
921
|
+
for (const an of el.getAttributeNames()) {
|
|
922
|
+
if (!an.startsWith('data-b')) continue;
|
|
923
|
+
const idx = Number(an.slice(6));
|
|
924
|
+
if (!Number.isFinite(idx)) continue;
|
|
925
|
+
const attr = el.getAttribute(an)!;
|
|
926
|
+
el.removeAttribute(an);
|
|
927
|
+
const effect = () => String((closes[idx] as (s?: unknown) => unknown)(scope));
|
|
928
|
+
if (attr === 'data-use-param') {
|
|
929
|
+
weff(() => {
|
|
930
|
+
const raw = effect();
|
|
931
|
+
let p: unknown = raw;
|
|
932
|
+
try { p = JSON.parse(raw); } catch { /* string param */ }
|
|
933
|
+
actionRecords.get(el)?.update?.(p);
|
|
934
|
+
});
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (attr === 'value') {
|
|
938
|
+
weff(() => {
|
|
939
|
+
const v = effect();
|
|
940
|
+
const out = v == null ? '' : v;
|
|
941
|
+
const inp = el as unknown as HTMLInputElement;
|
|
942
|
+
if (inp.value !== out) inp.value = out;
|
|
943
|
+
});
|
|
944
|
+
} else if (attr === 'data-selmulti') {
|
|
945
|
+
weff(() => {
|
|
946
|
+
const vals = effect().split(',');
|
|
947
|
+
for (const o of Array.from((el as unknown as HTMLSelectElement).options)) {
|
|
948
|
+
o.selected = vals.includes(o.value);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
} else if (attr.startsWith('!')) {
|
|
952
|
+
const name = attr.slice(1);
|
|
953
|
+
weff(() => {
|
|
954
|
+
const v = (closes[idx] as (s?: unknown) => unknown)(scope);
|
|
955
|
+
const live = !(v === false || v == null || v === '' || v === 'false' || v === 0);
|
|
956
|
+
const target = el as unknown as Record<string, unknown>;
|
|
957
|
+
if (target[name] !== live) target[name] = live;
|
|
958
|
+
if (live) el.setAttribute(name, '');
|
|
959
|
+
else el.removeAttribute(name);
|
|
960
|
+
});
|
|
961
|
+
} else {
|
|
962
|
+
weff(() => {
|
|
963
|
+
const out = attrValueString(attr, (closes[idx] as (s?: unknown) => unknown)(scope));
|
|
964
|
+
if (el.getAttribute(attr) !== out) el.setAttribute(attr, out);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
const uses = (holder as Element).querySelectorAll('[data-use]');
|
|
971
|
+
uses.forEach((el) => {
|
|
972
|
+
if (islandSkip && el.closest('[data-rv-island]')) return;
|
|
973
|
+
const name = el.getAttribute('data-use')!;
|
|
974
|
+
const fn = directives[name];
|
|
975
|
+
if (!fn) return;
|
|
976
|
+
const rawParam = el.getAttribute('data-use-param');
|
|
977
|
+
let initial: unknown = rawParam ?? undefined;
|
|
978
|
+
if (typeof initial === 'string') {
|
|
979
|
+
try { initial = JSON.parse(initial); } catch { /* plain string param */ }
|
|
980
|
+
}
|
|
981
|
+
const ret = fn(el, initial);
|
|
982
|
+
if (typeof ret === 'function') created.push(ret);
|
|
983
|
+
else if (ret && typeof ret === 'object') {
|
|
984
|
+
actionRecords.set(el, { update: ret.update, destroy: ret.destroy });
|
|
985
|
+
if (ret.destroy) created.push(ret.destroy);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const refs = (holder as Element).querySelectorAll('[data-bind-this]');
|
|
990
|
+
refs.forEach((el) => {
|
|
991
|
+
if (islandSkip && el.closest('[data-rv-island]')) return;
|
|
992
|
+
const key = el.getAttribute('data-bind-this')!;
|
|
993
|
+
setState(key, el);
|
|
994
|
+
created.push(() => setState(key, null));
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const trans = (holder as Element).querySelectorAll<HTMLElement>('[data-transition]');
|
|
998
|
+
if (trans.length > 0) {
|
|
999
|
+
trans.forEach((el) => {
|
|
1000
|
+
if (islandSkip && el.closest('[data-rv-island]')) return;
|
|
1001
|
+
const transAttr = el.getAttribute('data-transition')!;
|
|
1002
|
+
const parts = transAttr.split('|');
|
|
1003
|
+
for (const part of parts) {
|
|
1004
|
+
const segs = part.split(':');
|
|
1005
|
+
const dir = segs[0] === 'in' || segs[0] === 'out' ? segs[0]! : '';
|
|
1006
|
+
const name = (dir ? segs[1] : segs[0]) ?? '';
|
|
1007
|
+
const pstr = (dir ? segs.slice(2) : segs.slice(1)).join(':');
|
|
1008
|
+
const params = parseTransitionParams(pstr);
|
|
1009
|
+
const duration = params.duration != null ? Number(params.duration) : 300;
|
|
1010
|
+
if (dir === 'out') {
|
|
1011
|
+
const pose = TRANSITION_POSES[name];
|
|
1012
|
+
if (pose) {
|
|
1013
|
+
const parent = el.parentElement;
|
|
1014
|
+
const idx = parent ? Math.max(0, Array.from(parent.children).indexOf(el)) : 0;
|
|
1015
|
+
const vDelay = params.delay != null ? Number(params.delay) : null;
|
|
1016
|
+
const delay = name === 'stagger'
|
|
1017
|
+
? (vDelay != null ? Math.max(0, vDelay) : idx * (duration / 10))
|
|
1018
|
+
: (vDelay != null ? vDelay : 0);
|
|
1019
|
+
const easing = params.easing || 'ease';
|
|
1020
|
+
created.push(() => {
|
|
1021
|
+
const parts = [pose.transform ? `transform ${duration}ms ${easing} ${delay}ms` : '', pose.filter ? `filter ${duration}ms ${easing} ${delay}ms` : '', `opacity ${duration}ms ${easing} ${delay}ms`].filter(Boolean);
|
|
1022
|
+
el.style.transition = parts.join(', ');
|
|
1023
|
+
if (pose.transform) el.style.transform = pose.transform;
|
|
1024
|
+
if (pose.filter) el.style.filter = pose.filter;
|
|
1025
|
+
el.style.opacity = pose.opacity;
|
|
1026
|
+
setTimeout(() => {
|
|
1027
|
+
el.style.transform = '';
|
|
1028
|
+
el.style.filter = '';
|
|
1029
|
+
el.style.opacity = '';
|
|
1030
|
+
el.style.transition = '';
|
|
1031
|
+
}, duration + delay);
|
|
1032
|
+
});
|
|
1033
|
+
} else {
|
|
1034
|
+
const custom = (globalThis as any).__rosefdTransitions?.[name];
|
|
1035
|
+
if (custom) {
|
|
1036
|
+
const ret = custom(el, duration, 'out');
|
|
1037
|
+
if (typeof ret === 'function') created.push(ret);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const exitStart = created.length;
|
|
1043
|
+
const pose = TRANSITION_POSES[name];
|
|
1044
|
+
if (pose) {
|
|
1045
|
+
const parent = el.parentElement;
|
|
1046
|
+
const idx = parent ? Math.max(0, Array.from(parent.children).indexOf(el)) : 0;
|
|
1047
|
+
const vDelay = params.delay != null ? Number(params.delay) : null;
|
|
1048
|
+
const delay = name === 'stagger'
|
|
1049
|
+
? (vDelay != null ? Math.max(0, vDelay) : idx * (duration / 10))
|
|
1050
|
+
: (vDelay != null ? vDelay : 0);
|
|
1051
|
+
const easing = params.easing || 'ease';
|
|
1052
|
+
const cssParts: string[] = [];
|
|
1053
|
+
if (pose.transform) cssParts.push(`transform ${duration}ms ${easing} ${delay}ms`);
|
|
1054
|
+
if (pose.filter) cssParts.push(`filter ${duration}ms ${easing} ${delay}ms`);
|
|
1055
|
+
cssParts.push(`opacity ${duration}ms ${easing} ${delay}ms`);
|
|
1056
|
+
const tcss = cssParts.join(', ');
|
|
1057
|
+
el.style.opacity = '0';
|
|
1058
|
+
if (pose.transform) el.style.transform = pose.transform;
|
|
1059
|
+
if (pose.filter) el.style.filter = pose.filter;
|
|
1060
|
+
el.style.transition = tcss;
|
|
1061
|
+
requestAnimationFrame(() => {
|
|
1062
|
+
requestAnimationFrame(() => {
|
|
1063
|
+
el.style.transform = '';
|
|
1064
|
+
el.style.filter = '';
|
|
1065
|
+
el.style.opacity = '1';
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
created.push(() => {
|
|
1069
|
+
el.style.opacity = '0';
|
|
1070
|
+
if (pose.transform) el.style.transform = pose.transform;
|
|
1071
|
+
if (pose.filter) el.style.filter = pose.filter;
|
|
1072
|
+
setTimeout(() => {
|
|
1073
|
+
el.style.opacity = '';
|
|
1074
|
+
el.style.transform = '';
|
|
1075
|
+
el.style.filter = '';
|
|
1076
|
+
el.style.transition = '';
|
|
1077
|
+
}, duration + delay);
|
|
1078
|
+
});
|
|
1079
|
+
} else {
|
|
1080
|
+
const custom = (globalThis as any).__rosefdTransitions?.[name];
|
|
1081
|
+
if (custom) {
|
|
1082
|
+
const ret = custom(el, duration, dir || 'both');
|
|
1083
|
+
if (typeof ret === 'function') created.push(ret);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (dir === 'in') created.length = exitStart;
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const anim = (holder as Element).querySelectorAll<HTMLElement>('[data-animate]');
|
|
1092
|
+
if (anim.length > 0) {
|
|
1093
|
+
anim.forEach((el) => {
|
|
1094
|
+
if (islandSkip && el.closest('[data-rv-island]')) return;
|
|
1095
|
+
const animAttr = el.getAttribute('data-animate')!;
|
|
1096
|
+
const parts = animAttr.split('|');
|
|
1097
|
+
for (const part of parts) {
|
|
1098
|
+
const [name] = part.split(':');
|
|
1099
|
+
const rest = part.slice(name.length + 1);
|
|
1100
|
+
const duration = Number((/(\d+)\s*\}?\s*$/.exec(rest) ?? [])[1]) || 300;
|
|
1101
|
+
if (name === 'flip') {
|
|
1102
|
+
const firstRect = el.getBoundingClientRect();
|
|
1103
|
+
const firstTop = firstRect.top;
|
|
1104
|
+
const firstLeft = firstRect.left;
|
|
1105
|
+
requestAnimationFrame(() => {
|
|
1106
|
+
requestAnimationFrame(() => {
|
|
1107
|
+
const lastRect = el.getBoundingClientRect();
|
|
1108
|
+
const deltaTop = lastRect.top - firstTop;
|
|
1109
|
+
const deltaLeft = lastRect.left - firstLeft;
|
|
1110
|
+
if (Math.abs(deltaTop) < 0.5 && Math.abs(deltaLeft) < 0.5) return;
|
|
1111
|
+
el.style.transform = `translate(${-deltaLeft}px, ${-deltaTop}px)`;
|
|
1112
|
+
el.style.transition = 'none';
|
|
1113
|
+
requestAnimationFrame(() => {
|
|
1114
|
+
requestAnimationFrame(() => {
|
|
1115
|
+
el.style.transition = `transform ${duration}ms ease`;
|
|
1116
|
+
el.style.transform = '';
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
} else if (name === 'pulse') {
|
|
1122
|
+
el.style.transform = 'scale(1)';
|
|
1123
|
+
el.style.transition = `transform ${duration}ms ease`;
|
|
1124
|
+
requestAnimationFrame(() => {
|
|
1125
|
+
requestAnimationFrame(() => {
|
|
1126
|
+
el.style.transform = 'scale(1.1)';
|
|
1127
|
+
setTimeout(() => {
|
|
1128
|
+
el.style.transform = 'scale(1)';
|
|
1129
|
+
}, duration);
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
} else if (name === 'bounce') {
|
|
1133
|
+
el.style.transform = 'translateY(0)';
|
|
1134
|
+
el.style.transition = `transform ${duration}ms ease`;
|
|
1135
|
+
requestAnimationFrame(() => {
|
|
1136
|
+
requestAnimationFrame(() => {
|
|
1137
|
+
el.style.transform = 'translateY(-15px)';
|
|
1138
|
+
setTimeout(() => {
|
|
1139
|
+
el.style.transform = 'translateY(0)';
|
|
1140
|
+
}, duration);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
} else if (name === 'shake') {
|
|
1144
|
+
el.style.transform = 'translateX(0)';
|
|
1145
|
+
el.style.transition = `transform ${duration / 4}ms ease-in-out`;
|
|
1146
|
+
requestAnimationFrame(() => {
|
|
1147
|
+
requestAnimationFrame(() => {
|
|
1148
|
+
el.style.transform = 'translateX(-8px)';
|
|
1149
|
+
setTimeout(() => {
|
|
1150
|
+
el.style.transform = 'translateX(8px)';
|
|
1151
|
+
setTimeout(() => {
|
|
1152
|
+
el.style.transform = 'translateX(-8px)';
|
|
1153
|
+
setTimeout(() => {
|
|
1154
|
+
el.style.transform = 'translateX(8px)';
|
|
1155
|
+
setTimeout(() => {
|
|
1156
|
+
el.style.transform = 'translateX(0)';
|
|
1157
|
+
el.style.transition = '';
|
|
1158
|
+
}, duration / 4);
|
|
1159
|
+
}, duration / 4);
|
|
1160
|
+
}, duration / 4);
|
|
1161
|
+
}, duration / 4);
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
} else if (name === 'scale') {
|
|
1165
|
+
el.style.transform = 'scale(0.5)';
|
|
1166
|
+
el.style.opacity = '0';
|
|
1167
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1168
|
+
requestAnimationFrame(() => {
|
|
1169
|
+
requestAnimationFrame(() => {
|
|
1170
|
+
el.style.transform = '';
|
|
1171
|
+
el.style.opacity = '1';
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
created.push(() => {
|
|
1175
|
+
el.style.transform = 'scale(0.5)';
|
|
1176
|
+
el.style.opacity = '0';
|
|
1177
|
+
setTimeout(() => {
|
|
1178
|
+
el.style.transform = '';
|
|
1179
|
+
el.style.opacity = '';
|
|
1180
|
+
el.style.transition = '';
|
|
1181
|
+
}, duration);
|
|
1182
|
+
});
|
|
1183
|
+
} else if (name === 'rotate') {
|
|
1184
|
+
el.style.transform = 'rotate(-180deg)';
|
|
1185
|
+
el.style.opacity = '0';
|
|
1186
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1187
|
+
requestAnimationFrame(() => {
|
|
1188
|
+
requestAnimationFrame(() => {
|
|
1189
|
+
el.style.transform = '';
|
|
1190
|
+
el.style.opacity = '1';
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
created.push(() => {
|
|
1194
|
+
el.style.transform = 'rotate(-180deg)';
|
|
1195
|
+
el.style.opacity = '0';
|
|
1196
|
+
setTimeout(() => {
|
|
1197
|
+
el.style.transform = '';
|
|
1198
|
+
el.style.opacity = '';
|
|
1199
|
+
el.style.transition = '';
|
|
1200
|
+
}, duration);
|
|
1201
|
+
});
|
|
1202
|
+
} else if (name === 'fade-in') {
|
|
1203
|
+
el.style.opacity = '0';
|
|
1204
|
+
el.style.transition = `opacity ${duration}ms ease`;
|
|
1205
|
+
requestAnimationFrame(() => {
|
|
1206
|
+
requestAnimationFrame(() => {
|
|
1207
|
+
el.style.opacity = '1';
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
created.push(() => {
|
|
1211
|
+
el.style.opacity = '0';
|
|
1212
|
+
setTimeout(() => {
|
|
1213
|
+
el.style.opacity = '';
|
|
1214
|
+
el.style.transition = '';
|
|
1215
|
+
}, duration);
|
|
1216
|
+
});
|
|
1217
|
+
} else if (name === 'fade-out') {
|
|
1218
|
+
el.style.opacity = '1';
|
|
1219
|
+
el.style.transition = `opacity ${duration}ms ease`;
|
|
1220
|
+
requestAnimationFrame(() => {
|
|
1221
|
+
requestAnimationFrame(() => {
|
|
1222
|
+
el.style.opacity = '0';
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
created.push(() => {
|
|
1226
|
+
el.style.opacity = '1';
|
|
1227
|
+
setTimeout(() => {
|
|
1228
|
+
el.style.opacity = '';
|
|
1229
|
+
el.style.transition = '';
|
|
1230
|
+
}, duration);
|
|
1231
|
+
});
|
|
1232
|
+
} else if (name === 'stagger') {
|
|
1233
|
+
const parent = el.parentElement;
|
|
1234
|
+
if (parent) {
|
|
1235
|
+
const siblings = Array.from(parent.children);
|
|
1236
|
+
const index = siblings.indexOf(el);
|
|
1237
|
+
const delay = index * (duration / 10);
|
|
1238
|
+
el.style.opacity = '0';
|
|
1239
|
+
el.style.transform = 'translateY(10px)';
|
|
1240
|
+
el.style.transition = `opacity ${duration}ms ease ${delay}ms, transform ${duration}ms ease ${delay}ms`;
|
|
1241
|
+
requestAnimationFrame(() => {
|
|
1242
|
+
requestAnimationFrame(() => {
|
|
1243
|
+
el.style.opacity = '1';
|
|
1244
|
+
el.style.transform = '';
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1247
|
+
created.push(() => {
|
|
1248
|
+
el.style.opacity = '0';
|
|
1249
|
+
el.style.transform = 'translateY(10px)';
|
|
1250
|
+
setTimeout(() => {
|
|
1251
|
+
el.style.opacity = '';
|
|
1252
|
+
el.style.transform = '';
|
|
1253
|
+
el.style.transition = '';
|
|
1254
|
+
}, duration + delay);
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
} else if (name === 'zoom-in') {
|
|
1258
|
+
el.style.transform = 'scale(0)';
|
|
1259
|
+
el.style.opacity = '0';
|
|
1260
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1261
|
+
requestAnimationFrame(() => {
|
|
1262
|
+
requestAnimationFrame(() => {
|
|
1263
|
+
el.style.transform = '';
|
|
1264
|
+
el.style.opacity = '1';
|
|
1265
|
+
});
|
|
1266
|
+
});
|
|
1267
|
+
created.push(() => {
|
|
1268
|
+
el.style.transform = 'scale(0)';
|
|
1269
|
+
el.style.opacity = '0';
|
|
1270
|
+
setTimeout(() => {
|
|
1271
|
+
el.style.transform = '';
|
|
1272
|
+
el.style.opacity = '';
|
|
1273
|
+
el.style.transition = '';
|
|
1274
|
+
}, duration);
|
|
1275
|
+
});
|
|
1276
|
+
} else if (name === 'zoom-out') {
|
|
1277
|
+
el.style.transform = 'scale(1)';
|
|
1278
|
+
el.style.opacity = '1';
|
|
1279
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1280
|
+
requestAnimationFrame(() => {
|
|
1281
|
+
requestAnimationFrame(() => {
|
|
1282
|
+
el.style.transform = 'scale(0)';
|
|
1283
|
+
el.style.opacity = '0';
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
created.push(() => {
|
|
1287
|
+
el.style.transform = 'scale(1)';
|
|
1288
|
+
el.style.opacity = '1';
|
|
1289
|
+
setTimeout(() => {
|
|
1290
|
+
el.style.transform = '';
|
|
1291
|
+
el.style.opacity = '';
|
|
1292
|
+
el.style.transition = '';
|
|
1293
|
+
}, duration);
|
|
1294
|
+
});
|
|
1295
|
+
} else if (name === 'slide-up-fade') {
|
|
1296
|
+
el.style.transform = 'translateY(30px)';
|
|
1297
|
+
el.style.opacity = '0';
|
|
1298
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1299
|
+
requestAnimationFrame(() => {
|
|
1300
|
+
requestAnimationFrame(() => {
|
|
1301
|
+
el.style.transform = '';
|
|
1302
|
+
el.style.opacity = '1';
|
|
1303
|
+
});
|
|
1304
|
+
});
|
|
1305
|
+
created.push(() => {
|
|
1306
|
+
el.style.transform = 'translateY(30px)';
|
|
1307
|
+
el.style.opacity = '0';
|
|
1308
|
+
setTimeout(() => {
|
|
1309
|
+
el.style.transform = '';
|
|
1310
|
+
el.style.opacity = '';
|
|
1311
|
+
el.style.transition = '';
|
|
1312
|
+
}, duration);
|
|
1313
|
+
});
|
|
1314
|
+
} else if (name === 'slide-down-fade') {
|
|
1315
|
+
el.style.transform = 'translateY(-30px)';
|
|
1316
|
+
el.style.opacity = '0';
|
|
1317
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1318
|
+
requestAnimationFrame(() => {
|
|
1319
|
+
requestAnimationFrame(() => {
|
|
1320
|
+
el.style.transform = '';
|
|
1321
|
+
el.style.opacity = '1';
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
created.push(() => {
|
|
1325
|
+
el.style.transform = 'translateY(-30px)';
|
|
1326
|
+
el.style.opacity = '0';
|
|
1327
|
+
setTimeout(() => {
|
|
1328
|
+
el.style.transform = '';
|
|
1329
|
+
el.style.opacity = '';
|
|
1330
|
+
el.style.transition = '';
|
|
1331
|
+
}, duration);
|
|
1332
|
+
});
|
|
1333
|
+
} else if (name === 'flip-x') {
|
|
1334
|
+
el.style.transform = 'rotateX(90deg)';
|
|
1335
|
+
el.style.opacity = '0';
|
|
1336
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1337
|
+
requestAnimationFrame(() => {
|
|
1338
|
+
requestAnimationFrame(() => {
|
|
1339
|
+
el.style.transform = '';
|
|
1340
|
+
el.style.opacity = '1';
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
created.push(() => {
|
|
1344
|
+
el.style.transform = 'rotateX(90deg)';
|
|
1345
|
+
el.style.opacity = '0';
|
|
1346
|
+
setTimeout(() => {
|
|
1347
|
+
el.style.transform = '';
|
|
1348
|
+
el.style.opacity = '';
|
|
1349
|
+
el.style.transition = '';
|
|
1350
|
+
}, duration);
|
|
1351
|
+
});
|
|
1352
|
+
} else if (name === 'flip-y') {
|
|
1353
|
+
el.style.transform = 'rotateY(90deg)';
|
|
1354
|
+
el.style.opacity = '0';
|
|
1355
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1356
|
+
requestAnimationFrame(() => {
|
|
1357
|
+
requestAnimationFrame(() => {
|
|
1358
|
+
el.style.transform = '';
|
|
1359
|
+
el.style.opacity = '1';
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
created.push(() => {
|
|
1363
|
+
el.style.transform = 'rotateY(90deg)';
|
|
1364
|
+
el.style.opacity = '0';
|
|
1365
|
+
setTimeout(() => {
|
|
1366
|
+
el.style.transform = '';
|
|
1367
|
+
el.style.opacity = '';
|
|
1368
|
+
el.style.transition = '';
|
|
1369
|
+
}, duration);
|
|
1370
|
+
});
|
|
1371
|
+
} else if (name === 'spin') {
|
|
1372
|
+
el.style.transform = 'rotate(-360deg)';
|
|
1373
|
+
el.style.opacity = '0';
|
|
1374
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1375
|
+
requestAnimationFrame(() => {
|
|
1376
|
+
requestAnimationFrame(() => {
|
|
1377
|
+
el.style.transform = '';
|
|
1378
|
+
el.style.opacity = '1';
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
created.push(() => {
|
|
1382
|
+
el.style.transform = 'rotate(-360deg)';
|
|
1383
|
+
el.style.opacity = '0';
|
|
1384
|
+
setTimeout(() => {
|
|
1385
|
+
el.style.transform = '';
|
|
1386
|
+
el.style.opacity = '';
|
|
1387
|
+
el.style.transition = '';
|
|
1388
|
+
}, duration);
|
|
1389
|
+
});
|
|
1390
|
+
} else if (name === 'pop') {
|
|
1391
|
+
el.style.transform = 'scale(0.3)';
|
|
1392
|
+
el.style.opacity = '0';
|
|
1393
|
+
el.style.transition = `transform ${duration}ms ease, opacity ${duration}ms ease`;
|
|
1394
|
+
requestAnimationFrame(() => {
|
|
1395
|
+
requestAnimationFrame(() => {
|
|
1396
|
+
el.style.transform = 'scale(1.1)';
|
|
1397
|
+
setTimeout(() => {
|
|
1398
|
+
el.style.transform = '';
|
|
1399
|
+
}, duration / 3);
|
|
1400
|
+
});
|
|
1401
|
+
});
|
|
1402
|
+
created.push(() => {
|
|
1403
|
+
el.style.transform = 'scale(0.3)';
|
|
1404
|
+
el.style.opacity = '0';
|
|
1405
|
+
setTimeout(() => {
|
|
1406
|
+
el.style.transform = '';
|
|
1407
|
+
el.style.opacity = '';
|
|
1408
|
+
el.style.transition = '';
|
|
1409
|
+
}, duration);
|
|
1410
|
+
});
|
|
1411
|
+
} else if (name === 'blink') {
|
|
1412
|
+
el.style.opacity = '0';
|
|
1413
|
+
el.style.transition = `opacity ${duration}ms ease`;
|
|
1414
|
+
requestAnimationFrame(() => {
|
|
1415
|
+
requestAnimationFrame(() => {
|
|
1416
|
+
el.style.opacity = '1';
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
created.push(() => {
|
|
1420
|
+
el.style.opacity = '0';
|
|
1421
|
+
setTimeout(() => {
|
|
1422
|
+
el.style.opacity = '';
|
|
1423
|
+
el.style.transition = '';
|
|
1424
|
+
}, duration);
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const DELEGATED_SET = new Set(['click', 'input', 'change', 'submit']);
|
|
1432
|
+
const evRoot = holder as Element;
|
|
1433
|
+
const evTargets = [evRoot, ...Array.from(evRoot.querySelectorAll('*'))];
|
|
1434
|
+
for (const el of evTargets) {
|
|
1435
|
+
if (!el.getAttributeNames) continue;
|
|
1436
|
+
if (islandSkip && el.closest && el.closest('[data-rv-island]')) continue;
|
|
1437
|
+
for (const an of el.getAttributeNames()) {
|
|
1438
|
+
const mm = /^data-on(w|d)?-(.+)$/.exec(an);
|
|
1439
|
+
if (!mm) continue;
|
|
1440
|
+
const ev = mm[2];
|
|
1441
|
+
if (!mm[1] && DELEGATED_SET.has(ev)) continue;
|
|
1442
|
+
const g = globalThis as { window?: EventTarget; document?: { defaultView?: EventTarget } & EventTarget };
|
|
1443
|
+
const win = g.window ?? g.document?.defaultView;
|
|
1444
|
+
const target: EventTarget | undefined = mm[1] === 'w' ? win : mm[1] === 'd' ? (g.document as EventTarget) : el;
|
|
1445
|
+
if (!target) continue;
|
|
1446
|
+
let seen = directBound.get(el);
|
|
1447
|
+
if (!seen) directBound.set(el, (seen = new Set()));
|
|
1448
|
+
if (seen.has(ev)) continue;
|
|
1449
|
+
seen.add(ev);
|
|
1450
|
+
const spec = el.getAttribute(an)!;
|
|
1451
|
+
const listener = (e: Event) => {
|
|
1452
|
+
if (!el.hasAttribute(an)) return;
|
|
1453
|
+
if (spec.startsWith('$action:')) {
|
|
1454
|
+
if (actionDispatchCell.value) actionDispatchCell.value(spec.slice('$action:'.length), e);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const h = handlers[spec];
|
|
1458
|
+
if (h) h(e);
|
|
1459
|
+
};
|
|
1460
|
+
target.addEventListener(ev, listener, ev === 'scroll' ? { passive: true } : undefined);
|
|
1461
|
+
created.push(() => {
|
|
1462
|
+
target.removeEventListener(ev, listener);
|
|
1463
|
+
const s = directBound.get(el);
|
|
1464
|
+
if (s) s.delete(ev);
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (holder !== root) {
|
|
1470
|
+
while (holder.firstChild) root.appendChild(holder.firstChild);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
return () => created.forEach((fn) => fn());
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
export function resetEffects(): void {
|
|
1477
|
+
cleanups.forEach((fn) => fn());
|
|
1478
|
+
cleanups.clear();
|
|
1479
|
+
pendingIslandObservers.forEach((io) => io.disconnect());
|
|
1480
|
+
pendingIslandObservers.clear();
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
export function $data<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
1484
|
+
const cache = renderMaps().data;
|
|
1485
|
+
const key = fn.toString();
|
|
1486
|
+
let p = cache.get(key);
|
|
1487
|
+
if (!p) {
|
|
1488
|
+
p = Promise.resolve(fn());
|
|
1489
|
+
cache.set(key, p);
|
|
1490
|
+
}
|
|
1491
|
+
return p as Promise<T>;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const bffCache: Map<string, Promise<unknown>> = shared('__rosefd_bffCache', () => new Map());
|
|
1495
|
+
|
|
1496
|
+
export function loader<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
1497
|
+
let p = bffCache.get(key) as Promise<T> | undefined;
|
|
1498
|
+
if (!p) {
|
|
1499
|
+
p = fn().catch((e) => { bffCache.delete(key); throw e; });
|
|
1500
|
+
bffCache.set(key, p);
|
|
1501
|
+
}
|
|
1502
|
+
return p;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
export async function aggregate<R extends Record<string, () => Promise<unknown>>>(loaders: R): Promise<{ [K in keyof R]: Awaited<ReturnType<R[K]>> }> {
|
|
1506
|
+
const keys = Object.keys(loaders) as (keyof R)[];
|
|
1507
|
+
const results = await Promise.all(keys.map((k) => loaders[k]()));
|
|
1508
|
+
const out = {} as { [K in keyof R]: Awaited<ReturnType<R[K]>> };
|
|
1509
|
+
keys.forEach((k, i) => { (out as Record<string, unknown>)[k as string] = results[i]; });
|
|
1510
|
+
return out;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
export function clearRequestState(): void {
|
|
1514
|
+
renderMaps().signals.clear();
|
|
1515
|
+
renderMaps().data.clear();
|
|
1516
|
+
pendingDeltas.clear();
|
|
1517
|
+
renderMaps().seeded.clear();
|
|
1518
|
+
bffCache.clear();
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Run `fn` with request state no other in-flight render can observe or
|
|
1523
|
+
* destroy, then restore the caller's state.
|
|
1524
|
+
*
|
|
1525
|
+
* signalMap is a process-wide singleton (deliberate - every inlined module
|
|
1526
|
+
* shares one map), which is only safe while renders never overlap. They
|
|
1527
|
+
* DO overlap: prod-server warms every route in the background at startup, and
|
|
1528
|
+
* renderPageCached revalidates a stale cache entry in the background while the
|
|
1529
|
+
* foreground request is still streaming. Each of those renders used to open
|
|
1530
|
+
* with clearRequestState(), wiping whichever render was in flight; the
|
|
1531
|
+
* survivor then saw hasState()===true for a key it never seeded and
|
|
1532
|
+
* dereferenced the other render's null - "Cannot read properties of null
|
|
1533
|
+
* (reading 'bodyFix')", a 500 that hits a cold route and clears on refresh.
|
|
1534
|
+
*
|
|
1535
|
+
* Isolating here makes concurrent renders correct rather than merely unlikely.
|
|
1536
|
+
* The signal map and the seeded-key set are swapped on the resolved render maps
|
|
1537
|
+
* (renderMaps()), so under a context store each render chain gets its own pair;
|
|
1538
|
+
* without a store the swap is the old process-wide one, correct for the
|
|
1539
|
+
* single-render fallback. dataCache needs no swap at all under a store - it
|
|
1540
|
+
* already rides the async chain; see renderMaps().
|
|
1541
|
+
*
|
|
1542
|
+
* "correct for the single-render fallback" needed one more thing: a promise
|
|
1543
|
+
* that renders really are single. Without a context store (edge, client) the
|
|
1544
|
+
* swap rewrites the FIELDS of the one shared maps object, so two overlapping
|
|
1545
|
+
* renders interleave their swaps - the second overwrites the first's fresh
|
|
1546
|
+
* tables, the first's finally restores the process-wide pair out from under
|
|
1547
|
+
* the second, and both then read each other's signals, seeded keys and $data
|
|
1548
|
+
* cache (the edge handler answers concurrent requests exactly this way, and
|
|
1549
|
+
* the client's hover prefetch overlaps the click render the same way). The
|
|
1550
|
+
* fallback path therefore takes a process-wide render mutex first: one render
|
|
1551
|
+
* at a time, and the swap is sound again. Under a context store the mutex is
|
|
1552
|
+
* skipped - each chain already carries its own maps, and serialising there
|
|
1553
|
+
* would cap Node's throughput for no correctness gain.
|
|
1554
|
+
*/
|
|
1555
|
+
export async function isolateRender<T>(fn: () => Promise<T>): Promise<T> {
|
|
1556
|
+
if (contextStore) {
|
|
1557
|
+
const maps = renderMaps();
|
|
1558
|
+
const savedSignals = maps.signals;
|
|
1559
|
+
const savedSeeded = maps.seeded;
|
|
1560
|
+
maps.signals = new Map();
|
|
1561
|
+
maps.seeded = new Set();
|
|
1562
|
+
maps.data.clear();
|
|
1563
|
+
try {
|
|
1564
|
+
return await fn();
|
|
1565
|
+
} finally {
|
|
1566
|
+
maps.signals = savedSignals;
|
|
1567
|
+
maps.seeded = savedSeeded;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Fallback (no async-local store): serialise. The queue is a promise chain,
|
|
1571
|
+
// so waiters run in arrival order and a throwing render still releases.
|
|
1572
|
+
// The queue itself is shared across bundle copies (the maps it protects are),
|
|
1573
|
+
// so a per-page bundle's background render cannot interleave with another
|
|
1574
|
+
// copy's - a module-level mutex would only order this copy's own renders.
|
|
1575
|
+
const prev = renderMutexCell.value;
|
|
1576
|
+
let release!: () => void;
|
|
1577
|
+
renderMutexCell.value = new Promise<void>((r) => { release = r; });
|
|
1578
|
+
await prev;
|
|
1579
|
+
try {
|
|
1580
|
+
const maps = renderMaps();
|
|
1581
|
+
const savedSignals = maps.signals;
|
|
1582
|
+
const savedSeeded = maps.seeded;
|
|
1583
|
+
maps.signals = new Map();
|
|
1584
|
+
maps.seeded = new Set();
|
|
1585
|
+
maps.data.clear();
|
|
1586
|
+
try {
|
|
1587
|
+
return await fn();
|
|
1588
|
+
} finally {
|
|
1589
|
+
maps.signals = savedSignals;
|
|
1590
|
+
maps.seeded = savedSeeded;
|
|
1591
|
+
}
|
|
1592
|
+
} finally {
|
|
1593
|
+
release();
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// The tail of the fallback render queue; see isolateRender. Shared across
|
|
1598
|
+
// bundle copies because the tables the renders swap are shared; a module-level
|
|
1599
|
+
// mutex would order one copy's renders and let another copy's interleave.
|
|
1600
|
+
const renderMutexCell = sharedCell<Promise<void>>('__rosefd_renderMutex', Promise.resolve());
|
|
1601
|
+
|
|
1602
|
+
let requestQuery: Record<string, string> = {};
|
|
1603
|
+
/** R248: the request-scoped query slot in the context-store bag. */
|
|
1604
|
+
const REQUEST_QUERY = Symbol('requestQuery');
|
|
1605
|
+
/** R249: the in-flight prefetch's query, beside it in the same bag. */
|
|
1606
|
+
const PREFETCH_OVERRIDE = Symbol('prefetchOverride');
|
|
1607
|
+
let prefetchOverride: Record<string, string> | null = null;
|
|
1608
|
+
|
|
1609
|
+
export function $query(): Record<string, string> {
|
|
1610
|
+
const prefetch = getPrefetchOverride();
|
|
1611
|
+
if (prefetch) return prefetch;
|
|
1612
|
+
// R248: server-side reads the request-scoped bag, not the module-level one.
|
|
1613
|
+
if (typeof location === 'undefined') return getRequestQuery();
|
|
1614
|
+
const q: Record<string, string> = {};
|
|
1615
|
+
new URLSearchParams(location.search).forEach((v, k) => { q[k] = v; });
|
|
1616
|
+
return q;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** The per-request query object - the servers call this before every render.
|
|
1620
|
+
* R248: under a context store it lives IN that request's scope. It used to be a
|
|
1621
|
+
* module-level bag, so two concurrent requests in one process read each other's
|
|
1622
|
+
* query - `/api/slow?say=a` and `?say=f` both saw `f`. That is a real cross-user
|
|
1623
|
+
* leak (a search term or a token in the query string), not a cosmetic one. */
|
|
1624
|
+
export function setRequestQuery(q: Record<string, string>): void {
|
|
1625
|
+
if (contextStore) {
|
|
1626
|
+
const scoped = contextStore.getStore() as Record<symbol, unknown> | undefined;
|
|
1627
|
+
if (scoped) {
|
|
1628
|
+
scoped[REQUEST_QUERY] = q;
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
requestQuery = q;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** Read the current per-request query (the SWR background refresh snapshots it so a
|
|
1636
|
+
* concurrent request's setRequestQuery cannot leak the wrong query into a cached refresh). */
|
|
1637
|
+
export function getRequestQuery(): Record<string, string> {
|
|
1638
|
+
if (contextStore) {
|
|
1639
|
+
const scoped = contextStore.getStore() as Record<symbol, unknown> | undefined;
|
|
1640
|
+
const q = scoped?.[REQUEST_QUERY];
|
|
1641
|
+
if (q) return q as Record<string, string>;
|
|
1642
|
+
}
|
|
1643
|
+
return requestQuery;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** The query of the route a prefetch is rendering, or null outside one.
|
|
1647
|
+
* R249: request-scoped for the same reason as the query itself. This used to be
|
|
1648
|
+
* a module-level variable set before an `await` and cleared after, so two
|
|
1649
|
+
* concurrent prefetches overwrote each other: the `?q=cats` render read
|
|
1650
|
+
* `undefined` because the `?q=dogs` render had already nulled it. The HTML that
|
|
1651
|
+
* went into the cache was built with the wrong query and served as correct. */
|
|
1652
|
+
export function setPrefetchOverride(q: Record<string, string> | null): void {
|
|
1653
|
+
if (contextStore) {
|
|
1654
|
+
const scoped = contextStore.getStore() as Record<symbol, unknown> | undefined;
|
|
1655
|
+
if (scoped) {
|
|
1656
|
+
if (q) scoped[PREFETCH_OVERRIDE] = q;
|
|
1657
|
+
else delete scoped[PREFETCH_OVERRIDE];
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
prefetchOverride = q;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** The prefetch query in the current scope, or null. */
|
|
1665
|
+
function getPrefetchOverride(): Record<string, string> | null {
|
|
1666
|
+
if (contextStore) {
|
|
1667
|
+
const scoped = contextStore.getStore() as Record<symbol, unknown> | undefined;
|
|
1668
|
+
const q = scoped?.[PREFETCH_OVERRIDE];
|
|
1669
|
+
if (q) return q as Record<string, string>;
|
|
1670
|
+
}
|
|
1671
|
+
return prefetchOverride;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
let requestContext: Record<string, unknown> = {};
|
|
1675
|
+
|
|
1676
|
+
/** Pluggable per-async-chain store; see the comment above. */
|
|
1677
|
+
type ContextStore = {
|
|
1678
|
+
getStore(): Record<string, unknown> | undefined;
|
|
1679
|
+
run<T>(store: Record<string, unknown>, fn: () => T): T;
|
|
1680
|
+
};
|
|
1681
|
+
let contextStore: ContextStore | null = null;
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* The render-local maps for the CURRENT async chain.
|
|
1685
|
+
*
|
|
1686
|
+
* isolateRender() used to swap module-level `dataCache` / `signalMap` and
|
|
1687
|
+
* restore them in a finally. That is not isolation, it is a rename: `await`
|
|
1688
|
+
* yields, so two overlapping renders interleave and the second one saves the
|
|
1689
|
+
* FIRST one's fresh map as its "saved" value. Concretely, with A and B running:
|
|
1690
|
+
*
|
|
1691
|
+
* A: saved = D0; dataCache = DA
|
|
1692
|
+
* B: saved = DA; dataCache = DB <- A's writes now land in DB
|
|
1693
|
+
* A: restore dataCache = D0
|
|
1694
|
+
* B: restore dataCache = DA <- and A's map is live again
|
|
1695
|
+
*
|
|
1696
|
+
* So A and B shared one $data map, and $data is keyed on the thunk SOURCE
|
|
1697
|
+
* ALONE (the payload is not part of the key). Two renders of different paths
|
|
1698
|
+
* therefore shared one `chrome()` promise, and the second one was served the
|
|
1699
|
+
* first one's header - which is how the home page could come back with no menu
|
|
1700
|
+
* item highlighted, on roughly two cold starts out of three, while a warm
|
|
1701
|
+
* process was always correct.
|
|
1702
|
+
*
|
|
1703
|
+
* The fix is the same shape as the request context bag: keep the maps on the
|
|
1704
|
+
* async chain. Under a context store each request gets its own; without one
|
|
1705
|
+
* (edge, client) this is the single-render fallback and the old behaviour is
|
|
1706
|
+
* correct anyway.
|
|
1707
|
+
*/
|
|
1708
|
+
const RENDER_MAPS = Symbol('renderMaps');
|
|
1709
|
+
type RenderMaps = { data: Map<string, Promise<unknown>>; signals: Map<string, SignalEntry>; seeded: Set<string> };
|
|
1710
|
+
// The fallback is shared for the same reason the signal map is: without a
|
|
1711
|
+
// context store (edge, client) every copy of this module falls back to the
|
|
1712
|
+
// same single-render maps, so a $data promise or a seeded key written by one
|
|
1713
|
+
// copy's render is visible to the next one's - which is exactly what a
|
|
1714
|
+
// client-side navigation between two separately-bundled routes needs.
|
|
1715
|
+
const fallbackRenderMaps: RenderMaps = shared('__rosefd_fallbackRenderMaps', () => ({ data: new Map(), signals: signalMap, seeded: seededKeys }));
|
|
1716
|
+
|
|
1717
|
+
function renderMaps(): RenderMaps {
|
|
1718
|
+
// The store's map-of-maps is typed Record<string, unknown> but carries symbol
|
|
1719
|
+
// keys (the RENDER_MAPS / DIRTY / ENV slots) - reach them through the index.
|
|
1720
|
+
const scoped = contextStore?.getStore() as Record<symbol, unknown> | undefined;
|
|
1721
|
+
if (!scoped) return fallbackRenderMaps;
|
|
1722
|
+
let m = scoped[RENDER_MAPS] as RenderMaps | undefined;
|
|
1723
|
+
if (!m) {
|
|
1724
|
+
m = { data: new Map(), signals: new Map(), seeded: new Set() };
|
|
1725
|
+
scoped[RENDER_MAPS] = m;
|
|
1726
|
+
}
|
|
1727
|
+
return m;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Install the async-local context store (Node passes an AsyncLocalStorage
|
|
1732
|
+
* adapter). Pass null to return to the module-level bag.
|
|
1733
|
+
*/
|
|
1734
|
+
export function installContextStore(store: ContextStore | null): void {
|
|
1735
|
+
contextStore = store;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
export function getContext(): Record<string, unknown> {
|
|
1739
|
+
const scoped = contextStore?.getStore();
|
|
1740
|
+
return scoped ?? requestContext;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Fresh bag for a new request - runMiddleware() calls this before the
|
|
1745
|
+
* middleware. Under a context store it must NOT assign the module-level
|
|
1746
|
+
* variable (that is the shared-state bug); it clears the current scope's bag
|
|
1747
|
+
* in place so the object identity the store handed out stays valid.
|
|
1748
|
+
*/
|
|
1749
|
+
export function resetRequestContext(): void {
|
|
1750
|
+
if (contextStore) {
|
|
1751
|
+
const scoped = contextStore.getStore();
|
|
1752
|
+
if (scoped) {
|
|
1753
|
+
for (const k of Object.keys(scoped)) delete scoped[k];
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
requestContext = {};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Run `fn` inside its own context bag. A Node host calls this around each
|
|
1762
|
+
* request; without a store this degrades to a save/restore of the module-level
|
|
1763
|
+
* bag, which is what the pre-AsyncLocalStorage code did.
|
|
1764
|
+
*/
|
|
1765
|
+
export function runWithContext<T>(fn: () => T): T {
|
|
1766
|
+
if (contextStore) return contextStore.run({}, fn);
|
|
1767
|
+
const saved = requestContext;
|
|
1768
|
+
requestContext = {};
|
|
1769
|
+
try {
|
|
1770
|
+
return fn();
|
|
1771
|
+
} finally {
|
|
1772
|
+
requestContext = saved;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* Run an async render against a THROWAWAY signal map + data cache + mount
|
|
1778
|
+
* queue + cleanup set, returning the rendered result plus the signal entries,
|
|
1779
|
+
* the onMount callbacks and the onCleanup disposers it produced.
|
|
1780
|
+
*
|
|
1781
|
+
* Used by link prefetch: rendering the hovered route ahead of navigation must
|
|
1782
|
+
* not touch the live page's state (or queue its mounts / register its
|
|
1783
|
+
* cleanups), but the paint that consumes the entry later needs exactly those
|
|
1784
|
+
* things - compiled getters close over entry OBJECTS, so restoreState()
|
|
1785
|
+
* transplants them wholesale instead of copying values, the captured mounts
|
|
1786
|
+
* replay after the prefetched DOM is wired, and adoptCleanups() re-registers
|
|
1787
|
+
* the captured disposers so the NEXT resetEffects() disposes them (a prefetch
|
|
1788
|
+
* render's cleanups firing early would dispose a route that was never
|
|
1789
|
+
* painted, and dropping them would leak the painted route's timers).
|
|
1790
|
+
* Concurrent prefetches are queued by the caller (trade-off: hover prefetches
|
|
1791
|
+
* are rare and cheap).
|
|
1792
|
+
*/
|
|
1793
|
+
export async function isolateStateAsync<T>(
|
|
1794
|
+
fn: () => Promise<T>
|
|
1795
|
+
): Promise<{ result: T; state: Map<string, SignalEntry>; mounts: Array<() => void>; cleanups: Set<Cleanup> }> {
|
|
1796
|
+
const maps = renderMaps();
|
|
1797
|
+
const savedSignals = maps.signals;
|
|
1798
|
+
const savedSeeded = maps.seeded;
|
|
1799
|
+
const savedDeltas = new Map(pendingDeltas);
|
|
1800
|
+
pendingDeltas.clear();
|
|
1801
|
+
const savedMounts = mountQueue.splice(0, mountQueue.length);
|
|
1802
|
+
const savedCleanups = new Set(cleanups);
|
|
1803
|
+
cleanups.clear();
|
|
1804
|
+
maps.signals = new Map();
|
|
1805
|
+
maps.seeded = new Set();
|
|
1806
|
+
maps.data.clear();
|
|
1807
|
+
try {
|
|
1808
|
+
const result = await fn();
|
|
1809
|
+
return {
|
|
1810
|
+
result,
|
|
1811
|
+
state: maps.signals,
|
|
1812
|
+
mounts: mountQueue.splice(0, mountQueue.length),
|
|
1813
|
+
cleanups: new Set(cleanups),
|
|
1814
|
+
};
|
|
1815
|
+
} finally {
|
|
1816
|
+
maps.signals = savedSignals;
|
|
1817
|
+
maps.seeded = savedSeeded;
|
|
1818
|
+
pendingDeltas.clear();
|
|
1819
|
+
savedDeltas.forEach((d, k) => pendingDeltas.set(k, d));
|
|
1820
|
+
mountQueue.splice(0, 0, ...savedMounts);
|
|
1821
|
+
cleanups.clear();
|
|
1822
|
+
savedCleanups.forEach((fn: Cleanup) => cleanups.add(fn));
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/** Adopt a prefetched route's signal entries (objects keep their subs). */
|
|
1827
|
+
export function restoreState(entries: Map<string, SignalEntry>): void {
|
|
1828
|
+
const map = renderMaps().signals;
|
|
1829
|
+
map.clear();
|
|
1830
|
+
entries.forEach((e: SignalEntry, k: string) => map.set(k, e));
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
export function serializeState(): string {
|
|
1834
|
+
const obj: Record<string, unknown> = {};
|
|
1835
|
+
renderMaps().signals.forEach((v: { value: unknown }, k: string) => {
|
|
1836
|
+
obj[k] = v.value;
|
|
1837
|
+
});
|
|
1838
|
+
return JSON.stringify(obj);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
export function resumeState(json: string): void {
|
|
1842
|
+
try {
|
|
1843
|
+
const parsed = JSON.parse(json);
|
|
1844
|
+
if (Array.isArray(parsed)) {
|
|
1845
|
+
const [keys, values] = parsed as [string[], unknown[]];
|
|
1846
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1847
|
+
entry(keys[i], values[i]).value = values[i];
|
|
1848
|
+
renderMaps().seeded.add(keys[i]);
|
|
1849
|
+
}
|
|
1850
|
+
} else if (parsed && typeof parsed === 'object') {
|
|
1851
|
+
Object.entries(parsed as Record<string, unknown>).forEach(([k, v]) => {
|
|
1852
|
+
entry(k, v).value = v;
|
|
1853
|
+
renderMaps().seeded.add(k);
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
} catch {
|
|
1857
|
+
// ignore corrupt state
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
const SDUI_TAGS = ['div', 'span', 'a', 'p', 'img', 'ul', 'li', 'button', 'input', 'section', 'h1', 'h2', 'h3', 'nav', 'header', 'footer', 'form', 'label', 'strong', 'em', 'table', 'tr', 'td', 'code', 'pre', 'article', 'main', 'aside'];
|
|
1862
|
+
const sduiTagToId: Map<string, number> = shared('__rosefd_sduiTagToId', () => new Map(SDUI_TAGS.map((t, i) => [t, i])));
|
|
1863
|
+
const sduiIdToTag: Map<number, string> = shared('__rosefd_sduiIdToTag', () => new Map(SDUI_TAGS.map((t, i) => [i, t])));
|
|
1864
|
+
|
|
1865
|
+
export interface SduiNode {
|
|
1866
|
+
t: string;
|
|
1867
|
+
p?: Record<string, string>;
|
|
1868
|
+
c?: SduiNode[];
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
export function encodeSdui(node: SduiNode): string {
|
|
1872
|
+
const enc = (n: SduiNode): unknown[] => [
|
|
1873
|
+
sduiTagToId.get(n.t) ?? n.t,
|
|
1874
|
+
n.p ?? {},
|
|
1875
|
+
(n.c ?? []).map(enc),
|
|
1876
|
+
];
|
|
1877
|
+
return JSON.stringify(enc(node));
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
export function decodeSdui(json: string): SduiNode {
|
|
1881
|
+
const dec = (a: unknown): SduiNode => {
|
|
1882
|
+
const arr = a as [number | string, Record<string, string>, unknown[]];
|
|
1883
|
+
const raw = arr[0];
|
|
1884
|
+
const t = typeof raw === 'number' ? (sduiIdToTag.get(raw) ?? String(raw)) : String(raw);
|
|
1885
|
+
return { t, p: arr[1], c: (arr[2] ?? []).map(dec) };
|
|
1886
|
+
};
|
|
1887
|
+
return dec(JSON.parse(json));
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// The SWR refresh hook the client entry installs - shared like the action
|
|
1891
|
+
// dispatcher: it drives the one document every copy wired, last install wins.
|
|
1892
|
+
const refreshHookCell = sharedCell<(() => Promise<void>) | null>('__rosefd_refreshHook', null);
|
|
1893
|
+
|
|
1894
|
+
export function setRefreshHook(fn: () => Promise<void>): void {
|
|
1895
|
+
refreshHookCell.value = fn;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
export function refresh(): Promise<void> {
|
|
1899
|
+
const hook = refreshHookCell.value;
|
|
1900
|
+
return hook ? hook() : Promise.resolve();
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const mountQueue: Array<() => void> = shared('__rosefd_mountQueue', () => []);
|
|
1904
|
+
|
|
1905
|
+
export function onMount(fn: () => void): void {
|
|
1906
|
+
if (typeof document === 'undefined') return;
|
|
1907
|
+
mountQueue.push(fn);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/** Drop queued mounts - a failed render leaves stale callbacks behind. */
|
|
1911
|
+
export function clearMounts(): void {
|
|
1912
|
+
mountQueue.length = 0;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/** Run and clear the queue; the client entry calls this after every wire(). */
|
|
1916
|
+
export function flushMounts(): void {
|
|
1917
|
+
const q = mountQueue.splice(0, mountQueue.length);
|
|
1918
|
+
for (const fn of q) fn();
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/** Register a disposer for the current render (the effects' cleanup channel). */
|
|
1922
|
+
export function onCleanup(fn: Cleanup): void {
|
|
1923
|
+
fxSet().add(fn);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Adopt a prefetched route's captured onCleanup disposers after its cached
|
|
1928
|
+
* paint lands: the next resetEffects() (before the following re-render) then
|
|
1929
|
+
* disposes them like any live route's. Without this the prefetched paint's
|
|
1930
|
+
* mounts would start timers nothing ever clears.
|
|
1931
|
+
*/
|
|
1932
|
+
export function adoptCleanups(fns: Iterable<Cleanup>): void {
|
|
1933
|
+
for (const fn of fns) cleanups.add(fn);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// The locale catalog. One cell, not three lets: setLocales/setLocalePacks
|
|
1937
|
+
// RE-BIND these, and a plain re-bound `let` would leave every other copy of
|
|
1938
|
+
// this module reading the pre-install values - a second bundle's $t would
|
|
1939
|
+
// answer in the build default no matter what language the app is in. The cell
|
|
1940
|
+
// is why the writes are visible across copies.
|
|
1941
|
+
const localeCell = sharedCell<{
|
|
1942
|
+
dicts: Record<string, Record<string, string>>;
|
|
1943
|
+
def: string;
|
|
1944
|
+
packs: string[];
|
|
1945
|
+
}>('__rosefd_localeCell', { dicts: {}, def: 'en', packs: [] });
|
|
1946
|
+
|
|
1947
|
+
export function setLocales(dicts: Record<string, Record<string, string>>, def: string): void {
|
|
1948
|
+
const c = localeCell.value;
|
|
1949
|
+
// MERGE, never replace. Every copy of this module ships the subset of the
|
|
1950
|
+
// catalog its own build inlined (the rest arrive as runtime packs), so one
|
|
1951
|
+
// copy's install must not drop the languages another copy brought: in a
|
|
1952
|
+
// process that hosts both the server bundle and a client bundle the client's
|
|
1953
|
+
// two-language subset used to wipe the server's full catalog, and every
|
|
1954
|
+
// server render of the packed locale fell back to the default.
|
|
1955
|
+
c.dicts = { ...c.dicts, ...dicts };
|
|
1956
|
+
if (def) c.def = def;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** The locales the build left out of this bundle (generated code calls this). */
|
|
1960
|
+
export function setLocalePacks(langs: string[]): void {
|
|
1961
|
+
const c = localeCell.value;
|
|
1962
|
+
for (const l of langs) if (!c.packs.includes(l)) c.packs.push(l);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Merge a dictionary that arrived at runtime - a fetched pack, or an app's own import(). */
|
|
1966
|
+
export function loadLocale(lang: string, dict: Record<string, string>): void {
|
|
1967
|
+
const c = localeCell.value;
|
|
1968
|
+
c.dicts[lang] = { ...c.dicts[lang], ...dict };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Resolve once `lang`'s dictionary is present: immediately for an inline
|
|
1973
|
+
* locale, after one fetch for a runtime pack, never for an unknown one (the
|
|
1974
|
+
* URL is the contract - the server 404s it). A failed fetch leaves the locale
|
|
1975
|
+
* unresolved and $t falls back to the default language; the next navigation
|
|
1976
|
+
* retries. This is the seam a bundled-per-locale app can bypass entirely by
|
|
1977
|
+
* calling loadLocale() itself.
|
|
1978
|
+
*/
|
|
1979
|
+
export async function ensureLocale(lang: string): Promise<void> {
|
|
1980
|
+
const c = localeCell.value;
|
|
1981
|
+
if (c.dicts[lang] || !c.packs.includes(lang)) return;
|
|
1982
|
+
try {
|
|
1983
|
+
const res = await fetch(`/locales/${encodeURIComponent(lang)}.json`);
|
|
1984
|
+
if (res.ok) loadLocale(lang, await res.json());
|
|
1985
|
+
} catch {
|
|
1986
|
+
// offline, or the pack is missing: the inline dictionaries still render
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
export function localeList(): string[] {
|
|
1991
|
+
return Object.keys(localeCell.value.dicts);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
export function isLocale(lang: string): boolean {
|
|
1995
|
+
const c = localeCell.value;
|
|
1996
|
+
return Object.hasOwn(c.dicts, lang) || c.packs.includes(lang);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* Best match for an Accept-Language header among the known locales, or null
|
|
2001
|
+
* when it expresses no preference (absent, empty, or the `*` wildcard - which
|
|
2002
|
+
* Node's own fetch sends by default). A caller negotiates only on a real
|
|
2003
|
+
* preference; everything else serves the default locale's page in place.
|
|
2004
|
+
*/
|
|
2005
|
+
export function bestLocale(header: string | null | undefined): string | null {
|
|
2006
|
+
for (const part of (header || '').split(',')) {
|
|
2007
|
+
const tag = part.split(';')[0].trim().toLowerCase();
|
|
2008
|
+
if (!tag || tag === '*') continue;
|
|
2009
|
+
if (isLocale(tag)) return tag;
|
|
2010
|
+
const base = tag.split('-')[0];
|
|
2011
|
+
if (isLocale(base)) return base;
|
|
2012
|
+
}
|
|
2013
|
+
return null;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/** The signal `redirect()` throws. */
|
|
2017
|
+
export class RoseRedirect extends Error {
|
|
2018
|
+
readonly __rosefd = 'redirect';
|
|
2019
|
+
constructor(readonly path: string, readonly status = 307) {
|
|
2020
|
+
super(`redirect to ${path}`);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/** The signal `notFound()` throws. */
|
|
2025
|
+
export class RoseNotFound extends Error {
|
|
2026
|
+
readonly __rosefd = 'notFound';
|
|
2027
|
+
constructor() {
|
|
2028
|
+
super('notFound()');
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Send the visitor somewhere else: the response becomes a 30x with this
|
|
2034
|
+
* Location. Call it from a page's `$data` body (a wall, a renamed slug, a
|
|
2035
|
+
* canonical URL) or from an api handler. A route that calls it is buffered by
|
|
2036
|
+
* the compiler, so the status can still be set.
|
|
2037
|
+
*/
|
|
2038
|
+
export function redirect(path: string, status = 307): never {
|
|
2039
|
+
throw new RoseRedirect(path, status);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* Answer 404 with the app's own 404 route (pages/404.rose) instead of the
|
|
2044
|
+
* built-in page. Call it from a page's `$data` body when the slug resolves to
|
|
2045
|
+
* nothing - the status is a real 404 and the body is the app's design, which
|
|
2046
|
+
* is the one thing a middleware wall cannot do from outside the render.
|
|
2047
|
+
*/
|
|
2048
|
+
export function notFound(): never {
|
|
2049
|
+
throw new RoseNotFound();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Reading direction of a locale. The set of right-to-left scripts is closed
|
|
2054
|
+
* (Arabic, Hebrew and their relatives), so a base-tag test covers every
|
|
2055
|
+
* language that uses them - no per-app config, and the document's <html dir>
|
|
2056
|
+
* is server-rendered correct for no-JS visitors. A regex LITERAL, not a
|
|
2057
|
+
* string .split(): esbuild drops side-effect-free initializers, so an app
|
|
2058
|
+
* whose scripts never call localeDir ships neither it nor the table.
|
|
2059
|
+
*/
|
|
2060
|
+
const RTL_RE = /^(ar|fa|he|ur|ps|sd|ug|yi|ckb|dv|ks|prs|haz|mzn|nqo)/;
|
|
2061
|
+
/** 'rtl' for a right-to-left locale, '' otherwise - '' is the html default. */
|
|
2062
|
+
export function localeDir(lang: string): string {
|
|
2063
|
+
return RTL_RE.test(lang) ? 'rtl' : '';
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const ICU_RE = /\{([\w$]+),\s*(plural|selectordinal|select),\s*((?:\s*[=\w]+\s*\{(?:[^{}]|\{[^{}]*\})*\})*)\}/g;
|
|
2067
|
+
const BRANCH_RE = /([=\w]+)\s*\{((?:[^{}]|\{[^{}]*\})*)\}/g;
|
|
2068
|
+
|
|
2069
|
+
const PLURAL_RULES: Map<string, Intl.PluralRules> = shared('__rosefd_pluralRules', () => new Map());
|
|
2070
|
+
|
|
2071
|
+
/** The CLDR category of `n` in `locale`, or null where Intl has no answer. */
|
|
2072
|
+
function pluralCategory(locale: string, ordinal: boolean, n: number): string | null {
|
|
2073
|
+
try {
|
|
2074
|
+
const key = ordinal ? locale + '\u0000ord' : locale;
|
|
2075
|
+
let rules = PLURAL_RULES.get(key);
|
|
2076
|
+
if (!rules) {
|
|
2077
|
+
rules = new Intl.PluralRules(locale, ordinal ? { type: 'ordinal' } : undefined);
|
|
2078
|
+
PLURAL_RULES.set(key, rules);
|
|
2079
|
+
}
|
|
2080
|
+
return rules.select(n);
|
|
2081
|
+
} catch {
|
|
2082
|
+
return null;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/** Expand every ICU block of a message; leave the rest (and {name} holes) alone. */
|
|
2087
|
+
function expandIcu(msg: string, vars: Record<string, string | number> | undefined, locale: string): string {
|
|
2088
|
+
return msg.replace(ICU_RE, (whole, arg: string, kind: string, branchText: string) => {
|
|
2089
|
+
const branches: Record<string, string> = {};
|
|
2090
|
+
for (const m of branchText.matchAll(BRANCH_RE)) branches[m[1]] = m[2];
|
|
2091
|
+
if (!branches.other) return whole;
|
|
2092
|
+
const v = vars?.[arg];
|
|
2093
|
+
if (kind === 'select') return branches[typeof v === 'string' && branches[v] ? v : 'other'];
|
|
2094
|
+
const num = typeof v === 'number' ? v : Number(v);
|
|
2095
|
+
const cat = isFinite(num)
|
|
2096
|
+
? (branches['=' + num] ? '=' + num : pluralCategory(locale, kind === 'selectordinal', num) ?? 'other')
|
|
2097
|
+
: 'other';
|
|
2098
|
+
return (branches[cat] ?? branches.other).replace(/#/g, String(v));
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* Translate a key for the current language. The [lang] route param arrives
|
|
2104
|
+
* as state before the render (the same seeding as any dynamic param), and
|
|
2105
|
+
* reading it through the signal getter makes $t reactive: switching locale
|
|
2106
|
+
* re-runs the marker, so only the translated nodes repaint.
|
|
2107
|
+
*/
|
|
2108
|
+
export function $t(key: string, vars?: Record<string, string | number>): string {
|
|
2109
|
+
const c = localeCell.value;
|
|
2110
|
+
const [lang] = state('lang', c.def);
|
|
2111
|
+
const dict = c.dicts[lang()] ?? c.dicts[c.def] ?? {};
|
|
2112
|
+
const msg = dict[key];
|
|
2113
|
+
let out = expandIcu(typeof msg === 'string' ? msg : key, vars, lang());
|
|
2114
|
+
if (vars) for (const k in vars) out = out.split(`{${k}}`).join(String(vars[k]));
|
|
2115
|
+
return out;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const STORES: Map<string, { value: unknown; subs: Set<Subscriber> }> = shared('__rosefd_stores', () => new Map());
|
|
2119
|
+
// The SSE transport for $store - shared like the dispatcher and the refresh
|
|
2120
|
+
// hook: it is installed once per page by the client entry, and every copy's
|
|
2121
|
+
// store writes must travel over the one channel that entry opened.
|
|
2122
|
+
const storeTransportCell = sharedCell<((name: string, value: unknown) => void) | null>('__rosefd_storeTransport', null);
|
|
2123
|
+
|
|
2124
|
+
// A store write (or an applied patch) fans out to whoever read the store inside a
|
|
2125
|
+
// tracked scope - the wire() effects that render {store.get()} / {#each store.get()}.
|
|
2126
|
+
// Notify UNTRACKED, exactly like the signal setter: a write must not hand out new
|
|
2127
|
+
// subscriptions, and a throwing effect must not kill the route.
|
|
2128
|
+
function notifyStoreSubs(box: { subs: Set<Subscriber> }): void {
|
|
2129
|
+
if (box.subs.size === 0) return;
|
|
2130
|
+
const prevEffect = currentEffect;
|
|
2131
|
+
const prevTracking = trackingDeps;
|
|
2132
|
+
currentEffect = null;
|
|
2133
|
+
trackingDeps = null;
|
|
2134
|
+
// Snapshot, for the same reason as the signal setter: an effect running
|
|
2135
|
+
// here can subscribe another one (a nested block's first run), and a live
|
|
2136
|
+
// Set's iterator would visit that newcomer in the same pass.
|
|
2137
|
+
for (const fn of [...box.subs] as Subscriber[]) fn();
|
|
2138
|
+
currentEffect = prevEffect;
|
|
2139
|
+
trackingDeps = prevTracking;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* Install the cross-process transport. The generated server entry wires the
|
|
2144
|
+
* cluster channel; a standalone process (dev, preview) installs none - one
|
|
2145
|
+
* process is one copy, which is already correct. Pass null to detach.
|
|
2146
|
+
*/
|
|
2147
|
+
export function setStoreTransport(fn: ((name: string, value: unknown) => void) | null): void {
|
|
2148
|
+
storeTransportCell.value = fn;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** Apply a patch that arrived from another process (the transport's receiver
|
|
2152
|
+
* calls this; a store nobody opened yet is ignored - it has no reader). */
|
|
2153
|
+
export function applyStorePatch(name: string, value: unknown): void {
|
|
2154
|
+
const entry = STORES.get(name);
|
|
2155
|
+
if (!entry) return;
|
|
2156
|
+
entry.value = value;
|
|
2157
|
+
notifyStoreSubs(entry);
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* A named store shared by every request in this process - and, through the
|
|
2162
|
+
* transport, by every worker. Read it inside `$data` or a server action:
|
|
2163
|
+
* those are the server-only paths. A bare read in a render body is a local
|
|
2164
|
+
* snapshot, and on the client (where the store map is per page load and no
|
|
2165
|
+
* patch ever arrives) that snapshot is the initial value.
|
|
2166
|
+
*
|
|
2167
|
+
* Values must be JSON-serializable: the transport serializes them, exactly
|
|
2168
|
+
* like the state patch that already rides in every document.
|
|
2169
|
+
*/
|
|
2170
|
+
export function $store<T>(name: string, initial: T): { get(): T; set(v: T): void; update(fn: (v: T) => T): void } {
|
|
2171
|
+
let entry = STORES.get(name);
|
|
2172
|
+
if (!entry) {
|
|
2173
|
+
entry = { value: initial, subs: new Set<Subscriber>() };
|
|
2174
|
+
STORES.set(name, entry);
|
|
2175
|
+
}
|
|
2176
|
+
const box = entry;
|
|
2177
|
+
return {
|
|
2178
|
+
get: () => {
|
|
2179
|
+
if (currentEffect) {
|
|
2180
|
+
box.subs.add(currentEffect);
|
|
2181
|
+
if (trackingDeps) trackingDeps.add(box as unknown as SignalEntry);
|
|
2182
|
+
}
|
|
2183
|
+
return box.value as T;
|
|
2184
|
+
},
|
|
2185
|
+
set: (v: T) => {
|
|
2186
|
+
box.value = v;
|
|
2187
|
+
notifyStoreSubs(box);
|
|
2188
|
+
storeTransportCell.value?.(name, v);
|
|
2189
|
+
},
|
|
2190
|
+
update: (fn: (v: T) => T) => {
|
|
2191
|
+
box.value = fn(box.value as T);
|
|
2192
|
+
notifyStoreSubs(box);
|
|
2193
|
+
storeTransportCell.value?.(name, box.value);
|
|
2194
|
+
},
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// === Network listeners (isomorphic) ===
|
|
2199
|
+
// $udp / $tcp / $listen / $websocket let an app open a socket and react to the
|
|
2200
|
+
// outside world (LoRa gateways, MQTT-ish feeds, raw TCP devices, live dashboards)
|
|
2201
|
+
// from inside a Rosefd app. They only REGISTER a spec here; the host binds them
|
|
2202
|
+
// via __startNetwork() at serve-worker boot - so a build, a prerender, or a dev
|
|
2203
|
+
// hot-re-import never opens a socket. The registries live on globalThis: a dev
|
|
2204
|
+
// re-import re-registers specs against the same live handles.
|
|
2205
|
+
//
|
|
2206
|
+
// A page's <script> runs per render, so the same key re-registers on every
|
|
2207
|
+
// request. Re-registration therefore SWAPS the callbacks on the live handle
|
|
2208
|
+
// (opts are read dynamically at event time) instead of closing the socket: no
|
|
2209
|
+
// per-request churn, and a dev hot-reload's fresh closures take over immediately
|
|
2210
|
+
// instead of leaving stale ones bound. On the client every handle is an inert
|
|
2211
|
+
// no-op, so a page whose <script> opens a listener hydrates without a
|
|
2212
|
+
// ReferenceError.
|
|
2213
|
+
type NetOpts = Record<string, unknown>;
|
|
2214
|
+
const __netSpecs: { kind: string; port: number; host?: string; opts: NetOpts }[] =
|
|
2215
|
+
(globalThis as any).__rosefd_netSpecs || ((globalThis as any).__rosefd_netSpecs = []);
|
|
2216
|
+
const __netHandles: any[] = (globalThis as any).__rosefd_netHandles || ((globalThis as any).__rosefd_netHandles = []);
|
|
2217
|
+
/** key ('kind:port:host') -> live handle. A Map, not a Set: re-registering a key (per-request render or dev hot re-import) swaps the callbacks in place. */
|
|
2218
|
+
const __netBound: Map<string, any> = (globalThis as any).__rosefd_netBound || ((globalThis as any).__rosefd_netBound = new Map());
|
|
2219
|
+
|
|
2220
|
+
function __netBind(spec: { kind: string; port: number; host?: string; opts: NetOpts }): void {
|
|
2221
|
+
const key = spec.kind + ':' + spec.port + ':' + (spec.host || '');
|
|
2222
|
+
const prev = __netBound.get(key);
|
|
2223
|
+
if (prev) {
|
|
2224
|
+
// Live handle already bound: adopt the newest callbacks, keep the socket.
|
|
2225
|
+
prev.opts = spec.opts || {};
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const o = spec.opts || {};
|
|
2229
|
+
if (spec.kind === 'udp') {
|
|
2230
|
+
const dg = (globalThis as any).__rosefd_dgram;
|
|
2231
|
+
if (!dg) return;
|
|
2232
|
+
const handle = { opts: o, sock: null as any };
|
|
2233
|
+
const sock = dg.createSocket('udp4');
|
|
2234
|
+
handle.sock = sock;
|
|
2235
|
+
sock.on('message', (msg: Buffer, rinfo: unknown) => (handle.opts.onMessage as (m: Buffer, r: unknown) => void)?.(msg, rinfo));
|
|
2236
|
+
sock.on('error', (err: Error) => { if (handle.opts.onError) (handle.opts.onError as (e: Error) => void)(err); else console.error('[rosefd] UDP error:', err); });
|
|
2237
|
+
sock.bind(spec.port, spec.host || '0.0.0.0');
|
|
2238
|
+
__netHandles.push(sock);
|
|
2239
|
+
__netBound.set(key, handle);
|
|
2240
|
+
} else if (spec.kind === 'tcp') {
|
|
2241
|
+
const nt = (globalThis as any).__rosefd_net;
|
|
2242
|
+
if (!nt) return;
|
|
2243
|
+
const handle = { opts: o, srv: null as any };
|
|
2244
|
+
const idleMs = ((o.idleTimeout as number) ?? 0) * 1000;
|
|
2245
|
+
const srv = nt.createServer((socket: any) => {
|
|
2246
|
+
(handle.opts.onConnection as (s: unknown) => void)?.(socket);
|
|
2247
|
+
if (idleMs > 0) {
|
|
2248
|
+
// A device that connects and goes silent would hold the fd forever;
|
|
2249
|
+
// an idle window re-armed on every inbound byte bounds that.
|
|
2250
|
+
socket.setTimeout(idleMs);
|
|
2251
|
+
socket.on('timeout', () => {
|
|
2252
|
+
(handle.opts.onIdle as ((s: unknown) => void) | undefined)?.(socket);
|
|
2253
|
+
socket.destroy();
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
socket.on('data', (data: Buffer) => {
|
|
2257
|
+
if (idleMs > 0) socket.setTimeout(idleMs);
|
|
2258
|
+
(handle.opts.onData as (s: unknown, d: Buffer) => void)?.(socket, data);
|
|
2259
|
+
});
|
|
2260
|
+
socket.on('error', () => {});
|
|
2261
|
+
});
|
|
2262
|
+
handle.srv = srv;
|
|
2263
|
+
srv.on('error', (err: Error) => console.error('[rosefd] TCP server error:', err));
|
|
2264
|
+
srv.listen(spec.port, spec.host || '0.0.0.0');
|
|
2265
|
+
__netHandles.push(srv);
|
|
2266
|
+
__netBound.set(key, handle);
|
|
2267
|
+
} else if (spec.kind === 'ws') {
|
|
2268
|
+
const http = (globalThis as any).__rosefd_http;
|
|
2269
|
+
const cryptoMod = (globalThis as any).__rosefd_crypto;
|
|
2270
|
+
if (!http || !cryptoMod) return;
|
|
2271
|
+
const clients = new Set<any>();
|
|
2272
|
+
const handle = {
|
|
2273
|
+
opts: o,
|
|
2274
|
+
clients: () => Array.from(clients),
|
|
2275
|
+
broadcast: (data: unknown) => { for (const c of clients) c.send(data); },
|
|
2276
|
+
close: () => {
|
|
2277
|
+
for (const c of Array.from(clients)) c.close();
|
|
2278
|
+
if (handle.__pingTimer) { clearInterval(handle.__pingTimer); handle.__pingTimer = null; }
|
|
2279
|
+
try { srv.close(); } catch { /* already gone */ }
|
|
2280
|
+
},
|
|
2281
|
+
__pingTimer: null as ReturnType<typeof setInterval> | null,
|
|
2282
|
+
};
|
|
2283
|
+
const srv = http.createServer((_req: unknown, res: any) => { res.writeHead(426, { 'content-type': 'text/plain' }); res.end('Upgrade Required'); });
|
|
2284
|
+
srv.on('upgrade', (req: any, socket: any) => {
|
|
2285
|
+
const opts = handle.opts;
|
|
2286
|
+
if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) { socket.destroy(); return; }
|
|
2287
|
+
const path = String(req.url || '/').split('?')[0];
|
|
2288
|
+
if (opts.path && path !== opts.path) { socket.destroy(); return; }
|
|
2289
|
+
const accept = cryptoMod.createHash('sha1').update(String(req.headers['sec-websocket-key']) + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
2290
|
+
try {
|
|
2291
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n');
|
|
2292
|
+
} catch { socket.destroy(); return; }
|
|
2293
|
+
socket.setNoDelay(true);
|
|
2294
|
+
socket.setTimeout(0); // an upgraded socket is a long-lived channel: no request timeout
|
|
2295
|
+
const wrapped = __wsWrap(socket, clients, handle);
|
|
2296
|
+
clients.add(wrapped);
|
|
2297
|
+
(opts.onConnection as (ws: unknown) => void)?.(wrapped);
|
|
2298
|
+
});
|
|
2299
|
+
srv.on('error', (err: Error) => { if (handle.opts.onError) (handle.opts.onError as (e: Error) => void)(err); else console.error('[rosefd] WebSocket server error:', err); });
|
|
2300
|
+
srv.listen(spec.port, spec.host || '0.0.0.0');
|
|
2301
|
+
// Keepalive: a half-open peer (network partition, crashed device) never
|
|
2302
|
+
// sends FIN, so its socket would sit in `clients` forever and every
|
|
2303
|
+
// broadcast would write into the void. Each tick pings the live-looking
|
|
2304
|
+
// clients and destroys the ones that failed to answer the previous ping.
|
|
2305
|
+
const pingInterval = ((o.pingInterval as number) ?? 30);
|
|
2306
|
+
if (pingInterval > 0) {
|
|
2307
|
+
handle.__pingTimer = setInterval(() => {
|
|
2308
|
+
for (const c of Array.from(clients)) {
|
|
2309
|
+
const sock = c.__sock;
|
|
2310
|
+
if (!sock || sock.destroyed) { clients.delete(c); continue; }
|
|
2311
|
+
if (c.__alive === false) { try { sock.destroy(); } catch { /* already gone */ } clients.delete(c); continue; }
|
|
2312
|
+
c.__alive = false;
|
|
2313
|
+
try { sock.write(__wsEncodeFrame(9, Buffer.alloc(0))); } catch { /* already gone */ }
|
|
2314
|
+
}
|
|
2315
|
+
}, Math.max(1000, pingInterval * 1000));
|
|
2316
|
+
}
|
|
2317
|
+
__netHandles.push(handle);
|
|
2318
|
+
__netBound.set(key, handle);
|
|
2319
|
+
} else if (spec.kind === 'mqtt') {
|
|
2320
|
+
const nt = (globalThis as any).__rosefd_net;
|
|
2321
|
+
if (!nt) return;
|
|
2322
|
+
const client = __mqttConnect(nt, spec.port, spec.host || 'localhost', o);
|
|
2323
|
+
__netHandles.push(client);
|
|
2324
|
+
__netBound.set(key, client);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
function __netReg(kind: string, port: number, host: string | undefined, opts: NetOpts): void {
|
|
2328
|
+
const spec = { kind, port, host, opts };
|
|
2329
|
+
__netSpecs.push(spec);
|
|
2330
|
+
if ((globalThis as any).__rosefd_netStarted === true) __netBind(spec);
|
|
2331
|
+
}
|
|
2332
|
+
/** Drop every pending spec for a key and close its live handle (app-level close()). */
|
|
2333
|
+
function __netUnreg(kind: string, port: number, host: string | undefined): void {
|
|
2334
|
+
const key = kind + ':' + port + ':' + (host || '');
|
|
2335
|
+
const h = __netBound.get(key);
|
|
2336
|
+
if (h) {
|
|
2337
|
+
const live = (h as { sock?: { close?: () => void }; srv?: { close?: () => void }; close?: () => void });
|
|
2338
|
+
try { (live.close || live.sock?.close || live.srv?.close)?.call(live); } catch { /* already gone */ }
|
|
2339
|
+
__netBound.delete(key);
|
|
2340
|
+
}
|
|
2341
|
+
for (let i = __netSpecs.length - 1; i >= 0; i--) {
|
|
2342
|
+
const s = __netSpecs[i];
|
|
2343
|
+
if (s.kind === kind && s.port === port && (s.host || '') === (host || '')) __netSpecs.splice(i, 1);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
/** Bind every registered listener. Called once at serve-worker boot by the host. */
|
|
2347
|
+
export function __startNetwork(): void {
|
|
2348
|
+
(globalThis as any).__rosefd_netStarted = true;
|
|
2349
|
+
for (const spec of __netSpecs) __netBind(spec);
|
|
2350
|
+
}
|
|
2351
|
+
/** Close every bound listener. Called on worker shutdown. */
|
|
2352
|
+
export function __stopNetwork(): void {
|
|
2353
|
+
for (const h of __netHandles) {
|
|
2354
|
+
const live = h as { close?: () => void };
|
|
2355
|
+
try { live.close?.(); } catch { /* already gone */ }
|
|
2356
|
+
}
|
|
2357
|
+
__netHandles.length = 0;
|
|
2358
|
+
__netBound.clear();
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// === WebSocket server (RFC 6455, zero-dependency) ===
|
|
2362
|
+
// Minimal but complete enough for dashboards and device feeds: handshake,
|
|
2363
|
+
// text/binary frames, continuation reassembly, ping/pong, close handshake.
|
|
2364
|
+
// No extensions, no permessage-deflate - deliberately.
|
|
2365
|
+
const __WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
2366
|
+
|
|
2367
|
+
/** Parse as many complete frames as the buffer holds; return the tail (partial frame) for the next chunk. */
|
|
2368
|
+
function __wsParseFrames(buf: Buffer): { frames: { fin: boolean; opcode: number; payload: Buffer }[]; rest: Buffer } {
|
|
2369
|
+
const frames: { fin: boolean; opcode: number; payload: Buffer }[] = [];
|
|
2370
|
+
let off = 0;
|
|
2371
|
+
while (off + 2 <= buf.length) {
|
|
2372
|
+
const b0 = buf[off];
|
|
2373
|
+
const b1 = buf[off + 1];
|
|
2374
|
+
const fin = (b0 & 0x80) !== 0;
|
|
2375
|
+
const opcode = b0 & 0x0f;
|
|
2376
|
+
const masked = (b1 & 0x80) !== 0;
|
|
2377
|
+
let len = b1 & 0x7f;
|
|
2378
|
+
let p = off + 2;
|
|
2379
|
+
if (len === 126) {
|
|
2380
|
+
if (off + 4 > buf.length) break;
|
|
2381
|
+
len = buf.readUInt16BE(p);
|
|
2382
|
+
p += 2;
|
|
2383
|
+
} else if (len === 127) {
|
|
2384
|
+
if (off + 10 > buf.length) break;
|
|
2385
|
+
len = buf.readUInt32BE(p) * 4294967296 + buf.readUInt32BE(p + 4);
|
|
2386
|
+
p += 8;
|
|
2387
|
+
}
|
|
2388
|
+
const maskLen = masked ? 4 : 0;
|
|
2389
|
+
if (p + maskLen + len > buf.length) break;
|
|
2390
|
+
let payload = buf.slice(p + maskLen, p + maskLen + len);
|
|
2391
|
+
if (masked) {
|
|
2392
|
+
const mask = buf.slice(p, p + 4);
|
|
2393
|
+
const out = Buffer.allocUnsafe(len);
|
|
2394
|
+
for (let i = 0; i < len; i++) out[i] = payload[i] ^ mask[i & 3];
|
|
2395
|
+
payload = out;
|
|
2396
|
+
}
|
|
2397
|
+
frames.push({ fin, opcode, payload });
|
|
2398
|
+
off = p + maskLen + len;
|
|
2399
|
+
}
|
|
2400
|
+
return { frames, rest: off > 0 ? buf.slice(off) : buf };
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
/** Encode one server->client frame (never masked, FIN always set for our single-frame sends). */
|
|
2404
|
+
function __wsEncodeFrame(opcode: number, payload: Buffer): Buffer {
|
|
2405
|
+
const len = payload.length;
|
|
2406
|
+
let header: Buffer;
|
|
2407
|
+
if (len < 126) {
|
|
2408
|
+
header = Buffer.from([0x80 | opcode, len]);
|
|
2409
|
+
} else if (len < 65536) {
|
|
2410
|
+
header = Buffer.allocUnsafe(4);
|
|
2411
|
+
header[0] = 0x80 | opcode;
|
|
2412
|
+
header[1] = 126;
|
|
2413
|
+
header.writeUInt16BE(len, 2);
|
|
2414
|
+
} else {
|
|
2415
|
+
header = Buffer.allocUnsafe(10);
|
|
2416
|
+
header[0] = 0x80 | opcode;
|
|
2417
|
+
header[1] = 127;
|
|
2418
|
+
header.writeUInt32BE(0, 2);
|
|
2419
|
+
header.writeUInt32BE(len, 6);
|
|
2420
|
+
}
|
|
2421
|
+
return Buffer.concat([header, payload]);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/** Wrap an upgraded raw socket into the plan's WebSocket surface: send/close/on + message reassembly. Callbacks are read from the handle's opts at event time so a re-registration swaps them live. */
|
|
2425
|
+
function __wsWrap(socket: any, clients: Set<any>, handle: { opts: NetOpts }): any {
|
|
2426
|
+
const listeners: Record<string, ((arg: unknown) => void)[]> = {};
|
|
2427
|
+
const emit = (ev: string, arg: unknown) => { for (const fn of listeners[ev] || []) fn(arg); };
|
|
2428
|
+
let msgOpcode = 0;
|
|
2429
|
+
let parts: Buffer[] = [];
|
|
2430
|
+
const wrapped = {
|
|
2431
|
+
__sock: socket,
|
|
2432
|
+
__alive: true, // flipped false by each keepalive ping; any inbound frame (pong or data) proves the peer is there
|
|
2433
|
+
remoteAddress: socket.remoteAddress,
|
|
2434
|
+
send: (data: unknown) => {
|
|
2435
|
+
if (socket.destroyed) return;
|
|
2436
|
+
let opcode = 1;
|
|
2437
|
+
let payload: Buffer;
|
|
2438
|
+
if (typeof data === 'string') { opcode = 1; payload = Buffer.from(data, 'utf8'); }
|
|
2439
|
+
else if (Buffer.isBuffer(data)) { opcode = 2; payload = data; }
|
|
2440
|
+
else { opcode = 1; payload = Buffer.from(String(data), 'utf8'); }
|
|
2441
|
+
socket.write(__wsEncodeFrame(opcode, payload));
|
|
2442
|
+
},
|
|
2443
|
+
close: () => {
|
|
2444
|
+
if (socket.destroyed) return;
|
|
2445
|
+
try { socket.write(__wsEncodeFrame(8, Buffer.from([0x03, 0xe8]))); } catch { /* already gone */ }
|
|
2446
|
+
socket.end();
|
|
2447
|
+
},
|
|
2448
|
+
on: (ev: string, fn: (arg: unknown) => void) => { (listeners[ev] || (listeners[ev] = [])).push(fn); },
|
|
2449
|
+
};
|
|
2450
|
+
(socket as any).__wsBuf = Buffer.alloc(0);
|
|
2451
|
+
socket.on('data', (chunk: Buffer) => {
|
|
2452
|
+
const sock = socket as any;
|
|
2453
|
+
sock.__wsBuf = sock.__wsBuf.length ? Buffer.concat([sock.__wsBuf, chunk]) : chunk;
|
|
2454
|
+
const { frames, rest } = __wsParseFrames(sock.__wsBuf as Buffer);
|
|
2455
|
+
sock.__wsBuf = rest;
|
|
2456
|
+
for (const f of frames) {
|
|
2457
|
+
if (f.opcode === 8) {
|
|
2458
|
+
wrapped.close();
|
|
2459
|
+
} else if (f.opcode === 9) {
|
|
2460
|
+
wrapped.__alive = true;
|
|
2461
|
+
try { socket.write(__wsEncodeFrame(10, f.payload)); } catch { /* already gone */ }
|
|
2462
|
+
} else if (f.opcode === 10) {
|
|
2463
|
+
wrapped.__alive = true; // pong: the peer answered the keepalive ping
|
|
2464
|
+
} else if (f.opcode === 1 || f.opcode === 2) {
|
|
2465
|
+
wrapped.__alive = true; // inbound traffic is proof of life too
|
|
2466
|
+
if (msgOpcode !== 0) { socket.destroy(); return; } // protocol error: new data frame mid-message
|
|
2467
|
+
msgOpcode = f.opcode;
|
|
2468
|
+
parts = [f.payload];
|
|
2469
|
+
if (f.fin) { const msg = Buffer.concat(parts); const op = msgOpcode; msgOpcode = 0; parts = []; emit('message', op === 1 ? msg.toString('utf8') : msg); (handle.opts.onMessage as ((...a: unknown[]) => void) | undefined)?.(wrapped, op === 1 ? msg.toString('utf8') : msg); }
|
|
2470
|
+
} else if (f.opcode === 0) {
|
|
2471
|
+
if (msgOpcode === 0) { socket.destroy(); return; } // protocol error: continuation without a start
|
|
2472
|
+
parts.push(f.payload);
|
|
2473
|
+
if (f.fin) { const msg = Buffer.concat(parts); const op = msgOpcode; msgOpcode = 0; parts = []; emit('message', op === 1 ? msg.toString('utf8') : msg); (handle.opts.onMessage as ((...a: unknown[]) => void) | undefined)?.(wrapped, op === 1 ? msg.toString('utf8') : msg); }
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
socket.on('close', () => { clients.delete(wrapped); emit('close', null); (handle.opts.onClose as ((...a: unknown[]) => void) | undefined)?.(wrapped); });
|
|
2478
|
+
socket.on('error', (e: Error) => emit('error', e));
|
|
2479
|
+
return wrapped;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
const __NOOP_NET = {
|
|
2483
|
+
send: () => {}, close: () => {}, write: () => {}, end: () => {}, destroy: () => {}, on: () => {},
|
|
2484
|
+
joinMulticast: () => {}, leaveMulticast: (): void => {},
|
|
2485
|
+
address: () => ({ port: 0, host: 'localhost', family: 'IPv4' }),
|
|
2486
|
+
};
|
|
2487
|
+
const __NOOP_WS = (port: number, host?: string) => ({
|
|
2488
|
+
close: (): void => {},
|
|
2489
|
+
clients: (): unknown[] => [],
|
|
2490
|
+
broadcast: (): void => {},
|
|
2491
|
+
address: () => ({ port, host: host || '0.0.0.0' }),
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
export function $udp(port: number, opts: NetOpts = {}): any {
|
|
2495
|
+
if (typeof document === 'undefined') __netReg('udp', port, opts.host as string | undefined, opts);
|
|
2496
|
+
return { ...__NOOP_NET, address: () => ({ port, host: (opts.host as string) || '0.0.0.0', family: 'IPv4' }) };
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
export function $tcp(portOrHost: number | string, opts: NetOpts = {}): any {
|
|
2500
|
+
if (typeof document !== 'undefined') return { ...__NOOP_NET };
|
|
2501
|
+
const isServer = typeof portOrHost === 'number' || opts.onConnection !== undefined
|
|
2502
|
+
|| (opts.port !== undefined && opts.host !== undefined && opts.onConnect === undefined);
|
|
2503
|
+
if (isServer) {
|
|
2504
|
+
const port = typeof portOrHost === 'number' ? portOrHost : (opts.port as number);
|
|
2505
|
+
__netReg('tcp', port, opts.host as string | undefined, opts);
|
|
2506
|
+
return { ...__NOOP_NET, address: () => ({ port, host: (opts.host as string) || '0.0.0.0' }) };
|
|
2507
|
+
}
|
|
2508
|
+
const nt = (globalThis as any).__rosefd_net;
|
|
2509
|
+
if (!nt) return { ...__NOOP_NET };
|
|
2510
|
+
const host = typeof portOrHost === 'string' ? portOrHost : (opts.host as string);
|
|
2511
|
+
const sock = nt.createConnection({ host, port: opts.port as number });
|
|
2512
|
+
sock.on('connect', () => (opts.onConnect as (() => void) | undefined)?.());
|
|
2513
|
+
sock.on('data', (d: Buffer) => (opts.onData as (s: unknown, d: Buffer) => void)?.(sock, d));
|
|
2514
|
+
sock.on('close', () => (opts.onClose as (() => void) | undefined)?.());
|
|
2515
|
+
sock.on('error', (e: Error) => (opts.onError as (e: Error) => void)?.(e));
|
|
2516
|
+
__netHandles.push(sock);
|
|
2517
|
+
return sock;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
export function $listen(port: number, opts: NetOpts = {}): any {
|
|
2521
|
+
if (typeof document !== 'undefined') return { ...__NOOP_NET, address: () => ({ port, host: (opts.host as string) || 'localhost' }) };
|
|
2522
|
+
const protocol = (opts.protocol as string) || 'http';
|
|
2523
|
+
if (protocol === 'udp') $udp(port, { host: opts.host, onMessage: opts.onData, onError: opts.onError });
|
|
2524
|
+
else if (protocol === 'tcp') $tcp(port, { host: opts.host, onConnection: opts.onConnection, onData: opts.onData });
|
|
2525
|
+
else if (protocol === 'websocket') $websocket(port, { host: opts.host, path: opts.path, onConnection: opts.onConnection });
|
|
2526
|
+
return { close: () => {}, address: () => ({ port, host: (opts.host as string) || '0.0.0.0' }) };
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* $websocket - WebSocket server (RFC 6455).
|
|
2531
|
+
* Registers a spec like $udp/$tcp; the returned facade delegates to the live
|
|
2532
|
+
* handle once __startNetwork() binds it, so app code can broadcast/close at any
|
|
2533
|
+
* time (on the client the facade is an inert no-op).
|
|
2534
|
+
*/
|
|
2535
|
+
export function $websocket(port: number, opts: NetOpts = {}): any {
|
|
2536
|
+
const host = opts.host as string | undefined;
|
|
2537
|
+
if (typeof document !== 'undefined') return __NOOP_WS(port, host);
|
|
2538
|
+
__netReg('ws', port, host, opts);
|
|
2539
|
+
return {
|
|
2540
|
+
close: () => __netUnreg('ws', port, host),
|
|
2541
|
+
clients: () => {
|
|
2542
|
+
const h = __netBound.get('ws:' + port + ':' + (host || ''));
|
|
2543
|
+
return h ? (h.clients as () => unknown[])() : [];
|
|
2544
|
+
},
|
|
2545
|
+
broadcast: (data: unknown) => {
|
|
2546
|
+
const h = __netBound.get('ws:' + port + ':' + (host || ''));
|
|
2547
|
+
if (h) (h.broadcast as (d: unknown) => void)(data);
|
|
2548
|
+
},
|
|
2549
|
+
address: () => ({ port, host: host || '0.0.0.0' }),
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* $mqtt - MQTT 3.1.1 client (zero-dependency, like $websocket).
|
|
2555
|
+
* Registers a spec; __startNetwork() opens the TCP connection to the broker, so
|
|
2556
|
+
* a build/prerender never dials out. The facade delegates to the live client:
|
|
2557
|
+
* subscribe/publish work before CONNACK (packets queue and flush on connect).
|
|
2558
|
+
*/
|
|
2559
|
+
export function $mqtt(broker: string, opts: NetOpts = {}): any {
|
|
2560
|
+
const host = (opts.host as string) || __mqttHostOf(broker);
|
|
2561
|
+
const port = (opts.port as number) ?? __mqttPortOf(broker);
|
|
2562
|
+
if (typeof document !== 'undefined') return __NOOP_MQTT;
|
|
2563
|
+
__netReg('mqtt', port, host, opts);
|
|
2564
|
+
const key = 'mqtt:' + port + ':' + host;
|
|
2565
|
+
const bound = () => __netBound.get(key);
|
|
2566
|
+
return {
|
|
2567
|
+
connect: () => {
|
|
2568
|
+
const h = bound();
|
|
2569
|
+
if (h) return (h.connect as () => Promise<void>)();
|
|
2570
|
+
__netBind({ kind: 'mqtt', port, host, opts });
|
|
2571
|
+
const h2 = bound();
|
|
2572
|
+
return h2 ? (h2.connect as () => Promise<void>)() : Promise.reject(new Error('[rosefd] MQTT client unavailable (node:net not injected)'));
|
|
2573
|
+
},
|
|
2574
|
+
disconnect: () => { (bound() as { disconnect?: () => void } | undefined)?.disconnect?.(); },
|
|
2575
|
+
subscribe: (topic: string, sopts: NetOpts = {}) => { (bound() as { subscribe?: (t: string, s: NetOpts) => void } | undefined)?.subscribe?.(topic, sopts); },
|
|
2576
|
+
unsubscribe: (topic: string) => { (bound() as { unsubscribe?: (t: string) => void } | undefined)?.unsubscribe?.(topic); },
|
|
2577
|
+
publish: (topic: string, message: unknown, popts: NetOpts = {}) => { (bound() as { publish?: (t: string, m: unknown, p: NetOpts) => void } | undefined)?.publish?.(topic, message, popts); },
|
|
2578
|
+
get connected() { return (bound() as { connected?: boolean } | undefined)?.connected === true; },
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
const __NOOP_MQTT = {
|
|
2583
|
+
connect: (): Promise<void> => Promise.resolve(),
|
|
2584
|
+
disconnect: (): void => {},
|
|
2585
|
+
subscribe: (): void => {},
|
|
2586
|
+
unsubscribe: (): void => {},
|
|
2587
|
+
publish: (): void => {},
|
|
2588
|
+
connected: false,
|
|
2589
|
+
};
|
|
2590
|
+
|
|
2591
|
+
function __mqttHostOf(broker: string): string {
|
|
2592
|
+
return String(broker).replace(/^\w+:\/\//, '').split(':')[0] || 'localhost';
|
|
2593
|
+
}
|
|
2594
|
+
function __mqttPortOf(broker: string): number {
|
|
2595
|
+
const m = String(broker).match(/:(\d+)/);
|
|
2596
|
+
return m ? Number(m[1]) : 1883;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// === MQTT 3.1.1 codec (client side) ===
|
|
2600
|
+
function __mqttStr(s: string): Buffer {
|
|
2601
|
+
const b = Buffer.from(s, 'utf8');
|
|
2602
|
+
const out = Buffer.allocUnsafe(2 + b.length);
|
|
2603
|
+
out.writeUInt16BE(b.length, 0);
|
|
2604
|
+
b.copy(out, 2);
|
|
2605
|
+
return out;
|
|
2606
|
+
}
|
|
2607
|
+
function __mqttRemainingLen(len: number): Buffer {
|
|
2608
|
+
const out: number[] = [];
|
|
2609
|
+
do {
|
|
2610
|
+
let b = len % 128;
|
|
2611
|
+
len = Math.floor(len / 128);
|
|
2612
|
+
if (len > 0) b |= 0x80;
|
|
2613
|
+
out.push(b);
|
|
2614
|
+
} while (len > 0);
|
|
2615
|
+
return Buffer.from(out);
|
|
2616
|
+
}
|
|
2617
|
+
function __mqttPacket(type: number, flags: number, body: Buffer): Buffer {
|
|
2618
|
+
return Buffer.concat([Buffer.from([(type << 4) | flags]), __mqttRemainingLen(body.length), body]);
|
|
2619
|
+
}
|
|
2620
|
+
/** Incremental packet splitter: complete packets out, partial tail back (same discipline as the WS frame parser). */
|
|
2621
|
+
function __mqttParsePackets(buf: Buffer): { packets: { type: number; flags: number; body: Buffer }[]; rest: Buffer } {
|
|
2622
|
+
const packets: { type: number; flags: number; body: Buffer }[] = [];
|
|
2623
|
+
let off = 0;
|
|
2624
|
+
for (;;) {
|
|
2625
|
+
if (off >= buf.length) break;
|
|
2626
|
+
const type = (buf[off] >> 4) & 0x0f;
|
|
2627
|
+
const flags = buf[off] & 0x0f;
|
|
2628
|
+
let mult = 1;
|
|
2629
|
+
let len = 0;
|
|
2630
|
+
let p = off + 1;
|
|
2631
|
+
for (;;) {
|
|
2632
|
+
if (p >= buf.length) return { packets, rest: buf.slice(off) };
|
|
2633
|
+
const b = buf[p++];
|
|
2634
|
+
len += (b & 0x7f) * mult;
|
|
2635
|
+
if ((b & 0x80) === 0) break;
|
|
2636
|
+
mult *= 128;
|
|
2637
|
+
}
|
|
2638
|
+
if (p + len > buf.length) return { packets, rest: buf.slice(off) };
|
|
2639
|
+
packets.push({ type, flags, body: buf.slice(p, p + len) });
|
|
2640
|
+
off = p + len;
|
|
2641
|
+
}
|
|
2642
|
+
return { packets, rest: Buffer.alloc(0) };
|
|
2643
|
+
}
|
|
2644
|
+
/** Topic filter match: exact segments, '+' one level, '#' the rest (must be last). */
|
|
2645
|
+
function __mqttTopicMatch(filter: string, topic: string): boolean {
|
|
2646
|
+
const f = filter.split('/');
|
|
2647
|
+
const t = topic.split('/');
|
|
2648
|
+
for (let i = 0; i < f.length; i++) {
|
|
2649
|
+
if (f[i] === '#') return true;
|
|
2650
|
+
if (i >= t.length) return false;
|
|
2651
|
+
if (f[i] === '+') continue;
|
|
2652
|
+
if (f[i] !== t[i]) return false;
|
|
2653
|
+
}
|
|
2654
|
+
return f.length === t.length;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function __mqttU16(id: number): Buffer {
|
|
2658
|
+
const b = Buffer.allocUnsafe(2);
|
|
2659
|
+
b.writeUInt16BE(id & 0xffff, 0);
|
|
2660
|
+
return b;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
/**
|
|
2664
|
+
* Create the live MQTT client for a bound spec. Callbacks are read from
|
|
2665
|
+
* client.opts at event time (same swap-on-re-register discipline as the WS
|
|
2666
|
+
* handle). Packets sent before CONNACK queue and flush on connect.
|
|
2667
|
+
*/
|
|
2668
|
+
function __mqttConnect(nt: any, port: number, host: string, o: NetOpts): any {
|
|
2669
|
+
const client: any = {
|
|
2670
|
+
opts: o,
|
|
2671
|
+
connected: false,
|
|
2672
|
+
subs: new Map<string, number>(),
|
|
2673
|
+
outbox: [] as Buffer[],
|
|
2674
|
+
nextId: 1,
|
|
2675
|
+
connAck: null as Promise<void> | null,
|
|
2676
|
+
__keepalive: null as ReturnType<typeof setInterval> | null,
|
|
2677
|
+
__sock: null as any,
|
|
2678
|
+
__closed: false,
|
|
2679
|
+
__retry: null as ReturnType<typeof setTimeout> | null,
|
|
2680
|
+
__attempt: 0,
|
|
2681
|
+
__send: (buf: Buffer) => {
|
|
2682
|
+
const s = client.__sock;
|
|
2683
|
+
if (client.connected && s && !s.destroyed) { try { s.write(buf); } catch { /* already gone */ } }
|
|
2684
|
+
else client.outbox.push(buf);
|
|
2685
|
+
},
|
|
2686
|
+
connect: (): Promise<void> => {
|
|
2687
|
+
if (client.connected) return Promise.resolve();
|
|
2688
|
+
if (client.__closed) { client.__closed = false; dial(); return client.connAck; }
|
|
2689
|
+
const s = client.__sock;
|
|
2690
|
+
if (!s || s.destroyed) {
|
|
2691
|
+
if (client.__retry) { clearTimeout(client.__retry); client.__retry = null; }
|
|
2692
|
+
dial();
|
|
2693
|
+
}
|
|
2694
|
+
return client.connAck;
|
|
2695
|
+
},
|
|
2696
|
+
close: () => client.disconnect(),
|
|
2697
|
+
disconnect: () => {
|
|
2698
|
+
client.__closed = true;
|
|
2699
|
+
if (client.__retry) { clearTimeout(client.__retry); client.__retry = null; }
|
|
2700
|
+
try { client.__sock?.write(__mqttPacket(14, 0, Buffer.alloc(0))); } catch { /* already gone */ }
|
|
2701
|
+
try { client.__sock?.end(); } catch { /* already gone */ }
|
|
2702
|
+
},
|
|
2703
|
+
subscribe: (topic: string, sopts: NetOpts = {}) => {
|
|
2704
|
+
client.subs.set(topic, (sopts.qos as number) || 0);
|
|
2705
|
+
client.__send(__mqttPacket(8, 2, Buffer.concat([__mqttU16(client.nextId++), __mqttStr(topic), Buffer.from([(sopts.qos as number) || 0])])));
|
|
2706
|
+
},
|
|
2707
|
+
unsubscribe: (topic: string) => {
|
|
2708
|
+
client.subs.delete(topic);
|
|
2709
|
+
client.__send(__mqttPacket(10, 2, Buffer.concat([__mqttU16(client.nextId++), __mqttStr(topic)])));
|
|
2710
|
+
},
|
|
2711
|
+
publish: (topic: string, message: unknown, popts: NetOpts = {}) => {
|
|
2712
|
+
const qos = (popts.qos as number) || 0;
|
|
2713
|
+
let head = __mqttStr(topic);
|
|
2714
|
+
if (qos > 0) head = Buffer.concat([head, __mqttU16(client.nextId++)]);
|
|
2715
|
+
const payload = typeof message === 'string' ? Buffer.from(message, 'utf8')
|
|
2716
|
+
: Buffer.isBuffer(message) ? message
|
|
2717
|
+
: Buffer.from(String(message), 'utf8');
|
|
2718
|
+
client.__send(__mqttPacket(3, (qos << 1) | (popts.retain ? 1 : 0), Buffer.concat([head, payload])));
|
|
2719
|
+
},
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
const scheduleReconnect = () => {
|
|
2723
|
+
if (client.__closed || client.__retry) return;
|
|
2724
|
+
if (client.opts.reconnect === false) return;
|
|
2725
|
+
const base = (client.opts.reconnectDelay as number) ?? 500;
|
|
2726
|
+
const cap = (client.opts.reconnectMax as number) ?? 30000;
|
|
2727
|
+
const delay = Math.min(base * 2 ** client.__attempt, cap);
|
|
2728
|
+
client.__attempt++;
|
|
2729
|
+
client.__retry = setTimeout(() => { client.__retry = null; dial(); }, delay);
|
|
2730
|
+
};
|
|
2731
|
+
|
|
2732
|
+
const dial = () => {
|
|
2733
|
+
if (client.__closed) return;
|
|
2734
|
+
// One connAck per attempt with isolated resolvers: an awaiter of a dead
|
|
2735
|
+
// attempt sees that attempt's rejection, never a later attempt's.
|
|
2736
|
+
let resolveAck: () => void = () => {};
|
|
2737
|
+
let rejectAck: (e: Error) => void = () => {};
|
|
2738
|
+
client.connAck = new Promise<void>((res, rej) => { resolveAck = res; rejectAck = rej; });
|
|
2739
|
+
// The facade's connect() is optional: an app that ignores it must not turn
|
|
2740
|
+
// a refused broker into an unhandled rejection that kills the process.
|
|
2741
|
+
client.connAck.catch(() => {});
|
|
2742
|
+
const s = nt.createConnection({ host, port });
|
|
2743
|
+
client.__sock = s;
|
|
2744
|
+
(s as any).__mqttBuf = Buffer.alloc(0);
|
|
2745
|
+
s.on('connect', () => {
|
|
2746
|
+
const opts = client.opts; // read dynamically: a per-request render or dev reload swaps opts in place
|
|
2747
|
+
let flags = opts.clean !== false ? 0x02 : 0;
|
|
2748
|
+
const username = opts.username as string | undefined;
|
|
2749
|
+
const password = opts.password as string | undefined;
|
|
2750
|
+
if (username) flags |= 0x80;
|
|
2751
|
+
if (password) flags |= 0x40;
|
|
2752
|
+
const parts: Buffer[] = [__mqttStr('MQTT'), Buffer.from([4]), Buffer.from([flags]), __mqttU16((opts.keepalive as number) ?? 60), __mqttStr((opts.clientId as string) || ('rosefd_' + Math.random().toString(16).slice(2, 10)))];
|
|
2753
|
+
if (username) parts.push(__mqttStr(username));
|
|
2754
|
+
if (password) parts.push(__mqttStr(password));
|
|
2755
|
+
s.write(__mqttPacket(1, 0, Buffer.concat(parts)));
|
|
2756
|
+
if (client.__keepalive) { clearInterval(client.__keepalive); client.__keepalive = null; }
|
|
2757
|
+
const ka = (opts.keepalive as number) ?? 60;
|
|
2758
|
+
if (ka > 0) client.__keepalive = setInterval(() => { try { s.write(__mqttPacket(12, 0, Buffer.alloc(0))); } catch { /* already gone */ } }, Math.max(250, ka * 500));
|
|
2759
|
+
});
|
|
2760
|
+
s.on('data', (chunk: Buffer) => {
|
|
2761
|
+
const buf = (s as any).__mqttBuf as Buffer;
|
|
2762
|
+
(s as any).__mqttBuf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
|
|
2763
|
+
const { packets, rest } = __mqttParsePackets((s as any).__mqttBuf as Buffer);
|
|
2764
|
+
(s as any).__mqttBuf = rest;
|
|
2765
|
+
for (const pkt of packets) {
|
|
2766
|
+
if (pkt.type === 2) {
|
|
2767
|
+
if (pkt.body.length >= 2 && pkt.body[1] === 0) {
|
|
2768
|
+
client.connected = true;
|
|
2769
|
+
client.__attempt = 0; // a healthy connection resets the backoff
|
|
2770
|
+
for (const q of client.outbox) { try { s.write(q); } catch { /* already gone */ } }
|
|
2771
|
+
client.outbox.length = 0;
|
|
2772
|
+
// clean=true wipes the broker-side session, so every active filter
|
|
2773
|
+
// is re-sent on every (re)connect - the broker cannot restore them.
|
|
2774
|
+
for (const [topic, qos] of client.subs) {
|
|
2775
|
+
try { s.write(__mqttPacket(8, 2, Buffer.concat([__mqttU16(client.nextId++), __mqttStr(topic), Buffer.from([qos])]))); } catch { /* already gone */ }
|
|
2776
|
+
}
|
|
2777
|
+
resolveAck();
|
|
2778
|
+
(client.opts.onConnect as (() => void) | undefined)?.();
|
|
2779
|
+
} else {
|
|
2780
|
+
const err = new Error('[rosefd] MQTT broker refused the connection (code ' + (pkt.body[1] ?? '?') + ')');
|
|
2781
|
+
rejectAck(err);
|
|
2782
|
+
(client.opts.onError as ((e: Error) => void) | undefined)?.(err);
|
|
2783
|
+
}
|
|
2784
|
+
} else if (pkt.type === 3) {
|
|
2785
|
+
if (pkt.body.length < 2) continue;
|
|
2786
|
+
const tlen = pkt.body.readUInt16BE(0);
|
|
2787
|
+
const topic = pkt.body.slice(2, 2 + tlen).toString('utf8');
|
|
2788
|
+
let p = 2 + tlen;
|
|
2789
|
+
const qos = (pkt.flags >> 1) & 0x03;
|
|
2790
|
+
let pid = 0;
|
|
2791
|
+
if (qos > 0 && pkt.body.length >= p + 2) { pid = pkt.body.readUInt16BE(p); p += 2; }
|
|
2792
|
+
const payload = pkt.body.slice(p);
|
|
2793
|
+
for (const filter of client.subs.keys()) {
|
|
2794
|
+
if (__mqttTopicMatch(filter, topic)) { (client.opts.onMessage as ((t: string, m: Buffer) => void) | undefined)?.(topic, payload); break; }
|
|
2795
|
+
}
|
|
2796
|
+
if (qos === 1 && pid) client.__send(__mqttPacket(4, 0, __mqttU16(pid)));
|
|
2797
|
+
}
|
|
2798
|
+
// 9 SUBACK / 11 UNSUBACK / 13 PINGRESP: acknowledgement only, nothing to surface
|
|
2799
|
+
}
|
|
2800
|
+
});
|
|
2801
|
+
s.on('close', () => {
|
|
2802
|
+
if (s !== client.__sock) return; // a stale socket from a previous attempt
|
|
2803
|
+
client.connected = false;
|
|
2804
|
+
if (client.__keepalive) { clearInterval(client.__keepalive); client.__keepalive = null; }
|
|
2805
|
+
if (client.__closed) return; // an app-level disconnect() is final
|
|
2806
|
+
rejectAck(new Error('[rosefd] MQTT connection closed before CONNACK'));
|
|
2807
|
+
scheduleReconnect();
|
|
2808
|
+
});
|
|
2809
|
+
s.on('error', (err: Error) => {
|
|
2810
|
+
if (s !== client.__sock) return;
|
|
2811
|
+
(client.opts.onError as ((e: Error) => void) | undefined)?.(err);
|
|
2812
|
+
if (!client.connected) rejectAck(err);
|
|
2813
|
+
});
|
|
2814
|
+
};
|
|
2815
|
+
|
|
2816
|
+
dial();
|
|
2817
|
+
return client;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
export class ActionError extends Error {
|
|
2821
|
+
/** the HTTP status the response carries (400/401/403/409/422/...) */
|
|
2822
|
+
code: number;
|
|
2823
|
+
/** the form field the failure belongs to, when it belongs to one */
|
|
2824
|
+
field?: string;
|
|
2825
|
+
constructor(message: string, code = 400, field?: string) {
|
|
2826
|
+
super(message);
|
|
2827
|
+
this.name = 'ActionError';
|
|
2828
|
+
this.code = code;
|
|
2829
|
+
this.field = field;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
/** The JSON-safe shape the dispatch seeds into state (what `$actionError()` returns). */
|
|
2834
|
+
export type ActionErrorInfo = { action: string; message: string; code: number; field?: string };
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* The failure of the action that produced the response being rendered, or
|
|
2838
|
+
* null. It is ordinary state under the key `actionError`, so the client's
|
|
2839
|
+
* resumeState() restores it and the post-adopt re-render sees exactly what
|
|
2840
|
+
* the server saw - one read, both sides, no hydration payload.
|
|
2841
|
+
*
|
|
2842
|
+
* Read it as data (`{#if $actionError()}<p>{$actionError().message}</p>{/if}`)
|
|
2843
|
+
* or let a {#boundary} contain it ($actionErrorOrThrow below).
|
|
2844
|
+
*/
|
|
2845
|
+
export function $actionPending(): boolean {
|
|
2846
|
+
const entry = renderMaps().signals.get('actionPending');
|
|
2847
|
+
return entry ? entry.value === true : false;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
export function $actionError(): ActionErrorInfo | null {
|
|
2851
|
+
const e = renderMaps().signals.get('actionError');
|
|
2852
|
+
if (!e) return null;
|
|
2853
|
+
if (currentEffect) {
|
|
2854
|
+
e.subs.add(currentEffect);
|
|
2855
|
+
if (trackingDeps) trackingDeps.add(e);
|
|
2856
|
+
}
|
|
2857
|
+
return (e.value as ActionErrorInfo | null) ?? null;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* What a {#boundary} whose content depends on an action calls first: when the
|
|
2862
|
+
* action that produced this response failed, throw - the boundary's own catch
|
|
2863
|
+
* turns it into the fallback, so the section degrades to the error message
|
|
2864
|
+
* instead of rendering a widget built on work that never happened.
|
|
2865
|
+
*/
|
|
2866
|
+
export function $actionErrorOrThrow(): void {
|
|
2867
|
+
const info = $actionError();
|
|
2868
|
+
if (info) throw new ActionError(info.message, info.code, info.field);
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
/** The {#boundary} fallback: the action's own message when the failure is a business one. */
|
|
2872
|
+
export function $boundaryFallback(err: unknown): string {
|
|
2873
|
+
if (err instanceof ActionError) return `<p class="rosefd-action-error">${esc(err.message)}</p>`;
|
|
2874
|
+
return '<p>This section failed to render.</p>';
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// The runtime's own spellings: 'a' = append, 'p' = prepend, 'm' = merge. The
|
|
2878
|
+
// switch in `apply` compares against these, so long names would fall through
|
|
2879
|
+
// to merge - the union must say what the runtime honors.
|
|
2880
|
+
export type StateDeltaOp = 'a' | 'p' | 'm';
|
|
2881
|
+
|
|
2882
|
+
export class StateDelta {
|
|
2883
|
+
constructor(public op: StateDeltaOp, public value: unknown) {}
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
/** Append (or prepend) items to a list-valued state key. */
|
|
2887
|
+
export const $append = (value: unknown): StateDelta => new StateDelta('a', value);
|
|
2888
|
+
export const $prepend = (value: unknown): StateDelta => new StateDelta('p', value);
|
|
2889
|
+
/** Shallow-merge an object into an object-valued state key. */
|
|
2890
|
+
export const $merge = (value: Record<string, unknown>): StateDelta => new StateDelta('m', value);
|
|
2891
|
+
|
|
2892
|
+
const pendingDeltas: Map<string, StateDelta> = shared('__rosefd_pendingDeltas', () => new Map());
|
|
2893
|
+
|
|
2894
|
+
/** Record the deltas of the action that just ran (the dispatch calls this). */
|
|
2895
|
+
export function setStateDelta(key: string, delta: StateDelta): void {
|
|
2896
|
+
pendingDeltas.set(key, delta);
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/** Apply every pending delta to the live state and clear them. */
|
|
2900
|
+
export function applyStateDeltas(): void {
|
|
2901
|
+
if (pendingDeltas.size === 0) return;
|
|
2902
|
+
for (const [key, delta] of [...pendingDeltas]) {
|
|
2903
|
+
pendingDeltas.delete(key);
|
|
2904
|
+
applyStateDelta(key, delta);
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/** Apply one delta to the live state. */
|
|
2909
|
+
export function applyStateDelta(key: string, delta: StateDelta): void {
|
|
2910
|
+
const cur = renderMaps().signals.get(key)?.value;
|
|
2911
|
+
if (delta.op === 'm') {
|
|
2912
|
+
setState(key, { ...(cur as Record<string, unknown> | undefined), ...(delta.value as Record<string, unknown>) });
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
const list = (v: unknown): unknown[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
2916
|
+
const merged = delta.op === 'a' ? [...list(cur), ...list(delta.value)] : [...list(delta.value), ...list(cur)];
|
|
2917
|
+
setState(key, merged);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
/**
|
|
2921
|
+
* Parse the cookies of a request (server) or of this document (client, where
|
|
2922
|
+
* HttpOnly cookies are invisible by design - that is the point of HttpOnly).
|
|
2923
|
+
*/
|
|
2924
|
+
export function $cookies(request?: Request): Record<string, string> {
|
|
2925
|
+
const raw = request
|
|
2926
|
+
? request.headers.get('cookie')
|
|
2927
|
+
: (typeof document !== 'undefined' ? document.cookie : '');
|
|
2928
|
+
const out: Record<string, string> = {};
|
|
2929
|
+
for (const part of (raw || '').split(';')) {
|
|
2930
|
+
const eq = part.indexOf('=');
|
|
2931
|
+
if (eq < 0) continue;
|
|
2932
|
+
const k = part.slice(0, eq).trim();
|
|
2933
|
+
if (k) out[k] = decodeURIComponent(part.slice(eq + 1).trim());
|
|
2934
|
+
}
|
|
2935
|
+
return out;
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/**
|
|
2939
|
+
* Build a Set-Cookie header VALUE for a session cookie with the safe
|
|
2940
|
+
* defaults: HttpOnly, SameSite=Lax, Path=/, a 7-day Max-Age. `secure` is the
|
|
2941
|
+
* one attribute a helper cannot infer (it depends on the deployment's TLS),
|
|
2942
|
+
* so pass it when serving over https - and SameSite=None forces Secure,
|
|
2943
|
+
* because every modern browser rejects that pairing otherwise.
|
|
2944
|
+
*/
|
|
2945
|
+
export function $sessionCookie(
|
|
2946
|
+
name: string,
|
|
2947
|
+
value: string,
|
|
2948
|
+
opts: { path?: string; maxAge?: number; sameSite?: 'Strict' | 'Lax' | 'None'; secure?: boolean; httpOnly?: boolean } = {}
|
|
2949
|
+
): string {
|
|
2950
|
+
const { path = '/', maxAge = 60 * 60 * 24 * 7, sameSite = 'Lax', secure = false, httpOnly = true } = opts;
|
|
2951
|
+
return [
|
|
2952
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
2953
|
+
`Path=${path}`,
|
|
2954
|
+
`Max-Age=${maxAge}`,
|
|
2955
|
+
`SameSite=${sameSite}`,
|
|
2956
|
+
httpOnly ? 'HttpOnly' : '',
|
|
2957
|
+
secure || sameSite === 'None' ? 'Secure' : '',
|
|
2958
|
+
].filter(Boolean).join('; ');
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
export function reportVitals(): void {
|
|
2962
|
+
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
|
|
2963
|
+
const live: Record<string, number> = {};
|
|
2964
|
+
(window as unknown as { __rosefd_vitals?: Record<string, number> }).__rosefd_vitals = live;
|
|
2965
|
+
const send = (name: string, value: number) => {
|
|
2966
|
+
live[name] = value;
|
|
2967
|
+
window.dispatchEvent(new CustomEvent('rosefd:vitals', { detail: { name, value, route: location.pathname } }));
|
|
2968
|
+
};
|
|
2969
|
+
const nav = performance.getEntriesByType?.('navigation')?.[0] as PerformanceNavigationTiming | undefined;
|
|
2970
|
+
if (nav) send('ttfb', Math.round(nav.responseStart));
|
|
2971
|
+
const paints = performance.getEntriesByType?.('paint') ?? [];
|
|
2972
|
+
const fcp = paints.find((p) => p.name === 'first-contentful-paint');
|
|
2973
|
+
if (fcp) send('fcp', Math.round(fcp.startTime));
|
|
2974
|
+
const PO = (window as unknown as { PerformanceObserver?: typeof PerformanceObserver }).PerformanceObserver;
|
|
2975
|
+
if (!PO) return;
|
|
2976
|
+
let cls = 0;
|
|
2977
|
+
let inp = 0;
|
|
2978
|
+
const flush = () => {
|
|
2979
|
+
if (cls) send('cls', Math.round(cls * 1000) / 1000);
|
|
2980
|
+
if (inp) send('inp', Math.round(inp));
|
|
2981
|
+
};
|
|
2982
|
+
document.addEventListener('visibilitychange', () => {
|
|
2983
|
+
if (document.visibilityState === 'hidden') flush();
|
|
2984
|
+
});
|
|
2985
|
+
new PO((list) => {
|
|
2986
|
+
for (const e of list.getEntries() as Array<PerformanceEntry & { value?: number; duration?: number; interactionId?: number }>) {
|
|
2987
|
+
if (e.entryType === 'layout-shift' && !(e as unknown as { hadRecentInput?: boolean }).hadRecentInput) cls += e.value ?? 0;
|
|
2988
|
+
if (e.entryType === 'event' && e.interactionId) inp = Math.max(inp, e.duration ?? 0);
|
|
2989
|
+
if (e.entryType === 'largest-contentful-paint') send('lcp', Math.round(e.startTime));
|
|
2990
|
+
}
|
|
2991
|
+
}).observe({ entryTypes: ['layout-shift', 'event', 'largest-contentful-paint'] });
|
|
2992
|
+
}
|