Created
December 19, 2025 07:51
-
-
Save pixobe/67c022bf99dbe6cf671affd878294f8b to your computer and use it in GitHub Desktop.
Shopify Staged Upload
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // app/routes/apps.customizer.upload.tsx | |
| import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; | |
| import { authenticate } from "../shopify.server"; | |
| import { uploadFileToStaged } from "../utils/staged-file"; | |
| const sanitizeCartId = (value: FormDataEntryValue | null): string | null => { | |
| if (typeof value !== "string") { | |
| return null; | |
| } | |
| const trimmed = value.trim(); | |
| if (!trimmed) { | |
| return null; | |
| } | |
| const sanitized = trimmed.replace(/[^a-zA-Z0-9_-]/g, "_"); | |
| return sanitized || null; | |
| }; | |
| export const action = async ({ request }: ActionFunctionArgs) => { | |
| // App Proxy auth (for storefront → app proxy calls) | |
| const ctx = await authenticate.public.appProxy(request); | |
| const admin = ctx?.admin; | |
| if (!admin) { | |
| return new Response(JSON.stringify({ ok: false, error: "No admin context" }), { | |
| status: 401, | |
| headers: { "content-type": "application/json" }, | |
| }); | |
| } | |
| // Accept either a Blob `file` or a string `json` | |
| const form = await request.formData(); | |
| let file = form.get("file") as File | null; | |
| const jsonText = form.get("json") as string | null; | |
| const variantId = sanitizeCartId(form.get("variant_id")); | |
| const targetFileName = variantId ? `${variantId}_pixobe.json` : "customization.json"; | |
| if (!file && jsonText) { | |
| file = new File([jsonText], targetFileName, { type: "application/json" }); | |
| } else if (file && variantId) { | |
| const buffer = await file.arrayBuffer(); | |
| file = new File([buffer], targetFileName, { type: file.type || "application/json" }); | |
| } | |
| if (!file) { | |
| return new Response(JSON.stringify({ ok: false, error: "Missing `file` or `json` field" }), { | |
| status: 400, | |
| headers: { "content-type": "application/json" }, | |
| }); | |
| } | |
| const fileCreate = await uploadFileToStaged(admin, file); | |
| if (fileCreate.fileCreate.userErrors?.length) { | |
| return new Response( | |
| JSON.stringify({ ok: false, error: "fileCreate error", details: fileCreate.fileCreate.userErrors }), | |
| { status: 502, headers: { "content-type": "application/json" } } | |
| ); | |
| } | |
| const created = fileCreate.fileCreate.files?.[0]; | |
| if (!created?.id) { | |
| return new Response(JSON.stringify({ ok: false, error: "No file returned" }), { | |
| status: 502, | |
| headers: { "content-type": "application/json" }, | |
| }); | |
| } | |
| return new Response(JSON.stringify({ ok: true, fileGid: created.id, fileUrl: created.url ?? null }), { | |
| status: 200, | |
| headers: { "content-type": "application/json" }, | |
| }); | |
| }; | |
| // Optional: block GETs with 405 to make this clearly POST-only | |
| export const loader = async (_args: LoaderFunctionArgs) => | |
| new Response("Method Not Allowed", { status: 405 }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { authenticate } from "../shopify.server"; | |
| /** | |
| * Tiny helper to run Admin GraphQL and return the typed `data` object. | |
| * We don't rely on a top-level `errors` field; Shopify userErrors are read per-mutation. | |
| */ | |
| async function adminGraphQL<T>( | |
| admin: Awaited<ReturnType<typeof authenticate.public.appProxy>>["admin"], | |
| query: string, | |
| variables?: Record<string, unknown>, | |
| ): Promise<T> { | |
| const resp = await admin!.graphql(query, { variables }); | |
| const parsed = (await resp.json()) as { data: T }; | |
| return parsed.data; | |
| } | |
| /** | |
| * | |
| * @param fileUrl | |
| */ | |
| /** | |
| * Fetches the CDN URL of a Shopify GenericFile given its GID. | |
| * Returns the URL string if found, otherwise null. | |
| */ | |
| export async function getFileUrl( | |
| admin: any, | |
| fileGid: string, | |
| ): Promise<string | null> { | |
| const query = ` | |
| query getFileById($id: ID!) { | |
| node(id: $id) { | |
| ... on GenericFile { | |
| id | |
| url | |
| fileStatus | |
| mimeType | |
| alt | |
| createdAt | |
| } | |
| } | |
| } | |
| `; | |
| const res = await admin.graphql(query, { variables: { id: fileGid } }); | |
| const { data } = await res.json(); | |
| return data?.node?.url ?? null; | |
| } | |
| /** | |
| * | |
| * @returns | |
| */ | |
| export async function uploadFileToStaged(admin: any, file: any): Promise<any> { | |
| // ---- A) stagedUploadsCreate ------------------------------------------------ | |
| type StagedUploadsCreateData = { | |
| stagedUploadsCreate: { | |
| stagedTargets: Array<{ | |
| url: string; | |
| resourceUrl: string; | |
| parameters: Array<{ name: string; value: string }>; | |
| }>; | |
| userErrors: Array<{ field: string[] | null; message: string }>; | |
| }; | |
| }; | |
| const stagedData = await adminGraphQL<StagedUploadsCreateData>( | |
| admin, | |
| /* GraphQL */ ` | |
| mutation stagedUploadsCreate($input: [StagedUploadInput!]!) { | |
| stagedUploadsCreate(input: $input) { | |
| stagedTargets { | |
| url | |
| resourceUrl | |
| parameters { | |
| name | |
| value | |
| } | |
| } | |
| userErrors { | |
| field | |
| message | |
| } | |
| } | |
| } | |
| `, | |
| { | |
| input: [ | |
| { | |
| filename: file.name || "customization.json", | |
| mimeType: file.type || "application/json", | |
| httpMethod: "POST", | |
| resource: "FILE", | |
| }, | |
| ], | |
| }, | |
| ); | |
| if (stagedData.stagedUploadsCreate.userErrors?.length) { | |
| return new Response( | |
| JSON.stringify({ | |
| ok: false, | |
| error: "stagedUploadsCreate error", | |
| details: stagedData.stagedUploadsCreate.userErrors, | |
| }), | |
| { status: 502, headers: { "content-type": "application/json" } }, | |
| ); | |
| } | |
| const target = stagedData.stagedUploadsCreate.stagedTargets?.[0]; | |
| if (!target) { | |
| return new Response( | |
| JSON.stringify({ ok: false, error: "No staged target returned" }), | |
| { | |
| status: 502, | |
| headers: { "content-type": "application/json" }, | |
| }, | |
| ); | |
| } | |
| // ---- B) Upload the bytes (S3-style form) ----------------------------------- | |
| const s3Form = new FormData(); | |
| for (const p of target.parameters) s3Form.append(p.name, p.value); | |
| s3Form.append("file", file); | |
| const s3Resp = await fetch(target.url, { method: "POST", body: s3Form }); | |
| if (!s3Resp.ok) { | |
| const text = await s3Resp.text(); | |
| return new Response( | |
| JSON.stringify({ | |
| ok: false, | |
| error: "Staged file upload failed", | |
| details: text, | |
| }), | |
| { | |
| status: 502, | |
| headers: { "content-type": "application/json" }, | |
| }, | |
| ); | |
| } | |
| // ---- C) fileCreate (finalize as GenericFile) ------------------------------- | |
| type FileCreateData = { | |
| fileCreate: { | |
| files: Array<{ id: string; fileStatus: string; url?: string }>; | |
| userErrors: Array<{ field: string[] | null; message: string }>; | |
| }; | |
| }; | |
| const fileCreate = await adminGraphQL<FileCreateData>( | |
| admin, | |
| /* GraphQL */ ` | |
| mutation fileCreate($files: [FileCreateInput!]!) { | |
| fileCreate(files: $files) { | |
| files { | |
| id | |
| fileStatus | |
| ... on GenericFile { | |
| url | |
| mimeType | |
| } | |
| } | |
| userErrors { | |
| field | |
| message | |
| } | |
| } | |
| } | |
| `, | |
| { | |
| files: [ | |
| { | |
| contentType: "FILE", | |
| originalSource: target.resourceUrl, | |
| alt: "Pixobe Product Customization", | |
| }, | |
| ], | |
| }, | |
| ); | |
| return fileCreate; | |
| } | |
| /** | |
| * Fetches the JSON data stored in each file reference (GenericFile GID) | |
| * @param admin The Shopify admin GraphQL client | |
| * @param fileGids Array of GenericFile GIDs | |
| * @returns Promise resolving to an array of JSON objects | |
| */ | |
| export async function getJsonDataFromFiles( | |
| admin: any, | |
| fileGids: string[], | |
| ): Promise<any[]> { | |
| const results: any[] = []; | |
| for (const gid of fileGids) { | |
| try { | |
| const url = await getFileUrl(admin, gid); | |
| if (!url) { | |
| console.warn(`No URL found for file ${gid}`); | |
| results.push(null); | |
| continue; | |
| } | |
| const res = await fetch(url); | |
| if (!res.ok) { | |
| console.warn(`Failed to fetch ${url}: ${res.status}`); | |
| results.push(null); | |
| continue; | |
| } | |
| // Try parsing as JSON | |
| const jsonData = await res.json().catch(() => null); | |
| results.push(jsonData); | |
| } catch (err) { | |
| console.error("Error fetching file content:", gid, err); | |
| results.push(null); | |
| } | |
| } | |
| return results; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment