@vidact/start 0.2.0-beta.0 → 0.2.0-beta.11
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 +116 -0
- package/README.md +26 -5
- package/dist/client.d.ts +29 -16
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +352 -165
- package/dist/client.js.map +1 -1
- package/dist/index-client.d.ts +5 -4
- package/dist/index-client.js +4 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -4
- package/dist/link-client.d.ts +6 -4
- package/dist/link-client.d.ts.map +1 -1
- package/dist/link-client.js +10 -4
- package/dist/link-client.js.map +1 -1
- package/dist/link-contract.d.ts +12 -7
- package/dist/link-contract.d.ts.map +1 -1
- package/dist/link-contract.js +12 -7
- package/dist/link-contract.js.map +1 -1
- package/dist/link.d.ts +6 -4
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +8 -4
- package/dist/link.js.map +1 -1
- package/dist/router.d.ts +57 -55
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +150 -157
- package/dist/router.js.map +1 -1
- package/dist/server.d.ts +29 -16
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +159 -100
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +13 -10
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +19 -20
- package/dist/snapshot.js.map +1 -1
- package/dist/virtual.d.ts +3 -2
- package/dist/virtual.js +1 -2
- package/dist/vite.d.ts +18 -15
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +144 -171
- package/dist/vite.js.map +1 -1
- package/package.json +9 -8
- package/src/client.ts +577 -0
- package/src/index-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/link-client.ts +11 -0
- package/src/link-contract.ts +20 -0
- package/src/link.ts +10 -0
- package/src/router.ts +282 -0
- package/src/server.ts +295 -0
- package/src/snapshot.ts +40 -0
- package/src/virtual.ts +5 -0
- package/src/vite.ts +256 -0
- package/dist/index-client.d.ts.map +0 -1
- package/dist/index-client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/virtual.d.ts.map +0 -1
- package/dist/virtual.js.map +0 -1
package/src/router.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { CompiledRenderValue } from '@vidact/runtime'
|
|
2
|
+
import type { FrameworkValue } from '@vidact/runtime/framework/protocol'
|
|
3
|
+
|
|
4
|
+
const FILE_ROUTE = Symbol('Vidact.Start.FileRoute')
|
|
5
|
+
|
|
6
|
+
export type RouteParameters = Readonly<Record<string, string>>
|
|
7
|
+
export type StartComponent<LoaderData extends FrameworkValue = FrameworkValue> = {
|
|
8
|
+
bivarianceHack(props: RouteComponentProps<LoaderData>): unknown
|
|
9
|
+
}['bivarianceHack']
|
|
10
|
+
|
|
11
|
+
export interface RouteComponentProps<
|
|
12
|
+
LoaderData extends FrameworkValue = FrameworkValue,
|
|
13
|
+
> extends Readonly<Record<string, unknown>> {
|
|
14
|
+
readonly children?: CompiledRenderValue
|
|
15
|
+
readonly loaderData: LoaderData
|
|
16
|
+
readonly params: RouteParameters
|
|
17
|
+
readonly requestUrl: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RouteLoaderContext {
|
|
21
|
+
readonly parentData: Readonly<Record<string, FrameworkValue>>
|
|
22
|
+
readonly params: RouteParameters
|
|
23
|
+
readonly request: Request
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RouteServerContext {
|
|
27
|
+
readonly params: RouteParameters
|
|
28
|
+
readonly request: Request
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RouteServerHandler = (context: RouteServerContext) => Response | Promise<Response>
|
|
32
|
+
|
|
33
|
+
export interface FileRouteOptions<LoaderData extends FrameworkValue = FrameworkValue> {
|
|
34
|
+
readonly component?: StartComponent<LoaderData>
|
|
35
|
+
readonly loader?: (context: RouteLoaderContext) => LoaderData | Promise<LoaderData>
|
|
36
|
+
readonly server?: {
|
|
37
|
+
readonly handlers: Readonly<Partial<Record<string, RouteServerHandler>>>
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FileRouteDefinition<LoaderData extends FrameworkValue = FrameworkValue> {
|
|
42
|
+
readonly [FILE_ROUTE]: true
|
|
43
|
+
readonly options: FileRouteOptions<LoaderData>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RouteModule {
|
|
47
|
+
readonly Route?: FileRouteDefinition
|
|
48
|
+
readonly default?: FileRouteDefinition
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RouteManifestEntry {
|
|
52
|
+
readonly id: string
|
|
53
|
+
readonly parentId: string | null
|
|
54
|
+
readonly path: string
|
|
55
|
+
readonly load: () => PromiseLike<RouteModule>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RouteManifest {
|
|
59
|
+
readonly entries: readonly RouteManifestEntry[]
|
|
60
|
+
readonly byId: ReadonlyMap<string, RouteManifestEntry>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RouteMatch {
|
|
64
|
+
readonly entry: RouteManifestEntry
|
|
65
|
+
readonly params: RouteParameters
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ResolvedRouteMatch extends RouteMatch {
|
|
69
|
+
readonly definition: FileRouteDefinition
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LoadedRouteMatch extends ResolvedRouteMatch {
|
|
73
|
+
readonly loaderData: FrameworkValue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function defineFileRoute<const LoaderData extends FrameworkValue = undefined>(
|
|
77
|
+
options: FileRouteOptions<LoaderData>,
|
|
78
|
+
): FileRouteDefinition<LoaderData> {
|
|
79
|
+
return Object.freeze({ [FILE_ROUTE]: true as const, options: Object.freeze({ ...options }) })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isFileRouteDefinition(value: unknown): value is FileRouteDefinition {
|
|
83
|
+
return typeof value === 'object' && value !== null && FILE_ROUTE in value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createRouteManifest(entries: readonly RouteManifestEntry[]): RouteManifest {
|
|
87
|
+
const byId = new Map<string, RouteManifestEntry>()
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.id.length === 0) throw new TypeError('route id must be non-empty')
|
|
90
|
+
if (!entry.path.startsWith('/')) throw new TypeError(`route ${entry.id} path must start with /`)
|
|
91
|
+
if (byId.has(entry.id)) throw new TypeError(`duplicate route id ${entry.id}`)
|
|
92
|
+
byId.set(entry.id, Object.freeze({ ...entry }))
|
|
93
|
+
}
|
|
94
|
+
for (const entry of byId.values()) {
|
|
95
|
+
if (entry.parentId !== null && !byId.has(entry.parentId)) {
|
|
96
|
+
throw new TypeError(`route ${entry.id} has unknown parent ${entry.parentId}`)
|
|
97
|
+
}
|
|
98
|
+
assertAcyclicParentChain(entry, byId)
|
|
99
|
+
}
|
|
100
|
+
const frozenEntries = Object.freeze([...byId.values()])
|
|
101
|
+
return Object.freeze({ entries: frozenEntries, byId })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function matchRoutes(manifest: RouteManifest, pathname: string): readonly RouteMatch[] {
|
|
105
|
+
const normalized = normalizePathname(pathname)
|
|
106
|
+
let selected: { entry: RouteManifestEntry; params: RouteParameters; score: number } | undefined
|
|
107
|
+
for (const entry of manifest.entries) {
|
|
108
|
+
const match = matchPath(entry.path, normalized)
|
|
109
|
+
if (match === undefined) continue
|
|
110
|
+
const score = routeScore(entry, manifest)
|
|
111
|
+
if (selected === undefined || score > selected.score) selected = { entry, params: match, score }
|
|
112
|
+
}
|
|
113
|
+
if (selected === undefined) return []
|
|
114
|
+
const chain: RouteManifestEntry[] = []
|
|
115
|
+
let entry: RouteManifestEntry | undefined = selected.entry
|
|
116
|
+
while (entry !== undefined) {
|
|
117
|
+
chain.push(entry)
|
|
118
|
+
entry = entry.parentId === null ? undefined : manifest.byId.get(entry.parentId)
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze(
|
|
121
|
+
chain.toReversed().map((matchedEntry) =>
|
|
122
|
+
Object.freeze({
|
|
123
|
+
entry: matchedEntry,
|
|
124
|
+
params: Object.freeze({ ...selected.params }),
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function loadRouteMatches(
|
|
131
|
+
matches: readonly RouteMatch[],
|
|
132
|
+
request: Request,
|
|
133
|
+
suppliedData: Readonly<Record<string, FrameworkValue>> = {},
|
|
134
|
+
): Promise<readonly LoadedRouteMatch[]> {
|
|
135
|
+
return runRouteLoaders(await resolveRouteMatches(matches), request, suppliedData)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function resolveRouteMatches(
|
|
139
|
+
matches: readonly RouteMatch[],
|
|
140
|
+
): Promise<readonly ResolvedRouteMatch[]> {
|
|
141
|
+
return Object.freeze(
|
|
142
|
+
await Promise.all(
|
|
143
|
+
matches.map(async (match) => {
|
|
144
|
+
const module = await match.entry.load()
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
...match,
|
|
147
|
+
definition: readRouteDefinition(match.entry.id, module),
|
|
148
|
+
})
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runRouteLoaders(
|
|
155
|
+
matches: readonly ResolvedRouteMatch[],
|
|
156
|
+
request: Request,
|
|
157
|
+
suppliedData: Readonly<Record<string, FrameworkValue>> = {},
|
|
158
|
+
): Promise<readonly LoadedRouteMatch[]> {
|
|
159
|
+
const loaded: LoadedRouteMatch[] = []
|
|
160
|
+
const parentData: Record<string, FrameworkValue> = { ...suppliedData }
|
|
161
|
+
for (const match of matches) {
|
|
162
|
+
const loaderData = Object.hasOwn(suppliedData, match.entry.id)
|
|
163
|
+
? suppliedData[match.entry.id]
|
|
164
|
+
: match.definition.options.loader === undefined
|
|
165
|
+
? undefined
|
|
166
|
+
: // oxlint-disable-next-line no-await-in-loop -- Descendants receive settled parent data.
|
|
167
|
+
await match.definition.options.loader({
|
|
168
|
+
parentData: Object.freeze({ ...parentData }),
|
|
169
|
+
params: match.params,
|
|
170
|
+
request,
|
|
171
|
+
})
|
|
172
|
+
parentData[match.entry.id] = loaderData
|
|
173
|
+
loaded.push(Object.freeze({ ...match, loaderData }))
|
|
174
|
+
}
|
|
175
|
+
return Object.freeze(loaded)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function composeRouteMatches(
|
|
179
|
+
matches: readonly LoadedRouteMatch[],
|
|
180
|
+
createElement: (component: StartComponent, props: RouteComponentProps) => unknown,
|
|
181
|
+
requestUrl: string,
|
|
182
|
+
): unknown {
|
|
183
|
+
let children: CompiledRenderValue | undefined
|
|
184
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
185
|
+
const match = matches[index]!
|
|
186
|
+
const component = match.definition.options.component
|
|
187
|
+
if (component === undefined) continue
|
|
188
|
+
children = createElement(component, {
|
|
189
|
+
children,
|
|
190
|
+
loaderData: match.loaderData,
|
|
191
|
+
params: match.params,
|
|
192
|
+
requestUrl,
|
|
193
|
+
}) as CompiledRenderValue
|
|
194
|
+
}
|
|
195
|
+
return children
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function routeLoaderData(
|
|
199
|
+
matches: readonly LoadedRouteMatch[],
|
|
200
|
+
): Readonly<Record<string, FrameworkValue>> {
|
|
201
|
+
return Object.freeze(
|
|
202
|
+
Object.fromEntries(matches.map((match) => [match.entry.id, match.loaderData])) as Record<
|
|
203
|
+
string,
|
|
204
|
+
FrameworkValue
|
|
205
|
+
>,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readRouteDefinition(id: string, module: RouteModule): FileRouteDefinition {
|
|
210
|
+
const definition = module.Route ?? module.default
|
|
211
|
+
if (!isFileRouteDefinition(definition)) {
|
|
212
|
+
throw new TypeError(`route module ${id} must export a Route created by defineFileRoute`)
|
|
213
|
+
}
|
|
214
|
+
return definition
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function matchPath(pattern: string, pathname: string): RouteParameters | undefined {
|
|
218
|
+
if (pattern === '/') return pathname === '/' ? Object.freeze({}) : undefined
|
|
219
|
+
const patternSegments = segments(pattern)
|
|
220
|
+
const pathSegments = segments(pathname)
|
|
221
|
+
const parameters: Record<string, string> = {}
|
|
222
|
+
let pathIndex = 0
|
|
223
|
+
for (const segment of patternSegments) {
|
|
224
|
+
if (segment === '*') {
|
|
225
|
+
if (pathIndex >= pathSegments.length) return undefined
|
|
226
|
+
parameters['*'] = decodeSegment(pathSegments.slice(pathIndex).join('/'))
|
|
227
|
+
pathIndex = pathSegments.length
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
const value = pathSegments[pathIndex]
|
|
231
|
+
if (value === undefined) return undefined
|
|
232
|
+
if (segment.startsWith(':')) parameters[segment.slice(1)] = decodeSegment(value)
|
|
233
|
+
else if (segment !== value) return undefined
|
|
234
|
+
pathIndex += 1
|
|
235
|
+
}
|
|
236
|
+
return pathIndex === pathSegments.length ? Object.freeze(parameters) : undefined
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizePathname(pathname: string): string {
|
|
240
|
+
const path = pathname.split(/[?#]/u, 1)[0] ?? '/'
|
|
241
|
+
if (!path.startsWith('/')) throw new TypeError('route pathname must start with /')
|
|
242
|
+
return path.length > 1 ? path.replace(/\/+$/u, '') : '/'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function segments(pathname: string): string[] {
|
|
246
|
+
return pathname.split('/').filter(Boolean)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function decodeSegment(value: string): string {
|
|
250
|
+
try {
|
|
251
|
+
return decodeURIComponent(value)
|
|
252
|
+
} catch {
|
|
253
|
+
throw new TypeError(`invalid encoded route segment ${value}`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function routeScore(entry: RouteManifestEntry, manifest: RouteManifest): number {
|
|
258
|
+
const specificity = segments(entry.path).reduce(
|
|
259
|
+
(score, segment) => score + (segment === '*' ? 1 : segment.startsWith(':') ? 10 : 100),
|
|
260
|
+
0,
|
|
261
|
+
)
|
|
262
|
+
let depth = 0
|
|
263
|
+
let current: RouteManifestEntry | undefined = entry
|
|
264
|
+
while (current !== undefined) {
|
|
265
|
+
depth += 1
|
|
266
|
+
current = current.parentId === null ? undefined : manifest.byId.get(current.parentId)
|
|
267
|
+
}
|
|
268
|
+
return specificity * 1_000 + depth
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function assertAcyclicParentChain(
|
|
272
|
+
entry: RouteManifestEntry,
|
|
273
|
+
byId: ReadonlyMap<string, RouteManifestEntry>,
|
|
274
|
+
): void {
|
|
275
|
+
const visited = new Set<string>([entry.id])
|
|
276
|
+
let parentId = entry.parentId
|
|
277
|
+
while (parentId !== null) {
|
|
278
|
+
if (visited.has(parentId)) throw new TypeError(`route ${entry.id} has a cyclic parent chain`)
|
|
279
|
+
visited.add(parentId)
|
|
280
|
+
parentId = byId.get(parentId)?.parentId ?? null
|
|
281
|
+
}
|
|
282
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { renderToReadableStream } from '@vidact/runtime/framework/server'
|
|
2
|
+
import { createElement, type ServerChild, type ServerComponent } from '@vidact/runtime/server'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
composeRouteMatches,
|
|
6
|
+
matchRoutes,
|
|
7
|
+
resolveRouteMatches,
|
|
8
|
+
routeLoaderData,
|
|
9
|
+
runRouteLoaders,
|
|
10
|
+
type ResolvedRouteMatch,
|
|
11
|
+
type RouteManifest,
|
|
12
|
+
type RouteServerHandler,
|
|
13
|
+
type StartComponent,
|
|
14
|
+
} from './router.ts'
|
|
15
|
+
import {
|
|
16
|
+
encodeStartSnapshot,
|
|
17
|
+
VIDACT_START_NAVIGATION_HEADER,
|
|
18
|
+
VIDACT_START_PROTOCOL,
|
|
19
|
+
VIDACT_START_SNAPSHOT_MEDIA_TYPE,
|
|
20
|
+
} from './snapshot.ts'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The client mounts the route tree under a compiled root of its own, and every compiled
|
|
24
|
+
* root claims a component marker range while hydrating. Rendering the tree inside a
|
|
25
|
+
* matching root component on the server keeps the two sides' component ranges aligned;
|
|
26
|
+
* without it each client component would claim the range of the *next* server one.
|
|
27
|
+
*/
|
|
28
|
+
function StartApplication({ children }: { readonly children?: ServerChild }): ServerChild {
|
|
29
|
+
return children
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_ROOT_ID = 'vidact-start-root'
|
|
33
|
+
const DEFAULT_SNAPSHOT_ID = 'vidact-start-snapshot'
|
|
34
|
+
|
|
35
|
+
export interface StartDocumentContext {
|
|
36
|
+
readonly applicationHtml: string
|
|
37
|
+
readonly clientEntry: string | undefined
|
|
38
|
+
readonly rootId: string
|
|
39
|
+
readonly snapshot: string
|
|
40
|
+
readonly snapshotId: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StartDocumentShellContext {
|
|
44
|
+
readonly clientEntry: string | undefined
|
|
45
|
+
readonly rootId: string
|
|
46
|
+
readonly snapshot: string
|
|
47
|
+
readonly snapshotId: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StartDocumentShell {
|
|
51
|
+
readonly afterApplication: string
|
|
52
|
+
readonly beforeApplication: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StartHandlerOptions {
|
|
56
|
+
readonly clientEntry?: string
|
|
57
|
+
readonly manifest: RouteManifest
|
|
58
|
+
readonly notFound?: (request: Request) => Response | Promise<Response>
|
|
59
|
+
readonly renderDocument?: (context: StartDocumentContext) => string | Promise<string>
|
|
60
|
+
readonly renderDocumentShell?: (
|
|
61
|
+
context: StartDocumentShellContext,
|
|
62
|
+
) => StartDocumentShell | Promise<StartDocumentShell>
|
|
63
|
+
readonly rootId?: string
|
|
64
|
+
readonly snapshotId?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createStartHandler(
|
|
68
|
+
options: StartHandlerOptions,
|
|
69
|
+
): (request: Request) => Promise<Response> {
|
|
70
|
+
if (options.renderDocument !== undefined && options.renderDocumentShell !== undefined) {
|
|
71
|
+
throw new TypeError('renderDocument and renderDocumentShell cannot be used together')
|
|
72
|
+
}
|
|
73
|
+
const rootId = options.rootId ?? DEFAULT_ROOT_ID
|
|
74
|
+
const snapshotId = options.snapshotId ?? DEFAULT_SNAPSHOT_ID
|
|
75
|
+
return async (request) => {
|
|
76
|
+
const url = new URL(request.url)
|
|
77
|
+
const matches = matchRoutes(options.manifest, url.pathname)
|
|
78
|
+
if (matches.length === 0) {
|
|
79
|
+
return options.notFound === undefined
|
|
80
|
+
? new Response('Not found', { status: 404 })
|
|
81
|
+
: await options.notFound(request)
|
|
82
|
+
}
|
|
83
|
+
const resolved = await resolveRouteMatches(matches)
|
|
84
|
+
const endpoint = endpointHandler(resolved, request.method)
|
|
85
|
+
if (endpoint !== undefined) {
|
|
86
|
+
const response = await endpoint({
|
|
87
|
+
params: resolved.at(-1)!.params,
|
|
88
|
+
request,
|
|
89
|
+
})
|
|
90
|
+
return responseForRequest(request, response)
|
|
91
|
+
}
|
|
92
|
+
const canRender = resolved.some((match) => match.definition.options.component !== undefined)
|
|
93
|
+
if ((request.method !== 'GET' && request.method !== 'HEAD') || !canRender) {
|
|
94
|
+
const allow = allowedMethods(resolved, canRender)
|
|
95
|
+
if (allow.length === 0) {
|
|
96
|
+
return options.notFound === undefined
|
|
97
|
+
? new Response('Not found', { status: 404 })
|
|
98
|
+
: await options.notFound(request)
|
|
99
|
+
}
|
|
100
|
+
return new Response('Method not allowed', {
|
|
101
|
+
status: 405,
|
|
102
|
+
headers: { allow: allow.join(', ') },
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
let loaded
|
|
106
|
+
try {
|
|
107
|
+
loaded = await runRouteLoaders(resolved, request)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (isResponse(error)) return responseForRequest(request, error)
|
|
110
|
+
throw error
|
|
111
|
+
}
|
|
112
|
+
const snapshot = escapeScriptText(
|
|
113
|
+
encodeStartSnapshot({
|
|
114
|
+
protocol: VIDACT_START_PROTOCOL,
|
|
115
|
+
pathname: `${url.pathname}${url.search}`,
|
|
116
|
+
loaderData: routeLoaderData(loaded),
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
if (request.headers.get(VIDACT_START_NAVIGATION_HEADER) === '1') {
|
|
120
|
+
return new Response(request.method === 'HEAD' ? null : snapshot, {
|
|
121
|
+
headers: representationHeaders(`${VIDACT_START_SNAPSHOT_MEDIA_TYPE}; charset=utf-8`),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
const headers = representationHeaders('text/html; charset=utf-8')
|
|
125
|
+
if (request.method === 'HEAD') return new Response(null, { headers })
|
|
126
|
+
|
|
127
|
+
const application = composeRouteMatches(
|
|
128
|
+
loaded,
|
|
129
|
+
(component, props) =>
|
|
130
|
+
createElement(component as unknown as ServerComponent, props) as ServerChild,
|
|
131
|
+
request.url,
|
|
132
|
+
) as ServerChild
|
|
133
|
+
const stream = await renderToReadableStream(
|
|
134
|
+
() => createElement(StartApplication, { children: application }) as ServerChild,
|
|
135
|
+
{ identifierPrefix: 'start-', signal: request.signal },
|
|
136
|
+
)
|
|
137
|
+
void stream.allReady.catch(() => undefined)
|
|
138
|
+
const shellContext = {
|
|
139
|
+
clientEntry: options.clientEntry,
|
|
140
|
+
rootId,
|
|
141
|
+
snapshot,
|
|
142
|
+
snapshotId,
|
|
143
|
+
}
|
|
144
|
+
if (options.renderDocument !== undefined) {
|
|
145
|
+
const applicationHtml = await new Response(stream).text()
|
|
146
|
+
const html = await options.renderDocument({ ...shellContext, applicationHtml })
|
|
147
|
+
return new Response(html, { headers })
|
|
148
|
+
}
|
|
149
|
+
const shell =
|
|
150
|
+
options.renderDocumentShell === undefined
|
|
151
|
+
? defaultDocumentShell(shellContext)
|
|
152
|
+
: validateDocumentShell(await options.renderDocumentShell(shellContext))
|
|
153
|
+
return new Response(streamDocument(shell, stream), { headers })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function representationHeaders(contentType: string): Headers {
|
|
158
|
+
const headers = new Headers({ 'content-type': contentType })
|
|
159
|
+
headers.append('vary', VIDACT_START_NAVIGATION_HEADER)
|
|
160
|
+
return headers
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function responseForRequest(request: Request, response: Response): Response {
|
|
164
|
+
return request.method === 'HEAD' ? new Response(null, response) : response
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isResponse(value: unknown): value is Response {
|
|
168
|
+
if (value instanceof Response) return true
|
|
169
|
+
if (
|
|
170
|
+
typeof value !== 'object' ||
|
|
171
|
+
value === null ||
|
|
172
|
+
Object.prototype.hasOwnProperty.call(value, Symbol.toStringTag) ||
|
|
173
|
+
Object.prototype.toString.call(value) !== '[object Response]'
|
|
174
|
+
) {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const candidate = value as Response
|
|
179
|
+
return (
|
|
180
|
+
typeof candidate.arrayBuffer === 'function' &&
|
|
181
|
+
typeof candidate.clone === 'function' &&
|
|
182
|
+
typeof candidate.headers?.get === 'function' &&
|
|
183
|
+
Number.isInteger(candidate.status)
|
|
184
|
+
)
|
|
185
|
+
} catch {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function endpointHandler(
|
|
191
|
+
matches: readonly ResolvedRouteMatch[],
|
|
192
|
+
method: string,
|
|
193
|
+
): RouteServerHandler | undefined {
|
|
194
|
+
const handlers = matches.at(-1)?.definition.options.server?.handlers
|
|
195
|
+
return handlers?.[method.toUpperCase()] ?? (method === 'HEAD' ? handlers?.GET : undefined)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function allowedMethods(
|
|
199
|
+
matches: readonly ResolvedRouteMatch[],
|
|
200
|
+
canRender: boolean,
|
|
201
|
+
): readonly string[] {
|
|
202
|
+
const methods = new Set<string>()
|
|
203
|
+
if (canRender) {
|
|
204
|
+
methods.add('GET')
|
|
205
|
+
methods.add('HEAD')
|
|
206
|
+
}
|
|
207
|
+
const handlers = matches.at(-1)?.definition.options.server?.handlers
|
|
208
|
+
for (const method of Object.keys(handlers ?? {})) methods.add(method.toUpperCase())
|
|
209
|
+
if (methods.has('GET')) methods.add('HEAD')
|
|
210
|
+
return [...methods].toSorted(
|
|
211
|
+
(left, right) => methodRank(left) - methodRank(right) || left.localeCompare(right),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function methodRank(method: string): number {
|
|
216
|
+
return method === 'GET' ? 0 : method === 'HEAD' ? 1 : 2
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function defaultDocumentShell(context: StartDocumentShellContext): StartDocumentShell {
|
|
220
|
+
const clientScript =
|
|
221
|
+
context.clientEntry === undefined
|
|
222
|
+
? ''
|
|
223
|
+
: `<script type="module" src="${escapeAttribute(context.clientEntry)}"></script>`
|
|
224
|
+
return {
|
|
225
|
+
beforeApplication: `<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body><div id="${escapeAttribute(context.rootId)}">`,
|
|
226
|
+
afterApplication: `</div><script id="${escapeAttribute(context.snapshotId)}" type="application/json">${context.snapshot}</script>${clientScript}</body></html>`,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateDocumentShell(value: StartDocumentShell): StartDocumentShell {
|
|
231
|
+
if (
|
|
232
|
+
typeof value !== 'object' ||
|
|
233
|
+
value === null ||
|
|
234
|
+
typeof value.beforeApplication !== 'string' ||
|
|
235
|
+
typeof value.afterApplication !== 'string'
|
|
236
|
+
) {
|
|
237
|
+
throw new TypeError('renderDocumentShell must return beforeApplication and afterApplication')
|
|
238
|
+
}
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function streamDocument(
|
|
243
|
+
shell: StartDocumentShell,
|
|
244
|
+
application: ReadableStream<Uint8Array>,
|
|
245
|
+
): ReadableStream<Uint8Array> {
|
|
246
|
+
const encoder = new TextEncoder()
|
|
247
|
+
const before = encoder.encode(shell.beforeApplication)
|
|
248
|
+
const after = encoder.encode(shell.afterApplication)
|
|
249
|
+
const reader = application.getReader()
|
|
250
|
+
let phase: 'after' | 'application' | 'before' | 'done' = 'before'
|
|
251
|
+
return new ReadableStream<Uint8Array>({
|
|
252
|
+
async pull(controller) {
|
|
253
|
+
try {
|
|
254
|
+
if (phase === 'before') {
|
|
255
|
+
phase = 'application'
|
|
256
|
+
if (before.length > 0) controller.enqueue(before)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
if (phase === 'application') {
|
|
260
|
+
const result = await reader.read()
|
|
261
|
+
if (!result.done) {
|
|
262
|
+
controller.enqueue(result.value)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
phase = 'after'
|
|
266
|
+
}
|
|
267
|
+
if (phase === 'after') {
|
|
268
|
+
phase = 'done'
|
|
269
|
+
if (after.length > 0) controller.enqueue(after)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
controller.close()
|
|
273
|
+
} catch (error) {
|
|
274
|
+
controller.error(error)
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
cancel(reason) {
|
|
278
|
+
return reader.cancel(reason)
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function escapeAttribute(value: string): string {
|
|
284
|
+
return value
|
|
285
|
+
.replaceAll('&', '&')
|
|
286
|
+
.replaceAll('"', '"')
|
|
287
|
+
.replaceAll('<', '<')
|
|
288
|
+
.replaceAll('>', '>')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function escapeScriptText(value: string): string {
|
|
292
|
+
return value.replaceAll('&', '\\u0026').replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export type { StartComponent }
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeFrameworkValue,
|
|
3
|
+
encodeFrameworkValue,
|
|
4
|
+
type FrameworkValue,
|
|
5
|
+
} from '@vidact/runtime/framework/protocol'
|
|
6
|
+
|
|
7
|
+
export const VIDACT_START_PROTOCOL = 'vidact-start-v1'
|
|
8
|
+
export const VIDACT_START_NAVIGATION_HEADER = 'x-vidact-start-navigation'
|
|
9
|
+
export const VIDACT_START_SNAPSHOT_MEDIA_TYPE = 'application/x-vidact-start+json'
|
|
10
|
+
|
|
11
|
+
export interface StartSnapshot {
|
|
12
|
+
readonly protocol: typeof VIDACT_START_PROTOCOL
|
|
13
|
+
readonly loaderData: Readonly<Record<string, FrameworkValue>>
|
|
14
|
+
readonly pathname: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function encodeStartSnapshot(snapshot: StartSnapshot): string {
|
|
18
|
+
return encodeFrameworkValue(snapshot as unknown as FrameworkValue)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function decodeStartSnapshot(payload: string): StartSnapshot {
|
|
22
|
+
const value = decodeFrameworkValue(payload)
|
|
23
|
+
if (
|
|
24
|
+
!isRecord(value) ||
|
|
25
|
+
value.protocol !== VIDACT_START_PROTOCOL ||
|
|
26
|
+
typeof value.pathname !== 'string' ||
|
|
27
|
+
!isRecord(value.loaderData)
|
|
28
|
+
) {
|
|
29
|
+
throw new TypeError('invalid Vidact Start snapshot')
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
protocol: VIDACT_START_PROTOCOL,
|
|
33
|
+
pathname: value.pathname,
|
|
34
|
+
loaderData: value.loaderData as Readonly<Record<string, FrameworkValue>>,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
40
|
+
}
|
package/src/virtual.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Shipped verbatim as dist/virtual.d.ts: an ambient declaration cannot be bundled
|
|
2
|
+
// without becoming a module augmentation, so it names the built router file.
|
|
3
|
+
declare module 'virtual:vidact-start/routes' {
|
|
4
|
+
export const routeManifest: import('./router.js').RouteManifest
|
|
5
|
+
}
|