diff --git a/.changeset/tanstack-start-image-url.md b/.changeset/tanstack-start-image-url.md new file mode 100644 index 0000000..8c32fdc --- /dev/null +++ b/.changeset/tanstack-start-image-url.md @@ -0,0 +1,6 @@ +--- +'@solidjs/ogp': minor +--- + +- The `TanstackStart` runtime works with current TanStack Start: the image url encodes its payload the way TanStack Start reads it, and the compiled code imports `createServerFn`. +- New `createTanstackImageUrl(url, values)` export, which the compiled code uses to build that url. diff --git a/packages/ogp/package.json b/packages/ogp/package.json index a91a091..e35d4ad 100644 --- a/packages/ogp/package.json +++ b/packages/ogp/package.json @@ -100,6 +100,7 @@ "magic-string": "^1.4.5", "oxc-parser": "^0.158.0", "satori-html": "^0.3.2", + "seroval": "^1.7.0", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, diff --git a/packages/ogp/src/compiler/index.ts b/packages/ogp/src/compiler/index.ts index 631ba50..fa8419c 100644 --- a/packages/ogp/src/compiler/index.ts +++ b/packages/ogp/src/compiler/index.ts @@ -157,16 +157,14 @@ const DynamicImage${id} = (props) => { function createTanstackImage(image: DynamicImage, id: number): string { const serverFn = `DynamicImage${id}ServerFunction`; - return `const ${serverFn} = createServerFn({ - method: "GET", - response: "raw", -}).handler((ctx) => { + // The image request carries no x-tsr-serverFn header, so TanStack Start + // sends the returned Response as is. + return `const ${serverFn} = createServerFn({ method: 'GET' }).handler((ctx) => { const args = ctx.data.values; ${createArgumentDeclaration(image)}${createRenderCall(image)} }); const DynamicImage${id} = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }); - const url = createMemo(() => \`\${${serverFn}.url}?payload=\${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw\`); + const url = createMemo(() => createTanstackImageUrl(${serverFn}.url, props.values)); return url; }; `; @@ -269,7 +267,10 @@ export function compile( ['createMemo', 'solid-js'], ['createOpenGraphImage', '@solidjs/ogp/server'], ]; - if (runtime === 'Solid') { + if (runtime === 'TanstackStart') { + needed.push(['createServerFn', '@tanstack/solid-start']); + needed.push(['createTanstackImageUrl', '@solidjs/ogp']); + } else { needed.push(['GET', '@solidjs/web/server-functions']); needed.push(['serverFunctionUrl', '@solidjs/web/server-functions']); } diff --git a/packages/ogp/src/index.tsx b/packages/ogp/src/index.tsx index b364b21..d303673 100644 --- a/packages/ogp/src/index.tsx +++ b/packages/ogp/src/index.tsx @@ -3,6 +3,7 @@ import { useHead } from '@solidjs/web'; import type { ImageResponseOptions } from '@vercel/og'; import type { ParentProps } from 'solid-js'; import { children, createMemo, flatten } from 'solid-js'; +import { toJSON } from 'seroval'; export interface DynamicImageProps { children: JSX.Element | (() => JSX.Element | Promise); @@ -44,3 +45,13 @@ export function urlFromImage(comp: JSX.Element): () => string { // The compiler turns `DynamicImage` into an accessor of its url. return () => readUrl(flatten(comp)); } + +/** + * Builds the GET url of a TanStack Start server function called with + * `{ data: { values } }`. TanStack Start reads the `payload` search parameter + * with seroval's `fromJSON`, so the payload is encoded with seroval too. + */ +export function createTanstackImageUrl(url: string, values: unknown[]): string { + const payload = encodeURIComponent(JSON.stringify(toJSON({ data: { values } }))); + return `${url}${url.includes('?') ? '&' : '?'}payload=${payload}`; +} diff --git a/packages/ogp/tests/fixtures/tanstack-start/basic/output.js b/packages/ogp/tests/fixtures/tanstack-start/basic/output.js index aabbfa8..bfd6212 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/basic/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/basic/output.js @@ -1,19 +1,18 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' import { createSignal } from 'solid-js' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
) -}) +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js b/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js index 031d907..4957238 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js @@ -1,17 +1,16 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - return createOpenGraphImage(
Hi!
, { emoji: 'openmoji' }) -}) +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + return createOpenGraphImage(
Hi!
, { emoji: 'openmoji' }) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js b/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js index 0a3e031..1edddcb 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js @@ -1,34 +1,30 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
) -}) +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } -const DynamicImage2ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
, { width: 800 }) -}) +const DynamicImage2ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
, { width: 800 }) + }, +) const DynamicImage2 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage2ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage2ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js b/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js index d4e0327..829f9e6 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js @@ -1,20 +1,19 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' import { Image } from '@solidjs/ogp' import { createSignal } from 'solid-js' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{`Hello ${r0 * 2}!`}
) -}) +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{`Hello ${r0 * 2}!`}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/variables/output.js b/packages/ogp/tests/fixtures/tanstack-start/variables/output.js index b89023f..ee009d9 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/variables/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/variables/output.js @@ -1,18 +1,17 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
) -}) +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/tanstack.test.ts b/packages/ogp/tests/tanstack.test.ts new file mode 100644 index 0000000..4efb7b9 --- /dev/null +++ b/packages/ogp/tests/tanstack.test.ts @@ -0,0 +1,36 @@ +import type { SerovalJSON } from 'seroval'; +import { fromJSON } from 'seroval'; +import { describe, expect, it } from 'vitest'; +import { createTanstackImageUrl } from '../src/index'; + +function isSerovalJSON(value: unknown): value is SerovalJSON { + return typeof value === 'object' && value !== null && 't' in value; +} + +/** Reads the payload the way TanStack Start's server function handler does for a GET. */ +function readPayload(url: string): unknown { + const payload = new URL(url, 'http://localhost').searchParams.get('payload'); + if (payload === null) { + return {}; + } + const json: unknown = JSON.parse(payload); + if (!isSerovalJSON(json)) { + throw new TypeError('payload is not seroval JSON'); + } + return fromJSON(json); +} + +describe('createTanstackImageUrl', () => { + it('encodes the values as the payload data TanStack Start decodes', () => { + const values = ['hello', 42, { nested: true }, null]; + const url = createTanstackImageUrl('/_serverFn/abc', values); + expect(url.startsWith('/_serverFn/abc?payload=')).toBe(true); + expect(readPayload(url)).toEqual({ data: { values } }); + }); + + it('appends to a url that already has a query', () => { + const url = createTanstackImageUrl('/_serverFn/abc?x=1', []); + expect(url.startsWith('/_serverFn/abc?x=1&payload=')).toBe(true); + expect(readPayload(url)).toEqual({ data: { values: [] } }); + }); +}); diff --git a/packages/ogp/tests/unplugin.test.ts b/packages/ogp/tests/unplugin.test.ts index 2399e08..9a4c0ec 100644 --- a/packages/ogp/tests/unplugin.test.ts +++ b/packages/ogp/tests/unplugin.test.ts @@ -57,7 +57,7 @@ describe('transform', () => { runtime: 'TanstackStart', }); expect(res!.code).toContain('createServerFn('); - expect(res!.code).toContain('response: "raw"'); + expect(res!.code).toContain("import { createServerFn } from '@tanstack/solid-start'"); expect(res!.code).not.toContain("'use server'"); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4435b4f..e661ac9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: satori-html: specifier: ^0.3.2 version: 0.3.2 + seroval: + specifier: ^1.7.0 + version: 1.7.0 unplugin: specifier: ^3.3.0 version: 3.3.0(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(yaml@2.9.1))