toiljs 0.0.83 → 0.0.85
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/CHANGELOG.md +10 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/sri.d.ts +2 -0
- package/build/compiler/sri.js +42 -5
- package/build/compiler/toil-docs.generated.js +1 -1
- package/build/compiler/vite.js +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +109 -33
- package/build/devserver/runtime/module.js +2 -1
- package/docs/caching.md +3 -3
- package/examples/basic/client/routes/analytics.tsx +154 -26
- package/examples/basic/server/models/SiteAnalytics.ts +16 -1
- package/examples/basic/server/routes/Analytics.ts +31 -6
- package/package.json +1 -1
- package/server/runtime/exports/index.ts +1 -1
- package/server/runtime/response.ts +1 -1
- package/server/runtime/rpc/Rpc.ts +2 -2
- package/src/compiler/sri.ts +64 -10
- package/src/compiler/toil-docs.generated.ts +1 -1
- package/src/compiler/vite.ts +7 -2
- package/src/devserver/analytics/index.ts +133 -38
- package/src/devserver/db/routeKinds.ts +1 -1
- package/src/devserver/runtime/module.ts +2 -1
- package/test/analytics-dev.test.ts +75 -0
- package/test/rpc-dispatch.test.ts +7 -7
- package/test/sri.test.ts +57 -1
|
@@ -1,46 +1,104 @@
|
|
|
1
|
+
const FRAME_VERSION = 2;
|
|
2
|
+
const METRIC_COUNTERS = 41;
|
|
3
|
+
const MID = {
|
|
4
|
+
Requests: 0,
|
|
5
|
+
BytesOutL1: 1,
|
|
6
|
+
BytesInL1: 2,
|
|
7
|
+
Status2xx: 3,
|
|
8
|
+
Status4xx: 5,
|
|
9
|
+
StaticHits: 7,
|
|
10
|
+
WasmDispatches: 8,
|
|
11
|
+
GasUsed: 12,
|
|
12
|
+
DbOps: 13,
|
|
13
|
+
DbReads: 14,
|
|
14
|
+
DbWrites: 15,
|
|
15
|
+
StreamBytesIn: 25,
|
|
16
|
+
StreamBytesOut: 26,
|
|
17
|
+
MemGrownBytes: 39,
|
|
18
|
+
};
|
|
1
19
|
function devStats() {
|
|
20
|
+
const life = new Array(METRIC_COUNTERS).fill(0n);
|
|
21
|
+
life[MID.Requests] = 42n;
|
|
22
|
+
life[MID.BytesOutL1] = 12345n;
|
|
23
|
+
life[MID.BytesInL1] = 4096n;
|
|
24
|
+
life[MID.Status2xx] = 40n;
|
|
25
|
+
life[MID.Status4xx] = 2n;
|
|
26
|
+
life[MID.StaticHits] = 8n;
|
|
27
|
+
life[MID.WasmDispatches] = 34n;
|
|
28
|
+
life[MID.GasUsed] = 1000000n;
|
|
29
|
+
life[MID.DbOps] = 17n;
|
|
30
|
+
life[MID.DbReads] = 12n;
|
|
31
|
+
life[MID.DbWrites] = 5n;
|
|
32
|
+
life[MID.StreamBytesIn] = 2048n;
|
|
33
|
+
life[MID.StreamBytesOut] = 8192n;
|
|
34
|
+
life[MID.MemGrownBytes] = 262144n;
|
|
2
35
|
return {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
['status_2xx', 40],
|
|
7
|
-
['status_3xx', 0],
|
|
8
|
-
['status_4xx', 2],
|
|
9
|
-
['status_5xx', 0],
|
|
10
|
-
['static_hits', 8],
|
|
11
|
-
['wasm_dispatches', 34],
|
|
12
|
-
['db_ops', 17],
|
|
13
|
-
['db_reads', 12],
|
|
14
|
-
['db_writes', 5],
|
|
15
|
-
['db_errors', 0],
|
|
16
|
-
],
|
|
36
|
+
life,
|
|
37
|
+
connectedStreams: 3,
|
|
38
|
+
committedMemory: 65536,
|
|
17
39
|
reqMinuteUsed: 5,
|
|
18
40
|
reqMinuteCap: 100,
|
|
19
41
|
reqDayUsed: 42,
|
|
20
42
|
reqDayCap: 5000,
|
|
43
|
+
nowMs: 1_700_000_000_000,
|
|
21
44
|
};
|
|
22
45
|
}
|
|
23
46
|
function encodeStats(s) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
head.
|
|
27
|
-
head.writeUInt32LE(s.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const vb = Buffer.alloc(8);
|
|
34
|
-
vb.writeBigInt64LE(BigInt(value), 0);
|
|
35
|
-
parts.push(nl, nb, vb);
|
|
47
|
+
const head = Buffer.alloc(2 + 8 + 4);
|
|
48
|
+
head.writeUInt16LE(FRAME_VERSION, 0);
|
|
49
|
+
head.writeBigUInt64LE(BigInt(s.nowMs), 2);
|
|
50
|
+
head.writeUInt32LE(s.life.length, 10);
|
|
51
|
+
const body = Buffer.alloc(s.life.length * 8 + 48);
|
|
52
|
+
let o = 0;
|
|
53
|
+
for (const v of s.life) {
|
|
54
|
+
body.writeBigInt64LE(v, o);
|
|
55
|
+
o += 8;
|
|
36
56
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
body.writeBigInt64LE(BigInt(s.connectedStreams), o);
|
|
58
|
+
o += 8;
|
|
59
|
+
body.writeBigInt64LE(BigInt(s.committedMemory), o);
|
|
60
|
+
o += 8;
|
|
61
|
+
body.writeBigInt64LE(BigInt(s.reqMinuteUsed), o);
|
|
62
|
+
o += 8;
|
|
63
|
+
body.writeBigUInt64LE(BigInt(s.reqMinuteCap), o);
|
|
64
|
+
o += 8;
|
|
65
|
+
body.writeBigInt64LE(BigInt(s.reqDayUsed), o);
|
|
66
|
+
o += 8;
|
|
67
|
+
body.writeBigUInt64LE(BigInt(s.reqDayCap), o);
|
|
68
|
+
return Buffer.concat([head, body]);
|
|
69
|
+
}
|
|
70
|
+
const MINUTE_RING_METRICS = new Set([0, 2, 1, 25, 26, 12, 13, 39, 41, 43]);
|
|
71
|
+
function rangeShape(range, metricId) {
|
|
72
|
+
const isMinute = (range === 0 || range === 1) && MINUTE_RING_METRICS.has(metricId);
|
|
73
|
+
if (isMinute)
|
|
74
|
+
return range === 0 ? { count: 60, bucketSecs: 60 } : { count: 360, bucketSecs: 60 };
|
|
75
|
+
switch (range) {
|
|
76
|
+
case 0: return { count: 1, bucketSecs: 3600 };
|
|
77
|
+
case 1: return { count: 6, bucketSecs: 3600 };
|
|
78
|
+
case 2: return { count: 12, bucketSecs: 3600 };
|
|
79
|
+
case 3: return { count: 24, bucketSecs: 3600 };
|
|
80
|
+
case 4: return { count: 72, bucketSecs: 3600 };
|
|
81
|
+
case 5: return { count: 168, bucketSecs: 3600 };
|
|
82
|
+
case 6: return { count: 336, bucketSecs: 3600 };
|
|
83
|
+
case 7: return { count: 720, bucketSecs: 3600 };
|
|
84
|
+
default: return { count: 24, bucketSecs: 3600 };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function encodeSeries(metricId, range) {
|
|
88
|
+
const { count, bucketSecs } = rangeShape(range, metricId);
|
|
89
|
+
const nowMs = 1_700_000_000_000;
|
|
90
|
+
const head = Buffer.alloc(2 + 2 + 4 + 8 + 4);
|
|
91
|
+
head.writeUInt16LE(FRAME_VERSION, 0);
|
|
92
|
+
head.writeUInt16LE(metricId & 0xffff, 2);
|
|
93
|
+
head.writeUInt32LE(bucketSecs, 4);
|
|
94
|
+
head.writeBigUInt64LE(BigInt(nowMs), 8);
|
|
95
|
+
head.writeUInt32LE(count, 16);
|
|
96
|
+
const body = Buffer.alloc(count * 8);
|
|
97
|
+
for (let i = 0; i < count; i++) {
|
|
98
|
+
const v = BigInt(((i * 7 + metricId) % 50) + i);
|
|
99
|
+
body.writeBigInt64LE(v, i * 8);
|
|
100
|
+
}
|
|
101
|
+
return Buffer.concat([head, body]);
|
|
44
102
|
}
|
|
45
103
|
function encodeSiteList(names, hasMore) {
|
|
46
104
|
const parts = [];
|
|
@@ -78,6 +136,24 @@ export function buildAnalyticsImports(ref, db) {
|
|
|
78
136
|
db.lastResultVersion = -1;
|
|
79
137
|
return frame.length;
|
|
80
138
|
},
|
|
139
|
+
analytics_series: (domainPtr, domainLen, metricId, range) => {
|
|
140
|
+
if (domainLen > MAX_DOMAIN_LEN)
|
|
141
|
+
return ABSENT;
|
|
142
|
+
if (metricId < 0 || metricId > 44 || range < 0 || range > 7)
|
|
143
|
+
return ABSENT;
|
|
144
|
+
if (domainLen > 0) {
|
|
145
|
+
if (!ref.memory)
|
|
146
|
+
throw new Error('analytics_series called before memory was bound');
|
|
147
|
+
const m = Buffer.from(ref.memory.buffer);
|
|
148
|
+
if (domainPtr < 0 || domainPtr + domainLen > m.length) {
|
|
149
|
+
throw new Error('analytics_series: domain out of bounds');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const frame = encodeSeries(metricId, range);
|
|
153
|
+
db.lastResult = frame;
|
|
154
|
+
db.lastResultVersion = -1;
|
|
155
|
+
return frame.length;
|
|
156
|
+
},
|
|
81
157
|
analytics_list_sites: (cursorPtr, cursorLen, limit) => {
|
|
82
158
|
if (cursorLen > MAX_CURSOR_LEN)
|
|
83
159
|
return ABSENT;
|
|
@@ -37,6 +37,7 @@ const PROVIDED_IMPORTS = new Set([
|
|
|
37
37
|
'env_get',
|
|
38
38
|
'env_get_secure',
|
|
39
39
|
'analytics_read',
|
|
40
|
+
'analytics_series',
|
|
40
41
|
'analytics_list_sites',
|
|
41
42
|
'crypto.fill_random',
|
|
42
43
|
'crypto.random_uuid',
|
|
@@ -139,7 +140,7 @@ export class WasmServerModule {
|
|
|
139
140
|
const rpcMethod = req.method.toUpperCase();
|
|
140
141
|
const rpcMutating = rpcMethod === 'POST' || rpcMethod === 'PUT' || rpcMethod === 'PATCH' || rpcMethod === 'DELETE';
|
|
141
142
|
if (rpcPath === '/__toil_rpc' && rpcMutating) {
|
|
142
|
-
const idHeader = req.headers.find(([n]) => n.toLowerCase() === '
|
|
143
|
+
const idHeader = req.headers.find(([n]) => n.toLowerCase() === 'dacely-rpc')?.[1];
|
|
143
144
|
const id = idHeader !== undefined && /^\d+$/.test(idHeader) ? Number(idHeader) : NaN;
|
|
144
145
|
state.db.functionKind =
|
|
145
146
|
Number.isInteger(id) && id <= 0xffffffff
|
package/docs/caching.md
CHANGED
|
@@ -74,7 +74,7 @@ Because `@auth` guards and body-decode run before the cache directive is applied
|
|
|
74
74
|
an unauthorized request is rejected with 401 before anything is cached, and a
|
|
75
75
|
cached entry is only ever produced from a handler that actually ran.
|
|
76
76
|
|
|
77
|
-
Caching is **always opt-in.** A response with no `
|
|
77
|
+
Caching is **always opt-in.** A response with no `Dacely-Cache-Control` directive
|
|
78
78
|
(i.e. no `@cache` / `Response.cache(...)`) is never stored, there is no blind
|
|
79
79
|
"cache every GET" mode, because an automatic window cannot tell a personalized
|
|
80
80
|
response from a public one and would key it without a per-user component.
|
|
@@ -96,10 +96,10 @@ It has two tiers:
|
|
|
96
96
|
offloaded to a sibling thread so they never stall the request path; a separate
|
|
97
97
|
per-core disk budget caps total spilled bytes, with the same expiry + eviction.
|
|
98
98
|
If spill is not enabled, a big response is simply not cached (reported as not
|
|
99
|
-
stored by the `
|
|
99
|
+
stored by the `Dacely-Cache` tag).
|
|
100
100
|
|
|
101
101
|
From a tenant's point of view nothing changes: you still just set a
|
|
102
|
-
`
|
|
102
|
+
`Dacely-Cache-Control` directive (via `@cache` / `Response.cache(...)`). The edge
|
|
103
103
|
decides RAM vs disk; both honor the same TTL and the same safety rails above.
|
|
104
104
|
Expiry is enforced on read (a past-TTL entry is a miss) and reclaimed on the next
|
|
105
105
|
insert that needs room. Nothing persists across a process restart.
|
|
@@ -1,53 +1,181 @@
|
|
|
1
|
-
// Demo of the per-domain Analytics API
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// Demo of the per-domain Analytics API + the historical time-series (graphs).
|
|
2
|
+
//
|
|
3
|
+
// Server.REST.analytics.self() -> the current snapshot (typed getters)
|
|
4
|
+
// Server.REST.analytics.series({ query: { metric, range } }) -> a metric's history for a range
|
|
5
|
+
//
|
|
6
|
+
// Both are the real, typed fetch clients generated from the `@rest` controller in
|
|
7
|
+
// server/routes/Analytics.ts, which reads the toilscript `Analytics.self()` / `Analytics.series()` API.
|
|
8
|
+
// Under `toiljs dev` the dev server returns sample data; at the edge the SAME code reads the real
|
|
9
|
+
// metering rings. The chart is a self-contained inline SVG (no chart library).
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
6
11
|
|
|
7
|
-
import { SiteAnalytics } from 'shared/server';
|
|
12
|
+
import { SeriesData, SiteAnalytics } from 'shared/server';
|
|
8
13
|
|
|
9
14
|
export const metadata: Toil.Metadata = {
|
|
10
15
|
title: 'Analytics',
|
|
11
|
-
description: "This site's own analytics via the toilscript Analytics
|
|
16
|
+
description: "This site's own analytics + historical graphs via the toilscript Analytics API."
|
|
12
17
|
};
|
|
13
18
|
|
|
19
|
+
// MetricId (mirrors the server enum). Only the graph-worthy ones are listed for the picker.
|
|
20
|
+
const METRICS = [
|
|
21
|
+
{ id: 0, label: 'Requests', unit: 'count' },
|
|
22
|
+
{ id: 1, label: 'Bytes out (L1)', unit: 'bytes' },
|
|
23
|
+
{ id: 2, label: 'Bytes in (L1)', unit: 'bytes' },
|
|
24
|
+
{ id: 12, label: 'Gas used', unit: 'count' },
|
|
25
|
+
{ id: 13, label: 'DB ops', unit: 'count' },
|
|
26
|
+
{ id: 26, label: 'Stream bytes out', unit: 'bytes' },
|
|
27
|
+
{ id: 39, label: 'Memory bandwidth', unit: 'bytes' },
|
|
28
|
+
{ id: 41, label: 'Connected streams', unit: 'count' },
|
|
29
|
+
{ id: 43, label: 'Committed memory', unit: 'bytes' }
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
// AnalyticsRange (mirrors the server enum).
|
|
33
|
+
const RANGES = [
|
|
34
|
+
{ id: 0, label: '1h' },
|
|
35
|
+
{ id: 1, label: '6h' },
|
|
36
|
+
{ id: 3, label: '24h' },
|
|
37
|
+
{ id: 5, label: '7d' },
|
|
38
|
+
{ id: 7, label: '30d' }
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
type Unit = 'count' | 'bytes';
|
|
42
|
+
|
|
43
|
+
function fmt(n: number, unit: Unit): string {
|
|
44
|
+
if (unit === 'bytes') {
|
|
45
|
+
const u = ['B', 'KB', 'MB', 'GB'];
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (n >= 1024 && i < u.length - 1) {
|
|
48
|
+
n /= 1024;
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
return `${n.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
|
52
|
+
}
|
|
53
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A dependency-free SVG line chart of the per-second rate (value / bucketSecs). */
|
|
57
|
+
function LineChart({ points, bucketSecs, unit }: { points: number[]; bucketSecs: number; unit: Unit }) {
|
|
58
|
+
const W = 640;
|
|
59
|
+
const H = 220;
|
|
60
|
+
const pad = 40;
|
|
61
|
+
const rate = points.map((v) => (bucketSecs > 0 ? v / bucketSecs : 0));
|
|
62
|
+
const max = Math.max(1, ...rate);
|
|
63
|
+
const n = rate.length;
|
|
64
|
+
const x = (i: number) => pad + (n <= 1 ? 0 : (i / (n - 1)) * (W - 2 * pad));
|
|
65
|
+
const y = (v: number) => H - pad - (v / max) * (H - 2 * pad);
|
|
66
|
+
const line = rate.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
|
67
|
+
const area = `${line} L${x(n - 1).toFixed(1)},${H - pad} L${x(0).toFixed(1)},${H - pad} Z`;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<svg
|
|
71
|
+
viewBox={`0 0 ${W} ${H}`}
|
|
72
|
+
width="100%"
|
|
73
|
+
role="img"
|
|
74
|
+
aria-label="time series chart"
|
|
75
|
+
style={{ maxWidth: W, background: '#0b0f19', borderRadius: 8 }}>
|
|
76
|
+
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#33415580" />
|
|
77
|
+
<line x1={pad} y1={pad} x2={pad} y2={H - pad} stroke="#33415580" />
|
|
78
|
+
<text x={6} y={pad + 4} fill="#94a3b8" fontSize={11}>
|
|
79
|
+
{fmt(max, unit)}/s
|
|
80
|
+
</text>
|
|
81
|
+
<text x={6} y={H - pad} fill="#94a3b8" fontSize={11}>
|
|
82
|
+
0
|
|
83
|
+
</text>
|
|
84
|
+
{n > 0 && <path d={area} fill="#38bdf822" />}
|
|
85
|
+
{n > 1 && <path d={line} fill="none" stroke="#38bdf8" strokeWidth={2} />}
|
|
86
|
+
{n === 1 && <circle cx={x(0)} cy={y(rate[0])} r={3} fill="#38bdf8" />}
|
|
87
|
+
</svg>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
14
91
|
export default function AnalyticsDemo() {
|
|
15
92
|
const [stats, setStats] = useState<SiteAnalytics | null>(null);
|
|
93
|
+
const [series, setSeries] = useState<SeriesData | null>(null);
|
|
94
|
+
const [metric, setMetric] = useState(0);
|
|
95
|
+
const [range, setRange] = useState(3);
|
|
16
96
|
const [err, setErr] = useState('');
|
|
17
97
|
|
|
18
|
-
const
|
|
98
|
+
const loadStats = async () => {
|
|
19
99
|
try {
|
|
20
|
-
setStats(await Server.REST.
|
|
100
|
+
setStats(await Server.REST.analyticsRoutes.self());
|
|
21
101
|
setErr('');
|
|
22
102
|
} catch (e) {
|
|
23
103
|
setErr(parseError(e));
|
|
24
104
|
}
|
|
25
105
|
};
|
|
26
106
|
|
|
107
|
+
// Refetch the graph whenever the metric or range changes.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
Server.REST.analyticsRoutes
|
|
110
|
+
.series({ query: { metric, range } })
|
|
111
|
+
.then(setSeries)
|
|
112
|
+
.catch((e: unknown) => setErr(parseError(e)));
|
|
113
|
+
}, [metric, range]);
|
|
114
|
+
|
|
27
115
|
const cap = (used: bigint, max: bigint) => `${used} / ${max ? String(max) : '∞'}`;
|
|
116
|
+
const meta = METRICS.find((m) => m.id === metric) ?? METRICS[0];
|
|
117
|
+
const points = series ? series.points.map(Number) : [];
|
|
28
118
|
|
|
29
119
|
return (
|
|
30
120
|
<main>
|
|
31
121
|
<h1>Analytics</h1>
|
|
32
122
|
<p>
|
|
33
|
-
<code>
|
|
34
|
-
<code>
|
|
35
|
-
|
|
123
|
+
<code>analytics.self()</code> reads this site's snapshot; <code>analytics.series(metric, range)</code>{' '}
|
|
124
|
+
reads the historical rings (30-day retention). Under <code>toiljs dev</code> you get sample data; at
|
|
125
|
+
the edge it is the real metering, same code.
|
|
36
126
|
</p>
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
127
|
+
|
|
128
|
+
<section>
|
|
129
|
+
<h2>History</h2>
|
|
130
|
+
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8 }}>
|
|
131
|
+
<label>
|
|
132
|
+
Metric{' '}
|
|
133
|
+
<select value={metric} onChange={(e) => setMetric(Number(e.currentTarget.value))}>
|
|
134
|
+
{METRICS.map((m) => (
|
|
135
|
+
<option key={m.id} value={m.id}>
|
|
136
|
+
{m.label}
|
|
137
|
+
</option>
|
|
138
|
+
))}
|
|
139
|
+
</select>
|
|
140
|
+
</label>
|
|
141
|
+
<label>
|
|
142
|
+
Range{' '}
|
|
143
|
+
<select value={range} onChange={(e) => setRange(Number(e.currentTarget.value))}>
|
|
144
|
+
{RANGES.map((r) => (
|
|
145
|
+
<option key={r.id} value={r.id}>
|
|
146
|
+
{r.label}
|
|
147
|
+
</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
</label>
|
|
151
|
+
<span style={{ color: '#64748b' }}>
|
|
152
|
+
{series ? `${series.points.length} buckets · ${series.bucketSecs}s each` : ''}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
<LineChart points={points} bucketSecs={series?.bucketSecs ?? 3600} unit={meta.unit} />
|
|
156
|
+
</section>
|
|
157
|
+
|
|
158
|
+
<section style={{ marginTop: 24 }}>
|
|
159
|
+
<h2>Snapshot</h2>
|
|
160
|
+
<button onClick={loadStats}>Load my site analytics</button>
|
|
161
|
+
{err && <p style={{ color: '#f87171' }}>{err}</p>}
|
|
162
|
+
{stats && (
|
|
163
|
+
<ul>
|
|
164
|
+
<li>requests: {String(stats.requests)}</li>
|
|
165
|
+
<li>bytes out (L1): {String(stats.bytesOutL1)}</li>
|
|
166
|
+
<li>bytes in (L1): {String(stats.bytesInL1)}</li>
|
|
167
|
+
<li>gas used: {String(stats.gasUsed)}</li>
|
|
168
|
+
<li>2xx responses: {String(stats.status2xx)}</li>
|
|
169
|
+
<li>db ops: {String(stats.dbOps)}</li>
|
|
170
|
+
<li>connected streams (live): {String(stats.connectedStreams)}</li>
|
|
171
|
+
<li>committed memory (live): {fmt(Number(stats.committedMemory), 'bytes')}</li>
|
|
172
|
+
<li>requests this minute: {cap(stats.reqMinuteUsed, stats.reqMinuteCap)}</li>
|
|
173
|
+
<li>requests today: {cap(stats.reqDayUsed, stats.reqDayCap)}</li>
|
|
174
|
+
</ul>
|
|
175
|
+
)}
|
|
176
|
+
</section>
|
|
177
|
+
|
|
178
|
+
<p style={{ marginTop: 24 }}>
|
|
51
179
|
<Toil.Link href="/">Back home</Toil.Link>
|
|
52
180
|
</p>
|
|
53
181
|
</main>
|
|
@@ -3,12 +3,27 @@
|
|
|
3
3
|
@data
|
|
4
4
|
export class SiteAnalytics {
|
|
5
5
|
requests: i64 = 0;
|
|
6
|
-
|
|
6
|
+
bytesOutL1: i64 = 0;
|
|
7
|
+
bytesInL1: i64 = 0;
|
|
7
8
|
status2xx: i64 = 0;
|
|
8
9
|
wasmDispatches: i64 = 0;
|
|
9
10
|
dbOps: i64 = 0;
|
|
11
|
+
gasUsed: i64 = 0;
|
|
12
|
+
connectedStreams: i64 = 0;
|
|
13
|
+
committedMemory: i64 = 0;
|
|
10
14
|
reqMinuteUsed: i64 = 0;
|
|
11
15
|
reqMinuteCap: i64 = 0;
|
|
12
16
|
reqDayUsed: i64 = 0;
|
|
13
17
|
reqDayCap: i64 = 0;
|
|
14
18
|
}
|
|
19
|
+
|
|
20
|
+
/** One metric's historical time-series for a range, from `Analytics.series(metric, range)`: the raw
|
|
21
|
+
* per-bucket totals (oldest→newest) plus the bucket width + newest-bucket end, so the client can draw a
|
|
22
|
+
* graph and derive per-second rates. Demonstrates the typed series API. */
|
|
23
|
+
@data
|
|
24
|
+
export class SeriesData {
|
|
25
|
+
metric: i32 = 0;
|
|
26
|
+
bucketSecs: u32 = 0;
|
|
27
|
+
headMs: u64 = 0;
|
|
28
|
+
points: i64[] = [];
|
|
29
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
|
|
3
|
+
import { SeriesData, SiteAnalytics } from '../models/SiteAnalytics';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* This site's own analytics, mounted at `/analytics`. On the client:
|
|
@@ -16,15 +18,38 @@ class AnalyticsRoutes {
|
|
|
16
18
|
public self(): SiteAnalytics {
|
|
17
19
|
const s = Analytics.self();
|
|
18
20
|
const r = new SiteAnalytics();
|
|
19
|
-
|
|
20
|
-
r.
|
|
21
|
-
r.
|
|
22
|
-
r.
|
|
23
|
-
r.
|
|
21
|
+
// Typed getters: no magic strings, no `.has()/.get()` map dance.
|
|
22
|
+
r.requests = s.requests;
|
|
23
|
+
r.bytesOutL1 = s.bytesOutL1;
|
|
24
|
+
r.bytesInL1 = s.bytesInL1;
|
|
25
|
+
r.status2xx = s.status2xx;
|
|
26
|
+
r.wasmDispatches = s.wasmDispatches;
|
|
27
|
+
r.dbOps = s.dbOps;
|
|
28
|
+
r.gasUsed = s.gasUsed;
|
|
29
|
+
r.connectedStreams = s.connectedStreams;
|
|
30
|
+
r.committedMemory = s.committedMemory;
|
|
24
31
|
r.reqMinuteUsed = s.reqMinuteUsed;
|
|
25
32
|
r.reqMinuteCap = <i64>s.reqMinuteCap;
|
|
26
33
|
r.reqDayUsed = s.reqDayUsed;
|
|
27
34
|
r.reqDayCap = <i64>s.reqDayCap;
|
|
28
35
|
return r;
|
|
29
36
|
}
|
|
37
|
+
|
|
38
|
+
/// Any metric's historical time-series for a range, for graphs:
|
|
39
|
+
/// `await Server.REST.analytics.series({ query: { metric: MetricId, range: AnalyticsRange } })`
|
|
40
|
+
/// `metric` is a `MetricId` (0..44), `range` an `AnalyticsRange` (0=1h .. 7=30d, default 24h). Returns
|
|
41
|
+
/// the per-bucket totals oldest→newest; the client derives rates (value / bucketSecs) and draws them.
|
|
42
|
+
@get('/series')
|
|
43
|
+
public series(ctx: RouteContext): SeriesData {
|
|
44
|
+
const metric = <MetricId>i32.parse(ctx.query('metric'));
|
|
45
|
+
const rangeStr = ctx.query('range');
|
|
46
|
+
const range = rangeStr.length > 0 ? <AnalyticsRange>i32.parse(rangeStr) : AnalyticsRange.H24;
|
|
47
|
+
const s = Analytics.series(metric, range);
|
|
48
|
+
const out = new SeriesData();
|
|
49
|
+
out.metric = s.metric;
|
|
50
|
+
out.bucketSecs = s.bucketSecs;
|
|
51
|
+
out.headMs = s.headMs;
|
|
52
|
+
out.points = s.points;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
30
55
|
}
|
package/package.json
CHANGED
|
@@ -61,7 +61,7 @@ export function handle(req_ofs: i32, req_len: i32): i64 {
|
|
|
61
61
|
// Run the handler lifecycle hooks around BOTH the RPC and the normal path, so an app that does
|
|
62
62
|
// central bookkeeping/auth in onRequestStarted is not silently bypassed for /__toil_rpc.
|
|
63
63
|
handler.onRequestStarted(req);
|
|
64
|
-
// Reserved RPC endpoint: a `POST /__toil_rpc` carrying a `
|
|
64
|
+
// Reserved RPC endpoint: a `POST /__toil_rpc` carrying a `dacely-rpc` method id dispatches to the
|
|
65
65
|
// registered @service/@remote method (which applies its own @auth/@ratelimit guards); any other
|
|
66
66
|
// request returns null here and falls through to the normal handler.
|
|
67
67
|
const rpcHit = Rpc.dispatch(req);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* function self-registers here at module init (compiler-injected), keyed by a
|
|
4
4
|
* deterministic method id (FNV-1a of `"Service.method"` / `"fnName"` — the same
|
|
5
5
|
* hash the generated client sends). The wasm `handle` export dispatches a
|
|
6
|
-
* reserved `POST /__toil_rpc` (method id in the `
|
|
6
|
+
* reserved `POST /__toil_rpc` (method id in the `dacely-rpc` header, `@data`-encoded
|
|
7
7
|
* args in the body) to the matching method and returns its `@data`-encoded
|
|
8
8
|
* result. Mirrors `Rest` (../rest/Rest.ts); calls are STATELESS — a fresh service
|
|
9
9
|
* instance per call, exactly like a `@rest` controller.
|
|
@@ -15,7 +15,7 @@ import { Response } from '../response';
|
|
|
15
15
|
/** The reserved path a generated RPC client POSTs to. */
|
|
16
16
|
export const RPC_PATH: string = '/__toil_rpc';
|
|
17
17
|
/** The request header carrying the decimal `u32` method id. */
|
|
18
|
-
export const RPC_HEADER: string = '
|
|
18
|
+
export const RPC_HEADER: string = 'dacely-rpc';
|
|
19
19
|
|
|
20
20
|
/** A registered RPC method: takes the encoded-args body and returns a `Response` - the encoded result
|
|
21
21
|
* (`Response.bytes`) on success, or a guard's `401`/`429` when the method carries `@auth`/`@ratelimit`.
|
package/src/compiler/sri.ts
CHANGED
|
@@ -46,33 +46,84 @@ function toLocalAsset(url: string, base: string): string | null {
|
|
|
46
46
|
export function injectSri(html: string, outDir: string, base: string): string {
|
|
47
47
|
const cache = new Map<string, string | null>();
|
|
48
48
|
return html.replace(/<(script|link)\b([^>]*)>/gi, (full, tag: string, rawAttrs: string) => {
|
|
49
|
-
|
|
49
|
+
// `(?<![\w-])` (not `\b`) so a hyphenated decoy attr never counts as the real one: `\bintegrity`
|
|
50
|
+
// matches inside `data-integrity` (would wrongly skip us), `\bsrc` inside `data-src` (would hash
|
|
51
|
+
// the wrong URL). The guard requires the attr name to start at a real attribute boundary.
|
|
52
|
+
if (/(?<![\w-])integrity\s*=/i.test(rawAttrs)) return full; // already integrity-tagged
|
|
50
53
|
const selfClose = /\/\s*$/.test(rawAttrs);
|
|
51
54
|
const attrs = rawAttrs.replace(/\s*\/\s*$/, '');
|
|
52
55
|
const isScript = tag.toLowerCase() === 'script';
|
|
53
56
|
if (!isScript) {
|
|
54
|
-
const rel =
|
|
57
|
+
const rel = /(?<![\w-])rel\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
|
|
55
58
|
if (rel !== 'stylesheet' && rel !== 'modulepreload') return full;
|
|
56
59
|
}
|
|
57
60
|
const attr = isScript ? 'src' : 'href';
|
|
58
|
-
const url = new RegExp(
|
|
61
|
+
const url = new RegExp(`(?<![\\w-])${attr}\\s*=\\s*["']([^"']+)["']`, 'i').exec(attrs)?.[1];
|
|
59
62
|
if (url === undefined) return full; // inline script or no url
|
|
60
63
|
const rel = toLocalAsset(url, base);
|
|
61
64
|
if (rel === null) return full;
|
|
62
65
|
const sri = computeSri(path.join(outDir, rel), cache);
|
|
63
66
|
if (sri === null) return full;
|
|
64
|
-
const cross =
|
|
67
|
+
const cross = /(?<![\w-])crossorigin(?![\w-])/i.test(attrs) ? '' : ' crossorigin="anonymous"';
|
|
65
68
|
return `<${tag}${attrs} integrity="${sri}"${cross}${selfClose ? ' /' : ''}>`;
|
|
66
69
|
});
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Build an `<script type="importmap">` tag whose `integrity` section maps every emitted JS chunk
|
|
74
|
+
* (`assets/*.js`) to its sha384. Tag-level SRI only protects the ENTRY script: the rest of the
|
|
75
|
+
* module graph -- static imports (the react vendor chunk) and lazily `import()`ed route chunks --
|
|
76
|
+
* is fetched by the module loader, which does NOT inherit the tag's integrity. The import map's
|
|
77
|
+
* `integrity` field is the platform mechanism that extends verification to those fetches; browsers
|
|
78
|
+
* without support ignore the key (progressive enhancement, nothing breaks). Returns `null` when
|
|
79
|
+
* there are no chunks.
|
|
80
|
+
*/
|
|
81
|
+
export function buildImportMapIntegrity(
|
|
82
|
+
outDir: string,
|
|
83
|
+
base: string,
|
|
84
|
+
cache: Map<string, string | null> = new Map(),
|
|
85
|
+
): string | null {
|
|
86
|
+
const assetsDir = path.join(outDir, 'assets');
|
|
87
|
+
let names: string[];
|
|
88
|
+
try {
|
|
89
|
+
names = fs.readdirSync(assetsDir).filter((n) => n.endsWith('.js'));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const prefix = base.endsWith('/') ? base : `${base}/`;
|
|
94
|
+
const integrity: Record<string, string> = {};
|
|
95
|
+
for (const name of names.sort()) {
|
|
96
|
+
const sri = computeSri(path.join(assetsDir, name), cache);
|
|
97
|
+
if (sri !== null) integrity[`${prefix}assets/${name}`] = sri;
|
|
98
|
+
}
|
|
99
|
+
if (Object.keys(integrity).length === 0) return null;
|
|
100
|
+
return `<script type="importmap">${JSON.stringify({ integrity })}</script>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Insert the import-map tag where it takes effect: right after `<head>` (an import map must be
|
|
104
|
+
* parsed before any module load). Falls back to prepending before the first `<script`. No-op if
|
|
105
|
+
* an integrity import map is already present (idempotent on an already-processed shell). */
|
|
106
|
+
export function injectImportMap(html: string, importMapTag: string): string {
|
|
107
|
+
if (html.includes('<script type="importmap">{"integrity"')) return html;
|
|
108
|
+
const head = /<head[^>]*>/i.exec(html);
|
|
109
|
+
if (head !== null) {
|
|
110
|
+
const at = head.index + head[0].length;
|
|
111
|
+
return `${html.slice(0, at)}\n ${importMapTag}${html.slice(at)}`;
|
|
112
|
+
}
|
|
113
|
+
const script = html.indexOf('<script');
|
|
114
|
+
if (script !== -1) return `${html.slice(0, script)}${importMapTag}\n${html.slice(script)}`;
|
|
115
|
+
return html;
|
|
116
|
+
}
|
|
117
|
+
|
|
69
118
|
/**
|
|
70
119
|
* Build-only plugin: after the bundle is written, rewrites the built shell (`outDir/index.html`) so
|
|
71
|
-
* every local script/stylesheet/modulepreload carries an `integrity
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
120
|
+
* (a) every local script/stylesheet/modulepreload TAG carries an `integrity`, and (b) an import map
|
|
121
|
+
* `integrity` section covers every emitted JS chunk, so the FULL module graph -- the entry's static
|
|
122
|
+
* imports and every dynamically `import()`ed route chunk -- is verified, not just the entry tag. It
|
|
123
|
+
* runs in `closeBundle` (assets are on disk, so real bytes are hashed) and is registered BEFORE the
|
|
124
|
+
* prerender / SSR-template passes, which bake per-route HTML, bracket templates, and the SSR `.tmpl`
|
|
125
|
+
* FROM this shell -- so every emitted page inherits both and the `.tmpl` coherence hash covers them.
|
|
126
|
+
* Build-only: the dev server serves un-hashed modules straight from Vite, where SRI is meaningless.
|
|
76
127
|
*/
|
|
77
128
|
export function sriPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
78
129
|
return {
|
|
@@ -83,7 +134,10 @@ export function sriPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
83
134
|
const shell = path.join(outDir, 'index.html');
|
|
84
135
|
if (!fs.existsSync(shell)) return;
|
|
85
136
|
const html = fs.readFileSync(shell, 'utf8');
|
|
86
|
-
const
|
|
137
|
+
const cache = new Map<string, string | null>();
|
|
138
|
+
let out = injectSri(html, outDir, cfg.base);
|
|
139
|
+
const importMap = buildImportMapIntegrity(outDir, cfg.base, cache);
|
|
140
|
+
if (importMap !== null) out = injectImportMap(out, importMap);
|
|
87
141
|
if (out !== html) fs.writeFileSync(shell, out);
|
|
88
142
|
},
|
|
89
143
|
};
|