tachyon-dom 0.1.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/README.md +339 -0
- package/dist/adapters/lambda.d.ts +65 -0
- package/dist/adapters/lambda.js +211 -0
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +360 -0
- package/dist/adapters/workers.d.ts +149 -0
- package/dist/adapters/workers.js +272 -0
- package/dist/adapters.d.ts +3 -0
- package/dist/adapters.js +3 -0
- package/dist/app.d.ts +98 -0
- package/dist/app.js +306 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +711 -0
- package/dist/compiler/expression.d.ts +55 -0
- package/dist/compiler/expression.js +858 -0
- package/dist/compiler/index.d.ts +8 -0
- package/dist/compiler/index.js +50 -0
- package/dist/compiler/ir.d.ts +4 -0
- package/dist/compiler/ir.js +255 -0
- package/dist/compiler/parser.d.ts +3 -0
- package/dist/compiler/parser.js +236 -0
- package/dist/compiler/sfc.d.ts +33 -0
- package/dist/compiler/sfc.js +439 -0
- package/dist/compiler/targets/client.d.ts +3 -0
- package/dist/compiler/targets/client.js +568 -0
- package/dist/compiler/targets/server.d.ts +7 -0
- package/dist/compiler/targets/server.js +396 -0
- package/dist/compiler/targets/stream.d.ts +2 -0
- package/dist/compiler/targets/stream.js +204 -0
- package/dist/compiler/types.d.ts +185 -0
- package/dist/compiler/types.js +1 -0
- package/dist/compiler/utils.d.ts +40 -0
- package/dist/compiler/utils.js +185 -0
- package/dist/compiler/whitespace.d.ts +2 -0
- package/dist/compiler/whitespace.js +66 -0
- package/dist/compiler.d.ts +1 -0
- package/dist/compiler.js +1 -0
- package/dist/constant-time.d.ts +1 -0
- package/dist/constant-time.js +26 -0
- package/dist/cookies.d.ts +44 -0
- package/dist/cookies.js +199 -0
- package/dist/diagnostics.d.ts +22 -0
- package/dist/diagnostics.js +45 -0
- package/dist/env.d.ts +27 -0
- package/dist/env.js +37 -0
- package/dist/html-escape.d.ts +2 -0
- package/dist/html-escape.js +20 -0
- package/dist/html-whitespace.d.ts +6 -0
- package/dist/html-whitespace.js +83 -0
- package/dist/i18n.d.ts +19 -0
- package/dist/i18n.js +93 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -0
- package/dist/language-server.d.ts +17 -0
- package/dist/language-server.js +79 -0
- package/dist/package-integrity.d.ts +9 -0
- package/dist/package-integrity.js +64 -0
- package/dist/result.d.ts +13 -0
- package/dist/result.js +4 -0
- package/dist/router-node.d.ts +2 -0
- package/dist/router-node.js +12 -0
- package/dist/router.d.ts +324 -0
- package/dist/router.js +1024 -0
- package/dist/runtime/attr.d.ts +3 -0
- package/dist/runtime/attr.js +66 -0
- package/dist/runtime/class.d.ts +2 -0
- package/dist/runtime/class.js +10 -0
- package/dist/runtime/conditional.d.ts +78 -0
- package/dist/runtime/conditional.js +140 -0
- package/dist/runtime/enhancement.d.ts +11 -0
- package/dist/runtime/enhancement.js +70 -0
- package/dist/runtime/error-boundary.d.ts +8 -0
- package/dist/runtime/error-boundary.js +27 -0
- package/dist/runtime/event.d.ts +1 -0
- package/dist/runtime/event.js +11 -0
- package/dist/runtime/form.d.ts +41 -0
- package/dist/runtime/form.js +167 -0
- package/dist/runtime/fragment.d.ts +6 -0
- package/dist/runtime/fragment.js +18 -0
- package/dist/runtime/html.d.ts +8 -0
- package/dist/runtime/html.js +6 -0
- package/dist/runtime/hydrate.d.ts +68 -0
- package/dist/runtime/hydrate.js +205 -0
- package/dist/runtime/keyed-rows.d.ts +61 -0
- package/dist/runtime/keyed-rows.js +192 -0
- package/dist/runtime/list.d.ts +84 -0
- package/dist/runtime/list.js +424 -0
- package/dist/runtime/portal.d.ts +6 -0
- package/dist/runtime/portal.js +12 -0
- package/dist/runtime/router.d.ts +102 -0
- package/dist/runtime/router.js +649 -0
- package/dist/runtime/signal.d.ts +30 -0
- package/dist/runtime/signal.js +275 -0
- package/dist/runtime/store.d.ts +1 -0
- package/dist/runtime/store.js +1 -0
- package/dist/runtime/stream-client.d.ts +8 -0
- package/dist/runtime/stream-client.js +33 -0
- package/dist/runtime/text.d.ts +2 -0
- package/dist/runtime/text.js +10 -0
- package/dist/runtime/virtual-list.d.ts +15 -0
- package/dist/runtime/virtual-list.js +89 -0
- package/dist/security.d.ts +27 -0
- package/dist/security.js +173 -0
- package/dist/server/form-action.d.ts +52 -0
- package/dist/server/form-action.js +102 -0
- package/dist/server/html.d.ts +19 -0
- package/dist/server/html.js +101 -0
- package/dist/server/stream.d.ts +4 -0
- package/dist/server/stream.js +72 -0
- package/dist/source-map.d.ts +17 -0
- package/dist/source-map.js +18 -0
- package/dist/tachyon-html.d.ts +110 -0
- package/dist/testing.d.ts +14 -0
- package/dist/testing.js +36 -0
- package/dist/typed.d.ts +9 -0
- package/dist/typed.js +4 -0
- package/dist/vite.d.ts +84 -0
- package/dist/vite.js +426 -0
- package/package.json +604 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
const signalBrand = Symbol("tachyon.signal");
|
|
2
|
+
let activeEffect;
|
|
3
|
+
let batchDepth = 0;
|
|
4
|
+
let flushing = false;
|
|
5
|
+
const pendingComputedEffects = new Set();
|
|
6
|
+
const pendingEffects = new Set();
|
|
7
|
+
const cleanup = (runner) => {
|
|
8
|
+
for (const child of Array.from(runner.children)) {
|
|
9
|
+
disposeRunner(child);
|
|
10
|
+
}
|
|
11
|
+
runner.children.clear();
|
|
12
|
+
for (const dependency of runner.dependencies) {
|
|
13
|
+
dependency.delete(runner);
|
|
14
|
+
}
|
|
15
|
+
runner.dependencies.clear();
|
|
16
|
+
};
|
|
17
|
+
const disposeRunner = (runner) => {
|
|
18
|
+
if (runner.disposed) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
runner.disposed = true;
|
|
22
|
+
cleanup(runner);
|
|
23
|
+
runner.parent?.children.delete(runner);
|
|
24
|
+
};
|
|
25
|
+
const track = (subscribers) => {
|
|
26
|
+
if (activeEffect && !activeEffect.disposed) {
|
|
27
|
+
subscribers.add(activeEffect);
|
|
28
|
+
activeEffect.dependencies.add(subscribers);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const flushPendingEffects = () => {
|
|
32
|
+
if (flushing) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
flushing = true;
|
|
36
|
+
try {
|
|
37
|
+
while (pendingComputedEffects.size > 0 || pendingEffects.size > 0) {
|
|
38
|
+
const runner = pendingComputedEffects.values().next().value ?? pendingEffects.values().next().value;
|
|
39
|
+
if (!runner) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (runner.computed) {
|
|
43
|
+
pendingComputedEffects.delete(runner);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
pendingEffects.delete(runner);
|
|
47
|
+
}
|
|
48
|
+
runner.run();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
flushing = false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const scheduleFlush = () => {
|
|
56
|
+
if (batchDepth === 0 && !activeEffect) {
|
|
57
|
+
flushPendingEffects();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const notify = (subscribers) => {
|
|
61
|
+
const snapshot = Array.from(subscribers);
|
|
62
|
+
for (const subscriber of snapshot) {
|
|
63
|
+
if (!subscriber.disposed) {
|
|
64
|
+
if (subscriber.computed) {
|
|
65
|
+
pendingComputedEffects.add(subscriber);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
pendingEffects.add(subscriber);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
scheduleFlush();
|
|
73
|
+
};
|
|
74
|
+
export const isSignal = (value) => typeof value === "function" && value[signalBrand] === true;
|
|
75
|
+
export function read(value) {
|
|
76
|
+
return isSignal(value) ? value() : value;
|
|
77
|
+
}
|
|
78
|
+
export const untrack = (fn) => {
|
|
79
|
+
const previous = activeEffect;
|
|
80
|
+
activeEffect = undefined;
|
|
81
|
+
try {
|
|
82
|
+
return fn();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
activeEffect = previous;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
export const batch = (fn) => {
|
|
89
|
+
batchDepth++;
|
|
90
|
+
try {
|
|
91
|
+
return fn();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
batchDepth--;
|
|
95
|
+
scheduleFlush();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
export const createSignal = (initial) => {
|
|
99
|
+
let current = initial;
|
|
100
|
+
const subscribers = new Set();
|
|
101
|
+
const signal = (() => {
|
|
102
|
+
track(subscribers);
|
|
103
|
+
return current;
|
|
104
|
+
});
|
|
105
|
+
Object.defineProperty(signal, signalBrand, { value: true });
|
|
106
|
+
signal.set = (value) => {
|
|
107
|
+
if (Object.is(current, value)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
current = value;
|
|
111
|
+
notify(subscribers);
|
|
112
|
+
};
|
|
113
|
+
signal.update = (updater) => signal.set(updater(current));
|
|
114
|
+
return signal;
|
|
115
|
+
};
|
|
116
|
+
export const createMemo = (fn) => {
|
|
117
|
+
const value = createSignal(undefined);
|
|
118
|
+
createEffect(() => {
|
|
119
|
+
value.set(fn());
|
|
120
|
+
}, true);
|
|
121
|
+
const memo = (() => value());
|
|
122
|
+
Object.defineProperty(memo, signalBrand, { value: true });
|
|
123
|
+
return memo;
|
|
124
|
+
};
|
|
125
|
+
export const createStore = (initial) => {
|
|
126
|
+
const values = { ...initial };
|
|
127
|
+
const subscribers = new Map();
|
|
128
|
+
const subscribersFor = (property) => {
|
|
129
|
+
let set = subscribers.get(property);
|
|
130
|
+
if (!set) {
|
|
131
|
+
set = new Set();
|
|
132
|
+
subscribers.set(property, set);
|
|
133
|
+
}
|
|
134
|
+
return set;
|
|
135
|
+
};
|
|
136
|
+
return new Proxy(values, {
|
|
137
|
+
get(target, property, receiver) {
|
|
138
|
+
if (property === Symbol.toStringTag) {
|
|
139
|
+
return "TachyonStore";
|
|
140
|
+
}
|
|
141
|
+
track(subscribersFor(property));
|
|
142
|
+
return Reflect.get(target, property, receiver);
|
|
143
|
+
},
|
|
144
|
+
set(target, property, value, receiver) {
|
|
145
|
+
const previous = Reflect.get(target, property, receiver);
|
|
146
|
+
if (Object.is(previous, value)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
const didSet = Reflect.set(target, property, value, receiver);
|
|
150
|
+
if (didSet) {
|
|
151
|
+
notify(subscribersFor(property));
|
|
152
|
+
}
|
|
153
|
+
return didSet;
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const createEffect = (fn, computed) => {
|
|
158
|
+
const parent = activeEffect && !activeEffect.disposed ? activeEffect : undefined;
|
|
159
|
+
const runner = {
|
|
160
|
+
disposed: false,
|
|
161
|
+
computed,
|
|
162
|
+
dependencies: new Set(),
|
|
163
|
+
children: new Set(),
|
|
164
|
+
parent,
|
|
165
|
+
run: () => {
|
|
166
|
+
if (runner.disposed) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
cleanup(runner);
|
|
170
|
+
const previous = activeEffect;
|
|
171
|
+
activeEffect = runner;
|
|
172
|
+
try {
|
|
173
|
+
fn();
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
activeEffect = previous;
|
|
177
|
+
if (!previous) {
|
|
178
|
+
scheduleFlush();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
parent?.children.add(runner);
|
|
184
|
+
runner.run();
|
|
185
|
+
return () => disposeRunner(runner);
|
|
186
|
+
};
|
|
187
|
+
export const effect = (fn) => createEffect(fn, false);
|
|
188
|
+
export const catchError = (fn, onError) => effect(() => {
|
|
189
|
+
try {
|
|
190
|
+
fn();
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
onError(error);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
export const createResource = (source, fetcher) => {
|
|
197
|
+
const data = createSignal(undefined);
|
|
198
|
+
const error = createSignal(undefined);
|
|
199
|
+
const loading = createSignal(true);
|
|
200
|
+
let current;
|
|
201
|
+
let version = 0;
|
|
202
|
+
let disposed = false;
|
|
203
|
+
let controller;
|
|
204
|
+
let disposeTracking;
|
|
205
|
+
let hasSource = false;
|
|
206
|
+
let lastSource;
|
|
207
|
+
const sourceValue = () => (isSignal(source) ? source() : source);
|
|
208
|
+
const run = (value = sourceValue()) => {
|
|
209
|
+
if (disposed) {
|
|
210
|
+
return Promise.resolve(undefined);
|
|
211
|
+
}
|
|
212
|
+
if (loading() && current && hasSource && Object.is(lastSource, value)) {
|
|
213
|
+
return current;
|
|
214
|
+
}
|
|
215
|
+
hasSource = true;
|
|
216
|
+
lastSource = value;
|
|
217
|
+
controller?.abort();
|
|
218
|
+
const nextController = new AbortController();
|
|
219
|
+
controller = nextController;
|
|
220
|
+
const runVersion = ++version;
|
|
221
|
+
batch(() => {
|
|
222
|
+
loading.set(true);
|
|
223
|
+
error.set(undefined);
|
|
224
|
+
});
|
|
225
|
+
current = Promise.resolve()
|
|
226
|
+
.then(() => fetcher(value, { signal: nextController.signal }))
|
|
227
|
+
.then((value) => {
|
|
228
|
+
if (!disposed && runVersion === version) {
|
|
229
|
+
batch(() => {
|
|
230
|
+
data.set(value);
|
|
231
|
+
error.set(undefined);
|
|
232
|
+
loading.set(false);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return value;
|
|
236
|
+
}, (reason) => {
|
|
237
|
+
if (!disposed && runVersion === version) {
|
|
238
|
+
batch(() => {
|
|
239
|
+
error.set(reason);
|
|
240
|
+
loading.set(false);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
});
|
|
245
|
+
return current;
|
|
246
|
+
};
|
|
247
|
+
if (isSignal(source)) {
|
|
248
|
+
disposeTracking = effect(() => {
|
|
249
|
+
const value = source();
|
|
250
|
+
if (!hasSource || !Object.is(lastSource, value)) {
|
|
251
|
+
void untrack(() => run(value));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
void run();
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
data,
|
|
260
|
+
error,
|
|
261
|
+
loading,
|
|
262
|
+
refetch: run,
|
|
263
|
+
dispose: () => {
|
|
264
|
+
if (disposed) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
disposed = true;
|
|
268
|
+
version += 1;
|
|
269
|
+
controller?.abort();
|
|
270
|
+
disposeTracking?.();
|
|
271
|
+
current = undefined;
|
|
272
|
+
loading.set(false);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createStore } from "./signal.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createStore } from "./signal.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const readTextStreamChunks: (stream: ReadableStream<Uint8Array>) => Promise<string[]>;
|
|
2
|
+
export type DeferredDataChunk = {
|
|
3
|
+
id: string;
|
|
4
|
+
key: string;
|
|
5
|
+
value: unknown;
|
|
6
|
+
};
|
|
7
|
+
export declare const applyDeferredDataChunk: (root: ParentNode, chunk: DeferredDataChunk) => boolean;
|
|
8
|
+
export declare const readDeferredDataScript: <T = unknown>(root: ParentNode, id: string) => T | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const readTextStreamChunks = async (stream) => {
|
|
2
|
+
const reader = stream.getReader();
|
|
3
|
+
const decoder = new TextDecoder();
|
|
4
|
+
const chunks = [];
|
|
5
|
+
while (true) {
|
|
6
|
+
const result = await reader.read();
|
|
7
|
+
if (result.done) {
|
|
8
|
+
const trailing = decoder.decode();
|
|
9
|
+
if (trailing) {
|
|
10
|
+
chunks.push(trailing);
|
|
11
|
+
}
|
|
12
|
+
return chunks;
|
|
13
|
+
}
|
|
14
|
+
chunks.push(decoder.decode(result.value, { stream: true }));
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const escapeSelectorValue = (value) => value.replaceAll("\\", "\\\\").replaceAll(`"`, `\\"`);
|
|
18
|
+
export const applyDeferredDataChunk = (root, chunk) => {
|
|
19
|
+
const selector = `[data-tachyon-deferred-target="${escapeSelectorValue(`${chunk.id}:${chunk.key}`)}"]`;
|
|
20
|
+
const target = root.querySelector(selector);
|
|
21
|
+
if (!target) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
target.textContent = typeof chunk.value === "string" ? chunk.value : JSON.stringify(chunk.value);
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
export const readDeferredDataScript = (root, id) => {
|
|
28
|
+
const script = Array.from(root.querySelectorAll(`script[type="application/json"][data-tachyon-deferred]`)).find((candidate) => candidate.getAttribute("data-tachyon-deferred") === id);
|
|
29
|
+
if (!script) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return JSON.parse(script.textContent ?? "null");
|
|
33
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const textAt = (root, path) => {
|
|
2
|
+
let current = root;
|
|
3
|
+
for (const index of path) {
|
|
4
|
+
current = current.childNodes[index];
|
|
5
|
+
}
|
|
6
|
+
return current;
|
|
7
|
+
};
|
|
8
|
+
export const setText = (text, value) => {
|
|
9
|
+
text.nodeValue = value == null ? "" : String(value);
|
|
10
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type VirtualizedListOptions<T> = {
|
|
2
|
+
scroller: HTMLElement;
|
|
3
|
+
items: readonly T[];
|
|
4
|
+
itemHeight: number;
|
|
5
|
+
renderItem: (item: T, index: number) => Element;
|
|
6
|
+
getKey?: (item: T, index: number) => PropertyKey;
|
|
7
|
+
viewportHeight?: number | (() => number);
|
|
8
|
+
overscan?: number;
|
|
9
|
+
};
|
|
10
|
+
export type VirtualizedList<T> = {
|
|
11
|
+
update: (items: readonly T[]) => void;
|
|
12
|
+
scrollToIndex: (index: number) => void;
|
|
13
|
+
destroy: () => void;
|
|
14
|
+
};
|
|
15
|
+
export declare const createVirtualizedList: <T>(options: VirtualizedListOptions<T>) => VirtualizedList<T>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const viewportHeightFor = (options) => {
|
|
2
|
+
if (typeof options.viewportHeight === "function") {
|
|
3
|
+
return options.viewportHeight();
|
|
4
|
+
}
|
|
5
|
+
return options.viewportHeight ?? options.scroller.clientHeight;
|
|
6
|
+
};
|
|
7
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
8
|
+
export const createVirtualizedList = (options) => {
|
|
9
|
+
const overscan = options.overscan ?? 3;
|
|
10
|
+
let items = [...options.items];
|
|
11
|
+
let rendered = new Map();
|
|
12
|
+
let lastRangeKey = "";
|
|
13
|
+
let animationFrame;
|
|
14
|
+
const spacer = document.createElement("div");
|
|
15
|
+
const windowEl = document.createElement("div");
|
|
16
|
+
spacer.style.position = "relative";
|
|
17
|
+
windowEl.style.position = "absolute";
|
|
18
|
+
windowEl.style.insetInline = "0";
|
|
19
|
+
windowEl.style.insetBlockStart = "0";
|
|
20
|
+
options.scroller.replaceChildren(spacer);
|
|
21
|
+
const renderWindow = (force = false) => {
|
|
22
|
+
const viewportHeight = viewportHeightFor(options);
|
|
23
|
+
const visibleCount = Math.ceil(viewportHeight / options.itemHeight);
|
|
24
|
+
const start = clamp(Math.floor(options.scroller.scrollTop / options.itemHeight) - overscan, 0, items.length);
|
|
25
|
+
const end = clamp(start + visibleCount + overscan * 2, start, items.length);
|
|
26
|
+
const rangeKey = `${start}:${end}:${items.length}`;
|
|
27
|
+
if (!force && rangeKey === lastRangeKey) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
lastRangeKey = rangeKey;
|
|
31
|
+
spacer.style.height = `${items.length * options.itemHeight}px`;
|
|
32
|
+
windowEl.style.transform = `translateY(${start * options.itemHeight}px)`;
|
|
33
|
+
const nextRendered = new Map();
|
|
34
|
+
const nextElements = [];
|
|
35
|
+
for (let index = start; index < end; index++) {
|
|
36
|
+
const item = items[index];
|
|
37
|
+
const key = options.getKey?.(item, index) ?? index;
|
|
38
|
+
const element = rendered.get(key) ?? options.renderItem(item, index);
|
|
39
|
+
element.setAttribute("data-tachyon-virtual-item", String(key));
|
|
40
|
+
element.setAttribute("aria-posinset", String(index + 1));
|
|
41
|
+
element.setAttribute("aria-setsize", String(items.length));
|
|
42
|
+
nextRendered.set(key, element);
|
|
43
|
+
nextElements.push(element);
|
|
44
|
+
}
|
|
45
|
+
rendered = nextRendered;
|
|
46
|
+
windowEl.replaceChildren(...nextElements);
|
|
47
|
+
if (!windowEl.parentNode) {
|
|
48
|
+
spacer.appendChild(windowEl);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const scheduleRenderWindow = () => {
|
|
52
|
+
if (animationFrame !== undefined) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
56
|
+
renderWindow();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
animationFrame = requestAnimationFrame(() => {
|
|
60
|
+
animationFrame = undefined;
|
|
61
|
+
renderWindow();
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
const onScroll = () => scheduleRenderWindow();
|
|
65
|
+
options.scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
66
|
+
const resizeObserver = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => renderWindow());
|
|
67
|
+
resizeObserver?.observe(options.scroller);
|
|
68
|
+
renderWindow();
|
|
69
|
+
return {
|
|
70
|
+
update: (nextItems) => {
|
|
71
|
+
items = [...nextItems];
|
|
72
|
+
rendered = new Map();
|
|
73
|
+
lastRangeKey = "";
|
|
74
|
+
renderWindow(true);
|
|
75
|
+
},
|
|
76
|
+
scrollToIndex: (index) => {
|
|
77
|
+
options.scroller.scrollTop = clamp(index, 0, Math.max(0, items.length - 1)) * options.itemHeight;
|
|
78
|
+
renderWindow(true);
|
|
79
|
+
},
|
|
80
|
+
destroy: () => {
|
|
81
|
+
if (animationFrame !== undefined && typeof cancelAnimationFrame === "function") {
|
|
82
|
+
cancelAnimationFrame(animationFrame);
|
|
83
|
+
}
|
|
84
|
+
options.scroller.removeEventListener("scroll", onScroll);
|
|
85
|
+
resizeObserver?.disconnect();
|
|
86
|
+
options.scroller.replaceChildren();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type TrustedHtml } from "./router.js";
|
|
2
|
+
export type CsrfOptions = {
|
|
3
|
+
token: string;
|
|
4
|
+
headerName?: string;
|
|
5
|
+
fieldName?: string;
|
|
6
|
+
};
|
|
7
|
+
export type SanitizeHtmlOptions = {
|
|
8
|
+
allowedTags?: readonly string[];
|
|
9
|
+
allowedAttributes?: readonly string[];
|
|
10
|
+
allowedUrlOrigins?: readonly string[];
|
|
11
|
+
adapter?: HtmlSanitizer;
|
|
12
|
+
};
|
|
13
|
+
export type HtmlSanitizer = (markup: string) => TrustedHtml;
|
|
14
|
+
/**
|
|
15
|
+
* Best-effort allowlist sanitizer for constrained backend HTML.
|
|
16
|
+
*
|
|
17
|
+
* This fallback is intentionally small and regex-based; it is not a browser-grade sanitizer for arbitrary
|
|
18
|
+
* attacker-controlled HTML. Pass a vetted adapter such as DOMPurify through `options.adapter` for user-generated,
|
|
19
|
+
* CMS, imported, or otherwise untrusted markup.
|
|
20
|
+
*/
|
|
21
|
+
export declare const sanitizeHtml: (markup: string, options?: SanitizeHtmlOptions) => TrustedHtml;
|
|
22
|
+
export declare const createHtmlSanitizer: (adapter: {
|
|
23
|
+
sanitize: (markup: string) => string | TrustedHtml;
|
|
24
|
+
}) => HtmlSanitizer;
|
|
25
|
+
export declare const createCsrfToken: (seed?: string) => string;
|
|
26
|
+
export declare const csrfInput: (token: string, fieldName?: string) => TrustedHtml;
|
|
27
|
+
export declare const verifyCsrfRequest: (request: Request, options: CsrfOptions) => Promise<boolean>;
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { unsafeHtml } from "./router.js";
|
|
2
|
+
import { timingSafeEqual } from "./constant-time.js";
|
|
3
|
+
const defaultAllowedTags = [
|
|
4
|
+
"a",
|
|
5
|
+
"article",
|
|
6
|
+
"aside",
|
|
7
|
+
"b",
|
|
8
|
+
"blockquote",
|
|
9
|
+
"br",
|
|
10
|
+
"button",
|
|
11
|
+
"code",
|
|
12
|
+
"div",
|
|
13
|
+
"em",
|
|
14
|
+
"footer",
|
|
15
|
+
"form",
|
|
16
|
+
"h1",
|
|
17
|
+
"h2",
|
|
18
|
+
"h3",
|
|
19
|
+
"h4",
|
|
20
|
+
"h5",
|
|
21
|
+
"h6",
|
|
22
|
+
"header",
|
|
23
|
+
"i",
|
|
24
|
+
"img",
|
|
25
|
+
"input",
|
|
26
|
+
"label",
|
|
27
|
+
"li",
|
|
28
|
+
"main",
|
|
29
|
+
"nav",
|
|
30
|
+
"ol",
|
|
31
|
+
"option",
|
|
32
|
+
"p",
|
|
33
|
+
"pre",
|
|
34
|
+
"section",
|
|
35
|
+
"select",
|
|
36
|
+
"small",
|
|
37
|
+
"span",
|
|
38
|
+
"strong",
|
|
39
|
+
"textarea",
|
|
40
|
+
"ul",
|
|
41
|
+
];
|
|
42
|
+
const defaultAllowedAttributes = [
|
|
43
|
+
"action",
|
|
44
|
+
"alt",
|
|
45
|
+
"aria-label",
|
|
46
|
+
"aria-labelledby",
|
|
47
|
+
"checked",
|
|
48
|
+
"class",
|
|
49
|
+
"data-prefetch",
|
|
50
|
+
"disabled",
|
|
51
|
+
"for",
|
|
52
|
+
"href",
|
|
53
|
+
"id",
|
|
54
|
+
"method",
|
|
55
|
+
"name",
|
|
56
|
+
"placeholder",
|
|
57
|
+
"role",
|
|
58
|
+
"src",
|
|
59
|
+
"type",
|
|
60
|
+
"value",
|
|
61
|
+
];
|
|
62
|
+
const escapeAttribute = (value) => value.replaceAll("&", "&").replaceAll(`"`, """).replaceAll("<", "<");
|
|
63
|
+
const escapeText = (value) => value
|
|
64
|
+
.replaceAll("&", "&")
|
|
65
|
+
.replaceAll("<", "<")
|
|
66
|
+
.replaceAll(">", ">")
|
|
67
|
+
.replaceAll(`"`, """)
|
|
68
|
+
.replaceAll("'", "'");
|
|
69
|
+
const isSafeUrl = (value, allowedOrigins = []) => {
|
|
70
|
+
const controlCharacterPattern = /[\u0000-\u001F\u007F]/;
|
|
71
|
+
if (controlCharacterPattern.test(value)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const trimmed = value.trim().toLowerCase();
|
|
75
|
+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
|
|
76
|
+
try {
|
|
77
|
+
const decoded = decodeURIComponent(trimmed);
|
|
78
|
+
return !decoded.startsWith("//") && !decoded.includes("\\") && !controlCharacterPattern.test(decoded);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("mailto:")) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const url = new URL(value);
|
|
89
|
+
return (url.protocol === "https:" || url.protocol === "http:") && allowedOrigins.includes(url.origin);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const sanitizeAttributes = (raw, allowedAttributes, allowedUrlOrigins) => Array.from(raw.matchAll(/([:\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g))
|
|
96
|
+
.flatMap((match) => {
|
|
97
|
+
const name = match[1]?.toLowerCase();
|
|
98
|
+
if (!name || name.startsWith("on") || !allowedAttributes.has(name)) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
102
|
+
if ((name === "href" || name === "src" || name === "action") && value && !isSafeUrl(value, allowedUrlOrigins)) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return value === "" ? [` ${name}`] : [` ${name}="${escapeAttribute(value)}"`];
|
|
106
|
+
})
|
|
107
|
+
.join("");
|
|
108
|
+
/**
|
|
109
|
+
* Best-effort allowlist sanitizer for constrained backend HTML.
|
|
110
|
+
*
|
|
111
|
+
* This fallback is intentionally small and regex-based; it is not a browser-grade sanitizer for arbitrary
|
|
112
|
+
* attacker-controlled HTML. Pass a vetted adapter such as DOMPurify through `options.adapter` for user-generated,
|
|
113
|
+
* CMS, imported, or otherwise untrusted markup.
|
|
114
|
+
*/
|
|
115
|
+
export const sanitizeHtml = (markup, options = {}) => {
|
|
116
|
+
if (options.adapter) {
|
|
117
|
+
return options.adapter(markup);
|
|
118
|
+
}
|
|
119
|
+
const allowedTags = new Set(options.allowedTags ?? defaultAllowedTags);
|
|
120
|
+
const allowedAttributes = new Set(options.allowedAttributes ?? defaultAllowedAttributes);
|
|
121
|
+
const allowedUrlOrigins = options.allowedUrlOrigins ?? [];
|
|
122
|
+
const withoutScripts = markup.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
|
123
|
+
const tagPattern = /<\/?([a-zA-Z][\w:-]*)([^>]*)>/g;
|
|
124
|
+
let sanitized = "";
|
|
125
|
+
let lastIndex = 0;
|
|
126
|
+
for (const match of withoutScripts.matchAll(tagPattern)) {
|
|
127
|
+
const index = match.index ?? 0;
|
|
128
|
+
sanitized += escapeText(withoutScripts.slice(lastIndex, index));
|
|
129
|
+
lastIndex = index + match[0].length;
|
|
130
|
+
const rawName = match[1] ?? "";
|
|
131
|
+
const rawAttributes = match[2] ?? "";
|
|
132
|
+
const name = rawName.toLowerCase();
|
|
133
|
+
if (!allowedTags.has(name)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (match[0].startsWith("</")) {
|
|
137
|
+
sanitized += `</${name}>`;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
sanitized += `<${name}${sanitizeAttributes(rawAttributes, allowedAttributes, allowedUrlOrigins)}>`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
sanitized += escapeText(withoutScripts.slice(lastIndex));
|
|
144
|
+
return unsafeHtml(sanitized);
|
|
145
|
+
};
|
|
146
|
+
export const createHtmlSanitizer = (adapter) => (markup) => {
|
|
147
|
+
const result = adapter.sanitize(markup);
|
|
148
|
+
return typeof result === "string" ? unsafeHtml(result) : result;
|
|
149
|
+
};
|
|
150
|
+
export const createCsrfToken = (seed) => {
|
|
151
|
+
if (seed !== undefined) {
|
|
152
|
+
return seed;
|
|
153
|
+
}
|
|
154
|
+
const bytes = new Uint8Array(24);
|
|
155
|
+
crypto.getRandomValues(bytes);
|
|
156
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
157
|
+
};
|
|
158
|
+
export const csrfInput = (token, fieldName = "_csrf") => unsafeHtml(`<input type="hidden" name="${escapeAttribute(fieldName)}" value="${escapeAttribute(token)}">`);
|
|
159
|
+
export const verifyCsrfRequest = async (request, options) => {
|
|
160
|
+
const headerName = options.headerName ?? "x-csrf-token";
|
|
161
|
+
const fieldName = options.fieldName ?? "_csrf";
|
|
162
|
+
if (await timingSafeEqual(request.headers.get(headerName), options.token)) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
166
|
+
if (contentType.includes("application/x-www-form-urlencoded") ||
|
|
167
|
+
contentType.includes("multipart/form-data") ||
|
|
168
|
+
contentType.includes("text/plain")) {
|
|
169
|
+
const form = await request.clone().formData();
|
|
170
|
+
return await timingSafeEqual(form.get(fieldName), options.token);
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
};
|