@tanstack/start-server-core 1.158.4 → 1.159.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.
@@ -0,0 +1,113 @@
1
+ import { rootRouteId } from "@tanstack/router-core";
2
+ function resolveTransformConfig(transform) {
3
+ if (typeof transform === "string") {
4
+ const prefix = transform;
5
+ return {
6
+ type: "transform",
7
+ transformFn: ({ url }) => `${prefix}${url}`,
8
+ cache: true
9
+ };
10
+ }
11
+ if (typeof transform === "function") {
12
+ return {
13
+ type: "transform",
14
+ transformFn: transform,
15
+ cache: true
16
+ };
17
+ }
18
+ if ("createTransform" in transform && transform.createTransform) {
19
+ return {
20
+ type: "createTransform",
21
+ createTransform: transform.createTransform,
22
+ cache: transform.cache !== false
23
+ };
24
+ }
25
+ const transformFn = typeof transform.transform === "string" ? (({ url }) => `${transform.transform}${url}`) : transform.transform;
26
+ return {
27
+ type: "transform",
28
+ transformFn,
29
+ cache: transform.cache !== false
30
+ };
31
+ }
32
+ function buildClientEntryScriptTag(clientEntry, injectedHeadScripts) {
33
+ const clientEntryLiteral = JSON.stringify(clientEntry);
34
+ let script = `import(${clientEntryLiteral})`;
35
+ if (injectedHeadScripts) {
36
+ script = `${injectedHeadScripts};${script}`;
37
+ }
38
+ return {
39
+ tag: "script",
40
+ attrs: {
41
+ type: "module",
42
+ async: true
43
+ },
44
+ children: script
45
+ };
46
+ }
47
+ function transformManifestUrls(source, transformFn, opts) {
48
+ return (async () => {
49
+ const manifest = opts?.clone ? structuredClone(source.manifest) : source.manifest;
50
+ for (const route of Object.values(manifest.routes)) {
51
+ if (route.preloads) {
52
+ route.preloads = await Promise.all(
53
+ route.preloads.map(
54
+ (url) => Promise.resolve(transformFn({ url, type: "modulepreload" }))
55
+ )
56
+ );
57
+ }
58
+ if (route.assets) {
59
+ for (const asset of route.assets) {
60
+ if (asset.tag === "link" && asset.attrs?.href) {
61
+ asset.attrs.href = await Promise.resolve(
62
+ transformFn({
63
+ url: asset.attrs.href,
64
+ type: "stylesheet"
65
+ })
66
+ );
67
+ }
68
+ }
69
+ }
70
+ }
71
+ const transformedClientEntry = await Promise.resolve(
72
+ transformFn({
73
+ url: source.clientEntry,
74
+ type: "clientEntry"
75
+ })
76
+ );
77
+ const rootRoute = manifest.routes[rootRouteId];
78
+ if (rootRoute) {
79
+ rootRoute.assets = rootRoute.assets || [];
80
+ rootRoute.assets.push(
81
+ buildClientEntryScriptTag(
82
+ transformedClientEntry,
83
+ source.injectedHeadScripts
84
+ )
85
+ );
86
+ }
87
+ return manifest;
88
+ })();
89
+ }
90
+ function buildManifestWithClientEntry(source) {
91
+ const scriptTag = buildClientEntryScriptTag(
92
+ source.clientEntry,
93
+ source.injectedHeadScripts
94
+ );
95
+ const baseRootRoute = source.manifest.routes[rootRouteId];
96
+ const routes = {
97
+ ...source.manifest.routes,
98
+ ...baseRootRoute ? {
99
+ [rootRouteId]: {
100
+ ...baseRootRoute,
101
+ assets: [...baseRootRoute.assets || [], scriptTag]
102
+ }
103
+ } : {}
104
+ };
105
+ return { routes };
106
+ }
107
+ export {
108
+ buildClientEntryScriptTag,
109
+ buildManifestWithClientEntry,
110
+ resolveTransformConfig,
111
+ transformManifestUrls
112
+ };
113
+ //# sourceMappingURL=transformAssetUrls.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transformAssetUrls.js","sources":["../../src/transformAssetUrls.ts"],"sourcesContent":["import { rootRouteId } from '@tanstack/router-core'\n\nimport type {\n Awaitable,\n Manifest,\n RouterManagedTag,\n} from '@tanstack/router-core'\n\nexport type AssetUrlType = 'modulepreload' | 'stylesheet' | 'clientEntry'\n\nexport interface TransformAssetUrlsContext {\n url: string\n type: AssetUrlType\n}\n\nexport type TransformAssetUrlsFn = (\n context: TransformAssetUrlsContext,\n) => Awaitable<string>\n\nexport type CreateTransformAssetUrlsContext =\n | {\n /** True when the server is computing the cached manifest during startup warmup. */\n warmup: true\n }\n | {\n /**\n * The current Request.\n *\n * Only available during request handling (i.e. when `warmup: false`).\n */\n request: Request\n /** False when transforming URLs as part of request handling. */\n warmup: false\n }\n\n/**\n * Async factory that runs once per manifest computation and returns the\n * per-asset transform.\n */\nexport type CreateTransformAssetUrlsFn = (\n ctx: CreateTransformAssetUrlsContext,\n) => Awaitable<TransformAssetUrlsFn>\n\ntype TransformAssetUrlsOptionsBase = {\n /**\n * Whether to cache the transformed manifest after the first request.\n *\n * When `true` (default), the transform runs once on the first request and\n * the resulting manifest is reused for all subsequent requests in production.\n *\n * Set to `false` for per-request transforms (e.g. geo-routing to different\n * CDNs based on request headers).\n *\n * @default true\n */\n cache?: boolean\n\n /**\n * When `true`, warms up the cached transformed manifest in the background when\n * the server starts (production only).\n *\n * This can reduce latency for the first request when `cache` is `true`.\n * Has no effect when `cache: false` (per-request transforms) or in dev mode.\n *\n * @default false\n */\n warmup?: boolean\n}\n\nexport type TransformAssetUrlsOptions =\n | (TransformAssetUrlsOptionsBase & {\n /**\n * The transform to apply to asset URLs. Can be a string prefix or a callback.\n *\n * **String** — prepended to every asset URL.\n * **Callback** — receives `{ url, type }` and returns a new URL.\n */\n transform: string | TransformAssetUrlsFn\n createTransform?: never\n })\n | (TransformAssetUrlsOptionsBase & {\n /**\n * Create a per-asset transform function.\n *\n * This factory runs once per manifest computation (per request when\n * `cache: false`, or once per server when `cache: true`). It can do async\n * setup work (fetch config, read from a KV, etc.) and return a fast\n * per-asset transformer.\n */\n createTransform: CreateTransformAssetUrlsFn\n transform?: never\n })\n\nexport type TransformAssetUrls =\n | string\n | TransformAssetUrlsFn\n | TransformAssetUrlsOptions\n\nexport type ResolvedTransformAssetUrlsConfig =\n | {\n type: 'transform'\n transformFn: TransformAssetUrlsFn\n cache: boolean\n }\n | {\n type: 'createTransform'\n createTransform: CreateTransformAssetUrlsFn\n cache: boolean\n }\n\n/**\n * Resolves a TransformAssetUrls value (string prefix, callback, or options\n * object) into a concrete transform function and cache flag.\n */\nexport function resolveTransformConfig(\n transform: TransformAssetUrls,\n): ResolvedTransformAssetUrlsConfig {\n // String shorthand\n if (typeof transform === 'string') {\n const prefix = transform\n return {\n type: 'transform',\n transformFn: ({ url }) => `${prefix}${url}`,\n cache: true,\n }\n }\n\n // Callback shorthand\n if (typeof transform === 'function') {\n return {\n type: 'transform',\n transformFn: transform,\n cache: true,\n }\n }\n\n // Options object\n if ('createTransform' in transform && transform.createTransform) {\n return {\n type: 'createTransform',\n createTransform: transform.createTransform,\n cache: transform.cache !== false,\n }\n }\n\n const transformFn =\n typeof transform.transform === 'string'\n ? ((({ url }: TransformAssetUrlsContext) =>\n `${transform.transform}${url}`) as TransformAssetUrlsFn)\n : transform.transform\n\n return {\n type: 'transform',\n transformFn,\n cache: transform.cache !== false,\n }\n}\n\nexport interface StartManifestWithClientEntry {\n manifest: Manifest\n clientEntry: string\n /** Script content prepended before the client entry import (dev only) */\n injectedHeadScripts?: string\n}\n\n/**\n * Builds the client entry `<script>` tag from a (possibly transformed) client\n * entry URL and optional injected head scripts.\n */\nexport function buildClientEntryScriptTag(\n clientEntry: string,\n injectedHeadScripts?: string,\n): RouterManagedTag {\n const clientEntryLiteral = JSON.stringify(clientEntry)\n let script = `import(${clientEntryLiteral})`\n if (injectedHeadScripts) {\n script = `${injectedHeadScripts};${script}`\n }\n return {\n tag: 'script',\n attrs: {\n type: 'module',\n async: true,\n },\n children: script,\n }\n}\n\n/**\n * Applies a URL transform to every asset URL in the manifest and returns a\n * new manifest with a client entry script tag appended to the root route's\n * assets.\n *\n * The source manifest is deep-cloned so the cached original is never mutated.\n */\nexport function transformManifestUrls(\n source: StartManifestWithClientEntry,\n transformFn: TransformAssetUrlsFn,\n opts?: {\n /** When true, clone the source manifest before mutating it. */\n clone?: boolean\n },\n): Promise<Manifest> {\n return (async () => {\n const manifest = opts?.clone\n ? structuredClone(source.manifest)\n : source.manifest\n\n for (const route of Object.values(manifest.routes)) {\n // Transform preload URLs (modulepreload)\n if (route.preloads) {\n route.preloads = await Promise.all(\n route.preloads.map((url) =>\n Promise.resolve(transformFn({ url, type: 'modulepreload' })),\n ),\n )\n }\n\n // Transform asset tag URLs\n if (route.assets) {\n for (const asset of route.assets) {\n if (asset.tag === 'link' && asset.attrs?.href) {\n asset.attrs.href = await Promise.resolve(\n transformFn({\n url: asset.attrs.href,\n type: 'stylesheet',\n }),\n )\n }\n }\n }\n }\n\n // Transform and append the client entry script tag\n const transformedClientEntry = await Promise.resolve(\n transformFn({\n url: source.clientEntry,\n type: 'clientEntry',\n }),\n )\n\n const rootRoute = manifest.routes[rootRouteId]\n if (rootRoute) {\n rootRoute.assets = rootRoute.assets || []\n rootRoute.assets.push(\n buildClientEntryScriptTag(\n transformedClientEntry,\n source.injectedHeadScripts,\n ),\n )\n }\n\n return manifest\n })()\n}\n\n/**\n * Builds a final Manifest from a StartManifestWithClientEntry without any\n * URL transforms. Used when no transformAssetUrls option is provided.\n *\n * Returns a new manifest object so the cached base manifest is never mutated.\n */\nexport function buildManifestWithClientEntry(\n source: StartManifestWithClientEntry,\n): Manifest {\n const scriptTag = buildClientEntryScriptTag(\n source.clientEntry,\n source.injectedHeadScripts,\n )\n\n const baseRootRoute = source.manifest.routes[rootRouteId]\n const routes = {\n ...source.manifest.routes,\n ...(baseRootRoute\n ? {\n [rootRouteId]: {\n ...baseRootRoute,\n assets: [...(baseRootRoute.assets || []), scriptTag],\n },\n }\n : {}),\n }\n\n return { routes }\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/start-server-core",
3
- "version": "1.158.4",
3
+ "version": "1.159.0",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -19,6 +19,11 @@ import { runWithStartContext } from '@tanstack/start-storage-context'
19
19
  import { requestHandler } from './request-response'
