local-storefront 0.2.0 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ # Formatting
2
+
3
+ `npx local-storefront fmt` formats Liquid files the way [Prettier](https://prettier.io) does
4
+ with Shopify's Liquid plugin (`@shopify/prettier-plugin-liquid`). Neither is needed:
5
+ local-storefront does it itself, and Shopify's Horizon theme, some 280 files, takes about a
6
+ fifth of a second.
7
+
8
+ ## Format the theme
9
+
10
+ ```bash
11
+ npx local-storefront fmt
12
+ ```
13
+
14
+ Formats every `.liquid` file of the theme: Liquid, HTML, the JSON of `{% schema %}`, the CSS
15
+ of `<style>`, `{% style %}` and `{% stylesheet %}`, and the JavaScript of `<script>` and
16
+ `{% javascript %}`. A theme that Prettier has formatted comes out unchanged.
17
+
18
+ Format some files only:
19
+
20
+ ```bash
21
+ npx local-storefront fmt sections/header.liquid snippets
22
+ ```
23
+
24
+ Format the files you changed since the last commit, staged or not, and the new ones:
25
+
26
+ ```bash
27
+ npx local-storefront fmt $(git diff --name-only --diff-filter=d HEAD; git ls-files --others --exclude-standard)
28
+ ```
29
+
30
+ `git diff` leaves out the new files that are not staged: `git ls-files --others` adds them.
31
+ `--diff-filter=d` leaves out deleted files, which `fmt` could not read. With nothing changed,
32
+ the list is empty and `fmt` formats the whole theme.
33
+
34
+ A file named on its own is formatted if it is a `.liquid` file that `.prettierignore` does not
35
+ name, as Prettier does: the other files of that list are left alone.
36
+
37
+ ## Check in CI
38
+
39
+ ```bash
40
+ npx local-storefront fmt --check
41
+ ```
42
+
43
+ Writes nothing. Lists the files that are not formatted, and exits with `1` if there are any.
44
+
45
+ ## From an editor
46
+
47
+ ```bash
48
+ npx local-storefront fmt --stdin --stdin-filepath sections/header.liquid < sections/header.liquid
49
+ ```
50
+
51
+ Reads the source from the standard input and prints it formatted. `--stdin-filepath` gives
52
+ the configuration and the `.prettierignore` of that file; an ignored file is printed as it is.
53
+
54
+ ## Configuration
55
+
56
+ Your Prettier configuration applies. It is looked for as Prettier looks for it: from the folder
57
+ of each file, up. These files are read:
58
+
59
+ - `.prettierrc` (JSON or YAML), `.prettierrc.json`, `.prettierrc.yaml`, `.prettierrc.yml`;
60
+ - the `prettier` key of `package.json`;
61
+ - their `overrides`;
62
+ - `.editorconfig`;
63
+ - `.prettierignore`, the nearest one from the theme folder up.
64
+
65
+ A configuration written in JavaScript (`prettier.config.js`) cannot be read:
66
+ `npx local-storefront fmt` says so, and the files of its folder get the default options, as
67
+ Prettier's search stops there too.
68
+
69
+ To set an option from the command line, under the name Prettier gives it:
70
+
71
+ ```bash
72
+ npx local-storefront fmt -o printWidth=100 -o singleQuote=true
73
+ ```
74
+
75
+ It wins over the configuration files. `--no-config` ignores the files.
76
+
77
+ ### Options
78
+
79
+ | Prettier option | Default | What it changes |
80
+ |---|---|---|
81
+ | `printWidth` | `120` | The width lines are kept under. |
82
+ | `tabWidth` | `2` | Spaces per level of indentation. |
83
+ | `useTabs` | `false` | Indents with tabs. |
84
+ | `endOfLine` | `lf` | `lf`, `crlf`, `cr`, or `auto` for what the file uses. |
85
+ | `singleQuote` | `false` | Single quotes around HTML attributes. |
86
+ | `liquidSingleQuote` | `true` | Single quotes around the strings of Liquid. |
87
+ | `embeddedSingleQuote` | `true` | Single quotes in the CSS and the JavaScript of `<style>`, `<script>` and the `{% style %}`, `{% stylesheet %}` and `{% javascript %}` tags. |
88
+ | `bracketSameLine` | `false` | Puts the `>` of a tag at the end of its last attribute. |
89
+ | `singleAttributePerLine` | `false` | One attribute per line as soon as a tag has two. |
90
+ | `htmlWhitespaceSensitivity` | `css` | Where whitespace may be added or removed: `css`, `strict`, `ignore`. |
91
+ | `captureWhitespaceSensitivity` | `strict` | `ignore` formats what a `{% capture %}` holds. |
92
+ | `singleLineLinkTags` | `false` | Keeps `<link>` tags on one line. |
93
+ | `indentSchema` | `false` | Indents the JSON of `{% schema %}`. |
94
+ | `liquidDocParamDash` | `true` | A dash between a `@param` of `{% doc %}` and its description. |
95
+ | `embeddedLanguageFormatting` | `auto` | `off` leaves JSON, CSS and JavaScript as they are written. |
96
+ | `semi` | `true` | A semicolon at the end of each statement of JavaScript. |
97
+ | `jsxSingleQuote` | `false` | Single quotes around the attributes of JSX. |
98
+ | `quoteProps` | `as-needed` | Quotes around the keys of objects: `as-needed`, `consistent`, `preserve`. |
99
+ | `trailingComma` | `all` | A comma after the last item of a list on several lines: `all`, `es5`, `none`. |
100
+ | `bracketSpacing` | `true` | Spaces inside the braces of an object, in JavaScript and JSON. |
101
+ | `objectWrap` | `preserve` | `collapse` puts an object on one line when it fits, even if it started on several. |
102
+ | `arrowParens` | `always` | Parentheses around the only parameter of an arrow function: `always`, `avoid`. |
103
+ | `experimentalOperatorPosition` | `end` | `start` begins a broken line with its operator. |
104
+
105
+ Other options of Prettier are ignored in a configuration file.
106
+
107
+ ## Safety
108
+
109
+ - A file is replaced in one step, with its permissions: an interrupted run leaves no
110
+ half-written template, and a read-only file is not replaced.
111
+ - In a folder, `node_modules`, `.git`, links to folders and what `.prettierignore` names are
112
+ left out.
113
+ - A file or a folder that cannot be read, written or formatted is reported, the others are
114
+ formatted all the same, and the command exits with `2`.
115
+
116
+ ## What differs from Prettier
117
+
118
+ How the output is compared with Prettier's, and the few things it does not format:
119
+ [compatibility](compatibility.md#formatting).
@@ -0,0 +1,266 @@
1
+ # Performance
2
+
3
+ Two tools to make a page faster: render times on every response, and the profile of one page.
4
+ Both measure local-storefront on your machine, not Shopify. Use them to compare: before and
5
+ after a change, one section with another. To see what a page would cost Shopify, profile it in
6
+ [points](#what-the-page-would-cost-shopify).
7
+
8
+ > **Find the slow snippet**
9
+ >
10
+ > 1. `npx local-storefront profile <path>`: read "Slowest on their own", the files that take
11
+ > the most time. Skip `render`, the page as a whole.
12
+ > 2. `npx local-storefront profile <path> --lines`: the line, inside that file
13
+ > (`snippets/product-media:40`).
14
+ > 3. `npx local-storefront profile <path> --points --lines`: what that line would cost Shopify,
15
+ > such as a loop over `collections` or a metafield read for each product of a loop.
16
+
17
+ ## Profile a page
18
+
19
+ ```bash
20
+ npx local-storefront profile /collections/all
21
+ ```
22
+
23
+ ```text
24
+ /collections/all · template collection · 2.502 ms
25
+ The render in the middle of 15, which took from 2.325 to 2.901 ms.
26
+ Times are in ms, for all the calls of a row. "own" leaves out what is under the row.
27
+
28
+ total own calls
29
+ 2.502 0.201 1 render
30
+ 1.510 0.005 1 templates/collection.json
31
+ 1.464 0.029 1 section template--8034641070597__main
32
+ 1.435 0.031 1 sections/main-collection
33
+ 0.921 0.021 8 block product-card
34
+ 0.900 0.031 8 blocks/_product-card
35
+ 0.495 0.010 8 block card-gallery
36
+ 0.485 0.022 8 blocks/_product-card-gallery
37
+ 0.398 0.112 8 snippets/card-gallery
38
+ 0.122 0.122 15 snippets/product-media
39
+
40
+ Slowest on their own:
41
+
42
+ own calls
43
+ 0.201 1 render
44
+ 0.191 12 snippets/list-filter
45
+ 0.115 15 snippets/product-media
46
+ ```
47
+
48
+ Each row is under the row that rendered it: a section, its file, its blocks, their snippets.
49
+
50
+ - `total` is the time of the row and of everything under it.
51
+ - `own` is the time of the row alone.
52
+ - `calls` is how many times it was rendered. Both times are for all the calls.
53
+ - `section <id>` and `block <key>` are a section and a block as the template places them:
54
+ reading their settings, then their file, which is the row under them.
55
+ - `render` is the page as a whole. Its own time is the work outside the theme's files, such as
56
+ setting up the objects of the page: no file accounts for it. Skip it and read the files.
57
+
58
+ The page is rendered 3 times first, so that reading the files does not count. The profile is
59
+ the one of the render in the middle, by how long they took.
60
+
61
+ ### The slow line of a file
62
+
63
+ ```bash
64
+ npx local-storefront profile /collections/all --lines
65
+ ```
66
+
67
+ Rows such as `snippets/product-media:40` appear: a tag or an output, by its line. Timing each
68
+ one makes the render slower: compare the rows with each other.
69
+
70
+ ### In the browser
71
+
72
+ With the server running, the same report is a page:
73
+
74
+ ```text
75
+ http://127.0.0.1:9292/__lsf/profile?path=/collections/all
76
+ ```
77
+
78
+ It profiles the page as your session sees it: its cart, its customer. Add `html=1` for a
79
+ flame graph:
80
+
81
+ ```text
82
+ http://127.0.0.1:9292/__lsf/profile?path=/collections/all&html=1
83
+ ```
84
+
85
+ This is speedscope, the viewer `shopify theme profile` opens. local-storefront embeds it:
86
+ nothing is fetched from the internet. The name of the profile, at the top, switches between time
87
+ and points. The status page (`/__lsf`) links every page to its profiles.
88
+
89
+ Without a server, write the flame graph to a file and drop it on <https://www.speedscope.app>:
90
+
91
+ ```bash
92
+ npx local-storefront profile /collections/all --json > profile.json
93
+ ```
94
+
95
+ ### Options
96
+
97
+ | Option | In the URL | What it does |
98
+ |---|---|---|
99
+ | `--points` | `points=1` | Counts in points instead of time. In the flame graph, starts on points. |
100
+ | `--cost <kind>=<points>,...` | `cost=<kind>=<points>,...` | Changes what a kind of thing costs. |
101
+ | | `html=1` | Shows the flame graph. |
102
+ | `--lines` | `lines=1` | Adds every tag and every output, by file and line. Points do not change. |
103
+ | `--all` | `all=1` | Shows every row. Without it, what took less than a hundredth of the render is summed up as `... 3 more`. |
104
+ | `--runs <n>` | `runs=<n>` | Renders the page `n` times. Default: 15. |
105
+ | `--section-id <id>` | `section_id=<id>` | Profiles one section alone. |
106
+ | `--json` | `format=speedscope` | Gives the file of the flame graph instead of the report. It holds both profiles. |
107
+
108
+ ## What the page would cost Shopify
109
+
110
+ local-storefront loads a product or a metafield in no time. A Shopify storefront does not: a
111
+ page that is fast here can be slow there. `--points` counts what the page asks for, instead of
112
+ the time it takes:
113
+
114
+ ```bash
115
+ npx local-storefront profile /collections/all --points
116
+ ```
117
+
118
+ ```text
119
+ /collections/all · template collection · 15566 points
120
+ Points are a model of what the page costs a Shopify storefront, not a measure:
121
+ what they are made of is at the end. They are the same at every render.
122
+ Points are for all the calls of a row. "own" leaves out what is under the row.
123
+
124
+ total own calls
125
+ 15566 0 1 render
126
+ 11566 0 1 templates/collection.json
127
+ 11261 0 1 section template--8034641070597__main
128
+ 11261 870 1 sections/main-collection
129
+ 6877 0 8 block product-card
130
+ 6877 112 8 blocks/_product-card
131
+
132
+ Costliest on their own:
133
+
134
+ own calls
135
+ 1520 12 snippets/list-filter
136
+ 1276 33 snippets/spacing-style
137
+
138
+ What the points are made of:
139
+
140
+ points count each
141
+ 14246 14246 1 liquid A tag or an output rendered.
142
+ 800 8 100 product A product loaded.
143
+ 220 22 10 variant A variant loaded.
144
+ 100 1 100 collection A collection loaded.
145
+ 200 2 100 menu A menu loaded.
146
+ ```
147
+
148
+ The report is the same tree, in points, then the costliest rows on their own. A row has the
149
+ points of everything that happened under it: a snippet that reads a metafield of 50 products
150
+ has the points of 50 metafields.
151
+
152
+ | Kind | Points | Charged for |
153
+ |---|---|---|
154
+ | `liquid` | 1 | A tag or an output rendered. |
155
+ | `product` | 100 | A product loaded. |
156
+ | `variant` | 10 | A variant loaded. |
157
+ | `collection` | 100 | A collection loaded. |
158
+ | `metafield` | 100 | A metafield read. |
159
+ | `metaobject` | 100 | A metaobject loaded. |
160
+ | `page` | 100 | A page loaded. |
161
+ | `blog` | 100 | A blog loaded. |
162
+ | `article` | 100 | An article loaded. |
163
+ | `menu` | 100 | A menu loaded. |
164
+ | `search` | 1000 | A search, a predictive search or the recommendations of a product. |
165
+
166
+ - **Points are a model, not a measure.** Shopify does not publish what it spends on what. The
167
+ unit is a tag rendered, and what a store has to fetch is taken to weigh as much as 100
168
+ tags. Read the counts first: they are facts. The points only weigh them.
169
+ - What is loaded counts once per page, however many times the templates read it. A product in
170
+ a loop counts once per product.
171
+ - A metafield that does not exist counts too: the store looks for it all the same.
172
+ - Points are the same at every render and on every machine, so a test can compare them.
173
+
174
+ To change the costs, for example after comparing with `shopify theme profile` on your store:
175
+
176
+ ```bash
177
+ npx local-storefront profile /collections/all --points --cost product=300,metafield=50
178
+ ```
179
+
180
+ ## Render times
181
+
182
+ Every response says how long the server took, in the `Server-Timing` header, as Shopify does:
183
+
184
+ ```text
185
+ server-timing: processing;dur=3.09, render;dur=2.507, template;dur=1.27, layout;dur=0.986, compress;dur=0.53
186
+ ```
187
+
188
+ In Chrome: DevTools, Network, click the page, Timing. The times are under "Server Timing".
189
+
190
+ To see each section and each block too:
191
+
192
+ ```bash
193
+ npx local-storefront serve --timings
194
+ ```
195
+
196
+ ```text
197
+ section;dur=1.922;desc="main-collection template--8034641070597__main"
198
+ block;dur=0.636;desc="- filters"
199
+ block;dur=1.123;desc="- _product-card product-card x8"
200
+ block;dur=0.611;desc="- - _product-card-gallery card-gallery x8"
201
+ ```
202
+
203
+ A block comes under its section, with one dash per level. `x8` means it was rendered 8 times,
204
+ and the time is the time of all 8.
205
+
206
+ In a test:
207
+
208
+ ```ts
209
+ const response = await page.goto('/');
210
+ const timing = response.headers()['server-timing'];
211
+ ```
212
+
213
+ ### Many requests
214
+
215
+ Start from zero:
216
+
217
+ ```bash
218
+ curl -X DELETE http://127.0.0.1:9292/__lsf/timings
219
+ ```
220
+
221
+ Load your pages, then read the totals:
222
+
223
+ ```bash
224
+ curl http://127.0.0.1:9292/__lsf/timings
225
+ ```
226
+
227
+ ```json
228
+ {
229
+ "templates": [
230
+ { "template": "collection", "count": 2000, "total": 5792.9, "mean": 2.896, "p50": 2.851, "p95": 3.31, "min": 2.308, "max": 7.495 }
231
+ ],
232
+ "sections": [
233
+ { "id": "template--8034641070597__main", "type": "main-collection", "count": 2000, "total": 3455.621, "mean": 1.728, "p50": 1.688, "p95": 1.982, "min": 1.281, "max": 5.537 }
234
+ ],
235
+ "blocks": [
236
+ { "id": "A1456318a0478e95__product-card", "type": "_product-card", "section": "template--8034641070597__main", "count": 16000, "total": 2196.216, "mean": 0.137, "p50": 0.12, "p95": 0.223, "min": 0.064, "max": 2.549 }
237
+ ]
238
+ }
239
+ ```
240
+
241
+ What took the most time in all comes first. A block counts once each time it is rendered: 8
242
+ product cards on 2000 pages are 16000. `p50` and `p95` are within 2% of the exact value.
243
+
244
+ ### Entries of `Server-Timing`
245
+
246
+ Times are in milliseconds.
247
+
248
+ | Entry | Meaning |
249
+ |---|---|
250
+ | `processing` | Everything the server did for the request. Shopify uses the same name. |
251
+ | `render` | Rendering the Liquid of a page or of sections. |
252
+ | `template` | The template of the page: what goes into `content_for_layout`. |
253
+ | `layout` | The layout around it, with its header and its footer. |
254
+ | `section` | One section, with `--timings`. Its description is its type, then its id: the one of `#shopify-section-<id>`. |
255
+ | `block` | One theme block, with `--timings`. Its description is a dash per level, its type, its key in the template, then `x<n>` when it was rendered more than once. |
256
+ | `omitted` | Only on a page with hundreds of blocks: how many of the fastest ones the header leaves out. `/__lsf/timings` has them all. |
257
+ | `compress` | Compressing the response. |
258
+ | `throttle` | The delay of `--throttle`. It is not part of `processing`. |
259
+
260
+ - A time includes what is inside: a section is part of `template` or of `layout`, a block is
261
+ part of its section, and of the block it is in.
262
+ - Only theme blocks are timed: the files of `blocks/`, rendered with `content_for`. Blocks
263
+ that a section writes itself, in a loop over `section.blocks`, are part of the section.
264
+ - `/__lsf/timings` counts pages by template, sections and blocks by id. Sections and blocks
265
+ count wherever they are rendered: in a page, alone, or in a cart response. The id of a
266
+ block is the one of `#shopify-block-<id>`, and ends with its key.
@@ -0,0 +1,211 @@
1
+ # Quickstart: an Admin API backend
2
+
3
+ From the backend of a Shopify app to a local GraphQL Admin API (2026-10) it queries, webhooks it
4
+ receives, and a test that runs in CI. No development store, no tunnel. This page needs no theme
5
+ and no React: a sync job, a worker or any server works the same way.
6
+
7
+ ## 1. Start lsf for the app
8
+
9
+ lsf reads the app's `shopify.app.toml`: the client id, the scopes the token gets, and the
10
+ webhook subscriptions.
11
+
12
+ ```toml
13
+ client_id = "0123456789abcdef0123456789abcdef"
14
+ handle = "my-backend"
15
+ application_url = "https://my-app.trycloudflare.com"
16
+
17
+ [access_scopes]
18
+ scopes = "read_products,write_products,read_orders"
19
+
20
+ [webhooks]
21
+ api_version = "2026-10"
22
+
23
+ [[webhooks.subscriptions]]
24
+ topics = ["products/create", "app/uninstalled"]
25
+ uri = "/webhooks"
26
+ ```
27
+
28
+ The token gets these scopes and no others, as on Shopify: with `read_orders` alone, an order
29
+ reads, but its `customer` (`read_customers`), its returns (`read_returns`) and its fulfillment
30
+ orders (`read_merchant_managed_fulfillment_orders`) answer `ACCESS_DENIED`, which names the scope
31
+ to add. Add the scopes your backend reads, then restart lsf or call `POST /__lsf/admin/install`.
32
+
33
+ It signs webhooks with the client secret: `SHOPIFY_API_SECRET` of the environment or of the
34
+ app's `.env`, where `shopify app env pull` writes it. In the app's folder:
35
+
36
+ ```bash
37
+ npm install --save-dev local-storefront
38
+ npx local-storefront serve --app . --only admin --app-url http://localhost:3000
39
+ ```
40
+
41
+ ```text
42
+ app: . (no extensions: the Admin API, webhooks and the app proxy)
43
+ data: built-in demo store (the `init` command writes an editable copy)
44
+ serving: admin
45
+ admin: http://127.0.0.1:9292/admin/api/2026-10/graphql.json
46
+ X-Shopify-Access-Token: shpat_… (shop: local-supply-co.myshopify.com)
47
+ client secret: from SHOPIFY_API_SECRET of ./.env
48
+ GraphiQL: http://127.0.0.1:9292/__lsf/graphiql (queries in the browser, the token filled in)
49
+ webhooks: 2 subscriptions of shopify.app.toml → http://localhost:3000
50
+ embedded: http://127.0.0.1:9292/admin/apps/my-backend (the app in lsf's admin; …)
51
+ ready: http://127.0.0.1:9292/ (status and control API: http://127.0.0.1:9292/__lsf)
52
+ ```
53
+
54
+ `--only admin` serves the Admin API alone. `--app-url` is where your backend listens: webhooks
55
+ go there, and nowhere off this machine. `app dev --app . --app-url …` serves the app's checkout
56
+ too.
57
+
58
+ What the Admin API creates lives in memory: a restart of lsf puts every shop back to the store
59
+ data. The tokens survive it: the backend's stored sessions keep working.
60
+
61
+ ## 2. Query it
62
+
63
+ The token is printed, and is the same every run for this app: a test can hard-code it.
64
+ `GET /__lsf/admin` gives it too, as `shops[0].token`.
65
+
66
+ ```bash
67
+ curl -s http://127.0.0.1:9292/admin/api/2026-10/graphql.json \
68
+ -H 'Content-Type: application/json' -H "X-Shopify-Access-Token: $TOKEN" \
69
+ -d '{"query": "{ shop { name } }"}'
70
+ ```
71
+
72
+ With `@shopify/admin-api-client`, which always builds an `https://` URL, rewrite the URL in its
73
+ fetch. The same code talks to Shopify when `LSF_URL` is not set:
74
+
75
+ ```js
76
+ // query.mjs
77
+ import { createAdminApiClient } from "@shopify/admin-api-client";
78
+
79
+ const lsf = process.env.LSF_URL; // http://127.0.0.1:9292
80
+ const client = createAdminApiClient({
81
+ storeDomain: "local-supply-co.myshopify.com",
82
+ apiVersion: "2026-10",
83
+ accessToken: process.env.SHOPIFY_ACCESS_TOKEN,
84
+ customFetchApi: lsf
85
+ ? (url, init) => fetch(String(url).replace(/^https:\/\/[^/]+/, lsf), init)
86
+ : fetch,
87
+ });
88
+
89
+ const { data } = await client.request(`{ products(first: 3) { nodes { title } } }`);
90
+ console.log(data.products.nodes);
91
+ ```
92
+
93
+ ```bash
94
+ LSF_URL=http://127.0.0.1:9292 SHOPIFY_ACCESS_TOKEN=shpat_… node query.mjs
95
+ ```
96
+
97
+ <http://127.0.0.1:9292/__lsf/graphiql> is a query editor with the token and the schema's docs,
98
+ offline. Ruby, Python and the React Router package: [point the backend at
99
+ lsf](admin.md#point-the-backend-at-lsf).
100
+
101
+ ## 3. Receive webhooks
102
+
103
+ A mutation sends its webhooks to `--app-url`, signed as Shopify signs them. Check the HMAC on the
104
+ raw bytes of the body:
105
+
106
+ ```js
107
+ // receiver.mjs: node --env-file=.env receiver.mjs
108
+ import { createServer } from "node:http";
109
+ import { createHmac, timingSafeEqual } from "node:crypto";
110
+
111
+ createServer(async (req, res) => {
112
+ const chunks = [];
113
+ for await (const chunk of req) chunks.push(chunk);
114
+ const body = Buffer.concat(chunks);
115
+ const digest = createHmac("sha256", process.env.SHOPIFY_API_SECRET).update(body).digest();
116
+ const header = Buffer.from(req.headers["x-shopify-hmac-sha256"] ?? "", "base64");
117
+ if (header.length !== digest.length || !timingSafeEqual(header, digest)) {
118
+ return res.writeHead(401).end();
119
+ }
120
+ console.log(req.headers["x-shopify-topic"], JSON.parse(body).id);
121
+ res.writeHead(200).end();
122
+ }).listen(Number(process.env.PORT ?? 3000));
123
+ ```
124
+
125
+ A `productCreate` now prints `products/create 8397442660771`. To send one without a mutation:
126
+
127
+ ```bash
128
+ npx local-storefront app webhook trigger --topic app/uninstalled --app-url http://localhost:3000
129
+ ```
130
+
131
+ `GET /__lsf/webhooks` lists every delivery, with what the backend answered. A `401` there names
132
+ the secret lsf signed with.
133
+
134
+ ## 4. Write a test
135
+
136
+ Reset the shop before each test, read `last` before the action, and wait for the webhook it sends:
137
+
138
+ ```js
139
+ // test/products.test.mjs: node --test
140
+ import { test, beforeEach } from "node:test";
141
+ import assert from "node:assert/strict";
142
+
143
+ const LSF = process.env.LSF_URL ?? "http://127.0.0.1:9292";
144
+ const lsf = (method, path, body) =>
145
+ fetch(LSF + path, { method, body: body && JSON.stringify(body) }).then((r) => r.json());
146
+
147
+ beforeEach(() => lsf("DELETE", "/__lsf/admin")); // every shop back to the store data
148
+
149
+ test("productCreate sends products/create", async () => {
150
+ const { shops } = await lsf("GET", "/__lsf/admin");
151
+ const { last } = await lsf("GET", "/__lsf/webhooks"); // the newest delivery so far
152
+
153
+ const answer = await fetch(`${LSF}/admin/api/2026-10/graphql.json`, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": shops[0].token },
156
+ body: JSON.stringify({ query: `mutation { productCreate(product: {title: "Mug"}) { product { id } } }` }),
157
+ }).then((r) => r.json());
158
+ const id = answer.data.productCreate.product.id;
159
+
160
+ const { deliveries } = await lsf("GET", `/__lsf/webhooks?topic=products/create&since=${last}&wait=1`);
161
+ assert.equal(deliveries[0].resource, id);
162
+ assert.equal(deliveries[0].state, "delivered"); // the receiver checked the HMAC
163
+ });
164
+ ```
165
+
166
+ `DELETE /__lsf/admin` puts every shop back to the store data: records, clock, outbox and tokens.
167
+ It revokes the tokens the backend got by token exchange or OAuth; the printed one keeps working.
168
+ Tests that run in parallel each take a shop of their own (`POST /__lsf/admin/shops`):
169
+ [a shop per test](admin.md#a-shop-per-test).
170
+
171
+ ## 5. Move the clock
172
+
173
+ ```bash
174
+ curl -X POST http://127.0.0.1:9292/__lsf/admin/clock -d '{"advance": "31d"}'
175
+ ```
176
+
177
+ The shop's time moves on and stays there: payment schedules come due, checkouts are abandoned,
178
+ app subscriptions renew, each with its webhooks. `DELETE /__lsf/admin` puts the clock back.
179
+
180
+ ## 6. Run it in CI
181
+
182
+ ```yaml
183
+ # .github/workflows/backend.yml
184
+ name: backend
185
+ on: [push, pull_request]
186
+ jobs:
187
+ backend:
188
+ runs-on: ubuntu-latest
189
+ env:
190
+ SHOPIFY_API_SECRET: test-secret # lsf signs with it, the receiver checks with it
191
+ steps:
192
+ - uses: actions/checkout@v7
193
+ - uses: actions/setup-node@v7
194
+ with:
195
+ node-version: 24
196
+ cache: npm
197
+ - run: npm ci
198
+ - run: |
199
+ npx local-storefront serve --app . --only admin --app-url http://localhost:3000 --static --quiet &
200
+ node receiver.mjs &
201
+ until curl -sf http://127.0.0.1:9292/__lsf/status > /dev/null; do sleep 0.2; done
202
+ node --test
203
+ ```
204
+
205
+ ## Next
206
+
207
+ - [Admin API](admin.md): the libraries, tokens and OAuth, webhooks, a shop per test, orders from
208
+ the checkout, billing, the app proxy, API versions.
209
+ - [Admin API reference](admin-reference.md): what each mutation does, and what lsf leaves out.
210
+ - [Control API](control-api.md#apps-checkout-and-admin-api): every `/__lsf/admin` endpoint.
211
+ - [Quickstart: a public app](quickstart-app.md), for the React Router template.