@vidact/start 0.2.0-beta.0 → 0.2.0-beta.5
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 +65 -0
- package/dist/client.d.ts +24 -16
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +137 -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 +8 -4
- package/dist/link-client.js.map +1 -1
- package/dist/link-contract.d.ts +10 -7
- package/dist/link-contract.d.ts.map +1 -1
- package/dist/link-contract.js +11 -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 +18 -16
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +95 -99
- 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 +272 -0
- package/src/index-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/link-client.ts +10 -0
- package/src/link-contract.ts +17 -0
- package/src/link.ts +10 -0
- package/src/router.ts +282 -0
- package/src/server.ts +211 -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/server.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
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 StartHandlerOptions {
|
|
44
|
+
readonly clientEntry?: string
|
|
45
|
+
readonly manifest: RouteManifest
|
|
46
|
+
readonly notFound?: (request: Request) => Response | Promise<Response>
|
|
47
|
+
readonly renderDocument?: (context: StartDocumentContext) => string | Promise<string>
|
|
48
|
+
readonly rootId?: string
|
|
49
|
+
readonly snapshotId?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createStartHandler(
|
|
53
|
+
options: StartHandlerOptions,
|
|
54
|
+
): (request: Request) => Promise<Response> {
|
|
55
|
+
const rootId = options.rootId ?? DEFAULT_ROOT_ID
|
|
56
|
+
const snapshotId = options.snapshotId ?? DEFAULT_SNAPSHOT_ID
|
|
57
|
+
return async (request) => {
|
|
58
|
+
const url = new URL(request.url)
|
|
59
|
+
const matches = matchRoutes(options.manifest, url.pathname)
|
|
60
|
+
if (matches.length === 0) {
|
|
61
|
+
return options.notFound === undefined
|
|
62
|
+
? new Response('Not found', { status: 404 })
|
|
63
|
+
: await options.notFound(request)
|
|
64
|
+
}
|
|
65
|
+
const resolved = await resolveRouteMatches(matches)
|
|
66
|
+
const endpoint = endpointHandler(resolved, request.method)
|
|
67
|
+
if (endpoint !== undefined) {
|
|
68
|
+
const response = await endpoint({
|
|
69
|
+
params: resolved.at(-1)!.params,
|
|
70
|
+
request,
|
|
71
|
+
})
|
|
72
|
+
return responseForRequest(request, response)
|
|
73
|
+
}
|
|
74
|
+
const canRender = resolved.some((match) => match.definition.options.component !== undefined)
|
|
75
|
+
if ((request.method !== 'GET' && request.method !== 'HEAD') || !canRender) {
|
|
76
|
+
const allow = allowedMethods(resolved, canRender)
|
|
77
|
+
if (allow.length === 0) {
|
|
78
|
+
return options.notFound === undefined
|
|
79
|
+
? new Response('Not found', { status: 404 })
|
|
80
|
+
: await options.notFound(request)
|
|
81
|
+
}
|
|
82
|
+
return new Response('Method not allowed', {
|
|
83
|
+
status: 405,
|
|
84
|
+
headers: { allow: allow.join(', ') },
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
let loaded
|
|
88
|
+
try {
|
|
89
|
+
loaded = await runRouteLoaders(resolved, request)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (isResponse(error)) return responseForRequest(request, error)
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
const snapshot = escapeScriptText(
|
|
95
|
+
encodeStartSnapshot({
|
|
96
|
+
protocol: VIDACT_START_PROTOCOL,
|
|
97
|
+
pathname: `${url.pathname}${url.search}`,
|
|
98
|
+
loaderData: routeLoaderData(loaded),
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
if (request.headers.get(VIDACT_START_NAVIGATION_HEADER) === '1') {
|
|
102
|
+
return new Response(request.method === 'HEAD' ? null : snapshot, {
|
|
103
|
+
headers: { 'content-type': `${VIDACT_START_SNAPSHOT_MEDIA_TYPE}; charset=utf-8` },
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const application = composeRouteMatches(
|
|
108
|
+
loaded,
|
|
109
|
+
(component, props) =>
|
|
110
|
+
createElement(component as unknown as ServerComponent, props) as ServerChild,
|
|
111
|
+
request.url,
|
|
112
|
+
) as ServerChild
|
|
113
|
+
const stream = await renderToReadableStream(
|
|
114
|
+
() => createElement(StartApplication, { children: application }) as ServerChild,
|
|
115
|
+
{ identifierPrefix: 'start-' },
|
|
116
|
+
)
|
|
117
|
+
const applicationHtml = await new Response(stream).text()
|
|
118
|
+
const documentContext = {
|
|
119
|
+
applicationHtml,
|
|
120
|
+
clientEntry: options.clientEntry,
|
|
121
|
+
rootId,
|
|
122
|
+
snapshot,
|
|
123
|
+
snapshotId,
|
|
124
|
+
}
|
|
125
|
+
const html =
|
|
126
|
+
options.renderDocument === undefined
|
|
127
|
+
? defaultDocument(documentContext)
|
|
128
|
+
: await options.renderDocument(documentContext)
|
|
129
|
+
return new Response(request.method === 'HEAD' ? null : html, {
|
|
130
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function responseForRequest(request: Request, response: Response): Response {
|
|
136
|
+
return request.method === 'HEAD' ? new Response(null, response) : response
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isResponse(value: unknown): value is Response {
|
|
140
|
+
if (value instanceof Response) return true
|
|
141
|
+
if (
|
|
142
|
+
typeof value !== 'object' ||
|
|
143
|
+
value === null ||
|
|
144
|
+
Object.prototype.hasOwnProperty.call(value, Symbol.toStringTag) ||
|
|
145
|
+
Object.prototype.toString.call(value) !== '[object Response]'
|
|
146
|
+
) {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const candidate = value as Response
|
|
151
|
+
return (
|
|
152
|
+
typeof candidate.arrayBuffer === 'function' &&
|
|
153
|
+
typeof candidate.clone === 'function' &&
|
|
154
|
+
typeof candidate.headers?.get === 'function' &&
|
|
155
|
+
Number.isInteger(candidate.status)
|
|
156
|
+
)
|
|
157
|
+
} catch {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function endpointHandler(
|
|
163
|
+
matches: readonly ResolvedRouteMatch[],
|
|
164
|
+
method: string,
|
|
165
|
+
): RouteServerHandler | undefined {
|
|
166
|
+
const handlers = matches.at(-1)?.definition.options.server?.handlers
|
|
167
|
+
return handlers?.[method.toUpperCase()] ?? (method === 'HEAD' ? handlers?.GET : undefined)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function allowedMethods(
|
|
171
|
+
matches: readonly ResolvedRouteMatch[],
|
|
172
|
+
canRender: boolean,
|
|
173
|
+
): readonly string[] {
|
|
174
|
+
const methods = new Set<string>()
|
|
175
|
+
if (canRender) {
|
|
176
|
+
methods.add('GET')
|
|
177
|
+
methods.add('HEAD')
|
|
178
|
+
}
|
|
179
|
+
const handlers = matches.at(-1)?.definition.options.server?.handlers
|
|
180
|
+
for (const method of Object.keys(handlers ?? {})) methods.add(method.toUpperCase())
|
|
181
|
+
if (methods.has('GET')) methods.add('HEAD')
|
|
182
|
+
return [...methods].toSorted(
|
|
183
|
+
(left, right) => methodRank(left) - methodRank(right) || left.localeCompare(right),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function methodRank(method: string): number {
|
|
188
|
+
return method === 'GET' ? 0 : method === 'HEAD' ? 1 : 2
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function defaultDocument(context: StartDocumentContext): string {
|
|
192
|
+
const clientScript =
|
|
193
|
+
context.clientEntry === undefined
|
|
194
|
+
? ''
|
|
195
|
+
: `<script type="module" src="${escapeAttribute(context.clientEntry)}"></script>`
|
|
196
|
+
return `<!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)}">${context.applicationHtml}</div><script id="${escapeAttribute(context.snapshotId)}" type="application/json">${context.snapshot}</script>${clientScript}</body></html>`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function escapeAttribute(value: string): string {
|
|
200
|
+
return value
|
|
201
|
+
.replaceAll('&', '&')
|
|
202
|
+
.replaceAll('"', '"')
|
|
203
|
+
.replaceAll('<', '<')
|
|
204
|
+
.replaceAll('>', '>')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function escapeScriptText(value: string): string {
|
|
208
|
+
return value.replaceAll('&', '\\u0026').replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
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
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { vidact, type VidactPluginOptions } from '@vidact/vite'
|
|
6
|
+
import {
|
|
7
|
+
isRunnableDevEnvironment,
|
|
8
|
+
normalizePath,
|
|
9
|
+
type Plugin,
|
|
10
|
+
type ResolvedConfig,
|
|
11
|
+
type ViteDevServer,
|
|
12
|
+
} from 'vite'
|
|
13
|
+
|
|
14
|
+
const PUBLIC_ROUTES_ID = 'virtual:vidact-start/routes'
|
|
15
|
+
const INTERNAL_ROUTES_ID = `\0${PUBLIC_ROUTES_ID}`
|
|
16
|
+
const ROUTE_FILE = /\.(?:[cm]?[jt]sx?)$/u
|
|
17
|
+
const IGNORED_ROUTE_FILE = /(?:^|\.)\b(?:test|spec)\.[cm]?[jt]sx?$/u
|
|
18
|
+
|
|
19
|
+
export interface VidactStartOptions {
|
|
20
|
+
readonly compiler?: Omit<VidactPluginOptions, 'target'>
|
|
21
|
+
readonly routesDirectory?: string
|
|
22
|
+
readonly serverEntry?: string | false
|
|
23
|
+
readonly serverExport?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GeneratedRouteRecord {
|
|
27
|
+
readonly file: string
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly parentId: string | null
|
|
30
|
+
readonly path: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function vidactStart(options: VidactStartOptions = {}): Plugin[] {
|
|
34
|
+
const compiler = options.compiler ?? {}
|
|
35
|
+
const features = [...new Set(['framework', ...(compiler.features ?? [])])] as NonNullable<
|
|
36
|
+
VidactPluginOptions['features']
|
|
37
|
+
>
|
|
38
|
+
const plugins = [
|
|
39
|
+
vidactForEnvironment('client', { ...compiler, target: 'hydrate', features }),
|
|
40
|
+
vidactForEnvironment('ssr', { ...compiler, target: 'server', features }),
|
|
41
|
+
routeManifestPlugin(options.routesDirectory),
|
|
42
|
+
]
|
|
43
|
+
if (options.serverEntry !== false) {
|
|
44
|
+
plugins.push(
|
|
45
|
+
developmentServerPlugin(
|
|
46
|
+
options.serverEntry ?? '/src/server.ts',
|
|
47
|
+
options.serverExport ?? 'default',
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return plugins
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function routeManifestPlugin(configuredRoutesDirectory: string | undefined): Plugin {
|
|
55
|
+
let config: ResolvedConfig
|
|
56
|
+
let routesDirectory: string
|
|
57
|
+
let server: ViteDevServer | undefined
|
|
58
|
+
return {
|
|
59
|
+
name: 'vidact-start-routes',
|
|
60
|
+
configResolved(resolved) {
|
|
61
|
+
config = resolved
|
|
62
|
+
routesDirectory = path.resolve(config.root, configuredRoutesDirectory ?? 'src/routes')
|
|
63
|
+
},
|
|
64
|
+
configureServer(viteServer) {
|
|
65
|
+
server = viteServer
|
|
66
|
+
viteServer.watcher.add(routesDirectory)
|
|
67
|
+
},
|
|
68
|
+
resolveId(source) {
|
|
69
|
+
return source === PUBLIC_ROUTES_ID ? INTERNAL_ROUTES_ID : null
|
|
70
|
+
},
|
|
71
|
+
async load(id) {
|
|
72
|
+
if (id !== INTERNAL_ROUTES_ID) return null
|
|
73
|
+
return generateRouteModule(await discoverRouteRecords(routesDirectory))
|
|
74
|
+
},
|
|
75
|
+
handleHotUpdate(context) {
|
|
76
|
+
if (!isRouteFile(routesDirectory, context.file)) return
|
|
77
|
+
const module = server?.moduleGraph.getModuleById(INTERNAL_ROUTES_ID)
|
|
78
|
+
if (module !== undefined) server?.moduleGraph.invalidateModule(module)
|
|
79
|
+
server?.ws.send({ type: 'full-reload' })
|
|
80
|
+
return []
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function vidactForEnvironment(
|
|
86
|
+
environmentName: 'client' | 'ssr',
|
|
87
|
+
options: VidactPluginOptions,
|
|
88
|
+
): Plugin {
|
|
89
|
+
const plugin = vidact(options)
|
|
90
|
+
return {
|
|
91
|
+
...plugin,
|
|
92
|
+
name: `${plugin.name}:start:${environmentName}`,
|
|
93
|
+
applyToEnvironment: (environment) => environment.name === environmentName,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function developmentServerPlugin(serverEntry: string, serverExport: string): Plugin {
|
|
98
|
+
return {
|
|
99
|
+
name: 'vidact-start-development-server',
|
|
100
|
+
config: () => ({ appType: 'custom' }),
|
|
101
|
+
configureServer(server) {
|
|
102
|
+
return () => {
|
|
103
|
+
server.middlewares.use((nodeRequest, nodeResponse, next) => {
|
|
104
|
+
void serveDevelopmentRequest(
|
|
105
|
+
server,
|
|
106
|
+
serverEntry,
|
|
107
|
+
serverExport,
|
|
108
|
+
nodeRequest,
|
|
109
|
+
nodeResponse,
|
|
110
|
+
).catch((error: unknown) => {
|
|
111
|
+
if (error instanceof Error) server.ssrFixStacktrace(error)
|
|
112
|
+
next(error)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function serveDevelopmentRequest(
|
|
121
|
+
server: ViteDevServer,
|
|
122
|
+
serverEntry: string,
|
|
123
|
+
serverExport: string,
|
|
124
|
+
nodeRequest: IncomingMessage,
|
|
125
|
+
nodeResponse: ServerResponse,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const environment = server.environments.ssr
|
|
128
|
+
if (!isRunnableDevEnvironment(environment)) {
|
|
129
|
+
throw new Error("Vidact Start requires Vite's runnable SSR development environment")
|
|
130
|
+
}
|
|
131
|
+
const module = (await environment.runner.import(serverEntry)) as Record<string, unknown>
|
|
132
|
+
const handler = module[serverExport]
|
|
133
|
+
if (typeof handler !== 'function') {
|
|
134
|
+
throw new TypeError(`${serverEntry} must export a ${serverExport} request handler`)
|
|
135
|
+
}
|
|
136
|
+
const request = await nodeRequestToRequest(nodeRequest)
|
|
137
|
+
let response = await (handler as (request: Request) => Response | Promise<Response>)(request)
|
|
138
|
+
if (!(response instanceof Response)) {
|
|
139
|
+
throw new TypeError(`${serverEntry} ${serverExport} must return a Response`)
|
|
140
|
+
}
|
|
141
|
+
if (response.headers.get('content-type')?.startsWith('text/html') === true) {
|
|
142
|
+
const html = await server.transformIndexHtml(nodeRequest.url ?? '/', await response.text())
|
|
143
|
+
response = new Response(html, response)
|
|
144
|
+
}
|
|
145
|
+
await sendNodeResponse(nodeResponse, response)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function nodeRequestToRequest(request: IncomingMessage): Promise<Request> {
|
|
149
|
+
const headers = new Headers()
|
|
150
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
151
|
+
if (Array.isArray(value)) for (const item of value) headers.append(name, item)
|
|
152
|
+
else if (value !== undefined) headers.set(name, value)
|
|
153
|
+
}
|
|
154
|
+
const forwardedProtocol = headers.get('x-forwarded-proto')?.split(',', 1)[0]?.trim()
|
|
155
|
+
const protocol =
|
|
156
|
+
forwardedProtocol ?? (Reflect.get(request.socket, 'encrypted') === true ? 'https' : 'http')
|
|
157
|
+
const url = new URL(request.url ?? '/', `${protocol}://${headers.get('host') ?? 'localhost'}`)
|
|
158
|
+
const body =
|
|
159
|
+
request.method === 'GET' || request.method === 'HEAD'
|
|
160
|
+
? undefined
|
|
161
|
+
: await readRequestBody(request)
|
|
162
|
+
return new Request(url, {
|
|
163
|
+
method: request.method ?? 'GET',
|
|
164
|
+
headers,
|
|
165
|
+
...(body === undefined ? {} : { body }),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readRequestBody(request: IncomingMessage): Promise<ArrayBuffer> {
|
|
170
|
+
const chunks: Uint8Array[] = []
|
|
171
|
+
for await (const chunk of request) {
|
|
172
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
|
173
|
+
}
|
|
174
|
+
return Uint8Array.from(Buffer.concat(chunks)).buffer
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function sendNodeResponse(response: ServerResponse, webResponse: Response): Promise<void> {
|
|
178
|
+
response.statusCode = webResponse.status
|
|
179
|
+
response.statusMessage = webResponse.statusText
|
|
180
|
+
for (const [name, value] of webResponse.headers) response.setHeader(name, value)
|
|
181
|
+
const cookies = webResponse.headers.getSetCookie()
|
|
182
|
+
if (cookies.length > 0) response.setHeader('set-cookie', cookies)
|
|
183
|
+
response.end(Buffer.from(await webResponse.arrayBuffer()))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function discoverRouteRecords(
|
|
187
|
+
routesDirectory: string,
|
|
188
|
+
): Promise<readonly GeneratedRouteRecord[]> {
|
|
189
|
+
let filenames: string[]
|
|
190
|
+
try {
|
|
191
|
+
filenames = await readdir(routesDirectory, { recursive: true })
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isMissingDirectory(error)) return []
|
|
194
|
+
throw error
|
|
195
|
+
}
|
|
196
|
+
const records = filenames
|
|
197
|
+
.filter((filename) => ROUTE_FILE.test(filename) && !IGNORED_ROUTE_FILE.test(filename))
|
|
198
|
+
.map((filename) => routeFileToRecord(filename, routesDirectory))
|
|
199
|
+
const ids = new Set(records.map((record) => record.id))
|
|
200
|
+
return Object.freeze(
|
|
201
|
+
records
|
|
202
|
+
.map((record) => Object.freeze({ ...record, parentId: parentRouteId(record.id, ids) }))
|
|
203
|
+
.toSorted((left, right) => left.id.localeCompare(right.id)),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function routeFileToRecord(filename: string, routesDirectory: string): GeneratedRouteRecord {
|
|
208
|
+
const normalizedFilename = normalizePath(filename)
|
|
209
|
+
const id = normalizedFilename.replace(ROUTE_FILE, '')
|
|
210
|
+
if (id.length === 0) throw new TypeError(`invalid route filename ${filename}`)
|
|
211
|
+
const routeSegments = id
|
|
212
|
+
.split('/')
|
|
213
|
+
.filter((segment) => segment !== '__root' && segment !== 'index' && !segment.startsWith('_'))
|
|
214
|
+
.map((segment) =>
|
|
215
|
+
segment === '$' ? '*' : segment.startsWith('$') ? `:${segment.slice(1)}` : segment,
|
|
216
|
+
)
|
|
217
|
+
return {
|
|
218
|
+
file: normalizePath(path.resolve(routesDirectory, normalizedFilename)),
|
|
219
|
+
id,
|
|
220
|
+
parentId: null,
|
|
221
|
+
path: routeSegments.length === 0 ? '/' : `/${routeSegments.join('/')}`,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parentRouteId(id: string, ids: ReadonlySet<string>): string | null {
|
|
226
|
+
if (id === '__root') return null
|
|
227
|
+
const segments = id.split('/')
|
|
228
|
+
if (segments.at(-1) === 'index') segments.pop()
|
|
229
|
+
while (segments.length > 0) {
|
|
230
|
+
const candidate = segments.join('/')
|
|
231
|
+
if (candidate !== id && ids.has(candidate)) return candidate
|
|
232
|
+
segments.pop()
|
|
233
|
+
}
|
|
234
|
+
return ids.has('__root') ? '__root' : null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function generateRouteModule(records: readonly GeneratedRouteRecord[]): string {
|
|
238
|
+
const entries = records
|
|
239
|
+
.map(
|
|
240
|
+
(record) =>
|
|
241
|
+
`{ id: ${JSON.stringify(record.id)}, parentId: ${JSON.stringify(record.parentId)}, path: ${JSON.stringify(record.path)}, load: () => import(${JSON.stringify(record.file)}) }`,
|
|
242
|
+
)
|
|
243
|
+
.join(',\n ')
|
|
244
|
+
return `import { createRouteManifest } from '@vidact/start'\n\nexport const routeManifest = createRouteManifest([\n ${entries}\n])\n`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isRouteFile(routesDirectory: string, filename: string): boolean {
|
|
248
|
+
const relative = path.relative(routesDirectory, filename)
|
|
249
|
+
return (
|
|
250
|
+
!relative.startsWith('..') && ROUTE_FILE.test(relative) && !IGNORED_ROUTE_FILE.test(relative)
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isMissingDirectory(error: unknown): boolean {
|
|
255
|
+
return typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ENOENT'
|
|
256
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index-client.d.ts","sourceRoot":"","sources":["../src/index-client.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA"}
|
package/dist/index-client.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index-client.js","sourceRoot":"","sources":["../src/index-client.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA"}
|
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA"}
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA"}
|
package/dist/virtual.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"virtual.d.ts","sourceRoot":"","sources":["../src/virtual.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,6BAA6B,CAAC;IACpC,MAAM,aAAa,EAAE,OAAO,aAAa,EAAE,aAAa,CAAA;CAChE"}
|
package/dist/virtual.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"virtual.js","sourceRoot":"","sources":["../src/virtual.ts"],"names":[],"mappings":""}
|