20
20
  import { getStartManifest } from './router-manifest'
21
21
  import { handleServerAction } from './server-functions-handler'
22
+ import {
23
+ buildManifestWithClientEntry,
24
+ resolveTransformConfig,
25
+ transformManifestUrls,
26
+ } from './transformAssetUrls'
22
27
 
23
28
  import { HEADERS } from './constants'
24
29
  import { ServerFunctionSerializationAdapter } from './serializer/ServerFunctionSerializationAdapter'
@@ -39,6 +44,11 @@ import type {
39
44
  Register,
40
45
  } from '@tanstack/router-core'
41
46
  import type { HandlerCallback } from '@tanstack/router-core/ssr/server'
47
+ import type {
48
+ StartManifestWithClientEntry,
49
+ TransformAssetUrls,
50
+ TransformAssetUrlsFn,
51
+ } from './transformAssetUrls'
42
52
 
43
53
  type TODO = any
44
54
 
@@ -46,6 +56,61 @@ type AnyMiddlewareServerFn =
46
56
  | AnyRequestMiddleware['options']['server']
47
57
  | AnyFunctionMiddleware['options']['server']
48
58
 
59
+ export interface CreateStartHandlerOptions {
60
+ handler: HandlerCallback<AnyRouter>
61
+ /**
62
+ * Transform asset URLs at runtime, e.g. to prepend a CDN prefix.
63
+ *
64
+ * **String** — a URL prefix prepended to every asset URL (cached by default):
65
+ * ```ts
66
+ * createStartHandler({
67
+ * handler: defaultStreamHandler,
68
+ * transformAssetUrls: 'https://cdn.example.com',
69
+ * })
70
+ * ```
71
+ *
72
+ * **Callback** — receives `{ url, type }` and returns a new URL
73
+ * (cached by default — runs once on first request):
74
+ * ```ts
75
+ * createStartHandler({
76
+ * handler: defaultStreamHandler,
77
+ * transformAssetUrls: ({ url, type }) => {
78
+ * return `https://cdn.example.com${url}`
79
+ * },
80
+ * })
81
+ * ```
82
+ *
83
+ * **Object** — for explicit cache control:
84
+ * ```ts
85
+ * createStartHandler({
86
+ * handler: defaultStreamHandler,
87
+ * transformAssetUrls: {
88
+ * transform: ({ url }) => {
89
+ * const region = getRequest().headers.get('x-region') || 'us'
90
+ * return `https://cdn-${region}.example.com${url}`
91
+ * },
92
+ * cache: false, // transform per-request
93
+ * },
94
+ * })
95
+ * ```
96
+ *
97
+ * `type` is one of `'modulepreload' | 'stylesheet' | 'clientEntry'`.
98
+ *
99
+ * By default, the transformed manifest is cached after the first request
100
+ * (`cache: true`). Set `cache: false` for per-request transforms.
101
+ *
102
+ * If you're using a cached transform, you can optionally set `warmup: true`
103
+ * (object form only) to compute the transformed manifest in the background at
104
+ * server startup.
105
+ *
106
+ * Note: This only transforms URLs managed by TanStack Start's manifest
107
+ * (JS preloads, CSS links, and the client entry script). For asset imports
108
+ * used directly in components (e.g. `import logo from './logo.svg'`),
109
+ * configure Vite's `experimental.renderBuiltUrl` in your vite.config.ts.
110
+ */
111
+ transformAssetUrls?: TransformAssetUrls
112
+ }
113
+
49
114
  function getStartResponseHeaders(opts: { router: AnyRouter }) {
50
115
  const headers = mergeHeaders(
51
116
  {
@@ -66,7 +131,13 @@ let entriesPromise:
66
131
  routerEntry: RouterEntry
67
132
  }>
68
133
  | undefined
69
- let manifestPromise: Promise<Manifest> | undefined
134
+ let baseManifestPromise: Promise<StartManifestWithClientEntry> | undefined
135
+
136
+ /**
137
+ * Cached final manifest (with client entry script tag). In production,
138
+ * this is computed once and reused for every request when caching is enabled.
139
+ */
140
+ let cachedFinalManifestPromise: Promise<Manifest> | undefined
70
141
 
71
142
  async function loadEntries() {
72
143
  // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core
@@ -83,16 +154,59 @@ function getEntries() {
83
154
  return entriesPromise
84
155
  }
85
156
 
86
- function getManifest(matchedRoutes?: ReadonlyArray<AnyRoute>) {
157
+ /**
158
+ * Returns the raw manifest data (without client entry script tag baked in).
159
+ * In dev mode, always returns fresh data. In prod, cached.
160
+ */
161
+ function getBaseManifest(
162
+ matchedRoutes?: ReadonlyArray<AnyRoute>,
163
+ ): Promise<StartManifestWithClientEntry> {
87
164
  // In dev mode, always get fresh manifest (no caching) to include route-specific dev styles
88
165
  if (process.env.TSS_DEV_SERVER === 'true') {
89
166
  return getStartManifest(matchedRoutes)
90
167
  }
91
- // In prod, cache the manifest
92
- if (!manifestPromise) {
93
- manifestPromise = getStartManifest()
168
+ // In prod, cache the base manifest
169
+ if (!baseManifestPromise) {
170
+ baseManifestPromise = getStartManifest()
94
171
  }
95
- return manifestPromise
172
+ return baseManifestPromise
173
+ }
174
+
175
+ /**
176
+ * Resolves a final Manifest for a given request.
177
+ *
178
+ * - No transform: builds client entry script tag and returns (cached in prod).
179
+ * - Cached transform: transforms all URLs + builds script tag, caches result.
180
+ * - Per-request transform: deep-clones base manifest, transforms per-request.
181
+ */
182
+ async function resolveManifest(
183
+ matchedRoutes: ReadonlyArray<AnyRoute> | undefined,
184
+ transformFn: TransformAssetUrlsFn | undefined,
185
+ cache: boolean,
186
+ ): Promise<Manifest> {
187
+ const base = await getBaseManifest(matchedRoutes)
188
+
189
+ const computeFinalManifest = async () => {
190
+ return transformFn
191
+ ? await transformManifestUrls(base, transformFn, { clone: !cache })
192
+ : buildManifestWithClientEntry(base)
193
+ }
194
+
195
+ // In dev, always compute fresh to include route-specific dev styles.
196
+ if (process.env.TSS_DEV_SERVER === 'true') {
197
+ return computeFinalManifest()
198
+ }
199
+
200
+ // In prod, cache unless we're explicitly doing per-request transforms.
201
+ if (!transformFn || cache) {
202
+ if (!cachedFinalManifestPromise) {
203
+ cachedFinalManifestPromise = computeFinalManifest()
204
+ }
205
+ return cachedFinalManifestPromise
206
+ }
207
+
208
+ // Per-request transform — deep-clone and transform every time.
209
+ return computeFinalManifest()
96
210
  }
97
211
 
98
212
  // Pre-computed constants
@@ -206,9 +320,107 @@ function handlerToMiddleware(
206
320
  }
207
321
  }
208
322
 
323
+ /**
324
+ * Creates the TanStack Start request handler.
325
+ *
326
+ * @example Backwards-compatible usage (handler callback only):
327
+ * ```ts
328
+ * export default createStartHandler(defaultStreamHandler)
329
+ * ```
330
+ *
331
+ * @example With CDN URL rewriting:
332
+ * ```ts
333
+ * export default createStartHandler({
334
+ * handler: defaultStreamHandler,
335
+ * transformAssetUrls: 'https://cdn.example.com',
336
+ * })
337
+ * ```
338
+ *
339
+ * @example With per-request URL rewriting:
340
+ * ```ts
341
+ * export default createStartHandler({
342
+ * handler: defaultStreamHandler,
343
+ * transformAssetUrls: {
344
+ * transform: ({ url }) => {
345
+ * const cdnBase = getRequest().headers.get('x-cdn-base') || ''
346
+ * return `${cdnBase}${url}`
347
+ * },
348
+ * cache: false,
349
+ * },
350
+ * })
351
+ * ```
352
+ */
209
353
  export function createStartHandler<TRegister = Register>(
210
- cb: HandlerCallback<AnyRouter>,
354
+ cbOrOptions: HandlerCallback<AnyRouter> | CreateStartHandlerOptions,
211
355
  ): RequestHandler<TRegister> {
356
+ // Normalize the overloaded argument
357
+ const cb: HandlerCallback<AnyRouter> =
358
+ typeof cbOrOptions === 'function' ? cbOrOptions : cbOrOptions.handler
359
+ const transformAssetUrlsOption: TransformAssetUrls | undefined =
360
+ typeof cbOrOptions === 'function'
361
+ ? undefined
362
+ : cbOrOptions.transformAssetUrls
363
+
364
+ const warmupTransformManifest =
365
+ !!transformAssetUrlsOption &&
366
+ typeof transformAssetUrlsOption === 'object' &&
367
+ transformAssetUrlsOption.warmup === true
368
+
369
+ // Pre-resolve the transform function and cache flag
370
+ const resolvedTransformConfig = transformAssetUrlsOption
371
+ ? resolveTransformConfig(transformAssetUrlsOption)
372
+ : undefined
373
+ const cache = resolvedTransformConfig ? resolvedTransformConfig.cache : true
374
+
375
+ // Memoize a single createTransform() result when caching is enabled.
376
+ let cachedCreateTransformPromise: Promise<TransformAssetUrlsFn> | undefined
377
+
378
+ const getTransformFn = async (
379
+ opts: { warmup: true } | { warmup: false; request: Request },
380
+ ): Promise<TransformAssetUrlsFn | undefined> => {
381
+ if (!resolvedTransformConfig) return undefined
382
+ if (resolvedTransformConfig.type === 'createTransform') {
383
+ if (cache) {
384
+ if (!cachedCreateTransformPromise) {
385
+ cachedCreateTransformPromise = Promise.resolve(
386
+ resolvedTransformConfig.createTransform(opts),
387
+ )
388
+ }
389
+ return cachedCreateTransformPromise
390
+ }
391
+ return resolvedTransformConfig.createTransform(opts)
392
+ }
393
+ return resolvedTransformConfig.transformFn
394
+ }
395
+
396
+ // Background warmup for cached transforms (production only)
397
+ if (
398
+ warmupTransformManifest &&
399
+ cache &&
400
+ process.env.TSS_DEV_SERVER !== 'true' &&
401
+ !cachedFinalManifestPromise
402
+ ) {
403
+ // NOTE: Do not call resolveManifest() here.
404
+ // resolveManifest() reads from cachedFinalManifestPromise, and since we set
405
+ // cachedFinalManifestPromise to this warmup promise, that would create a
406
+ // self-referential promise and hang forever.
407
+ const warmupPromise = (async () => {
408
+ const base = await getBaseManifest(undefined)
409
+ const transformFn = await getTransformFn({ warmup: true })
410
+ return transformFn
411
+ ? await transformManifestUrls(base, transformFn, { clone: false })
412
+ : buildManifestWithClientEntry(base)
413
+ })()
414
+ cachedFinalManifestPromise = warmupPromise
415
+ warmupPromise.catch(() => {
416
+ // If warmup fails, allow the next request to retry.
417
+ if (cachedFinalManifestPromise === warmupPromise) {
418
+ cachedFinalManifestPromise = undefined
419
+ }
420
+ cachedCreateTransformPromise = undefined
421
+ })
422
+ }
423
+
212
424
  const startRequestResolver: RequestHandler<Register> = async (
213
425
  request,
214
426
  requestOpts,
@@ -345,7 +557,11 @@ export function createStartHandler<TRegister = Register>(
345
557
  )
346
558
  }
347
559
 
348
- const manifest = await getManifest(matchedRoutes)
560
+ const manifest = await resolveManifest(
561
+ matchedRoutes,
562
+ await getTransformFn({ warmup: false, request }),
563
+ cache,
564
+ )
349
565
  const routerInstance = await getRouter()
350
566
 
351
567
  attachRouterServerSsrUtils({
package/src/index.tsx CHANGED
@@ -1,4 +1,13 @@
1
1
  export { createStartHandler } from './createStartHandler'
2
+ export type { CreateStartHandlerOptions } from './createStartHandler'
3
+
4
+ export type {
5
+ TransformAssetUrls,
6
+ TransformAssetUrlsFn,
7
+ TransformAssetUrlsContext,
8
+ TransformAssetUrlsOptions,
9
+ AssetUrlType,
10
+ } from './transformAssetUrls'
2
11
 
3
12
  export {
4
13
  attachRouterServerSsrUtils,
@@ -1,21 +1,25 @@
1
1
  import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core'
2
2
  import type { AnyRoute, RouterManagedTag } from '@tanstack/router-core'
3
+ import type { StartManifestWithClientEntry } from './transformAssetUrls'
3
4
 
4
5
  // Pre-computed constant for dev styles URL
5
6
  const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/'
6
7
 
7
8
  /**
8
- * @description Returns the router manifest that should be sent to the client.
9
+ * @description Returns the router manifest data that should be sent to the client.
9
10
  * This includes only the assets and preloads for the current route and any
10
11
  * special assets that are needed for the client. It does not include relationships
11
12
  * between routes or any other data that is not needed for the client.
12
13
  *
14
+ * The client entry URL is returned separately so that it can be transformed
15
+ * (e.g. for CDN rewriting) before being embedded into the `<script>` tag.
16
+ *
13
17
  * @param matchedRoutes - In dev mode, the matched routes are used to build
14
18
  * the dev styles URL for route-scoped CSS collection.
15
19
  */
16
20
  export async function getStartManifest(
17
21
  matchedRoutes?: ReadonlyArray<AnyRoute>,
18
- ) {
22
+ ): Promise<StartManifestWithClientEntry> {
19
23
  const { tsrStartManifest } = await import('tanstack-start-manifest:v')
20
24
  const startManifest = tsrStartManifest()
21
25
 
@@ -37,22 +41,15 @@ export async function getStartManifest(
37
41
  })
38
42
  }
39
43
 
40
- let script = `import('${startManifest.clientEntry}')`
44
+ // Collect injected head scripts in dev mode (returned separately so we can
45
+ // build the client entry script tag after URL transforms are applied)
46
+ let injectedHeadScripts: string | undefined
41
47
  if (process.env.TSS_DEV_SERVER === 'true') {
42
- const { injectedHeadScripts } =
43
- await import('tanstack-start-injected-head-scripts:v')
44
- if (injectedHeadScripts) {
45
- script = `${injectedHeadScripts + ';'}${script}`
48
+ const mod = await import('tanstack-start-injected-head-scripts:v')
49
+ if (mod.injectedHeadScripts) {
50
+ injectedHeadScripts = mod.injectedHeadScripts
46
51
  }
47
52
  }
48
- rootRoute.assets.push({
49
- tag: 'script',
50
- attrs: {
51
- type: 'module',
52
- async: true,
53
- },
54
- children: script,
55
- })
56
53
 
57
54
  const manifest = {
58
55
  routes: Object.fromEntries(
@@ -78,6 +75,9 @@ export async function getStartManifest(
78
75
  ),
79
76
  }
80
77
 
81
- // Strip out anything that isn't needed for the client
82
- return manifest
78
+ return {
79
+ manifest,
80
+ clientEntry: startManifest.clientEntry,
81
+ injectedHeadScripts,
82
+ }
83
83
  }