From d5028b5792bc64cbc40e5c20126f521decce2c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Mon, 27 Apr 2026 12:54:11 +0200 Subject: [PATCH 1/2] Fix unhelpful error when extension locale file has invalid UTF-8 (shop/issues-develop#21558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate UTF-8 encoding of locale JSON files in `loadLocalesConfig` before base64-encoding them for upload. Previously, a single invalid byte (e.g. a Latin-1 "à" left in an Italian translation) would reach the server and surface only as a generic INTERNAL_SERVER_ERROR raised from `String#strip` deep in the framework's localization decoder. The new check fails fast on the CLI side with the offending locale file path and an actionable hint to re-save it as UTF-8. --- .changeset/validate-locale-utf8.md | 5 +++ .../extensions/locales-configuration.test.ts | 31 +++++++++++++++++++ .../extensions/locales-configuration.ts | 17 +++++++--- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .changeset/validate-locale-utf8.md diff --git a/.changeset/validate-locale-utf8.md b/.changeset/validate-locale-utf8.md new file mode 100644 index 00000000000..4cbea80a231 --- /dev/null +++ b/.changeset/validate-locale-utf8.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fail UI extension `dev` and `deploy` early with a clear error pointing at the offending locale file when a `locales/*.json` file contains invalid UTF-8 byte sequences, instead of letting the upload reach the server and abort with a generic `INTERNAL_SERVER_ERROR`. diff --git a/packages/app/src/cli/utilities/extensions/locales-configuration.test.ts b/packages/app/src/cli/utilities/extensions/locales-configuration.test.ts index 3966264ec5c..d3eb9793691 100644 --- a/packages/app/src/cli/utilities/extensions/locales-configuration.test.ts +++ b/packages/app/src/cli/utilities/extensions/locales-configuration.test.ts @@ -2,6 +2,7 @@ import {loadLocalesConfig} from './locales-configuration.js' import {describe, expect, test} from 'vitest' import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +import fs from 'fs' describe('loadLocalesConfig', () => { test('Works if all locales are correct', async () => { @@ -74,4 +75,34 @@ describe('loadLocalesConfig', () => { await expect(got).rejects.toThrow(/Error loading checkout_ui/) }) }) + + test('Throws with a helpful message if a locale file contains invalid UTF-8 bytes', async () => { + await inTemporaryDirectory(async (tmpDir: string) => { + // Given + const localesPath = joinPath(tmpDir, 'locales') + const enDefault = joinPath(localesPath, 'en.default.json') + const it = joinPath(localesPath, 'it.json') + + await mkdir(localesPath) + await writeFile(enDefault, JSON.stringify({hello: 'Hello'})) + // 0xE0 starts a 3-byte UTF-8 sequence but is followed by an ASCII space, + // mirroring the Latin-1 encoded "sarà" (`sar\xE0`) reported in shop/issues-develop#21558. + const invalidBytes = Buffer.concat([ + Buffer.from('{"hello":"sar', 'utf8'), + Buffer.from([0xe0]), + Buffer.from(' "}', 'utf8'), + ]) + fs.writeFileSync(it, invalidBytes) + + // When + const got = loadLocalesConfig(tmpDir, 'checkout_ui') + await expect(got).rejects.toThrow(/Error loading checkout_ui/) + await expect(got).rejects.toMatchObject({ + tryMessage: expect.stringMatching(/invalid UTF-8 byte sequences/), + }) + await expect(got).rejects.toMatchObject({ + tryMessage: expect.stringMatching(/it\.json/), + }) + }) + }) }) diff --git a/packages/app/src/cli/utilities/extensions/locales-configuration.ts b/packages/app/src/cli/utilities/extensions/locales-configuration.ts index 715fec0d1ad..4927f9967d2 100644 --- a/packages/app/src/cli/utilities/extensions/locales-configuration.ts +++ b/packages/app/src/cli/utilities/extensions/locales-configuration.ts @@ -1,6 +1,7 @@ import {joinPath, basename} from '@shopify/cli-kit/node/path' import {glob} from '@shopify/cli-kit/node/fs' import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {isUtf8} from 'node:buffer' import fs from 'fs' export async function loadLocalesConfig(extensionPath: string, extensionIdentifier: string) { @@ -30,7 +31,7 @@ export async function loadLocalesConfig(extensionPath: string, extensionIdentifi return { default_locale: defaultLanguageCode[0], - translations: getAllLocales(localesPaths), + translations: getAllLocales(localesPaths, extensionIdentifier), } } @@ -39,12 +40,20 @@ function findDefaultLocale(filePaths: string[]) { return defaultLocale.map((locale) => basename(locale).split('.')[0]) } -function getAllLocales(localesPath: string[]) { +function getAllLocales(localesPath: string[], extensionIdentifier: string) { const all: {[key: string]: string} = {} for (const localePath of localesPath) { const localeCode = failIfUnset(basename(localePath).split('.')[0], 'Locale code is unset') - const locale = fs.readFileSync(localePath, 'base64') - all[localeCode] = locale + const localeBuffer = fs.readFileSync(localePath) + // Validate UTF-8 client-side: the server decodes these as UTF-8 strings and a + // single invalid byte sequence aborts the upload with an unhelpful error. + if (!isUtf8(localeBuffer)) { + throw new AbortError( + `Error loading ${extensionIdentifier}`, + `Locale file ${localePath} contains invalid UTF-8 byte sequences. Re-save the file using UTF-8 encoding.`, + ) + } + all[localeCode] = localeBuffer.toString('base64') } return all } From 4fa3f45620136ccd2a85d32d3d1d801e18aa481c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 28 Apr 2026 14:56:26 +0200 Subject: [PATCH 2/2] Simplify changeset --- .changeset/validate-locale-utf8.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/validate-locale-utf8.md b/.changeset/validate-locale-utf8.md index 4cbea80a231..f1b1e34a49d 100644 --- a/.changeset/validate-locale-utf8.md +++ b/.changeset/validate-locale-utf8.md @@ -2,4 +2,4 @@ '@shopify/app': patch --- -Fail UI extension `dev` and `deploy` early with a clear error pointing at the offending locale file when a `locales/*.json` file contains invalid UTF-8 byte sequences, instead of letting the upload reach the server and abort with a generic `INTERNAL_SERVER_ERROR`. +Fix unhelpful error when extension locale file has invalid UTF-8