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.
@@ -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
- lifetime: [
4
- ['requests', 42],
5
- ['bytes_served', 12345],
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 parts = [];
25
- const head = Buffer.alloc(6);
26
- head.writeUInt16LE(1, 0);
27
- head.writeUInt32LE(s.lifetime.length, 2);
28
- parts.push(head);
29
- for (const [name, value] of s.lifetime) {
30
- const nb = Buffer.from(name, 'utf8');
31
- const nl = Buffer.alloc(4);
32
- nl.writeUInt32LE(nb.length, 0);
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
- const tail = Buffer.alloc(32);
38
- tail.writeBigInt64LE(BigInt(s.reqMinuteUsed), 0);
39
- tail.writeBigUInt64LE(BigInt(s.reqMinuteCap), 8);
40
- tail.writeBigInt64LE(BigInt(s.reqDayUsed), 16);
41
- tail.writeBigUInt64LE(BigInt(s.reqDayCap), 24);
42
- parts.push(tail);
43
- return Buffer.concat(parts);
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() === 'toil-rpc')?.[1];
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 `Toil-Cache-Control` directive
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 `Toil-Cache` tag).
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
- `Toil-Cache-Control` directive (via `@cache` / `Response.cache(...)`). The edge
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. `Server.REST.analytics.self()` is the real, typed
2
- // fetch client generated from the `@rest` controller in server/routes/Analytics.ts; the route
3
- // reads the toilscript `Analytics.self()` snapshot. Under `toiljs dev` the dev server returns
4
- // sample data; at the edge the same code reads the real metering.
5
- import { useState } from 'react';
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.self() API."
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 load = async () => {
98
+ const loadStats = async () => {
19
99
  try {
20
- setStats(await Server.REST.analytics.self());
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>Server.REST.analytics.self()</code> reads this site's own analytics via the toilscript{' '}
34
- <code>Analytics.self()</code> API (the per-domain metering counters + plan limits). Under{' '}
35
- <code>toiljs dev</code> you get sample data; at the edge it is the real metering, with the same code.
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
- <button onClick={load}>Load my site analytics</button>
38
- {err && <p>{err}</p>}
39
- {stats && (
40
- <ul>
41
- <li>requests: {String(stats.requests)}</li>
42
- <li>bytes served: {String(stats.bytesServed)}</li>
43
- <li>2xx responses: {String(stats.status2xx)}</li>
44
- <li>wasm dispatches: {String(stats.wasmDispatches)}</li>
45
- <li>db ops: {String(stats.dbOps)}</li>
46
- <li>requests this minute: {cap(stats.reqMinuteUsed, stats.reqMinuteCap)}</li>
47
- <li>requests today: {cap(stats.reqDayUsed, stats.reqDayCap)}</li>
48
- </ul>
49
- )}
50
- <p>
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
- bytesServed: i64 = 0;
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 { SiteAnalytics } from '../models/SiteAnalytics';
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
- r.requests = s.lifetime.has('requests') ? s.lifetime.get('requests') : 0;
20
- r.bytesServed = s.lifetime.has('bytes_served') ? s.lifetime.get('bytes_served') : 0;
21
- r.status2xx = s.lifetime.has('status_2xx') ? s.lifetime.get('status_2xx') : 0;
22
- r.wasmDispatches = s.lifetime.has('wasm_dispatches') ? s.lifetime.get('wasm_dispatches') : 0;
23
- r.dbOps = s.lifetime.has('db_ops') ? s.lifetime.get('db_ops') : 0;
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.83",
4
+ "version": "0.0.85",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -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 `toil-rpc` method id dispatches to the
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);
@@ -178,7 +178,7 @@ export class Response {
178
178
  v += 'auth=1';
179
179
  }
180
180
  if (v.length > 0) {
181
- this.setHeader('toil-cache-control', v);
181
+ this.setHeader('dacely-cache-control', v);
182
182
  }
183
183
 
184
184
  return this;
@@ -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 `toil-rpc` header, `@data`-encoded
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 = 'toil-rpc';
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`.
@@ -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
- if (/\bintegrity\s*=/i.test(rawAttrs)) return full; // already integrity-tagged
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 = /\brel\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
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(`\\b${attr}\\s*=\\s*["']([^"']+)["']`, 'i').exec(attrs)?.[1];
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 = /\bcrossorigin\b/i.test(attrs) ? '' : ' crossorigin="anonymous"';
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`. It runs in `closeBundle`
72
- * (assets are on disk, so real bytes are hashed) and is registered BEFORE the prerender / SSR-
73
- * template passes, which bake per-route HTML, bracket templates, and the SSR `.tmpl` FROM this
74
- * shell -- so every emitted page inherits SRI and the `.tmpl` coherence hash covers it. Build-only:
75
- * the dev server serves un-hashed modules straight from Vite, where SRI would be meaningless.
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 out = injectSri(html, outDir, cfg.base);
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
  };