@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +26 -5
  3. package/dist/client.d.ts +29 -16
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +352 -165
  6. package/dist/client.js.map +1 -1
  7. package/dist/index-client.d.ts +5 -4
  8. package/dist/index-client.js +4 -4
  9. package/dist/index.d.ts +5 -4
  10. package/dist/index.js +4 -4
  11. package/dist/link-client.d.ts +6 -4
  12. package/dist/link-client.d.ts.map +1 -1
  13. package/dist/link-client.js +10 -4
  14. package/dist/link-client.js.map +1 -1
  15. package/dist/link-contract.d.ts +12 -7
  16. package/dist/link-contract.d.ts.map +1 -1
  17. package/dist/link-contract.js +12 -7
  18. package/dist/link-contract.js.map +1 -1
  19. package/dist/link.d.ts +6 -4
  20. package/dist/link.d.ts.map +1 -1
  21. package/dist/link.js +8 -4
  22. package/dist/link.js.map +1 -1
  23. package/dist/router.d.ts +57 -55
  24. package/dist/router.d.ts.map +1 -1
  25. package/dist/router.js +150 -157
  26. package/dist/router.js.map +1 -1
  27. package/dist/server.d.ts +29 -16
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +159 -100
  30. package/dist/server.js.map +1 -1
  31. package/dist/snapshot.d.ts +13 -10
  32. package/dist/snapshot.d.ts.map +1 -1
  33. package/dist/snapshot.js +19 -20
  34. package/dist/snapshot.js.map +1 -1
  35. package/dist/virtual.d.ts +3 -2
  36. package/dist/virtual.js +1 -2
  37. package/dist/vite.d.ts +18 -15
  38. package/dist/vite.d.ts.map +1 -1
  39. package/dist/vite.js +144 -171
  40. package/dist/vite.js.map +1 -1
  41. package/package.json +9 -8
  42. package/src/client.ts +577 -0
  43. package/src/index-client.ts +3 -0
  44. package/src/index.ts +3 -0
  45. package/src/link-client.ts +11 -0
  46. package/src/link-contract.ts +20 -0
  47. package/src/link.ts +10 -0
  48. package/src/router.ts +282 -0
  49. package/src/server.ts +295 -0
  50. package/src/snapshot.ts +40 -0
  51. package/src/virtual.ts +5 -0
  52. package/src/vite.ts +256 -0
  53. package/dist/index-client.d.ts.map +0 -1
  54. package/dist/index-client.js.map +0 -1
  55. package/dist/index.d.ts.map +0 -1
  56. package/dist/index.js.map +0 -1
  57. package/dist/virtual.d.ts.map +0 -1
  58. 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('&', '&amp;')
286
+ .replaceAll('"', '&quot;')
287
+ .replaceAll('<', '&lt;')
288
+ .replaceAll('>', '&gt;')
289
+ }
290
+
291
+ function escapeScriptText(value: string): string {
292
+ return value.replaceAll('&', '\\u0026').replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
293
+ }
294
+
295
+ export type { StartComponent }
@@ -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
+ }