@mcansh/react-router-fastify 5.0.0

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/src/vite.ts ADDED
@@ -0,0 +1,272 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ import type { FastifyInstance } from "fastify"
6
+ import type { Plugin, ViteDevServer } from "vite"
7
+
8
+ import { importSsrModule } from "./vite-runtime.ts"
9
+
10
+ const adapterPackageNames = [
11
+ "@mcansh/react-router-fastify",
12
+ "@mcansh/remix-fastify",
13
+ ]
14
+
15
+ export type FastifyAppFactory = (
16
+ vite: ViteDevServer,
17
+ ) => FastifyInstance | Promise<FastifyInstance>
18
+
19
+ export interface FastifyReactRouterDevOptions {
20
+ /** Server module loaded by Vite during `react-router dev`. */
21
+ entry?: string
22
+ /** Named export that creates the Fastify app. Falls back to `default`. */
23
+ exportName?: string
24
+ /**
25
+ * Keeps local modules imported by the Fastify server entry external in the
26
+ * React Router SSR build. This preserves singleton module identity for shared
27
+ * values such as React Router context tokens.
28
+ *
29
+ * Pass an array to externalize explicit import specifiers instead.
30
+ */
31
+ externalizeServerEntryImports?: boolean | string[]
32
+ }
33
+
34
+ /**
35
+ * Vite plugin that lets `react-router dev` serve through a Fastify app.
36
+ *
37
+ * Vite continues to handle its internal client/HMR/module middleware first.
38
+ * Fastify receives the remaining requests, including the React Router catch-all
39
+ * installed by `fastifyReactRouter`.
40
+ *
41
+ * @param options Development server entry options.
42
+ * @returns Vite plugin.
43
+ */
44
+ export function fastifyReactRouterDev(
45
+ options: FastifyReactRouterDevOptions = {},
46
+ ): Plugin {
47
+ let {
48
+ entry = "./server.ts",
49
+ exportName = "createServer",
50
+ externalizeServerEntryImports = true,
51
+ } = options
52
+ let command: "build" | "serve" = "serve"
53
+ let root = process.cwd()
54
+ let serverEntryImportSpecifiers = new Set<string>()
55
+ let serverEntryImportFiles = new Set<string>()
56
+
57
+ return {
58
+ name: "fastify-react-router-dev",
59
+ enforce: "pre",
60
+ config(_config, environment) {
61
+ if (environment.command !== "serve") return
62
+
63
+ return {
64
+ ssr: {
65
+ noExternal: adapterPackageNames,
66
+ },
67
+ }
68
+ },
69
+ configResolved(config) {
70
+ command = config.command
71
+ root = config.root
72
+
73
+ let resolvedEntry = path.resolve(root, entry)
74
+ let externals = collectServerEntryExternals(
75
+ resolvedEntry,
76
+ externalizeServerEntryImports,
77
+ )
78
+ serverEntryImportSpecifiers = externals.specifiers
79
+ serverEntryImportFiles = externals.files
80
+ },
81
+ async resolveId(source, importer, resolveOptions) {
82
+ if (command !== "build" || !resolveOptions.ssr) return null
83
+ if (shouldExternalizeSpecifier(source) === false) return null
84
+
85
+ if (serverEntryImportSpecifiers.has(source)) {
86
+ return { id: source, external: true }
87
+ }
88
+
89
+ if (importer == null) return null
90
+
91
+ let resolved = await this.resolve(source, importer, {
92
+ ...resolveOptions,
93
+ skipSelf: true,
94
+ })
95
+ if (resolved == null) return null
96
+
97
+ let filePath = toFilePath(resolved.id)
98
+ if (filePath == null || serverEntryImportFiles.has(filePath) === false)
99
+ return null
100
+
101
+ return {
102
+ id: source.startsWith("#") ? source : filePath,
103
+ external: true,
104
+ }
105
+ },
106
+ configureServer(vite) {
107
+ let resolvedEntry = path.resolve(vite.config.root, entry)
108
+ let appPromise: Promise<FastifyInstance> | undefined
109
+
110
+ async function closeApp(): Promise<void> {
111
+ let current = appPromise
112
+ appPromise = undefined
113
+ if (current == null) return
114
+
115
+ try {
116
+ let app = await current
117
+ await app.close()
118
+ } catch {
119
+ // The app may have failed while loading; there is nothing to close.
120
+ }
121
+ }
122
+
123
+ async function loadApp(): Promise<FastifyInstance> {
124
+ if (appPromise == null) {
125
+ appPromise = (async () => {
126
+ let module = await importSsrModule(vite, resolvedEntry)
127
+ let factory = module[exportName] ?? module.default
128
+
129
+ if (typeof factory !== "function") {
130
+ throw new Error(
131
+ `[fastify-react-router-dev] Expected ${entry} to export "${exportName}" or a default Fastify app factory.`,
132
+ )
133
+ }
134
+
135
+ let app = await (factory as FastifyAppFactory)(vite)
136
+ await app.ready()
137
+ return app
138
+ })()
139
+ }
140
+
141
+ return appPromise
142
+ }
143
+
144
+ vite.watcher.on("change", () => {
145
+ void closeApp()
146
+ })
147
+ vite.watcher.on("unlink", () => {
148
+ void closeApp()
149
+ })
150
+ vite.httpServer?.once("close", () => {
151
+ void closeApp()
152
+ })
153
+
154
+ return () => {
155
+ vite.middlewares.use(async (request, response, next) => {
156
+ try {
157
+ let app = await loadApp()
158
+ app.routing(request, response)
159
+ } catch (error) {
160
+ if (error instanceof Error) vite.ssrFixStacktrace(error)
161
+ next(error)
162
+ }
163
+ })
164
+ }
165
+ },
166
+ }
167
+ }
168
+
169
+ function collectServerEntryExternals(
170
+ entryPath: string,
171
+ externalizeServerEntryImports: boolean | string[],
172
+ ): {
173
+ specifiers: Set<string>
174
+ files: Set<string>
175
+ } {
176
+ let specifiers = new Set<string>()
177
+ let files = new Set<string>()
178
+
179
+ if (externalizeServerEntryImports === false) {
180
+ return { specifiers, files }
181
+ }
182
+
183
+ let imports = Array.isArray(externalizeServerEntryImports)
184
+ ? externalizeServerEntryImports
185
+ : readImportSpecifiers(entryPath)
186
+
187
+ for (let specifier of imports) {
188
+ if (shouldExternalizeSpecifier(specifier) === false) continue
189
+
190
+ specifiers.add(specifier)
191
+
192
+ if (isLocalSpecifier(specifier)) {
193
+ let file = resolveLocalImport(entryPath, specifier)
194
+ if (file) files.add(file)
195
+ }
196
+ }
197
+
198
+ return { specifiers, files }
199
+ }
200
+
201
+ function readImportSpecifiers(filePath: string): string[] {
202
+ if (fs.existsSync(filePath) === false) return []
203
+
204
+ let source = fs.readFileSync(filePath, "utf8")
205
+ let specifiers = new Set<string>()
206
+ let importPattern =
207
+ /\b(?:import|export)\s+(?:[^'"]*?\s+from\s*)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g
208
+
209
+ for (let match of source.matchAll(importPattern)) {
210
+ specifiers.add(match[1] ?? match[2])
211
+ }
212
+
213
+ return [...specifiers]
214
+ }
215
+
216
+ function shouldExternalizeSpecifier(specifier: string): boolean {
217
+ return specifier.startsWith("#") || isLocalSpecifier(specifier)
218
+ }
219
+
220
+ function isLocalSpecifier(specifier: string): boolean {
221
+ return specifier.startsWith(".") || specifier.startsWith("/")
222
+ }
223
+
224
+ function resolveLocalImport(
225
+ importer: string,
226
+ specifier: string,
227
+ ): string | undefined {
228
+ let resolved = path.resolve(path.dirname(importer), specifier)
229
+ return resolveExistingFile(resolved)
230
+ }
231
+
232
+ function resolveExistingFile(filePath: string): string | undefined {
233
+ if (isFile(filePath)) return normalizePath(filePath)
234
+
235
+ for (let extension of [".ts", ".tsx", ".mts", ".mjs", ".js", ".jsx"]) {
236
+ let candidate = `${filePath}${extension}`
237
+ if (isFile(candidate)) return normalizePath(candidate)
238
+ }
239
+
240
+ for (let extension of [".ts", ".tsx", ".mts", ".mjs", ".js", ".jsx"]) {
241
+ let candidate = path.join(filePath, `index${extension}`)
242
+ if (isFile(candidate)) return normalizePath(candidate)
243
+ }
244
+
245
+ return undefined
246
+ }
247
+
248
+ function isFile(filePath: string): boolean {
249
+ try {
250
+ return fs.statSync(filePath).isFile()
251
+ } catch {
252
+ return false
253
+ }
254
+ }
255
+
256
+ function toFilePath(id: string): string | undefined {
257
+ let withoutQuery = id.replace(/[?#].*$/, "")
258
+
259
+ if (withoutQuery.startsWith("file://")) {
260
+ return normalizePath(fileURLToPath(withoutQuery))
261
+ }
262
+
263
+ if (path.isAbsolute(withoutQuery)) {
264
+ return normalizePath(withoutQuery)
265
+ }
266
+
267
+ return undefined
268
+ }
269
+
270
+ function normalizePath(filePath: string): string {
271
+ return filePath.split(path.sep).join("/")
272
+ }