diff --git a/.github/actions/vercel/action.yaml b/.github/actions/vercel/action.yaml index 3e3c1be8685b..7bab9a9a31a5 100644 --- a/.github/actions/vercel/action.yaml +++ b/.github/actions/vercel/action.yaml @@ -18,8 +18,12 @@ inputs: description: "Sha" required: true environment: - description: "Sha" + description: "Alias environment" required: true + production-staged: + description: "Create a production deployment without assigning production domains" + required: false + default: "false" PUBLIC_COLLAB_RELAY_URL: description: "Public collaboration relay URL injected into the builder bundle" required: false @@ -79,9 +83,14 @@ runs: run: | export GITHUB_SHA=${{ inputs.sha }} export GITHUB_REF_NAME=${{ inputs.ref-name }} - export PUBLIC_COLLAB_RELAY_URL=${{ inputs.PUBLIC_COLLAB_RELAY_URL }} - pnpx vercel build + if [ "${{ inputs.production-staged }}" = "true" ]; then + unset PUBLIC_COLLAB_RELAY_URL + pnpx vercel build --prod --token "${{ inputs.vercel-token }}" + else + export PUBLIC_COLLAB_RELAY_URL=${{ inputs.PUBLIC_COLLAB_RELAY_URL }} + pnpx vercel build --token "${{ inputs.vercel-token }}" + fi shell: bash - name: Patch @@ -108,9 +117,17 @@ runs: - name: Deploy id: deploy run: | + deploy_args=( + --prebuilt + --token "${{ inputs.vercel-token }}" + ) + + if [ "${{ inputs.production-staged }}" = "true" ]; then + deploy_args+=(--prod --skip-domain) + fi + pnpx vercel deploy \ - --prebuilt \ - --token ${{ inputs.vercel-token }} \ + "${deploy_args[@]}" \ 2> >(tee info.txt >&2) | tee domain.txt echo "domain=$(cat ./domain.txt)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/vercel-deploy-staging.yml b/.github/workflows/vercel-deploy-staging.yml index 1f41d0238d7c..7ee0980e34d5 100644 --- a/.github/workflows/vercel-deploy-staging.yml +++ b/.github/workflows/vercel-deploy-staging.yml @@ -86,6 +86,7 @@ jobs: ref-name: ${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} sha: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} environment: ${{ matrix.environment }} + production-staged: ${{ github.event_name == 'push' && matrix.environment == 'staging' && startsWith(github.ref_name, 'release-') && endsWith(github.ref_name, '.staging') }} PUBLIC_COLLAB_RELAY_URL: ${{ vars.PUBLIC_COLLAB_RELAY_URL }} - name: Debug Vercel Outputs diff --git a/apps/builder/app/builder/builder.tsx b/apps/builder/app/builder/builder.tsx index a6c7bd30c7c0..bfc52544480e 100644 --- a/apps/builder/app/builder/builder.tsx +++ b/apps/builder/app/builder/builder.tsx @@ -58,7 +58,7 @@ import type { TokenPermissions } from "@webstudio-is/authorization-token"; import { useToastErrors } from "~/shared/error/toast-error"; import { initBuilderApi } from "~/shared/builder-api"; import { updateWebstudioData } from "~/shared/instance-utils"; -import { migrateWebstudioDataMutable } from "@webstudio-is/sdk/migrations/webstudio-data"; +import { migrateWebstudioDataMutable } from "@webstudio-is/project-migrations"; import { Loading, LoadingBackground } from "./shared/loading"; import { mergeRefs } from "@react-aria/utils"; import { CommandPanel } from "./features/command-panel"; diff --git a/apps/builder/app/builder/features/marketplace/utils.ts b/apps/builder/app/builder/features/marketplace/utils.ts index 2b7882379111..bfee4a53077b 100644 --- a/apps/builder/app/builder/features/marketplace/utils.ts +++ b/apps/builder/app/builder/features/marketplace/utils.ts @@ -1,10 +1,12 @@ import { getStyleDeclKey, - migratePages, type Asset, - type SerializedPages, type WebstudioData, } from "@webstudio-is/sdk"; +import { + migratePages, + type SerializedPages, +} from "@webstudio-is/project-migrations/pages"; import type { CompactBuild } from "@webstudio-is/project-build"; const getPair = (item: Item) => diff --git a/apps/builder/app/routes/rest.$.ts b/apps/builder/app/routes/rest.$.ts new file mode 100644 index 000000000000..8c0b5b74c77f --- /dev/null +++ b/apps/builder/app/routes/rest.$.ts @@ -0,0 +1,38 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { + ApiCompatibilityTarget, + apiClientHeader, + createApiCompatibilityPayload, +} from "@webstudio-is/trpc-interface/api-compatibility"; + +const getTarget = (request: Request) => { + const client = ApiCompatibilityTarget.safeParse( + request.headers.get(apiClientHeader) + ); + if (client.success) { + return client.data; + } + + return "browser"; +}; + +const createResponse = (request: Request) => { + const payload = createApiCompatibilityPayload({ + reason: "apiRouteNotFound", + target: getTarget(request), + }); + + return new Response(JSON.stringify({ success: false, error: payload }), { + status: 426, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }, + }); +}; + +export const loader = ({ request }: LoaderFunctionArgs) => { + return createResponse(request); +}; + +export const action = loader; diff --git a/apps/builder/app/routes/rest.data.$projectId.ts b/apps/builder/app/routes/rest.data.$projectId.ts index 7ee4c96c52a0..b342555f9265 100644 --- a/apps/builder/app/routes/rest.data.$projectId.ts +++ b/apps/builder/app/routes/rest.data.$projectId.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import * as projectApi from "@webstudio-is/project/index.server"; import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server"; -import { serializePages } from "@webstudio-is/sdk"; +import { serializePages } from "@webstudio-is/project-migrations/pages"; import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; import { createContext } from "~/shared/context.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; diff --git a/apps/builder/app/services/auth-strategy/ws.server.ts b/apps/builder/app/services/auth-strategy/ws.server.ts index 916f56e13a55..2d4659b8d2b7 100644 --- a/apps/builder/app/services/auth-strategy/ws.server.ts +++ b/apps/builder/app/services/auth-strategy/ws.server.ts @@ -18,7 +18,10 @@ const asyncLocalStorage = new AsyncLocalStorage< >(); // remix-auth-oauth2 logs OAuth state, PKCE verifier, and full callback URLs. -createDebugRaw.enable(`${createDebugRaw.disable()},-OAuth2Strategy`); +// Match both the exact namespace and any prefixed namespace enabled by DEBUG=*. +createDebugRaw.enable( + `${createDebugRaw.disable()},-OAuth2Strategy,-*OAuth2Strategy*` +); /** * The main issue with OAuth2Strategy is that it forces us to define authorizationEndpoint, tokenEndpoint, and redirectURI diff --git a/apps/builder/app/services/trpc.server.ts b/apps/builder/app/services/trpc.server.ts index 8052efb03249..37b198237601 100644 --- a/apps/builder/app/services/trpc.server.ts +++ b/apps/builder/app/services/trpc.server.ts @@ -12,6 +12,7 @@ export const trpcSharedClient = createTrpcProxyServiceClient( url: TRPC_SERVER_URL, token: TRPC_SERVER_API_TOKEN, branchName: GITHUB_REF_NAME, + clientVersion: staticEnv.GITHUB_SHA ?? "local", } : undefined ); diff --git a/apps/builder/app/shared/builder-data.ts b/apps/builder/app/shared/builder-data.ts index 146f1e5a2742..5039203b6ab3 100644 --- a/apps/builder/app/shared/builder-data.ts +++ b/apps/builder/app/shared/builder-data.ts @@ -1,8 +1,5 @@ -import { - getStyleDeclKey, - migratePages, - type WebstudioData, -} from "@webstudio-is/sdk"; +import { getStyleDeclKey, type WebstudioData } from "@webstudio-is/sdk"; +import { migratePages } from "@webstudio-is/project-migrations/pages"; import type { MarketplaceProduct } from "@webstudio-is/project-build"; import type { Project } from "@webstudio-is/project"; import type { loader } from "~/routes/rest.data.$projectId"; diff --git a/apps/builder/app/shared/context.server.ts b/apps/builder/app/shared/context.server.ts index 84b9c778d3bd..6765d9e531ae 100644 --- a/apps/builder/app/shared/context.server.ts +++ b/apps/builder/app/shared/context.server.ts @@ -16,6 +16,11 @@ import { readLoginSessionBloomFilter } from "~/services/session.server"; import type { BloomFilter } from "~/services/bloom-filter.server"; import { isBuilder, isCanvas } from "./router-utils"; import { parseBuilderUrl } from "@webstudio-is/http-client"; +import { + ApiClient, + apiClientHeader, + apiClientVersionHeader, +} from "@webstudio-is/trpc-interface/api-compatibility"; export const extractAuthFromRequest = async (request: Request) => { if (isCanvas(request)) { @@ -180,6 +185,21 @@ const createTrpcCache = () => { }; }; +const createApiClientContext = (request: Request): AppContext["apiClient"] => { + const client = ApiClient.safeParse(request.headers.get(apiClientHeader)); + if (client.success === false) { + return { + type: "unknown", + version: undefined, + }; + } + + return { + type: client.data, + version: request.headers.get(apiClientVersionHeader) ?? undefined, + }; +}; + export const createPostgrestContext = () => { return { client: createClient(env.POSTGREST_URL, env.POSTGREST_API_KEY) }; }; @@ -212,6 +232,7 @@ export const createContext = async (request: Request): Promise => { const entri = createEntriContext(); const { planFeatures, purchases } = await resolvePlanInfo(authorization); const trpcCache = createTrpcCache(); + const apiClient = createApiClientContext(request); const getOwnerPlanFeatures = async (userId: string) => { const results = await getPlanInfo([userId], { postgrest }); @@ -233,6 +254,7 @@ export const createContext = async (request: Request): Promise => { entri, planFeatures, purchases, + apiClient, trpcCache, postgrest, createTokenContext, @@ -247,6 +269,7 @@ export const createContext = async (request: Request): Promise => { entri, planFeatures, purchases, + apiClient, trpcCache, postgrest, createTokenContext, diff --git a/apps/builder/app/shared/db/canvas.server.ts b/apps/builder/app/shared/db/canvas.server.ts index 9ca3bbae19fc..002ce55b2c06 100644 --- a/apps/builder/app/shared/db/canvas.server.ts +++ b/apps/builder/app/shared/db/canvas.server.ts @@ -6,8 +6,8 @@ import { findPageByIdOrPath, getAllPages, getStyleDeclKey, - serializePages, } from "@webstudio-is/sdk"; +import { serializePages } from "@webstudio-is/project-migrations/pages"; import * as projectApi from "@webstudio-is/project/index.server"; import { getUserById, type User } from "./user.server"; diff --git a/apps/builder/app/shared/fetch.client.ts b/apps/builder/app/shared/fetch.client.ts index 4372834ae57c..6da8853d2f08 100644 --- a/apps/builder/app/shared/fetch.client.ts +++ b/apps/builder/app/shared/fetch.client.ts @@ -1,6 +1,12 @@ import { toast } from "@webstudio-is/design-system"; +import { + apiClientHeader, + apiClientVersionHeader, + getApiCompatibilityPayload, +} from "@webstudio-is/trpc-interface/api-compatibility"; import { csrfToken } from "./csrf.client"; import { $authToken } from "./nano-states"; +import { publicStaticEnv } from "~/env/env.static"; /** * To avoid fetch interception from the canvas, i.e., `globalThis.fetch = () => console.log('INTERCEPTED');`, @@ -11,7 +17,10 @@ const _fetch = globalThis.fetch; * To avoid fetch interception from the canvas, i.e., `globalThis.fetch = () => console.log('INTERCEPTED');`, * To add csrf token to the headers. */ -export const fetch: typeof globalThis.fetch = (requestInfo, requestInit) => { +export const fetch: typeof globalThis.fetch = async ( + requestInfo, + requestInit +) => { if (csrfToken === undefined) { toast.error("CSRF token is not set."); throw new Error("CSRF token is not set."); @@ -20,6 +29,8 @@ export const fetch: typeof globalThis.fetch = (requestInfo, requestInit) => { const headers = new Headers(requestInit?.headers); headers.set("X-CSRF-Token", csrfToken); + headers.set(apiClientHeader, "browser"); + headers.set(apiClientVersionHeader, publicStaticEnv.VERSION); const authToken = $authToken.get(); @@ -34,5 +45,24 @@ export const fetch: typeof globalThis.fetch = (requestInfo, requestInit) => { headers, }; - return _fetch(requestInfo, modifiedInit); + const response = await _fetch(requestInfo, modifiedInit); + if (response.ok === false) { + const contentType = response.headers.get("Content-Type") ?? ""; + if (contentType.includes("application/json")) { + const body: unknown = await response + .clone() + .json() + .catch(() => undefined); + const payload = getApiCompatibilityPayload(body); + if (payload?.action.type === "reloadBrowser") { + toast.error(payload.message, { + id: "api-compatibility", + duration: Number.POSITIVE_INFINITY, + }); + throw new Error(payload.message, { cause: payload }); + } + } + } + + return response; }; diff --git a/apps/builder/app/shared/marketplace/db.server.ts b/apps/builder/app/shared/marketplace/db.server.ts index 529bc35e59bd..30bf5413bf64 100644 --- a/apps/builder/app/shared/marketplace/db.server.ts +++ b/apps/builder/app/shared/marketplace/db.server.ts @@ -7,7 +7,7 @@ import { import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; import type { Project } from "@webstudio-is/project"; import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; -import { serializePages } from "@webstudio-is/sdk"; +import { serializePages } from "@webstudio-is/project-migrations/pages"; export const getBuildProdData = async ( { projectId }: { projectId: Project["id"] }, diff --git a/apps/builder/app/shared/page-utils.test.tsx b/apps/builder/app/shared/page-utils.test.tsx index 58a3c182f3a3..a0b061979a09 100644 --- a/apps/builder/app/shared/page-utils.test.tsx +++ b/apps/builder/app/shared/page-utils.test.tsx @@ -5,10 +5,10 @@ import { ROOT_INSTANCE_ID, encodeDataVariableId, getHomePage, - migratePages, type Instance, type WebstudioData, } from "@webstudio-is/sdk"; +import { migratePages } from "@webstudio-is/project-migrations/pages"; import { createDefaultPages, createRootFolder, diff --git a/apps/builder/app/shared/trpc/trpc-client.ts b/apps/builder/app/shared/trpc/trpc-client.ts index 2cc9be28c185..755e86c9c9a2 100644 --- a/apps/builder/app/shared/trpc/trpc-client.ts +++ b/apps/builder/app/shared/trpc/trpc-client.ts @@ -1,5 +1,6 @@ import type { AppRouter } from "~/services/trcp-router.server"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; +import { getApiCompatibilityPayload } from "@webstudio-is/trpc-interface/api-compatibility"; import type { AnyMutationProcedure, @@ -117,6 +118,12 @@ export const trpcClient: { console.error("TRPC ERROR", error); + const payload = getApiCompatibilityPayload(error); + if (payload?.action.type === "reloadBrowser") { + setError(payload.message); + return; + } + if (error instanceof Error) { setError(error.message); return; diff --git a/apps/builder/package.json b/apps/builder/package.json index ba63d5e36b2a..28bccb86cf24 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -7,7 +7,7 @@ "scripts": { "prebuild": "which remix", "build": "remix vite:build", - "css-to-ws": "NODE_OPTIONS=--conditions=webstudio css-to-ws", + "css-to-ws": "tsx --conditions=webstudio ../../packages/css-data/bin/css-to-ws.ts", "build:webflow-presets": "pnpm css-to-ws ./app/shared/copy-paste/plugin-webflow/style-presets.css ./app/shared/copy-paste/plugin-webflow/__generated__/style-presets.ts", "build:tailwind-preflight": "tsx --conditions=webstudio ./app/shared/tailwind/preflight-bin.ts && prettier --write ./app/shared/tailwind/__generated__/preflight.ts", "start": "remix-serve build/server/index.js", @@ -75,6 +75,7 @@ "@webstudio-is/postgrest": "workspace:*", "@webstudio-is/project": "workspace:*", "@webstudio-is/project-build": "workspace:*", + "@webstudio-is/project-migrations": "workspace:*", "@webstudio-is/react-sdk": "workspace:*", "@webstudio-is/sdk": "workspace:*", "@webstudio-is/sdk-components-animation": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index f9a12d4d9de2..2174c13ab5d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,8 @@ "@clack/prompts": "^0.10.0", "@emotion/hash": "^0.9.2", "@trpc/client": "^10.45.2", + "@webstudio-is/project-migrations": "workspace:*", + "@webstudio-is/trpc-interface": "workspace:*", "acorn": "^8.14.1", "acorn-walk": "^8.3.4", "change-case": "^5.4.4", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d7e2ab5d332a..12e8430b3a4c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { initFlow } from "./commands/init-flow"; import makeCLI from "yargs"; import packageJson from "../package.json" assert { type: "json" }; import type { CommonYargsArgv } from "./commands/yargs-types"; +import { isHandledCliError } from "./errors"; export const main = async () => { try { @@ -61,6 +62,9 @@ export const main = async () => { await cmd.parse(); } catch (error) { + if (isHandledCliError(error)) { + exit(1); + } console.error(error); exit(1); } diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 15c3b0a946dd..91caf44068bf 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -4,10 +4,16 @@ import { join } from "node:path"; import pc from "picocolors"; import { spinner } from "@clack/prompts"; import { + apiClientHeader, + apiClientVersionHeader, + getApiCompatibilityPayload, +} from "@webstudio-is/trpc-interface/api-compatibility"; +import { + type Data, loadProjectDataByBuildId, loadProjectDataByProjectId, - type Data, } from "@webstudio-is/http-client"; +import packageJson from "../../package.json"; import { createFileIfNotExists, isFileExists } from "../fs-utils"; import { GLOBAL_CONFIG_FILE, @@ -20,6 +26,42 @@ import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "./yargs-types"; +import { HandledCliError } from "../errors"; + +const apiCompatibilityHeaders = { + [apiClientHeader]: "cli", + [apiClientVersionHeader]: packageJson.version, +}; + +const updateCliCommand = "npm install -g webstudio@latest"; + +const getCliCompatibilityMessage = (error: unknown) => { + const payload = getApiCompatibilityPayload(error); + if (payload?.action.type !== "updateCli") { + return; + } + + return `${payload.message} + +Update the CLI with: + ${updateCliCommand} + +Or run the latest version once with: + npx webstudio@latest sync`; +}; + +const stopSyncingWithError = ( + syncing: ReturnType, + error: unknown +) => { + const compatibilityMessage = getCliCompatibilityMessage(error); + const message = + error instanceof Error + ? error.message + : "Unable to synchronize project data"; + syncing.stop(compatibilityMessage ?? message, 2); + return compatibilityMessage; +}; export const syncOptions = (yargs: CommonYargsArgv) => yargs @@ -50,12 +92,21 @@ export const sync = async ( options.authToken !== undefined ) { syncing.message(`Synchronizing project data from ${options.origin}`); - project = await loadProjectDataByBuildId({ - buildId: options.buildId, - seviceToken: options.authToken, - origin: options.origin, - }); - project.origin = options.origin; + try { + project = await loadProjectDataByBuildId({ + buildId: options.buildId, + seviceToken: options.authToken, + origin: options.origin, + headers: apiCompatibilityHeaders, + }); + project.origin = options.origin; + } catch (error) { + const compatibilityMessage = stopSyncingWithError(syncing, error); + if (compatibilityMessage !== undefined) { + throw new HandledCliError(); + } + throw error; + } } else { const globalConfigText = await readFile(GLOBAL_CONFIG_FILE, "utf-8"); const globalConfig = jsonToGlobalConfig(JSON.parse(globalConfigText)); @@ -95,16 +146,21 @@ export const sync = async ( buildId: options.buildId, authToken: token, origin, + headers: apiCompatibilityHeaders, }) : await loadProjectDataByProjectId({ projectId: localConfig.projectId, authToken: token, origin, + headers: apiCompatibilityHeaders, }); project.origin = origin; } catch (error) { // catch errors about unpublished project - syncing.stop((error as Error).message, 2); + const compatibilityMessage = stopSyncingWithError(syncing, error); + if (compatibilityMessage !== undefined) { + throw new HandledCliError(); + } throw error; } diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 000000000000..3f752ac1feba --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,9 @@ +export class HandledCliError extends Error { + constructor() { + super("Handled CLI error"); + this.name = "HandledCliError"; + } +} + +export const isHandledCliError = (error: unknown) => + error instanceof HandledCliError; diff --git a/packages/cli/src/prebuild.ts b/packages/cli/src/prebuild.ts index 1bd838c4dae6..d1652faa2ac0 100644 --- a/packages/cli/src/prebuild.ts +++ b/packages/cli/src/prebuild.ts @@ -48,9 +48,9 @@ import { ROOT_INSTANCE_ID, elementComponent, getAssetUrl, - migratePages, toRuntimeAsset, } from "@webstudio-is/sdk"; +import { migratePages } from "@webstudio-is/project-migrations/pages"; import type { Data } from "@webstudio-is/http-client"; import { LOCAL_DATA_FILE } from "./config"; import { diff --git a/packages/css-data/package.json b/packages/css-data/package.json index 0796bb76f673..918e39ca8c30 100644 --- a/packages/css-data/package.json +++ b/packages/css-data/package.json @@ -1,12 +1,14 @@ { "name": "@webstudio-is/css-data", - "version": "0.0.0", + "version": "0.0.0-webstudio-version", "description": "CSS Data", "author": "Webstudio ", "homepage": "https://webstudio.is", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", + "build": "rm -rf lib && esbuild src/index.ts --outdir=lib --bundle --format=esm --packages=external", + "dts": "tsc --project tsconfig.dts.json && node ./scripts/prepare-dts.mjs", "build:html.css": "tsx ./bin/html.css.ts && prettier --write ./src/__generated__/html.ts", "build:mdn-data": "tsx ./bin/mdn-data.ts ./src/__generated__ && prettier --write \"./src/__generated__/\" \"../css-engine/src/__generated__/\"", "build:descriptions": "tsx ./bin/property-value-descriptions.ts && prettier --write ./src/__generated__/property-value-descriptions.ts", @@ -14,13 +16,14 @@ "build:all": "pnpm build:html.css && pnpm build:mdn-data && pnpm build:descriptions && pnpm build:property-var-fixtures", "test": "vitest run" }, - "bin": { - "css-to-ws": "./bin/css-to-ws.ts" - }, "devDependencies": { "@webstudio-is/tsconfig": "workspace:*", + "esbuild": "^0.25.3", "html-tags": "^4.0.0", "mdn-data": "2.28.0", + "openai": "^3.2.1", + "p-retry": "^6.2.1", + "typescript": "5.8.2", "vitest": "^3.1.2", "zod": "^3.24.2" }, @@ -28,17 +31,22 @@ "zod": "^3.19.1" }, "exports": { - "webstudio": "./src/index.ts" + ".": { + "webstudio": "./src/index.ts", + "types": "./lib/types/index.d.ts", + "import": "./lib/index.js" + } }, + "files": [ + "lib/*", + "!*.{test,stories}.*" + ], "license": "AGPL-3.0-or-later", - "private": true, "sideEffects": false, "dependencies": { "@webstudio-is/css-engine": "workspace:*", "change-case": "^5.4.4", "css-tree": "^3.1.0", - "openai": "^3.2.1", - "p-retry": "^6.2.1", "warn-once": "^0.1.1" } } diff --git a/packages/css-data/scripts/prepare-dts.mjs b/packages/css-data/scripts/prepare-dts.mjs new file mode 100644 index 000000000000..351b9c11c594 --- /dev/null +++ b/packages/css-data/scripts/prepare-dts.mjs @@ -0,0 +1,20 @@ +import { copyFile, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); +const repoRoot = resolve(packageDir, "../.."); +const typesDir = resolve(packageDir, "lib/types"); +const indexPath = resolve(typesDir, "index.d.ts"); + +await copyFile( + resolve(repoRoot, "@types/css-tree.d.ts"), + resolve(typesDir, "css-tree.d.ts") +); + +const cssTreeImport = 'import type {} from "./css-tree";\n'; +const indexContent = await readFile(indexPath, "utf8"); + +if (indexContent.startsWith(cssTreeImport) === false) { + await writeFile(indexPath, `${cssTreeImport}${indexContent}`); +} diff --git a/packages/css-data/tsconfig.dts.json b/packages/css-data/tsconfig.dts.json new file mode 100644 index 000000000000..034068b191a0 --- /dev/null +++ b/packages/css-data/tsconfig.dts.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/index.ts"], + "compilerOptions": { + "noCheck": true, + "declarationDir": "lib/types" + } +} diff --git a/packages/http-client/package.json b/packages/http-client/package.json index 57d641c16c0c..e277397cdb74 100644 --- a/packages/http-client/package.json +++ b/packages/http-client/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@trpc/client": "^10.45.2", + "@webstudio-is/project-migrations": "workspace:*", "@webstudio-is/sdk": "workspace:*" }, "devDependencies": { diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts index 901bf9aa9ad5..a904bd02f34c 100644 --- a/packages/http-client/src/index.ts +++ b/packages/http-client/src/index.ts @@ -5,7 +5,6 @@ import type { Deployment, Instance, Page, - SerializedPages, Prop, Resource, StyleDecl, @@ -13,6 +12,7 @@ import type { StyleSource, StyleSourceSelection, } from "@webstudio-is/sdk"; +import type { SerializedPages } from "@webstudio-is/project-migrations/pages"; import { createTRPCUntypedClient, httpBatchLink } from "@trpc/client"; export type Data = { @@ -39,10 +39,28 @@ export type Data = { origin?: string; }; +const createTrpcClient = ( + origin: string, + headers: Record +) => { + const { sourceOrigin } = parseBuilderUrl(origin); + const url = new URL("/trpc", sourceOrigin); + + return createTRPCUntypedClient({ + links: [ + httpBatchLink({ + url: url.href, + headers, + }), + ], + }); +}; + export const loadProjectDataByBuildId = async ( params: { buildId: string; origin: string; + headers?: Record; } & ( | { seviceToken: string; @@ -50,37 +68,27 @@ export const loadProjectDataByBuildId = async ( | { authToken: string } ) ): Promise => { - const headers: Record = + const headers: Record = "seviceToken" in params ? { Authorization: params.seviceToken } : { "x-auth-token": params.authToken }; - return (await createTrpcClient(params.origin, headers).query( - "build.loadProjectDataByBuildId", - { buildId: params.buildId } - )) as Data; -}; - -const createTrpcClient = (origin: string, headers: Record) => { - const { sourceOrigin } = parseBuilderUrl(origin); - const url = new URL("/trpc", sourceOrigin); - - return createTRPCUntypedClient({ - links: [ - httpBatchLink({ - url: url.href, - headers, - }), - ], - }); + return (await createTrpcClient(params.origin, { + ...params.headers, + ...headers, + }).query("build.loadProjectDataByBuildId", { + buildId: params.buildId, + })) as Data; }; export const loadProjectDataByProjectId = async (params: { projectId: string; origin: string; authToken: string; + headers?: Record; }): Promise => { return (await createTrpcClient(params.origin, { + ...params.headers, "x-auth-token": params.authToken, }).query("build.loadProjectDataByProjectId", { projectId: params.projectId, diff --git a/packages/project-build/package.json b/packages/project-build/package.json index 3254a05ff28f..84ee4fd4f3af 100644 --- a/packages/project-build/package.json +++ b/packages/project-build/package.json @@ -24,6 +24,7 @@ "dependencies": { "@webstudio-is/authorization-token": "workspace:*", "@webstudio-is/postgrest": "workspace:*", + "@webstudio-is/project-migrations": "workspace:*", "@webstudio-is/sdk": "workspace:*", "@webstudio-is/template": "workspace:*", "@webstudio-is/trpc-interface": "workspace:*", diff --git a/packages/project-build/src/db/build.ts b/packages/project-build/src/db/build.ts index 91151c64e72a..c486147940d0 100644 --- a/packages/project-build/src/db/build.ts +++ b/packages/project-build/src/db/build.ts @@ -8,7 +8,6 @@ import { } from "@webstudio-is/trpc-interface/index.server"; import { db as authDb } from "@webstudio-is/authorization-token/index.server"; import { - migratePages, type Deployment, type Resource, type StyleSource, @@ -18,8 +17,11 @@ import { type Breakpoint, type StyleSourceSelection, type StyleDecl, - serializePages, } from "@webstudio-is/sdk"; +import { + migratePages, + serializePages, +} from "@webstudio-is/project-migrations/pages"; import type { Build, CompactBuild } from "../types"; import { parseDeployment } from "./deployment"; import type { MarketplaceProduct } from "../shared//marketplace"; diff --git a/packages/project-build/src/db/pages.ts b/packages/project-build/src/db/pages.ts index 6ea53a372baf..5f22d9254e47 100644 --- a/packages/project-build/src/db/pages.ts +++ b/packages/project-build/src/db/pages.ts @@ -1,8 +1,8 @@ import { migratePages, serializePages as serializePagesData, - type Pages, -} from "@webstudio-is/sdk"; +} from "@webstudio-is/project-migrations/pages"; +import type { Pages } from "@webstudio-is/sdk"; export const parsePages = (pagesString: string): Pages => { return migratePages(JSON.parse(pagesString)); diff --git a/packages/project-migrations/package.json b/packages/project-migrations/package.json new file mode 100644 index 000000000000..0de7df81f864 --- /dev/null +++ b/packages/project-migrations/package.json @@ -0,0 +1,43 @@ +{ + "name": "@webstudio-is/project-migrations", + "version": "0.0.0-webstudio-version", + "description": "Webstudio project data migrations", + "author": "Webstudio ", + "homepage": "https://webstudio.is", + "license": "AGPL-3.0-or-later", + "type": "module", + "exports": { + ".": { + "webstudio": "./src/index.ts", + "types": "./lib/types/index.d.ts", + "import": "./lib/index.js" + }, + "./pages": { + "webstudio": "./src/pages.ts", + "types": "./lib/types/pages.d.ts", + "import": "./lib/pages.js" + } + }, + "files": [ + "lib/*", + "!*.{test,stories}.*" + ], + "sideEffects": false, + "scripts": { + "build": "rm -rf lib && esbuild src/index.ts src/pages.ts --outdir=lib --bundle --format=esm --packages=external", + "dts": "tsc --project tsconfig.dts.json", + "typecheck": "tsgo --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@webstudio-is/css-data": "workspace:*", + "@webstudio-is/css-engine": "workspace:*", + "@webstudio-is/sdk": "workspace:*" + }, + "devDependencies": { + "@webstudio-is/tsconfig": "workspace:*", + "esbuild": "^0.25.3", + "typescript": "5.8.2", + "vitest": "^3.1.2" + } +} diff --git a/packages/sdk/src/migrations/webstudio-data.test.ts b/packages/project-migrations/src/index.test.ts similarity index 94% rename from packages/sdk/src/migrations/webstudio-data.test.ts rename to packages/project-migrations/src/index.test.ts index 2361ca32edde..47ea49ad857c 100644 --- a/packages/sdk/src/migrations/webstudio-data.test.ts +++ b/packages/project-migrations/src/index.test.ts @@ -1,8 +1,7 @@ import { expect, test } from "vitest"; import type { StyleProperty } from "@webstudio-is/css-engine"; -import type { WebstudioData } from "../schema/webstudio"; -import type { Pages } from "../schema/pages"; -import { migrateWebstudioDataMutable } from "./webstudio-data"; +import type { Pages, WebstudioData } from "@webstudio-is/sdk"; +import { migrateWebstudioDataMutable } from "./index"; const emptyData: WebstudioData = { pages: { diff --git a/packages/sdk/src/migrations/webstudio-data.ts b/packages/project-migrations/src/index.ts similarity index 77% rename from packages/sdk/src/migrations/webstudio-data.ts rename to packages/project-migrations/src/index.ts index 519b33da937d..95725808c9bc 100644 --- a/packages/sdk/src/migrations/webstudio-data.ts +++ b/packages/project-migrations/src/index.ts @@ -1,7 +1,9 @@ -import type { WebstudioData } from "../schema/webstudio"; +import type { WebstudioData } from "@webstudio-is/sdk"; import { migratePages } from "./pages"; import { migrateStylesMutable } from "./styles"; +export { migratePages, serializePages, type SerializedPages } from "./pages"; + /** * Normalizes persisted project data after loading. * diff --git a/packages/sdk/src/migrations/pages.test.ts b/packages/project-migrations/src/pages.test.ts similarity index 92% rename from packages/sdk/src/migrations/pages.test.ts rename to packages/project-migrations/src/pages.test.ts index 8a0babdcfaf4..da74f119246f 100644 --- a/packages/sdk/src/migrations/pages.test.ts +++ b/packages/project-migrations/src/pages.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import type { Pages } from "../schema/pages"; +import type { Pages } from "@webstudio-is/sdk"; import { migratePages, serializePages } from "./pages"; test("keeps current pages shape unchanged", () => { @@ -35,6 +35,39 @@ test("keeps current pages shape unchanged", () => { expect(migratePages(pages)).toBe(pages); }); +test("removes orphan folder children from current pages shape", () => { + expect( + migratePages({ + homePageId: "home", + rootFolderId: "root", + pages: new Map([ + [ + "home", + { + id: "home", + name: "Home", + path: "", + title: `"Home"`, + meta: {}, + rootInstanceId: "homeRoot", + }, + ], + ]), + folders: new Map([ + [ + "root", + { + id: "root", + name: "Root", + slug: "", + children: ["home", "missing"], + }, + ], + ]), + }).folders.get("root")?.children + ).toEqual(["home"]); +}); + test("serializes map pages into arrays for JSON storage", () => { expect( serializePages({ diff --git a/packages/sdk/src/migrations/pages.ts b/packages/project-migrations/src/pages.ts similarity index 79% rename from packages/sdk/src/migrations/pages.ts rename to packages/project-migrations/src/pages.ts index 6c4c3d7d12ef..b8b9f3c72917 100644 --- a/packages/sdk/src/migrations/pages.ts +++ b/packages/project-migrations/src/pages.ts @@ -3,8 +3,9 @@ import { type Folder, type Page, type Pages, -} from "../schema/pages"; -import { isRootFolder, ROOT_FOLDER_ID } from "../page-utils"; + isRootFolder, + ROOT_FOLDER_ID, +} from "@webstudio-is/sdk"; type LegacyPages = { meta?: Pages["meta"]; @@ -65,6 +66,22 @@ const isSerializedPages = (pages: unknown): pages is MigratablePages => { ); }; +const removeOrphanFolderChildren = ( + pages: Map, + folders: Map +) => { + const nextFolders = new Map(); + for (const [folderId, folder] of folders) { + nextFolders.set(folderId, { + ...folder, + children: folder.children.filter( + (childId) => pages.has(childId) || folders.has(childId) + ), + }); + } + return nextFolders; +}; + export const serializePages = (pages: Pages): SerializedPages => { const parsedPages = PagesSchema.parse(pages); return { @@ -84,18 +101,31 @@ export const migratePages = (pages: unknown): Pages => { pages.pages instanceof Map && pages.folders instanceof Map ) { - return pages as Pages; + const currentPages = pages as Pages; + const result = PagesSchema.safeParse(currentPages); + if (result.success) { + return currentPages; + } + return { + ...currentPages, + folders: removeOrphanFolderChildren( + currentPages.pages, + currentPages.folders + ), + }; } if (isSerializedPages(pages)) { + const nextPages = toMap(pages.pages, normalizePage); + const nextFolders = toMap(pages.folders); return { meta: pages.meta, compiler: pages.compiler, redirects: pages.redirects, homePageId: pages.homePageId, rootFolderId: pages.rootFolderId, - pages: toMap(pages.pages, normalizePage), - folders: toMap(pages.folders), + pages: nextPages, + folders: removeOrphanFolderChildren(nextPages, nextFolders), }; } @@ -141,7 +171,9 @@ export const migratePages = (pages: unknown): Pages => { for (const folder of nextFolders.values()) { folder.children = folder.children.filter( - (childId) => childId !== homePage.id + (childId) => + childId !== homePage.id && + (nextPages.has(childId) || nextFolders.has(childId)) ); } nextRootFolder.children.unshift(homePage.id); diff --git a/packages/sdk/src/migrations/styles.test.ts b/packages/project-migrations/src/styles.test.ts similarity index 95% rename from packages/sdk/src/migrations/styles.test.ts rename to packages/project-migrations/src/styles.test.ts index d219658901b7..c018324533bc 100644 --- a/packages/sdk/src/migrations/styles.test.ts +++ b/packages/project-migrations/src/styles.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import type { StyleProperty } from "@webstudio-is/css-engine"; -import type { Styles } from "../schema/styles"; +import type { Styles } from "@webstudio-is/sdk"; import { migrateStylesMutable } from "./styles"; test("expands overflow shorthand", () => { diff --git a/packages/sdk/src/migrations/styles.ts b/packages/project-migrations/src/styles.ts similarity index 89% rename from packages/sdk/src/migrations/styles.ts rename to packages/project-migrations/src/styles.ts index fa1639f7c9bd..7aae76406189 100644 --- a/packages/sdk/src/migrations/styles.ts +++ b/packages/project-migrations/src/styles.ts @@ -4,8 +4,11 @@ import { expandShorthands, parseCssValue, } from "@webstudio-is/css-data"; -import { getStyleDeclKey } from "../schema/styles"; -import type { StyleDecl, Styles } from "../schema/styles"; +import { + getStyleDeclKey, + type StyleDecl, + type Styles, +} from "@webstudio-is/sdk"; const migratedShorthands = new Set([ "overflow", diff --git a/packages/project-migrations/tsconfig.dts.json b/packages/project-migrations/tsconfig.dts.json new file mode 100644 index 000000000000..18e4ee9827ca --- /dev/null +++ b/packages/project-migrations/tsconfig.dts.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/index.ts", "src/pages.ts"], + "compilerOptions": { + "noCheck": true, + "declarationDir": "lib/types" + } +} diff --git a/packages/project-migrations/tsconfig.json b/packages/project-migrations/tsconfig.json new file mode 100644 index 000000000000..0c8d2e790771 --- /dev/null +++ b/packages/project-migrations/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@webstudio-is/tsconfig/base.json", + "include": ["**/*.ts", "**/*.tsx", "../../@types/**/*.d.ts"] +} diff --git a/packages/project/package.json b/packages/project/package.json index 36957a14b83c..117d4e5208a3 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -17,6 +17,7 @@ "@webstudio-is/plans": "workspace:*", "@webstudio-is/postgrest": "workspace:*", "@webstudio-is/project-build": "workspace:*", + "@webstudio-is/project-migrations": "workspace:*", "@webstudio-is/sdk": "workspace:*", "@webstudio-is/trpc-interface": "workspace:*", "immer": "^10.1.1", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index bd193dab0338..50933f7a03fb 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -28,11 +28,6 @@ "types": "./lib/types/core-templates.d.ts", "import": "./lib/core-templates.js" }, - "./migrations/webstudio-data": { - "webstudio": "./src/migrations/webstudio-data.ts", - "types": "./lib/types/migrations/webstudio-data.d.ts", - "import": "./lib/migrations/webstudio-data.js" - }, "./router-path-test-data": { "webstudio": "./src/router-path-test-data.ts" } @@ -46,12 +41,11 @@ "typecheck": "tsgo --noEmit -p tsconfig.typecheck.json", "test": "vitest run", "build:normalize.css": "tsx --conditions=webstudio ./scripts/normalize.css.ts && prettier --write src/__generated__/normalize.css.ts", - "build": "rm -rf lib && esbuild src/index.ts src/runtime.ts src/__generated__/normalize.css.ts src/core-templates.tsx src/migrations/webstudio-data.ts --outdir=lib --bundle --format=esm --packages=external", + "build": "rm -rf lib && esbuild src/index.ts src/runtime.ts src/__generated__/normalize.css.ts src/core-templates.tsx --outdir=lib --bundle --format=esm --packages=external", "dts": "tsc --project tsconfig.dts.json" }, "dependencies": { "@emotion/hash": "^0.9.2", - "@webstudio-is/css-data": "workspace:*", "@webstudio-is/css-engine": "workspace:*", "@webstudio-is/fonts": "workspace:*", "@webstudio-is/icons": "workspace:*", @@ -64,6 +58,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@webstudio-is/css-data": "workspace:*", "@webstudio-is/template": "workspace:*", "@webstudio-is/tsconfig": "workspace:*", "html-tags": "^4.0.0", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8d58126a1f90..35d0b1dde26f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,7 +17,6 @@ export * from "./assets"; export * from "./core-metas"; export * from "./instances-utils"; export * from "./page-utils"; -export * from "./migrations"; export * from "./scope"; export * from "./expression"; export * from "./resources-generator"; diff --git a/packages/sdk/src/migrations/index.ts b/packages/sdk/src/migrations/index.ts deleted file mode 100644 index c4e34b2701d9..000000000000 --- a/packages/sdk/src/migrations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./pages"; diff --git a/packages/sdk/tsconfig.dts.json b/packages/sdk/tsconfig.dts.json index 0acd471d1c8f..0829a39f424d 100644 --- a/packages/sdk/tsconfig.dts.json +++ b/packages/sdk/tsconfig.dts.json @@ -1,10 +1,10 @@ { "extends": "./tsconfig.json", "include": [ + "../../@types/**/*.d.ts", "src/index.ts", "src/runtime.ts", - "src/__generated__/normalize.css.ts", - "src/migrations/webstudio-data.ts" + "src/__generated__/normalize.css.ts" ], "compilerOptions": { "declarationDir": "lib/types" diff --git a/packages/trpc-interface/package.json b/packages/trpc-interface/package.json index 0a3af4b8c59c..7d84f74a7a10 100644 --- a/packages/trpc-interface/package.json +++ b/packages/trpc-interface/package.json @@ -10,7 +10,6 @@ "test": "vitest run" }, "dependencies": { - "@webstudio-is/feature-flags": "workspace:*", "@webstudio-is/plans": "workspace:*", "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", @@ -24,6 +23,10 @@ "vitest": "^3.1.2" }, "exports": { + "./api-compatibility": { + "webstudio": "./src/api-compatibility.ts", + "import": "./src/api-compatibility.ts" + }, "./index.server": { "webstudio": "./src/index.server.ts", "import": "./src/index.server.ts" diff --git a/packages/trpc-interface/src/api-compatibility.test.ts b/packages/trpc-interface/src/api-compatibility.test.ts new file mode 100644 index 000000000000..a4f3ec80b2b5 --- /dev/null +++ b/packages/trpc-interface/src/api-compatibility.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; +import { + apiCompatibilityErrorType, + createApiCompatibilityPayload, + getApiCompatibilityPayload, +} from "./api-compatibility"; + +describe("api compatibility", () => { + test("uses camelCase payload values", () => { + const payload = createApiCompatibilityPayload({ + reason: "apiProcedureNotFound", + target: "browser", + }); + + expect(payload).toMatchObject({ + type: "webstudioApiCompatibilityError", + reason: "apiProcedureNotFound", + action: { type: "reloadBrowser" }, + }); + expect(apiCompatibilityErrorType).toBe("webstudioApiCompatibilityError"); + }); + + test("finds nested tRPC error payload", () => { + const payload = createApiCompatibilityPayload({ + reason: "apiRouteNotFound", + target: "cli", + }); + + expect( + getApiCompatibilityPayload({ + data: { + apiCompatibility: payload, + }, + }) + ).toEqual(payload); + }); + + test("finds payload in batched tRPC response", () => { + const payload = createApiCompatibilityPayload({ + reason: "apiProcedureNotFound", + target: "browser", + }); + + expect( + getApiCompatibilityPayload([ + { + error: { + data: { + apiCompatibility: payload, + }, + }, + }, + ]) + ).toEqual(payload); + }); + + test("ignores circular objects", () => { + const error: { cause?: unknown } = {}; + error.cause = error; + + expect(getApiCompatibilityPayload(error)).toBeUndefined(); + }); + + test("ignores ordinary errors", () => { + expect( + getApiCompatibilityPayload(new Error("Cannot POST")) + ).toBeUndefined(); + }); +}); diff --git a/packages/trpc-interface/src/api-compatibility.ts b/packages/trpc-interface/src/api-compatibility.ts new file mode 100644 index 000000000000..82eab2fd3a63 --- /dev/null +++ b/packages/trpc-interface/src/api-compatibility.ts @@ -0,0 +1,115 @@ +import { z } from "zod"; + +export const apiCompatibilityErrorType = "webstudioApiCompatibilityError"; + +export const apiClientHeader = "x-webstudio-client"; +export const apiClientVersionHeader = "x-webstudio-client-version"; + +export const ApiCompatibilityTarget = z.enum(["browser", "cli"]); +export type ApiCompatibilityTarget = z.infer; + +export const ApiClient = z.enum(["browser", "cli", "service"]); +export type ApiClient = z.infer; + +const ApiCompatibilityAction = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("reloadBrowser"), + }), + z.object({ + type: z.literal("updateCli"), + }), +]); + +export const ApiCompatibilityPayload = z.object({ + type: z.literal(apiCompatibilityErrorType), + reason: z.enum(["apiRouteNotFound", "apiProcedureNotFound"]), + target: ApiCompatibilityTarget, + message: z.string(), + action: ApiCompatibilityAction, +}); + +export type ApiCompatibilityPayload = z.infer; + +export const createApiCompatibilityPayload = ({ + reason, + target, +}: { + reason: ApiCompatibilityPayload["reason"]; + target: ApiCompatibilityTarget; +}): ApiCompatibilityPayload => { + if (target === "browser") { + return { + type: apiCompatibilityErrorType, + reason, + target, + message: "This browser tab is out of date. Reload to continue.", + action: { type: "reloadBrowser" }, + }; + } + + return { + type: apiCompatibilityErrorType, + reason, + target, + message: + "This version of the Webstudio CLI is incompatible with the current API.", + action: { type: "updateCli" }, + }; +}; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const getApiCompatibilityPayload = ( + value: unknown +): ApiCompatibilityPayload | undefined => { + const findPayload = ( + value: unknown, + seen: WeakSet + ): ApiCompatibilityPayload | undefined => { + const parsed = ApiCompatibilityPayload.safeParse(value); + if (parsed.success) { + return parsed.data; + } + + if (isObject(value) === false) { + return; + } + + if (seen.has(value)) { + return; + } + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + const payload = findPayload(item, seen); + if (payload !== undefined) { + return payload; + } + } + return; + } + + const candidates = [ + value.payload, + value.error, + value.data, + isObject(value.data) ? value.data.apiCompatibility : undefined, + isObject(value.shape) ? value.shape.data : undefined, + isObject(value.shape) && isObject(value.shape.data) + ? value.shape.data.apiCompatibility + : undefined, + value.cause, + ]; + + for (const candidate of candidates) { + const payload = findPayload(candidate, seen); + if (payload !== undefined) { + return payload; + } + } + }; + + return findPayload(value, new WeakSet()); +}; diff --git a/packages/trpc-interface/src/context/context.server.ts b/packages/trpc-interface/src/context/context.server.ts index 634d97ed7d26..8ef15b1cfa72 100644 --- a/packages/trpc-interface/src/context/context.server.ts +++ b/packages/trpc-interface/src/context/context.server.ts @@ -1,6 +1,7 @@ import type { TrpcInterfaceClient } from "../shared/shared-router"; import type { Client } from "@webstudio-is/postgrest/index.server"; import type { PlanFeatures, Purchase } from "@webstudio-is/plans"; +import type { ApiClient } from "../api-compatibility"; /** * All necessary parameters for Authorization @@ -74,6 +75,16 @@ type PostgrestContext = { client: Client; }; +type ApiClientContext = + | { + type: ApiClient; + version: string | undefined; + } + | { + type: "unknown"; + version: undefined; + }; + /** * AppContext is a global context that is passed to all trpc/api queries/mutations * "authorization" is made inside the namespace because eventually there will be @@ -86,6 +97,7 @@ export type AppContext = { entri: EntriContext; planFeatures: PlanFeatures; purchases: Array; + apiClient: ApiClientContext; trpcCache: TrpcCache; postgrest: PostgrestContext; createTokenContext: (token: string) => Promise; diff --git a/packages/trpc-interface/src/context/router.server.ts b/packages/trpc-interface/src/context/router.server.ts index 670d2ae6d60c..445a7b778321 100644 --- a/packages/trpc-interface/src/context/router.server.ts +++ b/packages/trpc-interface/src/context/router.server.ts @@ -1,5 +1,9 @@ import { initTRPC } from "@trpc/server"; import type { AppContext } from "./context.server"; +import { + createApiCompatibilityPayload, + getApiCompatibilityPayload, +} from "../api-compatibility"; export const { router, @@ -7,7 +11,31 @@ export const { middleware, mergeRouters, createCallerFactory, -} = initTRPC.context().create(); +} = initTRPC.context().create({ + errorFormatter({ shape, error, ctx }) { + const target = ctx?.apiClient.type; + const payload = + getApiCompatibilityPayload(error.cause) ?? + (error.code === "NOT_FOUND" && (target === "browser" || target === "cli") + ? createApiCompatibilityPayload({ + reason: "apiProcedureNotFound", + target, + }) + : undefined); + + if (payload === undefined) { + return shape; + } + + return { + ...shape, + data: { + ...shape.data, + apiCompatibility: payload, + }, + }; + }, +}); export const createCacheMiddleware = (seconds: number) => middleware(async ({ path, ctx, next }) => { diff --git a/packages/trpc-interface/src/shared/client.ts b/packages/trpc-interface/src/shared/client.ts index e5cca3b0dd8c..9ea94f50574f 100644 --- a/packages/trpc-interface/src/shared/client.ts +++ b/packages/trpc-interface/src/shared/client.ts @@ -5,11 +5,13 @@ import { type SharedRouter, } from "./shared-router"; import { callerLink } from "../trpc-caller-link"; +import { apiClientHeader, apiClientVersionHeader } from "../api-compatibility"; type SharedClientOptions = { url: string; token: string; branchName: string | undefined; + clientVersion: string | undefined; }; export const createTrpcProxyServiceClient = ( @@ -24,6 +26,8 @@ export const createTrpcProxyServiceClient = ( Authorization: options.token, // We use this header for SaaS preview service discovery proxy "x-branch-name": options.branchName, + [apiClientHeader]: "service", + [apiClientVersionHeader]: options.clientVersion, }), }), ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f273891e3283..472a923ccf77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@webstudio-is/project-build': specifier: workspace:* version: link:../../packages/project-build + '@webstudio-is/project-migrations': + specifier: workspace:* + version: link:../../packages/project-migrations '@webstudio-is/react-sdk': specifier: workspace:* version: link:../../packages/react-sdk @@ -1089,6 +1092,12 @@ importers: '@trpc/client': specifier: ^10.45.2 version: 10.45.2(@trpc/server@10.45.2) + '@webstudio-is/project-migrations': + specifier: workspace:* + version: link:../project-migrations + '@webstudio-is/trpc-interface': + specifier: workspace:* + version: link:../trpc-interface acorn: specifier: ^8.14.1 version: 8.14.1 @@ -1258,12 +1267,6 @@ importers: css-tree: specifier: ^3.1.0 version: 3.1.0 - openai: - specifier: ^3.2.1 - version: 3.2.1 - p-retry: - specifier: ^6.2.1 - version: 6.2.1 warn-once: specifier: ^0.1.1 version: 0.1.1 @@ -1271,12 +1274,24 @@ importers: '@webstudio-is/tsconfig': specifier: workspace:* version: link:../tsconfig + esbuild: + specifier: ^0.25.3 + version: 0.25.3 html-tags: specifier: ^4.0.0 version: 4.0.0 mdn-data: specifier: 2.28.0 version: 2.28.0 + openai: + specifier: ^3.2.1 + version: 3.2.1 + p-retry: + specifier: ^6.2.1 + version: 6.2.1 + typescript: + specifier: 5.8.2 + version: 5.8.2 vitest: specifier: ^3.1.2 version: 3.1.2(@types/debug@4.1.12)(@types/node@22.13.10)(@vitest/browser@3.1.2)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.13.2(@types/node@22.13.10)(typescript@5.8.2))(tsx@4.19.3) @@ -1633,6 +1648,9 @@ importers: '@trpc/client': specifier: ^10.45.2 version: 10.45.2(@trpc/server@10.45.2) + '@webstudio-is/project-migrations': + specifier: workspace:* + version: link:../project-migrations '@webstudio-is/sdk': specifier: workspace:* version: link:../sdk @@ -1794,6 +1812,9 @@ importers: '@webstudio-is/project-build': specifier: workspace:* version: link:../project-build + '@webstudio-is/project-migrations': + specifier: workspace:* + version: link:../project-migrations '@webstudio-is/sdk': specifier: workspace:* version: link:../sdk @@ -1834,6 +1855,9 @@ importers: '@webstudio-is/postgrest': specifier: workspace:* version: link:../postgrest + '@webstudio-is/project-migrations': + specifier: workspace:* + version: link:../project-migrations '@webstudio-is/sdk': specifier: workspace:* version: link:../sdk @@ -1857,6 +1881,31 @@ importers: specifier: ^3.1.2 version: 3.1.2(@types/debug@4.1.12)(@types/node@22.13.10)(@vitest/browser@3.1.2)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.13.2(@types/node@22.13.10)(typescript@5.8.2))(tsx@4.19.3) + packages/project-migrations: + dependencies: + '@webstudio-is/css-data': + specifier: workspace:* + version: link:../css-data + '@webstudio-is/css-engine': + specifier: workspace:* + version: link:../css-engine + '@webstudio-is/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@webstudio-is/tsconfig': + specifier: workspace:* + version: link:../tsconfig + esbuild: + specifier: ^0.25.3 + version: 0.25.3 + typescript: + specifier: 5.8.2 + version: 5.8.2 + vitest: + specifier: ^3.1.2 + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.13.10)(@vitest/browser@3.1.2)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.13.2(@types/node@22.13.10)(typescript@5.8.2))(tsx@4.19.3) + packages/react-sdk: dependencies: '@webstudio-is/css-engine': @@ -1920,9 +1969,6 @@ importers: '@emotion/hash': specifier: ^0.9.2 version: 0.9.2 - '@webstudio-is/css-data': - specifier: workspace:* - version: link:../css-data '@webstudio-is/css-engine': specifier: workspace:* version: link:../css-engine @@ -1954,6 +2000,9 @@ importers: specifier: ^3.24.2 version: 3.24.2 devDependencies: + '@webstudio-is/css-data': + specifier: workspace:* + version: link:../css-data '@webstudio-is/template': specifier: workspace:* version: link:../template @@ -2340,9 +2389,6 @@ importers: '@trpc/server': specifier: ^10.45.2 version: 10.45.2 - '@webstudio-is/feature-flags': - specifier: workspace:* - version: link:../feature-flags '@webstudio-is/plans': specifier: workspace:* version: link:../plans