toiljs 0.0.72 → 0.0.74

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.
@@ -0,0 +1,14 @@
1
+ /** This site's own analytics, mapped from the toilscript `Analytics.self()` snapshot for the
2
+ * client. Counts + usage are i64; the rate-limit caps are i64 too (0 = unlimited). */
3
+ @data
4
+ export class SiteAnalytics {
5
+ requests: i64 = 0;
6
+ bytesServed: i64 = 0;
7
+ status2xx: i64 = 0;
8
+ wasmDispatches: i64 = 0;
9
+ dbOps: i64 = 0;
10
+ reqMinuteUsed: i64 = 0;
11
+ reqMinuteCap: i64 = 0;
12
+ reqDayUsed: i64 = 0;
13
+ reqDayCap: i64 = 0;
14
+ }
@@ -0,0 +1,30 @@
1
+ import { SiteAnalytics } from '../models/SiteAnalytics';
2
+
3
+ /**
4
+ * This site's own analytics, mounted at `/analytics`. On the client:
5
+ * await Server.REST.analytics.self()
6
+ *
7
+ * Reads the toilscript `Analytics.self()` snapshot (the per-domain metering counters + this
8
+ * site's plan limits) and maps it into a `@data` response. A site can only read ITS OWN
9
+ * analytics here; the privileged `dacely.com` domain additionally has `Analytics.site(domain)`
10
+ * / `Analytics.listSites(...)`. Under `toiljs dev` the dev server returns sample data; at the
11
+ * edge the SAME code reads the real metering. `Analytics` is a global (no import).
12
+ */
13
+ @rest('analytics')
14
+ class AnalyticsRoutes {
15
+ @get('/')
16
+ public self(): SiteAnalytics {
17
+ const s = Analytics.self();
18
+ 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;
24
+ r.reqMinuteUsed = s.reqMinuteUsed;
25
+ r.reqMinuteCap = <i64>s.reqMinuteCap;
26
+ r.reqDayUsed = s.reqDayUsed;
27
+ r.reqDayCap = <i64>s.reqDayCap;
28
+ return r;
29
+ }
30
+ }
@@ -1,44 +1,47 @@
1
1
  /**
2
2
  * A `@stream` protocol handler mounted at `/echo` - the SERVER side of the Realtime feature.
3
3
  *
4
- * Unlike a `@rest` route (a fresh handler per request), a `@stream` runs as a RESIDENT wasm box per
5
- * connection: it is created on `@connect`, lives for the whole connection, and is torn down on `@close`.
6
- * On the Toil edge that box is distributed across the L2/L3 stream nodes and pinned to ONE worker for the
7
- * connection's life via QUIC connection-id steering, so its in-box state survives every event.
4
+ * A `@stream` box is RESIDENT for the life of ONE connection: created on `@connect`, it handles every
5
+ * `@message` on that connection (on the Toil edge it is pinned to one worker for the connection's life
6
+ * via QUIC connection-id steering), and is DESTROYED on `@close`. So in-box state persists across the
7
+ * messages of a SINGLE connection - but it does NOT survive the connection. The next connection (and a
8
+ * reconnect) gets a brand-new box that starts clean, and a box is NEVER reused across connections, so
9
+ * one connection's state can never leak into another.
8
10
  *
9
- * The `count` field below is the proof: it persists across every `@message` because the box is never
10
- * reset between events (a `@rest` handler's fields reset each request). Each reply carries the running
11
- * count, so the client can watch the SAME resident box advance.
11
+ * => Box fields are per-connection scratch ONLY. For state that must outlive the connection (survive a
12
+ * reconnect, or be shared/durable), use `@data` / the DB - never a class field.
12
13
  *
13
- * Two browser clients drive this exact box (both land here in dev over a WebSocket, and on the edge over
14
- * WebTransport):
14
+ * The `count` below is exactly that per-connection scratch: it advances across THIS connection's frames
15
+ * and resets to 0 the next time someone connects.
16
+ *
17
+ * Two browser clients drive this box (over a WebSocket in dev, WebTransport on the edge):
15
18
  * - the raw socket: Toil.useChannel({ path: '/echo' }) (client/routes/features/realtime.tsx)
16
19
  * - the typed stream: await Server.Stream.Echo.connect() (client/routes/features/stream.tsx)
17
20
  */
18
21
  @stream('echo')
