local-storefront 0.2.0 → 0.3.0-rc.2
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.
- package/CHANGELOG.md +1384 -0
- package/README.md +14 -9
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1412 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1612 -0
- package/docs/cli.md +917 -0
- package/docs/compatibility.md +502 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +1161 -0
- package/docs/data-reference.md +1272 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +267 -0
- package/docs/quickstart-admin-api.md +211 -0
- package/docs/quickstart-app.md +271 -0
- package/docs/quickstart-functions.md +207 -0
- package/docs/quickstart-theme.md +177 -0
- package/docs/storefront.md +251 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +18 -9
|
@@ -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,267 @@
|
|
|
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>` the first time it renders, and ends with its key:
|
|
267
|
+
the renders after it (`<id>-1`, `<id>-2`, as on Shopify) count under it.
|
|
@@ -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.
|