Function 扩展开发指南
Function 扩展让你的应用把自定义逻辑运行在 Shoplazza 的服务器上,而不是浏览器里。你用 JavaScript 编写逻辑,Shoplazza CLI 把它编译成 WebAssembly(WASM)模块,平台在商家店铺的指定环节沙箱化、确定性地运行它。
Function 开发是应用开发的一部分:函数存在于应用项目中,以你应用的 client_id 发布,并随应用一起上线。本指南覆盖从脚手架到上线的完整链路。
函数能做什么
当前的函数类型是 cart_transform,用于调整买家购物车中的行项目(line item)计价。当一个 cart_transform 函数被绑定到店铺后,Shoplazza 运行你的代码并把结果应用到每一个购物车行。
一个 cart_transform 函数可以修改每行的两个字段:
| 输出字段 | 效果 | 约束 |
|---|---|---|
price.adjustment_fixed_price | 加在商品原价上的金额。该行最终单价 = 原价 + adjustment_fixed_price,行小计按 单价 × 数量 重算。 | 十进制字符串,非负,≤ 999999999,保留 2 位小数 |
title | 覆盖行项目展示的标题。覆盖后的标题只在结账页显示,购物车页面仍显示原标题。 | ≤ 255 字符 |
唯一支持的操作是 update(修改已有行)。函数没有 UI——它是纯服务端逻辑,读取购物车上下文、返回计价操作。
以 WASM 形式运行,让你的逻辑能在 Shoplazza 基础设施上确定性、沙箱化地执行,无需每次购物车变动都回调你的后端。你仍然写 JavaScript,CLI 用 Javy 工具链编译,平台用 Wasmtime 引擎运行。
运行位置:购物车与结账
你发布的同一个 cart_transform 函数会自动在以下两种场景运行——你不需要写两份:
| 场景 | 何时运行 | 影响什么 |
|---|---|---|
| 购物车页 | 加入购物车,以及行的自定义属性变化时(纯删除某行不会重跑) | 买家在购物车看到的 price 与 line_price |
| 结账 | 结账创建、在结账页增删改商品行、结账校验时——都在折扣之前 | 订单据以构建的行价格 |
因此函数算出的改价会反映在购物车页,并被带入结账。结账时,你的函数总是在订单/行折扣之前运行,折扣在你改后的价格基础上叠加。订单级、运费级折扣由独立的折扣端点处理,不属于 cart_transform;cart_transform 函数只改行价与行标题。
由于函数会在每一次重新计价的事件(购物车加购/更新、结账创建/变更/校验)时重跑,请把逻辑写成由当前购物车内容确定性推导——不要依赖上一次运行的状态——以确保购物车与结账结果始终一致。
少数店铺仍在使用旧版结账,这些店铺在结账阶段只计算折扣,不运行 cart_transform。使用新版结账的店铺不受影响。
上面「每次重新计价都会重跑」有一个例外:购物车里已有的商品行再增加数量时,会沿用该行加购时算出的价格。改了函数或元字段值后,先删除该行再重新加购,才能看到当前函数的计算结果。
工作原理
src/index.js
│ javy build
▼
<name>.<hash>.wasm
│ function release / app deploy
▼
函数运行时 (注册表 + WASM 引擎:Wasmtime + Javy)
▲
│ run(输入 JSON) ──▶ 输出 JSON
│
购物车服务 / 结账服务
│ 读取该店铺已绑定的 cart_transform 端点
▼
把 {operation:{update:[…]}} 应用到每一行的价格
- 函数运行时 —— 存储函数源码 + 编译后 WASM 的注册表,以及 WASM 执行引擎(Wasmtime + Javy/QuickJS)。它用 JSON 输入运行你的模块、返回 JSON 输出。
- 购物车 / 结账服务 —— 买家操作时,查出该店铺绑定的函数,调用 函数运行时 执行,再把结果应用到行价。
前置条件
-
已安装并授权 Shoplazza CLI(
shoplazza auth login)。 -
已用
shoplazza app init创建的应用项目。参见构建你的第一个应用。 -
shoplazza.app.toml配置了正确的权限范围:cart_transform需要write_cart_transform(读取购物车数据再加read_cart_transform)。默认应用模板已自带:tomlscopes = "read_customer write_cart_transform"
你无需自己安装 Javy。首次编译时,CLI 会自动下载并锁定正确的 Javy 版本(当前 v5.0.1)并校验 checksum。
第 1 步 —— 创建函数扩展
在应用项目根目录下执行:
shoplazza app extension create --type function --name my-function
名称必须是纯目录名——小写字母、数字、- 或 _,以字母或数字开头(^[a-z0-9][a-z0-9_-]*$)。路径分隔符和 .. 会被拒绝。
生成结构:
extensions/
└── my-function/
├── .gitignore
├── package.json
├── shoplazza.extension.toml # 扩展配置(id、name、type = "function")
└── src/
└── index.js # 你的函数入口
生成的 shoplazza.extension.toml:
id = ""
name = "my-function"
type = "function"
id 在首次发布前为空,首次发布后 CLI 会把函数 id 回写进来(第 5 步)。
第 2 步 —— 编写函数逻辑
逻辑写在 extensions/my-function/src/index.js。函数遵循 Javy 执行模型:运行时从 stdin 读取 JSON 输入,计算后把 JSON 输出写到 stdout。
输入:购物车上下文
函数在 stdin 收到如下 JSON(字段名即真实契约):
{
"cart": {
"line_items": [
{
"id": "line-id", // 行项目 id(作为操作目标)
"quantity": 2,
"properties": "[]", // 行自定义属性的 JSON 字符串
"product": {
"product_id": "...",
"variant_id": "...",
"price": "19.90", // 当前单价(十进制字符串)
"product_title": "T-Shirt",
"metafields": [ // 你声明拉取的商品 metafield(见下)
{ "namespace": "custom", "key": "markup", "value": 3 }
]
}
}
],
"attributes": "[]" // 购物车级属性的 JSON 字符串
},
"currency_settings": { "actual_rate": "1.0" },
"shop": {
"metafields": [ // 你声明拉取的店铺 metafield
{ "namespace": "custom", "key": "vip", "value": true }
]
}
}
只有当函数声明了要读取哪些元字段时,metafields 才会出现(每个函数最多 5 条查询,见声明要读取的元字段)。
声明要读取的元字段
函数不能自己查数据。除了购物车本身,函数校验买家选择、计算价格所需的数据(比如附加费、价目表、规则)都要放在商品或店铺的元字段里。平台在函数运行前加载你声明的元字段,放进函数输入。
在 shoplazza.extension.toml 里为函数需要的每个元字段(metafield)写一个 [[metafields_query]] 块:
id = ""
name = "my-function"
type = "function"
[[metafields_query]]
resource = "product"
namespace = "custom"
key = "markup"
| 字段 | 说明 |
|---|---|
resource | product(出现在 cart.line_items[].product.metafields)或 shop(出现在 shop.metafields) |
namespace | 元字段的命名空间 |
key | 元字段的键 |
每条声明过的元字段以 { namespace, key, value } 的形式传入。商品没有设置某个声明过的元字段时,它的 metafields 数组里就没有这一条。
- 最多 5 条。
- CLI 在
app deploy(以及app dev)时校验声明,并随发布一起提交。声明不合法会直接让发布失败,例如:function "my-function" input_query: metafields_query has 6 entries, max 5function "my-function" input_query: metafields_query[0] resource "order" invalid, must be "product" or "shop"
- 声明在商家安装应用时生效(见第 6 步)。
单个元字段的值最大 1 MB。函数读取的那条建议控制在 10 KB 左右以内,因为函数每次运行都会加载它。
同一份数据既要用来渲染商品页、又要给函数计价时,拆成两个元字段:一个给店铺前台渲染,可以大;另一个给函数,只放函数要读的字段。只声明第二个。这样函数输入更小,改页面展示也不会影响函数。
输出:计价操作
把如下 JSON 写到 stdout:
{
"operation": {
"update": [
{
"id": "line-id", // 必须匹配某个购物车行 id
"price": { "adjustment_fixed_price": "10.00" }, // 最终单价 = 19.90 + 10.00 = 29.90
"title": "T-Shirt (定制)" // 可选
}
]
}
}
adjustment_fixed_price是加在该行原单价上的金额(非负、≤999999999、2 位小数)。title可选(≤ 255 字符)。- 只识别
update操作;其他键被忽略。
入口文件参考
这个示例按商品元字段 custom.markup 的值给商品加价,元字段按声明要读取的元字段声明。原价 100、custom.markup = 5 的商品最终单价为 105,结账页标题变成 <原标题> (+5)。没有该元字段的商品行保持不变。
// extensions/my-function/src/index.js
const input = readInput();
const result = run(input);
writeOutput(result);
// 按商品元字段 custom.markup 加价:有值的行输出 adjustment_fixed_price = markup,平台把它加在原价上
// (最终单价 = 原价 + markup),并在标题后标出加价金额;没有值的行不输出,保持原价。
// 元字段需要在 shoplazza.extension.toml 的 [[metafields_query]] 里声明,否则函数输入里拿不到。
function run(input) {
const update = [];
input.cart.line_items.forEach((lineItem) => {
const metafields = lineItem.product.metafields || [];
const markupField = metafields.find(
(metafield) => metafield.namespace === "custom" && metafield.key === "markup"
);
if (!markupField) {
return;
}
const markup = parseFloat(markupField.value);
// adjustment_fixed_price 必须在 0 到 999999999 之间:任何一行不合法,整个输出都会被拒绝
if (!Number.isFinite(markup) || markup < 0 || markup > 999999999) {
return;
}
update.push({
id: lineItem.id,
price: {
adjustment_fixed_price: markup.toFixed(2),
},
title: `${lineItem.product.product_title} (+${markup})`,
});
});
return { operation: { update } };
}
function readInput() {
const chunkSize = 1024;
const inputChunks = [];
let totalBytes = 0;
// 读取全部可用字节
while (1) {
const buffer = new Uint8Array(chunkSize);
// 标准输入的文件描述符
const fd = 0;
const bytesRead = Javy.IO.readSync(fd, buffer);
totalBytes += bytesRead;
if (bytesRead === 0) {
break;
}
inputChunks.push(buffer.subarray(0, bytesRead));
}
// 把所有分块拼成一个 Uint8Array
const { finalBuffer } = inputChunks.reduce(
(context, chunk) => {
context.finalBuffer.set(chunk, context.bufferOffset);
context.bufferOffset += chunk.length;
return context;
},
{ bufferOffset: 0, finalBuffer: new Uint8Array(totalBytes) }
);
return JSON.parse(new TextDecoder().decode(finalBuffer));
}
// 把输出写到标准输出
function writeOutput(output) {
const encodedOutput = new TextEncoder().encode(JSON.stringify(output));
const buffer = new Uint8Array(encodedOutput);
// 标准输出的文件描述符
const fd = 1;
Javy.IO.writeSync(fd, buffer);
}
校验与失败行为
- 平台先校验全部操作,再应用任意一条——全有或全无。若有一行校验失败(负价/超限价、标题超 255 字符、未知行 id 等),整个输出被拒绝。
- 每个绑定端点带一个
block_on_failure开关:true→ 函数失败会阻断购物车/结账操作并报错。false→ 函数结果被丢弃,流程以未改价继续。
保证可编译且快
Javy 只编译 JavaScript 的一个子集。为确保构建稳定、运行在预算内:
- 入口自包含在
src/index.js内;如有依赖请打包进这一个文件。 - 避免 Node.js / 浏览器 API(
fs、fetch、window、document、定时器)。只有stdin/stdout可用。 - 做算术、快速返回。引擎强制以下硬限制:
| 限制 | 值 |
|---|---|
| 指令预算(fuel) | 约 11,000,000 |
| 线性内存 | 10 MB |
| 单次执行时长 | 约 300 ms |
| 输入 JSON 大小 | 10 MB |
| 输出 JSON 大小 | 20 MB |
| 编译后 WASM 大小 | 2 MB |
| 单个元字段的值 | 1 MB |
第 3 步 —— 编译为 WASM
本地编译(无需登录、不上传),尽早发现构建错误:
shoplazza app function compile --name my-function
该命令运行 javy build extensions/my-function/src/index.js,产物写到:
app-deploy/my-function.<md5-of-index.js>.wasm
语法错误或不支持的 API 会在此以 Javy 错误失败。
第 4 步 —— 本地测试
启动开发服务器并在前台验证:
shoplazza app dev
按终端提示操作(输入测试店铺域名,把 App URL / Redirect URL 填入 Partner Admin,打开 Install URL)。然后在前台:
- 加入商品到购物车 → 确认价格/行小计体现了你的函数。
- 进入结账 → 确认改价被带入结账。
第 5 步 —— 发布函数
可以发布单个函数,也可以发布整个应用。
发布单个函数
shoplazza app function release --name my-function
release 会编译并上传函数到当前激活应用:
- 首次发布(create) —— 在
cart_transform命名空间下以你应用的client_id注册新函数,返回function_id和version。 - 后续发布(commit) —— 复用已记录的
function_id,自增补丁版本号,提交新版本。版本号为语义化(MAJOR.MINOR.PATCH)且必须严格递增——后端会以Version not match拒绝非递增版本。CLI 自动处理递增并把权威值回写:
id = "1234567890123456789"
name = "my-function"
type = "function"
version = "1.0.1"
发布整个应用
要把所有扩展(主题、结账、函数)一起构建发布并切出新的应用版本:
shoplazza app deploy
app deploy 只把函数 id 回写到 shoplazza.extension.toml,不回写 version。
迭代单个函数用 function release;发布完整应用版本用 app deploy。
[[metafields_query]] 声明随 app deploy(以及 app dev)提交,单独执行 function release 不会提交声明。
查看已发布函数
shoplazza app function list
第 6 步 —— 绑定与上线
已发布的函数要在店铺运行,必须绑定到该店铺。绑定是自动的——基础场景下你无需调用任何接口。
安装即自动绑定
商家安装你的应用时,平台会用默认配置把你已发布的函数绑定到他的店铺:失败不阻断买家(block_on_failure: false),并加载 [[metafields_query]] 里声明的元字段。之后函数就在该店铺的加入购物车、购物车更新、结账(创建/变更/校验)时运行。应用被卸载时,平台会自动解绑。
也就是说:发布函数(第 5 步)→ 安装/发布应用,函数即生效,无需额外的绑定调用。
- 绑定在安装时创建,所以函数必须在应用安装到店铺之前用
app deploy发布。函数发布前就已安装应用的店铺不会运行该函数。 - 已经安装应用的店铺会继续运行绑定时的函数版本。之后再发布新的函数版本不会自动切换,用
PATCH修改绑定配置也不会切换。要让测试店铺运行新版本,需要卸载并重新安装应用。 - 重新安装会创建新的绑定,绑定
id会变化。
可选:自定义或管理绑定(OpenAPI)
只有当你要超出默认行为时才用购物车转换 OpenAPI——失败时阻断、修改元字段声明、或查看/覆盖绑定:
| 方法与路径 | 作用 |
|---|---|
GET /openapi/2025-06/function/cart-transform | 查询该店铺的绑定列表 |
POST /openapi/2025-06/function/cart-transform | 绑定函数(或用自定义配置重绑) |
PATCH /openapi/2025-06/function/cart-transform/{id} | 更换函数或修改配置 |
用店铺的 access token 调用:Access-Token: {token},host 为 https://{店铺域名}。请求体:
{
"function_id": "1234567890123456789", // 回写到 shoplazza.extension.toml 的函数 id
"block_on_failure": false, // true = 函数报错时阻断购物车/结账
"input_query": "{\"metafields_query\":[{\"resource\":\"product\",\"namespace\":\"custom\",\"key\":\"markup\"}]}"
}
block_on_failure:true—— 函数失败会让加购请求失败。店铺前台收到通用错误({"state":"server_error","message":"Service error, please try again later"}),不会带出函数的具体错误。false(安装默认值)—— 丢弃函数结果,商品按原价加入购物车。
input_query—— 可选 JSON 字符串,声明要加载进函数输入的元字段,格式与 CLI 根据[[metafields_query]]提交的一致。最多 5 条,超过返回metafields query max limit is 5。PATCH时input_query传空字符串或不传,会保留当前声明。
GET 在 data.cart_transforms 里返回每个绑定的 id、function_id、block_on_failure,以及 data.count,不返回 input_query。
如果你的应用托管在自己的后端(而非 CLI 的 dev 隧道),把 App URL / Redirect URL 配到 Partner 后台(shoplazza app config push)、在后端处理 OAuth 即可。函数绑定依然在安装时自动完成——它挂在安装事件上,不依赖 shoplazza app dev。
上线检查清单
由于 Function 开发是应用开发的一部分,上线遵循常规应用生命周期:
- ✅
function release(或app deploy)成功并把版本置为生效;shoplazza app function list能看到函数与版本。 - ✅ 应用已有部署版本(
shoplazza app versions)且具备write_cart_transform权限。 - ✅ 在开发/测试店铺验证通过:购物车与结账都显示了改价。
- ✅ 函数要读取的元字段已在
[[metafields_query]]里声明,并通过app deploy发布。(可选)若需要失败阻断,应用调用购物车转换 OpenAPI 设置block_on_failure。 - ✅ 按提交审核提交并发布应用。审核通过后,每个安装你应用的商家都会自动获得已绑定、已生效的函数。
配置参考
shoplazza.extension.toml
| 字段 | 设置方 | 说明 |
|---|---|---|
name | 你(通过 --name) | 扩展名称,同时是 extensions/ 下的目录名 |
type | 脚手架 | 恒为 "function" |
id | 首次发布 | 远端 function_id;决定下次 release 走 create 还是 commit |
version | function release | 上次发布的版本号,由 function release 回写(app deploy 不回写),用于计算下一次递增 |
[[metafields_query]] | 你 | 函数要读取的元字段:resource(product 或 shop)、namespace、key,最多 5 条。见声明要读取的元字段 |
shoplazza.app.toml
| 字段 | 说明 |
|---|---|
scopes | 必须包含 write_cart_transform(读取购物车数据再加 read_cart_transform) |
命令参考
| 命令 | 作用 |
|---|---|
shoplazza app extension create --type function --name <name> | 生成一个新的函数扩展 |
shoplazza app function compile --name <name> | 本地把 src/index.js 编译为 WASM(不发布) |
shoplazza app function release --name <name> | 编译并发布单个函数 |
shoplazza app function list | 列出当前激活应用的函数扩展 |
shoplazza app deploy | 构建并发布整个应用,切出新版本 |
完整参数参考:app 命令参考。
故障排查
extension "my-function" already exists at …——extensions/下已有同名目录。换一个名字,或删掉旧目录。function entry not found——extensions/<name>/下缺少src/index.js。检查--name,或重新生成。javy build failed——src/index.js有语法错误或使用了不支持的 API。任何 Javy stderr 都会失败(即使退出码为 0)。精简后重编译。invalid --name—— 名称含路径分隔符或非法字符。使用^[a-z0-9][a-z0-9_-]*$。- release 时报
Version not match—— toml 的version与后端不同步。运行function list对齐。 app deploy时报input_query: metafields_query has 6 entries, max 5或resource "order" invalid——[[metafields_query]]超过 5 条,或resource不是product/shop。改好 toml 后重新发布。- 发布新的函数版本后店铺价格没变 —— 已安装应用的店铺会沿用原来的绑定。在测试店铺卸载并重新安装应用,再删除购物车里的旧商品行、重新加购。
- 店铺里价格没变 —— 确认应用已安装、该店铺已绑定
cart_transform端点;若输出被静默丢弃,检查校验是否通过、block_on_failure行为是否符合预期。 - 函数超限 —— 命中 fuel/时长/输出上限时,减少每行计算量,只输出真正改动的行。
常见问题
一个函数同时覆盖购物车和结账吗? 是。发布一次即可;同一函数在购物车页(加入/更新)和结账创建时都会运行。
函数能涨价也能降价吗?
只能加价或保持原价。adjustment_fixed_price 必须是非负数,并且加在原价上,所以函数不能降价。需要降价时使用折扣功能。
函数能做订单级或运费级折扣吗?
不能——cart_transform 只改行价与行标题。订单/运费级折扣由 cart_transform 命名空间之外的独立折扣机制处理。
下一步
app命令参考 —— 函数子命令的全部参数。- 构建你的第一个应用 —— 端到端应用开发流程。
- 提交审核 —— 把应用发布给商家。
- 用函数按商品加收附加费 —— 按商品元字段给购物车商品行计价的完整示例。