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.
- package/CHANGELOG.md +17 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/navigation/Link.js +5 -1
- package/build/client/routing/mount.js +19 -17
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.js +11 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.d.ts +3 -0
- package/build/devserver/analytics/index.js +111 -0
- package/build/devserver/production.js +2 -1
- package/build/devserver/runtime/host.js +2 -0
- package/examples/basic/client/routes/analytics.tsx +55 -0
- package/examples/basic/client/routes/rest.tsx +1 -1
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/models/SiteAnalytics.ts +14 -0
- package/examples/basic/server/routes/Analytics.ts +30 -0
- package/examples/basic/server/streams/Echo.ts +19 -16
- package/package.json +3 -3
- package/src/client/navigation/Link.tsx +15 -1
- package/src/client/routing/mount.tsx +41 -34
- package/src/compiler/index.ts +14 -1
- package/src/devserver/analytics/index.ts +158 -0
- package/src/devserver/production.ts +7 -1
- package/src/devserver/runtime/host.ts +6 -0
- package/test/dom/Link.test.tsx +20 -0
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
32
|
-
// the
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
42
|
-
//
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
package/src/compiler/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/test/dom/Link.test.tsx
CHANGED
|
@@ -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
|