toiljs 0.0.115 → 0.0.117
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/README.md +16 -7
- package/TYPESCRIPT_LAW.md +1946 -2073
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2375 -258
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +1 -1
- package/build/client/dev/error-overlay.js +1 -1
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.js +7 -3
- package/build/compiler/emails.js +1 -1
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +25 -4
- package/build/compiler/pages.js +2 -3
- package/build/compiler/prerender.d.ts +3 -8
- package/build/compiler/prerender.js +88 -127
- package/build/compiler/ssg.js +3 -4
- package/build/compiler/template-build.js +3 -4
- package/build/compiler/toil-docs.generated.js +6 -5
- package/build/compiler/vite.js +3 -6
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/database.js +7 -3
- package/build/devserver/db/types.js +1 -1
- package/build/devserver/production.js +4 -3
- package/build/devserver/runtime/crypto.d.ts +1 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/logger/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/routes/analytics.tsx +7 -5
- package/examples/basic/server/tsconfig.json +1 -3
- package/package.json +45 -44
- package/presets/no-uint8array-tostring.js +120 -153
- package/presets/oxlint.d.ts +5 -0
- package/presets/oxlint.js +224 -0
- package/server/runtime/tsconfig.json +1 -3
- package/tsconfig.json +2 -11
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
- package/.github/changelog-config.json +0 -45
- package/.github/dependabot.yml +0 -27
- package/.github/workflows/ci.yml +0 -222
- package/.vscode/settings.json +0 -10
- package/docs/README.md +0 -107
- package/docs/auth/README.md +0 -104
- package/docs/auth/configuration.md +0 -105
- package/docs/auth/emails.md +0 -92
- package/docs/auth/extending.md +0 -202
- package/docs/auth/how-it-works.md +0 -138
- package/docs/auth/usage.md +0 -188
- package/docs/backend/README.md +0 -143
- package/docs/backend/data.md +0 -351
- package/docs/backend/rest.md +0 -402
- package/docs/backend/rpc.md +0 -226
- package/docs/background/README.md +0 -114
- package/docs/background/daemons.md +0 -276
- package/docs/background/derive.md +0 -179
- package/docs/cli/README.md +0 -379
- package/docs/concepts/ai-guide.md +0 -132
- package/docs/concepts/config.md +0 -416
- package/docs/concepts/decorators.md +0 -127
- package/docs/concepts/security.md +0 -108
- package/docs/concepts/tiers.md +0 -166
- package/docs/concepts/types.md +0 -216
- package/docs/database/README.md +0 -143
- package/docs/database/capacity.md +0 -350
- package/docs/database/counters.md +0 -174
- package/docs/database/documents.md +0 -277
- package/docs/database/events.md +0 -307
- package/docs/database/membership.md +0 -246
- package/docs/database/setup.md +0 -155
- package/docs/database/unique.md +0 -216
- package/docs/database/views.md +0 -246
- package/docs/frontend/README.md +0 -104
- package/docs/frontend/components.md +0 -286
- package/docs/frontend/data-fetching.md +0 -260
- package/docs/frontend/images.md +0 -148
- package/docs/frontend/metadata.md +0 -344
- package/docs/frontend/navigation.md +0 -296
- package/docs/frontend/rendering.md +0 -236
- package/docs/frontend/routing.md +0 -346
- package/docs/frontend/scripts.md +0 -118
- package/docs/frontend/search.md +0 -191
- package/docs/frontend/styling.md +0 -147
- package/docs/frontend/toil-global.md +0 -216
- package/docs/getting-started/README.md +0 -81
- package/docs/getting-started/create-project.md +0 -131
- package/docs/getting-started/deploy.md +0 -101
- package/docs/getting-started/first-app.md +0 -215
- package/docs/getting-started/installation.md +0 -124
- package/docs/getting-started/migrating.md +0 -125
- package/docs/getting-started/project-structure.md +0 -163
- package/docs/introduction/README.md +0 -66
- package/docs/introduction/design-principles.md +0 -81
- package/docs/introduction/distributed.md +0 -91
- package/docs/introduction/how-it-works.md +0 -121
- package/docs/introduction/hyperscale.md +0 -70
- package/docs/introduction/modern-stack.md +0 -85
- package/docs/introduction/vs-other-frameworks.md +0 -64
- package/docs/introduction/why-toil.md +0 -99
- package/docs/llms.txt +0 -95
- package/docs/realtime/README.md +0 -102
- package/docs/realtime/channels.md +0 -211
- package/docs/realtime/streams.md +0 -369
- package/docs/services/README.md +0 -60
- package/docs/services/analytics.md +0 -279
- package/docs/services/caching.md +0 -175
- package/docs/services/cookies.md +0 -235
- package/docs/services/crypto.md +0 -209
- package/docs/services/email.md +0 -289
- package/docs/services/environment.md +0 -141
- package/docs/services/ratelimit.md +0 -174
- package/docs/services/time.md +0 -85
- package/eslint.config.js +0 -57
- package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
- package/presets/eslint.js +0 -88
- package/src/backend/index.ts +0 -204
- package/src/cli/configure.ts +0 -363
- package/src/cli/create.ts +0 -924
- package/src/cli/db.ts +0 -158
- package/src/cli/diagnostics.ts +0 -665
- package/src/cli/doctor.ts +0 -982
- package/src/cli/features.ts +0 -170
- package/src/cli/index.ts +0 -326
- package/src/cli/notify.ts +0 -120
- package/src/cli/proc.ts +0 -56
- package/src/cli/ui.ts +0 -169
- package/src/cli/update.ts +0 -248
- package/src/cli/updates.ts +0 -92
- package/src/cli/validate.ts +0 -31
- package/src/cli/version-check.ts +0 -125
- package/src/client/auth.ts +0 -869
- package/src/client/channel/channel.ts +0 -146
- package/src/client/components/Form.tsx +0 -65
- package/src/client/components/Image.tsx +0 -160
- package/src/client/components/Script.tsx +0 -113
- package/src/client/components/Slot.tsx +0 -21
- package/src/client/dev/devtools.tsx +0 -1089
- package/src/client/dev/error-overlay.tsx +0 -225
- package/src/client/errors.ts +0 -11
- package/src/client/head/head.ts +0 -182
- package/src/client/head/metadata.ts +0 -110
- package/src/client/index.ts +0 -130
- package/src/client/navigation/Link.tsx +0 -108
- package/src/client/navigation/NavLink.tsx +0 -86
- package/src/client/navigation/navigation.ts +0 -235
- package/src/client/navigation/prefetch.ts +0 -169
- package/src/client/navigation/scroll.ts +0 -53
- package/src/client/routing/Router.tsx +0 -216
- package/src/client/routing/action.ts +0 -122
- package/src/client/routing/error-boundary.tsx +0 -43
- package/src/client/routing/hooks.ts +0 -140
- package/src/client/routing/lazy.ts +0 -77
- package/src/client/routing/loader.ts +0 -381
- package/src/client/routing/match.ts +0 -47
- package/src/client/routing/mount.tsx +0 -90
- package/src/client/routing/params-context.ts +0 -10
- package/src/client/routing/slot-context.ts +0 -7
- package/src/client/rpc.ts +0 -97
- package/src/client/search/search.ts +0 -189
- package/src/client/search/use-page-search.ts +0 -73
- package/src/client/ssr/markers.tsx +0 -172
- package/src/client/stream/client.ts +0 -237
- package/src/client/types.ts +0 -73
- package/src/compiler/auth-emails.ts +0 -312
- package/src/compiler/config.ts +0 -369
- package/src/compiler/docs.ts +0 -127
- package/src/compiler/email-preview.ts +0 -314
- package/src/compiler/emails.ts +0 -404
- package/src/compiler/fonts.ts +0 -89
- package/src/compiler/generate.ts +0 -471
- package/src/compiler/image-blur.ts +0 -42
- package/src/compiler/image-report.ts +0 -88
- package/src/compiler/index.ts +0 -1362
- package/src/compiler/pages.ts +0 -49
- package/src/compiler/plugin.ts +0 -391
- package/src/compiler/prerender.ts +0 -263
- package/src/compiler/routes.ts +0 -170
- package/src/compiler/seo.ts +0 -415
- package/src/compiler/sri.ts +0 -144
- package/src/compiler/ssg.ts +0 -177
- package/src/compiler/ssr-codegen.ts +0 -85
- package/src/compiler/template-build.ts +0 -676
- package/src/compiler/template.ts +0 -262
- package/src/compiler/toil-docs.generated.ts +0 -77
- package/src/compiler/vite.ts +0 -245
- package/src/devserver/analytics/index.ts +0 -317
- package/src/devserver/config/dotenv.ts +0 -102
- package/src/devserver/config/env.ts +0 -30
- package/src/devserver/config/ratelimit.ts +0 -123
- package/src/devserver/daemon/catalog.ts +0 -120
- package/src/devserver/daemon/cron.ts +0 -87
- package/src/devserver/daemon/host.ts +0 -134
- package/src/devserver/daemon/index.ts +0 -369
- package/src/devserver/daemon/runtime.ts +0 -48
- package/src/devserver/db/catalog.ts +0 -109
- package/src/devserver/db/database.ts +0 -1834
- package/src/devserver/db/derives.ts +0 -121
- package/src/devserver/db/index.ts +0 -20
- package/src/devserver/db/routeKinds.ts +0 -191
- package/src/devserver/db/types.ts +0 -168
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +0 -141
- package/src/devserver/email/console.ts +0 -348
- package/src/devserver/email/index.ts +0 -155
- package/src/devserver/email/providers.ts +0 -131
- package/src/devserver/email/status.ts +0 -23
- package/src/devserver/email/validate.ts +0 -37
- package/src/devserver/email/wire.ts +0 -55
- package/src/devserver/http/cache.ts +0 -0
- package/src/devserver/http/envelope.ts +0 -157
- package/src/devserver/http/proxy.ts +0 -172
- package/src/devserver/http/runtime.ts +0 -387
- package/src/devserver/index.ts +0 -43
- package/src/devserver/production-ipc.ts +0 -63
- package/src/devserver/production-worker.ts +0 -83
- package/src/devserver/production.ts +0 -718
- package/src/devserver/runtime/crypto.ts +0 -587
- package/src/devserver/runtime/host.ts +0 -427
- package/src/devserver/runtime/module.ts +0 -442
- package/src/devserver/server.ts +0 -251
- package/src/devserver/ssr.ts +0 -208
- package/src/devserver/stream/catalog.ts +0 -106
- package/src/devserver/stream/host.ts +0 -42
- package/src/devserver/stream/index.ts +0 -301
- package/src/devserver/stream/manager.ts +0 -167
- package/src/devserver/stream/router.ts +0 -101
- package/src/devserver/stream/wire.ts +0 -58
- package/src/devserver/stream/ws.ts +0 -78
- package/src/devserver/wasm/sections.ts +0 -59
- package/src/devserver/wasm/surface.ts +0 -115
- package/src/io/FastMap.ts +0 -151
- package/src/io/FastSet.ts +0 -113
- package/src/io/codec.ts +0 -362
- package/src/io/index.ts +0 -10
- package/src/io/lengths.ts +0 -14
- package/src/io/types.ts +0 -19
- package/src/logger/index.ts +0 -22
- package/src/shared/index.ts +0 -46
- package/test/analytics-dev.test.ts +0 -113
- package/test/assembly/aspect-shim.ts +0 -33
- package/test/assembly/cookie.spec.ts +0 -303
- package/test/assembly/example.spec.ts +0 -22
- package/test/assembly/ssr.spec.ts +0 -95
- package/test/assembly.test.ts +0 -59
- package/test/auth-emails.test.ts +0 -118
- package/test/built-ssr.test.ts +0 -148
- package/test/channel.test.ts +0 -21
- package/test/config-host-port.test.ts +0 -59
- package/test/configure.test.ts +0 -94
- package/test/daemon-build.test.ts +0 -278
- package/test/daemon-catalog.test.ts +0 -349
- package/test/daemon-emulation.test.ts +0 -256
- package/test/db.test.ts +0 -0
- package/test/devserver-database.test.ts +0 -1102
- package/test/devserver-email.test.ts +0 -306
- package/test/devserver-pqauth.test.ts +0 -93
- package/test/devserver-secrets.test.ts +0 -63
- package/test/devserver.test.ts +0 -259
- package/test/doctor.test.ts +0 -298
- package/test/dom/Image.test.tsx +0 -144
- package/test/dom/Link.test.tsx +0 -67
- package/test/dom/NavLink.test.tsx +0 -37
- package/test/dom/Script.test.tsx +0 -48
- package/test/dom/action.test.tsx +0 -146
- package/test/dom/error-overlay.test.tsx +0 -44
- package/test/dom/loader.test.tsx +0 -121
- package/test/dom/navigation.test.ts +0 -59
- package/test/dom/revalidate.test.tsx +0 -38
- package/test/dom/route-head.test.tsx +0 -56
- package/test/dom/router-loading.test.tsx +0 -44
- package/test/dom/scroll.test.ts +0 -56
- package/test/dom/slot.test.tsx +0 -131
- package/test/dom/use-metadata.test.tsx +0 -58
- package/test/dom/view-transitions.test.tsx +0 -53
- package/test/dynamic-routing.test.ts +0 -132
- package/test/email-console.test.ts +0 -86
- package/test/email-preview.test.ts +0 -73
- package/test/emails.test.ts +0 -58
- package/test/errors.test.ts +0 -21
- package/test/example-guestbook.test.ts +0 -123
- package/test/features.test.ts +0 -149
- package/test/fixtures/bignum-wire/spec.ts +0 -25
- package/test/fixtures/daemon-app.ts +0 -65
- package/test/fixtures/stream-echo.ts +0 -26
- package/test/fixtures/stream-gate.ts +0 -28
- package/test/fixtures/stream-trap.ts +0 -18
- package/test/fixtures/stream-typed.ts +0 -41
- package/test/fonts.test.ts +0 -28
- package/test/global-setup.ts +0 -17
- package/test/head.test.ts +0 -42
- package/test/image-blur.test.ts +0 -50
- package/test/io.test.ts +0 -117
- package/test/metadata.test.ts +0 -40
- package/test/navlink.test.ts +0 -28
- package/test/pages.test.ts +0 -105
- package/test/placeholder.test.ts +0 -9
- package/test/pqauth-e2e.test.ts +0 -216
- package/test/pqauth-email.test.ts +0 -801
- package/test/prerender.test.ts +0 -116
- package/test/prettier-plugin.test.ts +0 -46
- package/test/routes.test.ts +0 -76
- package/test/rpc-bignum-wire.test.ts +0 -164
- package/test/rpc-dispatch.test.ts +0 -132
- package/test/rpc-kinds.test.ts +0 -18
- package/test/rpc.test.ts +0 -66
- package/test/search.test.ts +0 -114
- package/test/seo.test.ts +0 -175
- package/test/slot-layouts.test.ts +0 -69
- package/test/sri.test.ts +0 -129
- package/test/ssg.test.ts +0 -36
- package/test/ssr-hydration.test.tsx +0 -122
- package/test/ssr-render.test.ts +0 -197
- package/test/ssr-template.test.tsx +0 -482
- package/test/stream-emulation.test.ts +0 -479
- package/test/ui.test.ts +0 -40
- package/test/update.test.ts +0 -58
- package/test/validate.test.ts +0 -42
- package/test/version-check.test.ts +0 -130
- package/tests/data-parity/generated-parity.ts +0 -99
- package/tests/data-parity/parity.ts +0 -80
- package/tests/data-parity/spec.ts +0 -46
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Site-wide page search over route {@link Metadata}. The compiler bakes a static index of every
|
|
3
|
-
* page's title/description/keywords/OpenGraph (extracted from each route's `export const metadata`)
|
|
4
|
-
* into the generated bundle and registers it with {@link registerPages} at startup. User code then
|
|
5
|
-
* queries it with {@link searchPages} (pure, framework-agnostic) or the {@link usePageSearch} hook,
|
|
6
|
-
* getting back ranked pages with their `path` ready to feed to `Link` / `navigate`.
|
|
7
|
-
*
|
|
8
|
-
* Only statically-analyzable metadata is indexed: a route's `generateMetadata` (dynamic, per-request)
|
|
9
|
-
* and computed values can't be known at build time. A dynamic route can still be made discoverable
|
|
10
|
-
* by exporting static {@link SearchHints} (`export const searchHints = { title, keywords, … }`),
|
|
11
|
-
* which the compiler merges over the route's static `metadata` when building the index.
|
|
12
|
-
*/
|
|
13
|
-
import type { Metadata } from '../head/metadata.js';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Static search hints a route can `export const searchHints` to seed the search index, useful when
|
|
17
|
-
* the route's real `<head>` is produced by a dynamic `generateMetadata` (so nothing else is
|
|
18
|
-
* statically indexable). Merged over the route's static `metadata`, winning ties.
|
|
19
|
-
*/
|
|
20
|
-
export interface SearchHints {
|
|
21
|
-
/** Indexed as the page title (highest-weighted field). */
|
|
22
|
-
readonly title?: string;
|
|
23
|
-
/** Indexed as the page description. */
|
|
24
|
-
readonly description?: string;
|
|
25
|
-
/** Indexed keywords (string or array). */
|
|
26
|
-
readonly keywords?: string | readonly string[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** A searchable page: its route pattern plus the statically-known metadata baked at build time. */
|
|
30
|
-
export interface PageMeta {
|
|
31
|
-
/** Route URL pattern, e.g. `'/'`, `'/about'`, `'/blog/:id'`. */
|
|
32
|
-
readonly path: string;
|
|
33
|
-
/** Whether `path` has dynamic (`:param` / `*catch-all`) segments, not navigable without params. */
|
|
34
|
-
readonly dynamic: boolean;
|
|
35
|
-
/** The page's statically-extracted metadata (empty object when the route declares none). */
|
|
36
|
-
readonly metadata: Metadata;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** A metadata field that {@link searchPages} can match against. */
|
|
40
|
-
export type SearchField = 'title' | 'description' | 'keywords' | 'path' | 'openGraph';
|
|
41
|
-
|
|
42
|
-
/** Options for {@link searchPages}. */
|
|
43
|
-
export interface PageSearchOptions {
|
|
44
|
-
/** Cap the number of results returned (after ranking). Default: no cap. */
|
|
45
|
-
readonly limit?: number;
|
|
46
|
-
/** Include dynamic (`:param` / `*`) routes, which can't be navigated to as-is. Default: `false`. */
|
|
47
|
-
readonly includeDynamic?: boolean;
|
|
48
|
-
/** Restrict matching to these fields. Default: every searchable field. */
|
|
49
|
-
readonly fields?: readonly SearchField[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** A page that matched a query, with its relevance {@link score} and the fields that matched. */
|
|
53
|
-
export interface PageSearchResult {
|
|
54
|
-
readonly page: PageMeta;
|
|
55
|
-
/** Relevance score (higher = better); always `> 0` for a returned result. */
|
|
56
|
-
readonly score: number;
|
|
57
|
-
/** The metadata fields that contributed to the match, e.g. `['title', 'keywords']`. */
|
|
58
|
-
readonly matches: readonly SearchField[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Relative weight of each field, title is the strongest signal, OpenGraph the weakest. */
|
|
62
|
-
const FIELD_WEIGHT: Record<SearchField, number> = {
|
|
63
|
-
title: 10,
|
|
64
|
-
path: 6,
|
|
65
|
-
keywords: 5,
|
|
66
|
-
description: 3,
|
|
67
|
-
openGraph: 2,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const ALL_FIELDS: readonly SearchField[] = [
|
|
71
|
-
'title',
|
|
72
|
-
'description',
|
|
73
|
-
'keywords',
|
|
74
|
-
'path',
|
|
75
|
-
'openGraph',
|
|
76
|
-
];
|
|
77
|
-
|
|
78
|
-
/** The live page index, populated by {@link registerPages} from the compiler-generated bundle. */
|
|
79
|
-
let registry: readonly PageMeta[] = [];
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Registers the project's page index. Called once at startup by the generated `globals` module
|
|
83
|
-
* (`Toil.registerPages(pages)`); replaces any previous registration. Rarely called by user code,
|
|
84
|
-
* but exposed for tests and advanced setups that build their own index.
|
|
85
|
-
*/
|
|
86
|
-
export function registerPages(pages: readonly PageMeta[]): void {
|
|
87
|
-
registry = pages;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The registered page index (every page, including dynamic ones). Empty before registration. */
|
|
91
|
-
export function getPages(): readonly PageMeta[] {
|
|
92
|
-
return registry;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Normalizes a search target (a result, a page, or a raw path) to its route path string. */
|
|
96
|
-
export function pagePath(target: string | PageMeta | PageSearchResult): string {
|
|
97
|
-
if (typeof target === 'string') return target;
|
|
98
|
-
return 'page' in target ? target.page.path : target.path;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Joins a page's keyword list (string or array) into one searchable string. */
|
|
102
|
-
function keywordsText(keywords: Metadata['keywords']): string {
|
|
103
|
-
if (keywords === undefined) return '';
|
|
104
|
-
return typeof keywords === 'string' ? keywords : keywords.join(' ');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The searchable text for one field of a page (empty string when the field is unset). */
|
|
108
|
-
function fieldText(page: PageMeta, field: SearchField): string {
|
|
109
|
-
const m = page.metadata;
|
|
110
|
-
switch (field) {
|
|
111
|
-
case 'title':
|
|
112
|
-
return m.title ?? '';
|
|
113
|
-
case 'description':
|
|
114
|
-
return m.description ?? '';
|
|
115
|
-
case 'keywords':
|
|
116
|
-
return keywordsText(m.keywords);
|
|
117
|
-
case 'path':
|
|
118
|
-
// Make slugs word-searchable: '/get-started' → 'get started', '/blog/:id' → 'blog id'.
|
|
119
|
-
return page.path.replace(/[/:*\-_]+/g, ' ').trim();
|
|
120
|
-
case 'openGraph': {
|
|
121
|
-
const og = m.openGraph;
|
|
122
|
-
if (!og) return '';
|
|
123
|
-
return [og.title, og.description, og.siteName, og.type].filter(Boolean).join(' ');
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Whether the character before `index` is a word boundary (start of string or non-alphanumeric). */
|
|
129
|
-
function isWordStart(text: string, index: number): boolean {
|
|
130
|
-
return index === 0 || !/[a-z0-9]/i.test(text[index - 1]);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Scores a single field against one query term, returning `0` for no match. Substring matches count;
|
|
135
|
-
* a whole-field exact match, a prefix match, and a word-boundary match each rank progressively higher.
|
|
136
|
-
*/
|
|
137
|
-
function scoreTerm(text: string, term: string, weight: number): number {
|
|
138
|
-
const index = text.indexOf(term);
|
|
139
|
-
if (index === -1) return 0;
|
|
140
|
-
if (text === term) return weight * 3; // the field IS the term
|
|
141
|
-
if (index === 0) return weight * 1.6; // prefix of the field
|
|
142
|
-
if (isWordStart(text, index)) return weight * 1.2; // start of a word within the field
|
|
143
|
-
return weight; // mid-word substring
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Searches the registered page index for `query`, returning pages ranked by relevance (best first).
|
|
148
|
-
* Matching is case-insensitive; the query is split on whitespace and every term must match somewhere
|
|
149
|
-
* (AND semantics) for a page to be included. An empty query returns no results. Dynamic routes are
|
|
150
|
-
* excluded unless {@link PageSearchOptions.includeDynamic} is set, since they need params to navigate.
|
|
151
|
-
*/
|
|
152
|
-
export function searchPages(query: string, options: PageSearchOptions = {}): PageSearchResult[] {
|
|
153
|
-
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
154
|
-
if (terms.length === 0) return [];
|
|
155
|
-
const fields = options.fields ?? ALL_FIELDS;
|
|
156
|
-
|
|
157
|
-
const results: PageSearchResult[] = [];
|
|
158
|
-
for (const page of registry) {
|
|
159
|
-
if (page.dynamic && !options.includeDynamic) continue;
|
|
160
|
-
|
|
161
|
-
const texts = fields.map((field) => ({
|
|
162
|
-
field,
|
|
163
|
-
text: fieldText(page, field).toLowerCase(),
|
|
164
|
-
}));
|
|
165
|
-
const matched = new Set<SearchField>();
|
|
166
|
-
let score = 0;
|
|
167
|
-
// AND semantics: every term must hit at least one field, or the page is dropped.
|
|
168
|
-
const allTermsMatch = terms.every((term) => {
|
|
169
|
-
let termScore = 0;
|
|
170
|
-
for (const { field, text } of texts) {
|
|
171
|
-
if (!text) continue;
|
|
172
|
-
const s = scoreTerm(text, term, FIELD_WEIGHT[field]);
|
|
173
|
-
if (s > 0) {
|
|
174
|
-
termScore += s;
|
|
175
|
-
matched.add(field);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
score += termScore;
|
|
179
|
-
return termScore > 0;
|
|
180
|
-
});
|
|
181
|
-
if (allTermsMatch && score > 0) {
|
|
182
|
-
results.push({ page, score, matches: [...matched] });
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Best score first; ties broken by path for a stable, deterministic order.
|
|
187
|
-
results.sort((a, b) => b.score - a.score || a.page.path.localeCompare(b.page.path));
|
|
188
|
-
return options.limit !== undefined ? results.slice(0, options.limit) : results;
|
|
189
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* React binding for the page-metadata {@link searchPages search}. Gives a route component reactive,
|
|
3
|
-
* memoized search results plus a `goTo` helper that navigates straight to a matched page, a drop-in
|
|
4
|
-
* for a site-wide "jump to page" / command-palette style search box.
|
|
5
|
-
*/
|
|
6
|
-
import { useMemo } from 'react';
|
|
7
|
-
|
|
8
|
-
import { navigate, type NavigateOptions } from '../navigation/navigation.js';
|
|
9
|
-
import type { Href } from '../types.js';
|
|
10
|
-
import {
|
|
11
|
-
getPages,
|
|
12
|
-
type PageMeta,
|
|
13
|
-
pagePath,
|
|
14
|
-
type PageSearchOptions,
|
|
15
|
-
type PageSearchResult,
|
|
16
|
-
searchPages,
|
|
17
|
-
} from './search.js';
|
|
18
|
-
|
|
19
|
-
/** What {@link usePageSearch} returns. */
|
|
20
|
-
export interface PageSearch {
|
|
21
|
-
/** Ranked matches for the current query (best first); empty when the query is blank. */
|
|
22
|
-
readonly results: readonly PageSearchResult[];
|
|
23
|
-
/** The full registered page index (handy for rendering an "all pages" listing). */
|
|
24
|
-
readonly pages: readonly PageMeta[];
|
|
25
|
-
/**
|
|
26
|
-
* Navigates to a result / page / raw path. A dynamic (`:param`) page can't be navigated to
|
|
27
|
-
* as-is, so passing one (or its result) is a no-op unless you pass a concrete path string with
|
|
28
|
-
* the params already filled in. A stable reference, safe to destructure.
|
|
29
|
-
*/
|
|
30
|
-
readonly goTo: (
|
|
31
|
-
target: string | PageMeta | PageSearchResult,
|
|
32
|
-
options?: NavigateOptions,
|
|
33
|
-
) => void;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Whether a path can be navigated to directly (no unfilled dynamic segments). */
|
|
37
|
-
function isNavigable(path: string): boolean {
|
|
38
|
-
return !/[:*]/.test(path);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Searches the project's pages by `query` and returns ranked {@link PageSearchResult}s, recomputed
|
|
43
|
-
* only when the query or options change. Use the returned `goTo` to redirect to a match:
|
|
44
|
-
*
|
|
45
|
-
* ```tsx
|
|
46
|
-
* const { results, goTo } = usePageSearch(query);
|
|
47
|
-
* return results.map((r) => (
|
|
48
|
-
* <button key={r.page.path} onClick={() => { goTo(r); }}>{r.page.metadata.title ?? r.page.path}</button>
|
|
49
|
-
* ));
|
|
50
|
-
* ```
|
|
51
|
-
*/
|
|
52
|
-
export function usePageSearch(query: string, options: PageSearchOptions = {}): PageSearch {
|
|
53
|
-
const { limit, includeDynamic, fields } = options;
|
|
54
|
-
const fieldsKey = fields?.join(',');
|
|
55
|
-
const results = useMemo(
|
|
56
|
-
() => searchPages(query, { limit, includeDynamic, fields }),
|
|
57
|
-
// `fields` is compared by content (fieldsKey) so a fresh array literal each render is fine.
|
|
58
|
-
[query, limit, includeDynamic, fieldsKey],
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
return useMemo<PageSearch>(
|
|
62
|
-
() => ({
|
|
63
|
-
results,
|
|
64
|
-
pages: getPages(),
|
|
65
|
-
goTo(target, navOptions) {
|
|
66
|
-
const path = pagePath(target);
|
|
67
|
-
if (typeof target !== 'string' && !isNavigable(path)) return;
|
|
68
|
-
navigate(path as Href, navOptions);
|
|
69
|
-
},
|
|
70
|
-
}),
|
|
71
|
-
[results],
|
|
72
|
-
);
|
|
73
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Edge-SSR hole markers. A route opts into server rendering by wrapping its
|
|
3
|
-
* dynamic bits in these components; the compiler's template extractor finds
|
|
4
|
-
* them deterministically (rather than guessing which `{expr}` is dynamic).
|
|
5
|
-
*
|
|
6
|
-
* The markers are TRANSPARENT at runtime: in the browser `<Hole>` renders its
|
|
7
|
-
* children, `<Repeat>` renders `each.map(...)`, `<RawHtml>` renders a
|
|
8
|
-
* `dangerouslySetInnerHTML` wrapper, `<Island>` renders its children. So the
|
|
9
|
-
* client's React tree is exactly the normal app.
|
|
10
|
-
*
|
|
11
|
-
* Under the BUILD extractor (which calls {@link __setSsrBuild}(true) before
|
|
12
|
-
* `renderToStaticMarkup`), each marker instead emits a unique PUA sentinel
|
|
13
|
-
* token marking an insertion point. The extractor strips the tokens, records
|
|
14
|
-
* their byte offsets, and emits the `.tmpl` + `.slots`. PUA codepoints
|
|
15
|
-
* (U+E000..) never occur in real HTML and React serialises them verbatim, so
|
|
16
|
-
* the tokens are collision-proof and strip to zero bytes.
|
|
17
|
-
*
|
|
18
|
-
* Because the static scaffold around every hole is React's OWN
|
|
19
|
-
* `renderToStaticMarkup` output, the browser's `hydrateRoot` sees byte-
|
|
20
|
-
* identical markup as long as the guest escapes hole values the same way React
|
|
21
|
-
* does (it does: see `server/runtime/ssr/escape.ts`).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { createElement, Fragment, type ReactNode, useEffect, useState } from 'react';
|
|
25
|
-
|
|
26
|
-
/** Token framing codepoints (Unicode Private Use Area). */
|
|
27
|
-
export const SENTINEL_START = String.fromCharCode(0xe000);
|
|
28
|
-
export const SENTINEL_SEP = String.fromCharCode(0xe001);
|
|
29
|
-
export const SENTINEL_END = String.fromCharCode(0xe002);
|
|
30
|
-
|
|
31
|
-
/** Kind chars embedded in a sentinel token. Lowercase letters; `R`/`r` open and
|
|
32
|
-
* close a repeat region. */
|
|
33
|
-
export const enum HoleKindChar {
|
|
34
|
-
Text = 't',
|
|
35
|
-
Raw = 'h',
|
|
36
|
-
Attr = 'a',
|
|
37
|
-
RepeatOpen = 'R',
|
|
38
|
-
RepeatClose = 'r',
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let ssrBuild = false;
|
|
42
|
-
|
|
43
|
-
/** Build-extractor switch. Flipped on around a `renderToStaticMarkup` pass so
|
|
44
|
-
* the markers emit sentinels; left `false` in the browser bundle so they are
|
|
45
|
-
* transparent. */
|
|
46
|
-
export function __setSsrBuild(on: boolean): void {
|
|
47
|
-
ssrBuild = on;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** `true` while the extractor is rendering (test/diagnostic use). */
|
|
51
|
-
export function __isSsrBuild(): boolean {
|
|
52
|
-
return ssrBuild;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** A self-closing insertion-point token, e.g. `␀t<id>␂`. */
|
|
56
|
-
function token(kind: HoleKindChar, id: string): string {
|
|
57
|
-
return SENTINEL_START + kind + id + SENTINEL_END;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Wrap a string so a component always returns a `ReactElement` (a Fragment
|
|
61
|
-
* with one text child renders the string verbatim). */
|
|
62
|
-
function textNode(s: string): ReactNode {
|
|
63
|
-
return createElement(Fragment, null, s);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface HoleProps {
|
|
67
|
-
/** Stable hole name; the extractor maps it to a numeric slot id. */
|
|
68
|
-
id: string;
|
|
69
|
-
children?: ReactNode;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** A scalar text hole. Client: renders `children`. Build: a text insertion
|
|
73
|
-
* point the guest fills with the React-escaped value. */
|
|
74
|
-
export function Hole(props: HoleProps): ReactNode {
|
|
75
|
-
if (ssrBuild) return textNode(token(HoleKindChar.Text, props.id));
|
|
76
|
-
return createElement(Fragment, null, props.children);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* An attribute-value hole. Unlike the child markers, an attribute is not a child
|
|
81
|
-
* node, so this is a *function* used in attribute position rather than a JSX
|
|
82
|
-
* element:
|
|
83
|
-
*
|
|
84
|
-
* ```tsx
|
|
85
|
-
* <a href={attr('profileUrl', d.url)} class={attr('cls', d.cls)}>…</a>
|
|
86
|
-
* ```
|
|
87
|
-
*
|
|
88
|
-
* Browser: returns `value` unchanged, so the attribute renders normally. Build:
|
|
89
|
-
* returns a PUA sentinel token, so the extractor records an `attr` slot at the
|
|
90
|
-
* attribute's byte offset (the token sits between the quotes and strips to zero
|
|
91
|
-
* bytes). The guest fills it per request with the React-escaped value (kind
|
|
92
|
-
* `attr`), which the host splices at that offset — exactly like a text hole, but
|
|
93
|
-
* inside an attribute. It composes with surrounding literal text
|
|
94
|
-
* (`` `btn ${attr('x', v)}` ``), and `setAttr`/`HtmlBuilder.attr` produce the
|
|
95
|
-
* value on the server side.
|
|
96
|
-
*/
|
|
97
|
-
export function attr(id: string, value: string): string {
|
|
98
|
-
return ssrBuild ? token(HoleKindChar.Attr, id) : value;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export interface RawHtmlProps {
|
|
102
|
-
id: string;
|
|
103
|
-
/** Raw HTML string. The author owns sanitisation (same as React
|
|
104
|
-
* `dangerouslySetInnerHTML`). */
|
|
105
|
-
html: string;
|
|
106
|
-
/** Wrapper element tag (raw HTML needs a host element to live in so server
|
|
107
|
-
* and client DOM match). Defaults to `div`. */
|
|
108
|
-
as?: keyof React.JSX.IntrinsicElements;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** A raw-HTML block hole. Client: `<as dangerouslySetInnerHTML>`. Build:
|
|
112
|
-
* `<as>SENTINEL</as>` so the `.tmpl` carries the wrapper and the guest fills
|
|
113
|
-
* its inner HTML. */
|
|
114
|
-
export function RawHtml(props: RawHtmlProps): ReactNode {
|
|
115
|
-
const tag = props.as ?? 'div';
|
|
116
|
-
if (ssrBuild) {
|
|
117
|
-
return createElement(tag, null, token(HoleKindChar.Raw, props.id));
|
|
118
|
-
}
|
|
119
|
-
return createElement(tag, { dangerouslySetInnerHTML: { __html: props.html } });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface RepeatProps<T> {
|
|
123
|
-
id: string;
|
|
124
|
-
/** The data rows. The build extractor requires a representative sample with
|
|
125
|
-
* at least one row to capture the row sub-template. */
|
|
126
|
-
each: readonly T[];
|
|
127
|
-
children: (item: T, index: number) => ReactNode;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** A repeat region. Client: `each.map(children)`. Build: a region wrapping
|
|
131
|
-
* exactly ONE representative row (so the extractor captures the row sub-
|
|
132
|
-
* template + its nested holes); the host inserts the guest's pre-stamped,
|
|
133
|
-
* concatenated rows at the region offset. */
|
|
134
|
-
export function Repeat<T>(props: RepeatProps<T>): ReactNode {
|
|
135
|
-
if (ssrBuild) {
|
|
136
|
-
const sample = props.each.length > 0 ? props.each[0] : undefined;
|
|
137
|
-
return createElement(
|
|
138
|
-
Fragment,
|
|
139
|
-
null,
|
|
140
|
-
token(HoleKindChar.RepeatOpen, props.id),
|
|
141
|
-
sample !== undefined ? props.children(sample, 0) : null,
|
|
142
|
-
token(HoleKindChar.RepeatClose, props.id),
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
return createElement(
|
|
146
|
-
Fragment,
|
|
147
|
-
null,
|
|
148
|
-
// Each row is wrapped in a keyed Fragment so React has a stable list key (the
|
|
149
|
-
// row markup itself need not carry one). Index keys are fine here: an SSR'd
|
|
150
|
-
// region hydrates 1:1 against the host's pre-stamped rows and does not reorder.
|
|
151
|
-
props.each.map((item, i) => createElement(Fragment, { key: i }, props.children(item, i))),
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export interface IslandProps {
|
|
156
|
-
children?: ReactNode;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** A client-only escape hatch for content outside the server-template subset.
|
|
160
|
-
* Build: renders nothing (empty in the server HTML). Client: ALSO renders nothing
|
|
161
|
-
* on the first (hydration) render, so it matches the empty server markup, then a
|
|
162
|
-
* mount effect reveals `children` on the next commit. Rendering the children
|
|
163
|
-
* during hydration instead would diverge from the server's empty markup and trip
|
|
164
|
-
* a hydration mismatch. So an island gets no first-paint / SEO, by design. */
|
|
165
|
-
export function Island(props: IslandProps): ReactNode {
|
|
166
|
-
const [hydrated, setHydrated] = useState(false);
|
|
167
|
-
useEffect(() => {
|
|
168
|
-
setHydrated(true);
|
|
169
|
-
}, []);
|
|
170
|
-
if (ssrBuild || !hydrated) return null;
|
|
171
|
-
return createElement(Fragment, null, props.children);
|
|
172
|
-
}
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client runtime for the typed `@stream` channel (doc 08 section 8.2). `Server.Stream.<Class>.connect(path?)`
|
|
3
|
-
* opens a browser `WebTransport` session to the class's `route` on the production edge (an `https://`
|
|
4
|
-
* stream-tier origin), or a `WebSocket` against the dev server (a `ws(s)://` origin), and returns a channel:
|
|
5
|
-
* `onMessage` / `send` / `onClose` / `close`. A raw `@stream` channel sends `Uint8Array`; a typed
|
|
6
|
-
* `@stream({ message: T })` channel sends the `@data` class and encodes it on send (the per-class
|
|
7
|
-
* encoder the generated module passes). The inbound reply is always raw bytes. The generated
|
|
8
|
-
* `shared/server.ts` (toilscript hot pass) attaches `makeStreamClient(routes, undefined, encoders)` to
|
|
9
|
-
* `globalThis.__toilStream`, and `Server.Stream`
|
|
10
|
-
* (`rpc.ts`) surfaces it - the same wiring the REST client uses via `globalThis.__toilRest`.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** A live `@stream` connection. `TSend` is the outbound message type: `Uint8Array` for a raw `@stream`,
|
|
14
|
-
* or the `@data` class for a typed `@stream({ message: T })` (the channel encodes it before sending).
|
|
15
|
-
* The inbound reply is ALWAYS raw bytes - the server's `StreamOutbound` is raw (doc 03 2.5). */
|
|
16
|
-
export interface StreamChannel<TSend = Uint8Array> {
|
|
17
|
-
/** Register the inbound-frame handler (one call per server reply frame). */
|
|
18
|
-
onMessage(cb: (data: Uint8Array) => void): void;
|
|
19
|
-
/** Send one outbound message (one `@message` on the server); a typed channel encodes it first. */
|
|
20
|
-
send(data: TSend): void;
|
|
21
|
-
/** Register the close handler (`code` is the `0x02xx` stream close code, or the WS code). */
|
|
22
|
-
onClose(cb: (code: number) => void): void;
|
|
23
|
-
/** Close the connection. */
|
|
24
|
-
close(): void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The connect factory for one `@stream` class. `path` is the `@connect` path (default `''`). */
|
|
28
|
-
export interface StreamConnectable<TSend = Uint8Array> {
|
|
29
|
-
connect(path?: string): Promise<StreamChannel<TSend>>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** `Server.Stream`: one `connect()` factory per `@stream` class name. */
|
|
33
|
-
export type StreamClient = Record<string, StreamConnectable>;
|
|
34
|
-
|
|
35
|
-
/** Open a WebSocket to `url` and resolve a channel once the upgrade completes; reject if the socket
|
|
36
|
-
* closes/errors BEFORE it opens (a 421 redirect / wrong-node / unreachable). A server close AFTER
|
|
37
|
-
* open (e.g. a `@connect` reject or a guest reject) surfaces through `onClose(code)`. */
|
|
38
|
-
function connectStream<TSend = Uint8Array>(
|
|
39
|
-
url: string,
|
|
40
|
-
encode?: (msg: never) => Uint8Array,
|
|
41
|
-
): Promise<StreamChannel<TSend>> {
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
const ws = new WebSocket(url);
|
|
44
|
-
ws.binaryType = 'arraybuffer';
|
|
45
|
-
let opened = false;
|
|
46
|
-
let messageCb: ((data: Uint8Array) => void) | undefined;
|
|
47
|
-
let closeCb: ((code: number) => void) | undefined;
|
|
48
|
-
const pending: Uint8Array[] = [];
|
|
49
|
-
|
|
50
|
-
const channel: StreamChannel<TSend> = {
|
|
51
|
-
onMessage: (cb): void => {
|
|
52
|
-
messageCb = cb;
|
|
53
|
-
for (const m of pending) cb(m);
|
|
54
|
-
pending.length = 0;
|
|
55
|
-
},
|
|
56
|
-
onClose: (cb): void => {
|
|
57
|
-
closeCb = cb;
|
|
58
|
-
},
|
|
59
|
-
send: (data): void => {
|
|
60
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
61
|
-
// A typed channel encodes the @data message; a raw channel sends the bytes as-is.
|
|
62
|
-
const bytes = encode ? encode(data as never) : (data as unknown as Uint8Array);
|
|
63
|
-
try {
|
|
64
|
-
ws.send(bytes as BufferSource);
|
|
65
|
-
} catch {
|
|
66
|
-
/* socket transitioned OPEN -> CLOSING between the readyState check and send */
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
close: (): void => {
|
|
70
|
-
ws.close();
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
ws.addEventListener('open', () => {
|
|
75
|
-
opened = true;
|
|
76
|
-
resolve(channel);
|
|
77
|
-
});
|
|
78
|
-
ws.addEventListener('message', (event: MessageEvent) => {
|
|
79
|
-
if (!(event.data instanceof ArrayBuffer)) return;
|
|
80
|
-
const bytes = new Uint8Array(event.data);
|
|
81
|
-
// Buffer a reply that arrives before onMessage() registers (mirror the WebTransport path),
|
|
82
|
-
// so an eager server reply is not silently dropped.
|
|
83
|
-
if (messageCb) messageCb(bytes);
|
|
84
|
-
else pending.push(bytes);
|
|
85
|
-
});
|
|
86
|
-
ws.addEventListener('close', (event: CloseEvent) => {
|
|
87
|
-
if (!opened) reject(new Error(`stream connect failed (closed ${String(event.code)})`));
|
|
88
|
-
else closeCb?.(event.code);
|
|
89
|
-
});
|
|
90
|
-
ws.addEventListener('error', () => {
|
|
91
|
-
if (!opened) reject(new Error('stream connect error'));
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Open a browser `WebTransport` session to `url` (an `https://` stream-tier origin) and resolve a
|
|
97
|
-
* channel once the session is ready. The browser drives the QUIC handshake, H3 Extended-CONNECT, and
|
|
98
|
-
* the RFC 9297 Quarter-Stream-ID datagram framing, so `send`/`onMessage` deal in raw bytes (no manual
|
|
99
|
-
* prefix). Rejects if the session fails to open (wrong node / unreachable / cert); a server close AFTER
|
|
100
|
-
* open surfaces via `onClose(code)`. This is the PRODUCTION transport - the L2/L3 edge is
|
|
101
|
-
* WebTransport-only; the dev server uses the `connectStream` WebSocket above. */
|
|
102
|
-
function connectStreamWT<TSend = Uint8Array>(
|
|
103
|
-
url: string,
|
|
104
|
-
encode?: (msg: never) => Uint8Array,
|
|
105
|
-
): Promise<StreamChannel<TSend>> {
|
|
106
|
-
return new Promise((resolve, reject) => {
|
|
107
|
-
if (!('WebTransport' in globalThis)) {
|
|
108
|
-
reject(new Error('WebTransport is not available in this browser'));
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
let transport: WebTransport;
|
|
112
|
-
try {
|
|
113
|
-
transport = new WebTransport(url);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
reject(e instanceof Error ? e : new Error(String(e)));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
let messageCb: ((data: Uint8Array) => void) | undefined;
|
|
119
|
-
let closeCb: ((code: number) => void) | undefined;
|
|
120
|
-
let writer: WritableStreamDefaultWriter<Uint8Array> | undefined;
|
|
121
|
-
let opened = false;
|
|
122
|
-
const pending: Uint8Array[] = [];
|
|
123
|
-
|
|
124
|
-
const channel: StreamChannel<TSend> = {
|
|
125
|
-
onMessage: (cb): void => {
|
|
126
|
-
messageCb = cb;
|
|
127
|
-
for (const m of pending) cb(m);
|
|
128
|
-
pending.length = 0;
|
|
129
|
-
},
|
|
130
|
-
onClose: (cb): void => {
|
|
131
|
-
closeCb = cb;
|
|
132
|
-
},
|
|
133
|
-
send: (data): void => {
|
|
134
|
-
if (!writer) return;
|
|
135
|
-
const bytes = encode ? encode(data as never) : (data as unknown as Uint8Array);
|
|
136
|
-
// Fire-and-forget; a write that rejects (transport closing / backpressure) is surfaced
|
|
137
|
-
// via onClose, so swallow it here to avoid an unhandled promise rejection.
|
|
138
|
-
void writer.write(bytes).catch(() => {});
|
|
139
|
-
},
|
|
140
|
-
close: (): void => {
|
|
141
|
-
try {
|
|
142
|
-
transport.close();
|
|
143
|
-
} catch {
|
|
144
|
-
/* already closing */
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
// A close AFTER open (guest reject, idle sweep, server shutdown) reports through onClose; a
|
|
150
|
-
// failure BEFORE open rejects the connect() promise via the `ready` catch below.
|
|
151
|
-
transport.closed
|
|
152
|
-
.then((info) => {
|
|
153
|
-
if (opened) closeCb?.(info?.closeCode ?? 0);
|
|
154
|
-
})
|
|
155
|
-
.catch(() => {
|
|
156
|
-
if (opened) closeCb?.(1);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
transport.ready
|
|
160
|
-
.then(() => {
|
|
161
|
-
opened = true;
|
|
162
|
-
writer = transport.datagrams.writable.getWriter();
|
|
163
|
-
const reader = transport.datagrams.readable.getReader();
|
|
164
|
-
void (async (): Promise<void> => {
|
|
165
|
-
try {
|
|
166
|
-
for (;;) {
|
|
167
|
-
const { value, done } = await reader.read();
|
|
168
|
-
if (done) break;
|
|
169
|
-
const bytes =
|
|
170
|
-
value instanceof Uint8Array
|
|
171
|
-
? value
|
|
172
|
-
: new Uint8Array(value as ArrayBufferLike);
|
|
173
|
-
if (messageCb) messageCb(bytes);
|
|
174
|
-
else pending.push(bytes);
|
|
175
|
-
}
|
|
176
|
-
} catch {
|
|
177
|
-
/* read loop ended on session close */
|
|
178
|
-
}
|
|
179
|
-
})();
|
|
180
|
-
resolve(channel);
|
|
181
|
-
})
|
|
182
|
-
.catch((e) => reject(e instanceof Error ? e : new Error(String(e))));
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Pick the transport by origin scheme: `https://` -> WebTransport (the production L2/L3 edge),
|
|
187
|
-
* `ws(s)://` -> WebSocket (the dev server). The generated `shared/server.ts` sets the scheme per build
|
|
188
|
-
* (edge: `https://wt.<tenant>`; dev: same-origin `wss://`). */
|
|
189
|
-
function openChannel<TSend = Uint8Array>(
|
|
190
|
-
url: string,
|
|
191
|
-
encode?: (msg: never) => Uint8Array,
|
|
192
|
-
): Promise<StreamChannel<TSend>> {
|
|
193
|
-
return url.startsWith('https://')
|
|
194
|
-
? connectStreamWT<TSend>(url, encode)
|
|
195
|
-
: connectStream<TSend>(url, encode);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** The same-origin WebSocket base (`ws://` / `wss://` per the page protocol). */
|
|
199
|
-
function defaultOrigin(): string {
|
|
200
|
-
const loc = globalThis.location;
|
|
201
|
-
return `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Resolve the stream-tier origin when the generated client passes none. A deploy/runtime override -
|
|
205
|
-
* `globalThis.__TOIL_STREAM_ORIGIN__` (e.g. `"https://wt.dacely.com"`, the production L2/L3 edge) -
|
|
206
|
-
* wins; otherwise fall back to the same-origin WebSocket base (the dev server). The edge override is
|
|
207
|
-
* `https://` so `openChannel` selects WebTransport; the dev fallback is `ws(s)://` so it selects
|
|
208
|
-
* WebSocket. This is how a deployed app points at its `wt.<tenant>` tier without the build knowing it. */
|
|
209
|
-
function resolveStreamOrigin(): string {
|
|
210
|
-
const override = (globalThis as { __TOIL_STREAM_ORIGIN__?: unknown }).__TOIL_STREAM_ORIGIN__;
|
|
211
|
-
if (typeof override === 'string' && override.length > 0) return override;
|
|
212
|
-
return defaultOrigin();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Build the `Server.Stream` client from the generated route map (`{ ClassName: route }`). `origin`
|
|
217
|
-
* defaults to the page origin. `encoders` carries one `@data` encoder per typed `@stream({ message: T })`
|
|
218
|
-
* class (the generated `(m) => m.encode()`); a class with no entry is a raw byte channel. The generated
|
|
219
|
-
* `shared/server.ts` calls this and assigns the result to `globalThis.__toilStream`.
|
|
220
|
-
*/
|
|
221
|
-
export function makeStreamClient(
|
|
222
|
-
routes: Record<string, string>,
|
|
223
|
-
origin?: string,
|
|
224
|
-
encoders?: Record<string, (msg: never) => Uint8Array>,
|
|
225
|
-
): StreamClient {
|
|
226
|
-
const client: StreamClient = {};
|
|
227
|
-
for (const [name, route] of Object.entries(routes)) {
|
|
228
|
-
const encode = encoders?.[name];
|
|
229
|
-
client[name] = {
|
|
230
|
-
// Resolve the origin LAZILY, per connect() - so a deploy/app that sets
|
|
231
|
-
// `__TOIL_STREAM_ORIGIN__` after this module loads is still honoured.
|
|
232
|
-
connect: (path = ''): Promise<StreamChannel> =>
|
|
233
|
-
openChannel(`${origin ?? resolveStreamOrigin()}${route}${path}`, encode),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
return client;
|
|
237
|
-
}
|