19
22
  class Echo {
20
- // Resident per-connection state: survives across events (the box is never reset between them).
23
+ // Per-CONNECTION scratch: persists across this connection's messages, gone when it closes (the box
24
+ // is destroyed). NOT durable - use @data for anything that must survive a reconnect.
21
25
  private count: i32 = 0;
22
26
 
23
27
  @connect
24
28
  onConnect(): void {
25
- // A fresh connection: its dedicated box starts the counter at 0.
29
+ // A fresh connection gets its OWN new box; the counter starts at 0.
26
30
  this.count = 0;
27
31
  }
28
32
 
29
33
  @message
30
34
  onMessage(packet: StreamPacket): StreamOutbound {
31
- // Count every inbound frame - and PERSIST across them, because this is the same resident box for
32
- // the whole connection - then reply with the running count so the client sees it advance.
35
+ // Advances on every frame of THIS connection - and persists across them, because it is the same
36
+ // resident box for the connection's life - then replies with the running count.
33
37
  this.count = this.count + 1;
34
- // The count proves residency (the same box handled every frame); the inbound length proves the
35
- // frame arrived. We avoid decoding the inbound here to keep the handler bytes-safe.
36
38
  const reply = 'pong #' + this.count.toString() + ' (' + packet.bytes().length.toString() + ' bytes in)';
37
39
  return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(reply)));
38
40
  }
39
41
 
40
42
  @close
41
43
  onClose(): void {
42
- // Graceful close: the per-connection box is torn down after this hook.
44
+ // Graceful close: the box is DESTROYED after this hook, so the next connection starts clean and
45
+ // no state leaks across connections.
43
46
  }
44
47
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.72",
4
+ "version": "0.0.74",
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": {
@@ -134,7 +134,7 @@
134
134
  "nodemailer": "^9.0.1",
135
135
  "picocolors": "^1.1.1",
136
136
  "sharp": "^0.35.2",
137
- "toilscript": "^0.1.47",
137
+ "toilscript": "^0.1.48",
138
138
  "typescript-eslint": "^8.62.0",
139
139
  "vite": "^8.1.0",
140
140
  "vite-imagetools": "^10.0.1",
@@ -145,7 +145,7 @@
145
145
  "prettier": ">=3.0.0",
146
146
  "react": ">=18.0.0",
147
147
  "react-dom": ">=18.0.0",
148
- "toilscript": ">=0.1.47",
148
+ "toilscript": ">=0.1.48",
149
149
  "typescript": ">=6.0.0"
150
150
  },
151
151
  "overrides": {
@@ -29,6 +29,17 @@ function isExternalHref(href: string): boolean {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Whether `href` is a usable client-navigation target. A data-driven href (`` `routeMap[key]` ``)
34
+ * can resolve to `undefined` / `null` / `''` at runtime when the target page doesn't exist; the
35
+ * resulting anchor is inert, so we must not intercept its click, calling `href.startsWith` /
36
+ * `navigate` on a missing href would throw. `Href` is always a string at the type level, hence the
37
+ * `unknown` parameter, the guard is purely a runtime safety net.
38
+ */
39
+ function isNavigableHref(href: unknown): href is string {
40
+ return typeof href === 'string' && href !== '';
41
+ }
42
+
32
43
  /**
33
44
  * Client-side navigation link. Forwards all anchor attributes to the underlying `<a>`, and
34
45
  * prefetches the target route's chunk on hover/focus. Intercepts only plain same-origin clicks ,
@@ -52,6 +63,9 @@ export function Link(props: LinkProps): ReactNode {
52
63
  onClick?.(event);
53
64
  if (
54
65
  event.defaultPrevented ||
66
+ // A missing href (the target page doesn't exist) makes the anchor inert: leave the click
67
+ // to the browser instead of intercepting it. Checked before `startsWith` so it can't throw.
68
+ !isNavigableHref(href) ||
55
69
  event.button !== 0 ||
56
70
  event.metaKey ||
57
71
  event.ctrlKey ||
@@ -69,7 +83,7 @@ export function Link(props: LinkProps): ReactNode {
69
83
  };
70
84
 
71
85
  const warm = (): void => {
72
- if (prefetchProp) prefetch(href);
86
+ if (prefetchProp && isNavigableHref(href)) prefetch(href);
73
87
  };
74
88
  const handlePointerEnter = (event: PointerEvent<HTMLAnchorElement>): void => {
75
89
  onPointerEnter?.(event);
@@ -1,7 +1,5 @@
1
1
  import { createRoot, hydrateRoot } from 'react-dom/client';
2
2
 
3
- import { DevToolbar } from '../dev/devtools.js';
4
- import { DevErrorBoundary, DevErrorOverlay, initDevErrorOverlay } from '../dev/error-overlay.js';
5
3
  import { initNavigation } from '../navigation/navigation.js';
6
4
  import { startPrefetcher } from '../navigation/prefetch.js';
7
5
  import { Router } from './Router.js';
@@ -38,39 +36,48 @@ export function mount(
38
36
  />
39
37
  );
40
38
  // In dev, wrap the app in the error overlay + dev toolbar so uncaught errors surface and dev info
41
- // is available. The guard is the literal `import.meta.env.DEV` (not `isDevMode()`) so the whole
42
- // branch, and the dev-only imports, are dead-code-eliminated and tree-shaken from production.
39
+ // is available. The guard is the literal `import.meta.env.DEV`, and the dev modules are pulled in
40
+ // with DYNAMIC `import()` INSIDE this branch. A production build dead-code-eliminates the whole
41
+ // branch - the `import()` calls included - so the devtools/overlay never enter the bundle, not even
42
+ // as an unreferenced chunk. (A static top-level import would otherwise survive into production unless
43
+ // the package opts into tree-shaking via a `sideEffects` declaration.)
43
44
  if ((import.meta as unknown as { env: { DEV: boolean } }).env.DEV) {
44
- initDevErrorOverlay();
45
- // Dev tools (error overlay + toolbar) render into their OWN body-level
46
- // container, never inside `#root`, so `#root` holds only the app markup.
47
- // That lets an SSR document hydrate cleanly (the server only rendered the
48
- // app into `#root`), and is harmless for a plain client-rendered page.
49
- const devEl = document.createElement('div');
50
- devEl.id = '__toil_dev';
51
- document.body.appendChild(devEl);
52
- createRoot(devEl).render(
53
- <>
54
- <DevErrorOverlay />
55
- <DevToolbar
56
- routes={routes}
57
- slots={slots}
58
- />
59
- </>,
60
- );
61
- const tree = <DevErrorBoundary>{app}</DevErrorBoundary>;
62
- if (isSsrDocument()) {
63
- // Edge-SSR: hydrate the server-rendered markup in place.
64
- hydrateRoot(el, tree);
65
- // The dev shell inlined `<style data-toil-dev-ssr>` so this server-rendered first paint was
66
- // already styled (no FOUC). Vite has since injected the same CSS via the entry's imports
67
- // (HMR-managed), so drop the static style to avoid a stale copy surviving a hot edit.
68
- document.querySelectorAll('[data-toil-dev-ssr]').forEach((n) => {
69
- n.remove();
70
- });
71
- } else {
72
- createRoot(el).render(tree);
73
- }
45
+ void (async () => {
46
+ const { DevToolbar } = await import('../dev/devtools.js');
47
+ const { DevErrorBoundary, DevErrorOverlay, initDevErrorOverlay } = await import(
48
+ '../dev/error-overlay.js'
49
+ );
50
+ initDevErrorOverlay();
51
+ // Dev tools (error overlay + toolbar) render into their OWN body-level container, never
52
+ // inside `#root`, so `#root` holds only the app markup. That lets an SSR document hydrate
53
+ // cleanly (the server only rendered the app into `#root`), and is harmless for a plain
54
+ // client-rendered page.
55
+ const devEl = document.createElement('div');
56
+ devEl.id = '__toil_dev';
57
+ document.body.appendChild(devEl);
58
+ createRoot(devEl).render(
59
+ <>
60
+ <DevErrorOverlay />
61
+ <DevToolbar
62
+ routes={routes}
63
+ slots={slots}
64
+ />
65
+ </>,
66
+ );
67
+ const tree = <DevErrorBoundary>{app}</DevErrorBoundary>;
68
+ if (isSsrDocument()) {
69
+ // Edge-SSR: hydrate the server-rendered markup in place.
70
+ hydrateRoot(el, tree);
71
+ // The dev shell inlined `<style data-toil-dev-ssr>` so the server-rendered first paint
72
+ // was already styled (no FOUC). Vite has since injected the same CSS via the entry's
73
+ // imports (HMR-managed), so drop the static style to avoid a stale copy surviving a hot edit.
74
+ document.querySelectorAll('[data-toil-dev-ssr]').forEach((n) => {
75
+ n.remove();
76
+ });
77
+ } else {
78
+ createRoot(el).render(tree);
79
+ }
80
+ })();
74
81
  } else if (isSsrDocument()) {
75
82
  // Edge-SSR: the document already holds server-rendered markup; hydrate it
76
83
  // (reuse the DOM) rather than client-rendering from scratch.
@@ -880,7 +880,20 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
880
880
  process.stdout.write(
881
881
  pc.green(' ✓ ') + pc.dim('server built; building the client (vite)…') + '\n',
882
882
  );
883
- await viteBuild(await createViteConfig(cfg));
883
+ // Keep dev-only tooling (the dev toolbar + error overlay mounted in `mount`) OUT of the production
884
+ // bundle. It lives behind `import.meta.env.DEV`, which Vite derives from NODE_ENV - and with NODE_ENV
885
+ // unset a `vite build` here still resolves to a DEV build (even with mode:'production'), leaving that
886
+ // branch live so the devtools ship in `build`. Force NODE_ENV=production for the client build so the
887
+ // branch is dead-code-eliminated, then restore NODE_ENV so a later in-process `dev()` (which must
888
+ // stay a development build, devtools on) is unaffected. `createViteConfig` is shared with `dev`.
889
+ const prevNodeEnv = process.env.NODE_ENV;
890
+ process.env.NODE_ENV = 'production';
891
+ try {
892
+ await viteBuild(mergeConfig(await createViteConfig(cfg), { mode: 'production' }));
893
+ } finally {
894
+ if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
895
+ else process.env.NODE_ENV = prevNodeEnv;
896
+ }
884
897
  // SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.
885
898
  await prerenderStaticParams(cfg);
886
899
  // Edge SSR: render `export const ssr = true` routes to template-with-holes
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Dev-server stand-in for the edge `env.analytics_read` host import
3
+ * (toil-backend `src/wasm/host/import_functions/analytics.rs`). It returns a fixed
4
+ * sample `TenantStats` frame so a tenant's wasm exercises the toilscript `Analytics`
5
+ * API under `toiljs dev`. The frame is stashed into the SAME per-request result
6
+ * buffer the `data.*` ops use (`db.lastResult`), so the guest drains it with the
7
+ * existing `data.take_result`.
8
+ *
9
+ * Dev is intentionally PERMISSIVE: it returns sample data for ANY domain (own or
10
+ * named) so a dashboard can be built locally. The real `dacely.com`-only
11
+ * authorization is enforced host-side at the edge; the dev server has no
12
+ * multi-tenant host resolution to authorize against.
13
+ */
14
+ import type { DbDevState } from '../db/index.js';
15
+ import type { MemoryRef } from '../runtime/host.js';
16
+
17
+ interface DevTenantStats {
18
+ lifetime: [string, number][];
19
+ reqMinuteUsed: number;
20
+ reqMinuteCap: number;
21
+ reqDayUsed: number;
22
+ reqDayCap: number;
23
+ }
24
+
25
+ /** A plausible sample so a guest sees non-zero analytics under `toiljs dev`. */
26
+ function devStats(): DevTenantStats {
27
+ return {
28
+ lifetime: [
29
+ ['requests', 42],
30
+ ['bytes_served', 12345],
31
+ ['status_2xx', 40],
32
+ ['status_3xx', 0],
33
+ ['status_4xx', 2],
34
+ ['status_5xx', 0],
35
+ ['static_hits', 8],
36
+ ['wasm_dispatches', 34],
37
+ ['db_ops', 17],
38
+ ['db_reads', 12],
39
+ ['db_writes', 5],
40
+ ['db_errors', 0],
41
+ ],
42
+ reqMinuteUsed: 5,
43
+ reqMinuteCap: 100,
44
+ reqDayUsed: 42,
45
+ reqDayCap: 5000,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Encode the versioned little-endian frame BYTE-FOR-BYTE like the edge encoder
51
+ * (analytics.rs `encode_stats`) so the toilscript `DataReader` decode is identical
52
+ * in dev and prod:
53
+ * u16 version | u32 count | (u32 nameLen, name bytes, i64 value)* |
54
+ * i64 reqMinuteUsed | u64 reqMinuteCap | i64 reqDayUsed | u64 reqDayCap
55
+ */
56
+ function encodeStats(s: DevTenantStats): Buffer {
57
+ const parts: Buffer[] = [];
58
+ const head = Buffer.alloc(6);
59
+ head.writeUInt16LE(1, 0); // frame version
60
+ head.writeUInt32LE(s.lifetime.length, 2);
61
+ parts.push(head);
62
+ for (const [name, value] of s.lifetime) {
63
+ const nb = Buffer.from(name, 'utf8');
64
+ const nl = Buffer.alloc(4);
65
+ nl.writeUInt32LE(nb.length, 0);
66
+ const vb = Buffer.alloc(8);
67
+ vb.writeBigInt64LE(BigInt(value), 0);
68
+ parts.push(nl, nb, vb);
69
+ }
70
+ const tail = Buffer.alloc(32);
71
+ tail.writeBigInt64LE(BigInt(s.reqMinuteUsed), 0);
72
+ tail.writeBigUInt64LE(BigInt(s.reqMinuteCap), 8);
73
+ tail.writeBigInt64LE(BigInt(s.reqDayUsed), 16);
74
+ tail.writeBigUInt64LE(BigInt(s.reqDayCap), 24);
75
+ parts.push(tail);
76
+ return Buffer.concat(parts);
77
+ }
78
+
79
+ /**
80
+ * Encode a site page BYTE-FOR-BYTE like the edge (analytics.rs `encode_site_list`):
81
+ * count u32 | (u32 nameLen, name bytes)* | has_more u8, all little-endian.
82
+ */
83
+ function encodeSiteList(names: string[], hasMore: boolean): Buffer {
84
+ const parts: Buffer[] = [];
85
+ const head = Buffer.alloc(4);
86
+ head.writeUInt32LE(names.length, 0);
87
+ parts.push(head);
88
+ for (const name of names) {
89
+ const nb = Buffer.from(name, 'utf8');
90
+ const nl = Buffer.alloc(4);
91
+ nl.writeUInt32LE(nb.length, 0);
92
+ parts.push(nl, nb);
93
+ }
94
+ parts.push(Buffer.from([hasMore ? 1 : 0]));
95
+ return Buffer.concat(parts);
96
+ }
97
+
98
+ // Mirror the edge ABI bounds (analytics.rs) so dev exercises the SAME negative-status paths as prod.
99
+ const MAX_DOMAIN_LEN = 256;
100
+ const MAX_CURSOR_LEN = 256;
101
+ const ABSENT = -2; // the edge's ABSENT sentinel; the guest maps a negative status to empty stats/list.
102
+ // Pre-sorted so cursor pagination is deterministic and matches the edge's sorted enumeration.
103
+ const SITE_SAMPLE = ['demo.dacely.com', 'example.com', 'shop.test'];
104
+
105
+ /** The `env.analytics_read` + `env.analytics_list_sites` dev imports. Mirrors `buildDatabaseImports`. */
106
+ export function buildAnalyticsImports(
107
+ ref: MemoryRef,
108
+ db: DbDevState,
109
+ ): Record<string, (...args: number[]) => number> {
110
+ return {
111
+ analytics_read: (domainPtr: number, domainLen: number): number => {
112
+ // Over-long domain -> ABSENT, mirroring the edge (which returns a negative status, not a
113
+ // trap); the guest maps it to empty stats. domainLen 0 = the caller's own stats.
114
+ if (domainLen > MAX_DOMAIN_LEN) return ABSENT;
115
+ if (domainLen > 0) {
116
+ if (!ref.memory) throw new Error('analytics_read called before memory was bound');
117
+ const m = Buffer.from(ref.memory.buffer);
118
+ if (domainPtr < 0 || domainPtr + domainLen > m.length) {
119
+ throw new Error('analytics_read: domain out of bounds');
120
+ }
121
+ }
122
+ const frame = encodeStats(devStats());
123
+ db.lastResult = frame;
124
+ db.lastResultVersion = -1;
125
+ return frame.length;
126
+ },
127
+
128
+ // The dacely dashboard enumerate-all-sites stub. Dev returns a fixed SORTED sample for any
129
+ // caller (the real dacely.com-only authz is the edge), but mirrors the edge ABI exactly:
130
+ // over-long/non-utf8 cursor -> ABSENT, cursor = strictly-after, limit cap, and a REAL has_more.
131
+ analytics_list_sites: (cursorPtr: number, cursorLen: number, limit: number): number => {
132
+ if (cursorLen > MAX_CURSOR_LEN) return ABSENT;
133
+ let cursor = '';
134
+ if (cursorLen > 0) {
135
+ if (!ref.memory) throw new Error('analytics_list_sites called before memory was bound');
136
+ const m = Buffer.from(ref.memory.buffer);
137
+ if (cursorPtr < 0 || cursorPtr + cursorLen > m.length) {
138
+ throw new Error('analytics_list_sites: cursor out of bounds');
139
+ }
140
+ const cb = m.subarray(cursorPtr, cursorPtr + cursorLen);
141
+ try {
142
+ cursor = new TextDecoder('utf-8', { fatal: true }).decode(cb);
143
+ } catch {
144
+ return ABSENT; // non-utf8 cursor -> empty page, mirroring the edge
145
+ }
146
+ }
147
+ const start = SITE_SAMPLE.findIndex((n) => n > cursor); // strictly after the cursor
148
+ const from = start < 0 ? SITE_SAMPLE.length : start;
149
+ const lim = limit > 0 ? Math.min(limit, 256) : 256;
150
+ const page = SITE_SAMPLE.slice(from, from + lim);
151
+ const hasMore = from + page.length < SITE_SAMPLE.length;
152
+ const frame = encodeSiteList(page, hasMore);
153
+ db.lastResult = frame;
154
+ db.lastResultVersion = -1;
155
+ return frame.length;
156
+ },
157
+ };
158
+ }
@@ -450,7 +450,13 @@ async function startBuiltHttpServer(
450
450
  if (await dynamicHandler(request, response)) return;
451
451
 
452
452
  if (request.method === 'GET' || request.method === 'HEAD') {
453
- response.sendFile(paths.indexHtml);
453
+ // Serve the route's OWN prerendered HTML if the build baked one
454
+ // (`<route>/index.html`, written by prerender.ts/ssg.ts with that route's metadata);
455
+ // otherwise fall back to the root shell for client-side routing. Without this per-route
456
+ // lookup EVERY route served the root index.html, so view-source on e.g. /login showed
457
+ // the home page's <head> (title/description/canonical/og) instead of the page's own.
458
+ const routeHtml = resolveStaticFile(paths.staticRoot, `${request.path}/index.html`);
459
+ response.sendFile(routeHtml ?? paths.indexHtml);
454
460
  return;
455
461
  }
456
462
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { devEnvGet, devEnvGetSecure } from '../config/env.js';
21
21
  import { ratelimitCheck } from '../config/ratelimit.js';
22
+ import { buildAnalyticsImports } from '../analytics/index.js';
22
23
  import { buildDatabaseImports, type DbDevState, freshDbState } from '../db/index.js';
23
24
  import { EmailStatus, getEmailService } from '../email/index.js';
24
25
  import { parseEmailBlob } from '../email/wire.js';
@@ -405,6 +406,11 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
405
406
  // challenges so register/login spans requests under `toiljs dev`;
406
407
  // the production edge backs the SAME imports with ScyllaDB.
407
408
  ...buildDatabaseImports(ref, state.db),
409
+
410
+ // `env.analytics_read`: the per-domain analytics import, emulated in process (see
411
+ // ../analytics/index.js). Stashes a sample TenantStats frame into the same `db.lastResult`
412
+ // the data.* ops use; the production edge backs it with the real metering counters.
413
+ ...buildAnalyticsImports(ref, state.db),
408
414
  },
409
415
  };
410
416
  }
@@ -31,6 +31,26 @@ describe('Link', () => {
31
31
  expect(window.location.pathname).toBe('/');
32
32
  });
33
33
 
34
+ it('ignores a click when href is missing (data-driven href to a page that does not exist)', () => {
35
+ // A runtime-undefined href (e.g. `routeMap[missingKey]`) used to throw on `href.startsWith`
36
+ // inside the click handler. React reports a handler throw as an uncaught *window* error (what
37
+ // the dev overlay surfaces), not a synchronous throw, so assert no such error fires. The anchor
38
+ // is inert now: the click is left to the browser, the page stays put.
39
+ const errors: string[] = [];
40
+ const onError = (e: ErrorEvent): void => {
41
+ errors.push(e.message || String(e.error));
42
+ };
43
+ window.addEventListener('error', onError);
44
+ try {
45
+ const { getByText } = render(<Link href={undefined as unknown as string as never}>x</Link>);
46
+ fireEvent.click(getByText('x'));
47
+ } finally {
48
+ window.removeEventListener('error', onError);
49
+ }
50
+ expect(errors).toEqual([]);
51
+ expect(window.location.pathname).toBe('/');
52
+ });
53
+
34
54
  it('forwards anchor attributes (rel, target)', () => {
35
55
  const { getByText } = render(
36
56
  <Link