From 8286309227f3f75c419af48f49c83758e37fcfbd Mon Sep 17 00:00:00 2001 From: spacedevin Date: Wed, 21 Oct 2026 20:23:10 -0700 Subject: [PATCH 1/2] feat(sdk): popcraft.upload sends a file from PopCraft itself A plugin's own fetch runs in the sandbox, so a big export has to squeeze through whatever its server accepts in one request. upload hands the bytes to the editor, which sends them, so a plugin can put a file straight onto a presigned storage URL. It needs the network permission, like openExternal. --- src/runtime/main.ts | 17 ++++++++++++++++- src/types.ts | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/runtime/main.ts b/src/runtime/main.ts index 00013b3..9a892cc 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -5,7 +5,8 @@ import { createRpcClient, type RpcClient } from './rpc.js' import type { PopCraftEvents, PopCraftExportOptions, PopCraftFindQuery, PopCraftNode, PopCraftNodeType, - PopCraftPage, PopCraftPaint, PopCraftShowUIOptions, PopCraftVariables, + PopCraftPage, PopCraftPaint, PopCraftShowUIOptions, PopCraftUploadOptions, PopCraftUploadResult, + PopCraftVariables, } from '../types.js' import type { PopCraftSerialisedWidget, PopCraftWidgetDefinition, PopCraftWidgetHandler, @@ -265,6 +266,20 @@ export function createMainApi(rpc: RpcClient = createRpcClient()) { exportNode: (id: string, options?: PopCraftExportOptions) => rpc.call('exportNode', { id, options: options || {} }), openExternal: (url: string) => rpc.call('openExternal', { url }), + /** + * Send a file to an https URL. PopCraft makes the request, not the sandbox, so it works for files of + * any size the server accepts, including straight to a presigned storage URL. Needs `network`. + */ + upload: (url: string, options: PopCraftUploadOptions) => { + const body = options.body instanceof ArrayBuffer ? new Uint8Array(options.body) : options.body + return rpc.call('upload', { + url, + body, + method: options.method, + contentType: options.contentType, + headers: options.headers, + }) + }, notify: (message: string, options?: { error?: boolean }) => rpc.call('notify', { message: String(message), error: !!(options && options.error) }), showUI: (options?: PopCraftShowUIOptions) => rpc.call('showUI', options || {}), diff --git a/src/types.ts b/src/types.ts index bebf62c..376a742 100644 --- a/src/types.ts +++ b/src/types.ts @@ -150,6 +150,27 @@ export interface PopCraftExportOptions { scale?: number } +/** What `popcraft.upload` sends. */ +export interface PopCraftUploadOptions { + /** The file: bytes (an `exportNode` result, say) or text. */ + body: Uint8Array | ArrayBuffer | string + /** Defaults to `PUT`, the verb presigned storage URLs expect. */ + method?: 'PUT' | 'POST' + /** Sent as the request's `Content-Type`. */ + contentType?: string + /** Extra request headers, such as one a storage service asks for. */ + headers?: Record +} + +/** The server's answer to `popcraft.upload`. */ +export interface PopCraftUploadResult { + ok: boolean + status: number + headers: Record + /** The response body as text (parse it yourself if it is JSON). */ + body: string +} + export interface PopCraftShowUIOptions { width?: number height?: number From e0760574538540bcb6f18e2591d8c8d9d447fcc5 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Wed, 21 Oct 2026 20:36:30 -0700 Subject: [PATCH 2/2] fix(sdk): node types match the editor; ecommerce links the selection's ids PopCraftNodeType was missing IMAGE, INSTANCE, COMPONENT, COMPONENT_SET and the rest of the editor's node types, so the ecommerce example's checks for them failed to typecheck. The example also handed setRecord and bindField the selected nodes where they take ids. main has failed CI on this since #8. --- examples/plugins/ecommerce/main.js | 8 +++++--- src/types.ts | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/plugins/ecommerce/main.js b/examples/plugins/ecommerce/main.js index d65ceff..f29e831 100644 --- a/examples/plugins/ecommerce/main.js +++ b/examples/plugins/ecommerce/main.js @@ -129,6 +129,7 @@ async function syncRecords(collection, rows, keep = false) { } /** Field keys a layer shows of a product, by what the layer or a component property is called. */ +/** @type {[RegExp, string][]} */ const LINKS = [[/title|name/i, 'title'], [/price/i, 'price'], [/image|picture|photo|cover/i, 'image'], [/description|body/i, 'description']] /** @@ -139,7 +140,8 @@ const LINKS = [[/title|name/i, 'title'], [/price/i, 'price'], [/image|picture|ph async function link(product) { const col = await collectionFor('commerce.product') if (!col) throw new Error('This design has no products collection: Make it a store first') - const ids = await popcraft.getSelection() + // getSelection gives nodes; setRecord and bindField take their ids. + const ids = (await popcraft.getSelection()).map(n => n.id) if (!ids.length) throw new Error('Select the layers that should show this product first') await syncRecords(col, [product], true) await popcraft.setRecord({ ids, collection: col.id, record: product.handle }) @@ -147,12 +149,12 @@ async function link(product) { for (const id of ids) { const node = await popcraft.getNode(id) if (!node) continue - const has = node.boundExpressions ?? {} + const has = /** @type {Record} */ (node.boundExpressions ?? {}) const field = name => LINKS.find(([re]) => re.test(name))?.[1] if (node.type === 'IMAGE' && !has.imageUrl) { await popcraft.bindField({ ids: [id], property: 'picture', field: 'image' }); bound++ } if (node.type === 'TEXT' && !has.characters && field(node.name)) { await popcraft.bindField({ ids: [id], property: 'text', field: field(node.name) }); bound++ } if (node.type === 'INSTANCE') { - const main = await popcraft.getNode(node.mainComponentId) + const main = await popcraft.getNode(String(node.mainComponentId)) const set = main?.parentId ? await popcraft.getNode(main.parentId) : null const defs = (set?.type === 'COMPONENT_SET' ? set : main)?.componentPropertyDefinitions ?? {} for (const [name, def] of Object.entries(defs)) { diff --git a/src/types.ts b/src/types.ts index 376a742..6921d86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,8 @@ export const ALL_PERMISSIONS: readonly PopCraftPermission[] = ['read', 'write', export type PopCraftNodeType = | 'RECTANGLE' | 'ELLIPSE' | 'POLYGON' | 'STAR' | 'LINE' | 'ARROW' - | 'FRAME' | 'SECTION' | 'TEXT' | 'STICKY' | 'VECTOR' | 'WIDGET' + | 'FRAME' | 'SECTION' | 'SLICE' | 'GROUP' | 'TEXT' | 'STICKY' | 'VECTOR' | 'BOOLEAN' | 'WIDGET' + | 'IMAGE' | 'COMPONENT' | 'COMPONENT_SET' | 'INSTANCE' | 'PIXEL_GRID' | 'RASTER' export interface PopCraftCommand { /** Passed to the plugin as `popcraft.command`. */