@proveanything/smartlinks 2.0.56 → 2.0.58

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,241 @@
1
+ # Integration connectors
2
+
3
+ A **connector** is an app that knows one external provider (a PIM, a content network, a shop
4
+ platform…). It is installed on a collection like any app, versioned and released like any app, and
5
+ it adds a manifest `connector` block plus a handful of [server functions](server-functions.md) the
6
+ integration engine and the integrations console call by convention.
7
+
8
+ Three layers, each with one job:
9
+
10
+ | Layer | Holds |
11
+ |---|---|
12
+ | **Engine** (the API) | [Integration flows](integrations.md), runs and their logs, triggers (events, schedule, Run now), the secret store, **the mapping** (each customer's field mappings and rules), generic transports (HTTP, Event Hub). No provider code. |
13
+ | **Integrations console** (a core app) | The one admin screen: installed connectors, connect forms, settings, the mapper with AI assist, flows, runs. |
14
+ | **Connector apps** | One per provider: an **adapter**. Talks to the provider and converts between its format and a clean, provider-neutral document. |
15
+
16
+ **A connector adapts; core maps.** A connector never holds a customer's mapping. It turns the
17
+ provider's records into provider-neutral documents (`shape`), or provider-ready payloads out of
18
+ documents core has already mapped (`serialize`). Every customer's mapping, routing and rules live in
19
+ core, so they work the same for every provider and can be replayed and traced. This is the line
20
+ SnapLogic (endpoint Snaps vs the Mapper), MuleSoft and Airbyte all draw.
21
+
22
+ A flow that only posts JSON to a webhook or an Event Hub needs no connector; it uses the engine's
23
+ generic transports (see [integrations.md](integrations.md)).
24
+
25
+ **No AI at run time.** The AI helps an admin *author* a mapping in the console (from the connector's
26
+ target schema plus real sample records); a person approves it, and it is saved as data and runs
27
+ deterministically. A connector's `shape` and `serialize` must be deterministic too.
28
+
29
+ ---
30
+
31
+ ## The manifest block
32
+
33
+ ```jsonc
34
+ // app.manifest.json
35
+ "connector": {
36
+ "provider": "oneworldsync",
37
+ "title": "1WorldSync",
38
+ "directions": ["inbound"],
39
+ "entities": ["product"],
40
+ "connection": {
41
+ "fields": [
42
+ // Optional: SmartLinks' own credentials are the default; a customer may enter theirs.
43
+ { "name": "appId", "label": "App ID", "secret": "oneworldsync-app-id" },
44
+ { "name": "secretKey", "label": "Secret key", "secret": "oneworldsync-secret-key" },
45
+ { "name": "gln", "label": "GLN", "required": true },
46
+ { "name": "environment", "label": "Environment", "type": "select",
47
+ "options": [ { "value": "preprod", "label": "Pre-production" }, { "value": "production", "label": "Production" } ] }
48
+ ]
49
+ },
50
+ "settings": { // JSON Schema → a form in the console
51
+ "type": "object",
52
+ "properties": { "targetMarket": { "type": "string", "default": "GB" } }
53
+ },
54
+ "presets": [
55
+ { "id": "import-products", "title": "Import products", "direction": "inbound", "entity": "product",
56
+ "flow": { "config": { "fieldMappings": [ /* a suggested mapping the customer can edit */ ] } } }
57
+ ],
58
+ "functions": { // names of this app's server functions
59
+ "testConnection": "testConnection",
60
+ "describeTarget": "describeTarget",
61
+ "sample": "sample",
62
+ "shape": "shape",
63
+ "fetch": "fetch"
64
+ },
65
+ "panels": [ { "id": "explorer", "title": "Explorer", "component": "Explorer" } ],
66
+ "aiGuide": "connector-ai-guide.md"
67
+ },
68
+ "functions": {
69
+ "files": { "js": { "umd": "dist/functions.umd.js" } },
70
+ "definitions": [
71
+ { "name": "testConnection", "trigger": { "type": "http", "method": "POST" },
72
+ "visibility": "admin", "authority": "collection",
73
+ "capabilities": ["network:content1-api.1worldsync.com", "secrets:oneworldsync-app-id", "secrets:oneworldsync-secret-key"] }
74
+ // …one definition per function named above
75
+ ]
76
+ }
77
+ ```
78
+
79
+ Types: `AppConnectorDeclaration` and friends in `@proveanything/smartlinks` (`src/types/appManifest.ts`).
80
+
81
+ | Field | Meaning |
82
+ |---|---|
83
+ | `provider` | Stable provider key (`syndigo`, `oneworldsync`). |
84
+ | `title` | Provider display name; defaults to the app name. |
85
+ | `directions` | `inbound` (provider → SmartLinks), `outbound` (SmartLinks → provider), or both. |
86
+ | `entities` | SmartLinks entities it maps, e.g. `["product"]`. |
87
+ | `connection.fields[]` | The console's **Connect** form. A field with `secret` is stored as this app's secret of that name on the collection (write-only; never returned to the browser). Other fields are saved in the installation's connection settings. |
88
+ | `settings` | JSON Schema for installation settings; the console renders it as a form. |
89
+ | `presets[]` | Ready-made flows ("Products → provider"). A preset's `flow.config.fieldMappings` is a **suggested** mapping: the connector's knowledge of the provider, which the customer accepts or edits in core. |
90
+ | `functions` | Which of this app's server functions implement each step (below). Fill in only what applies. |
91
+ | `panels[]` | Provider-specific screens the console mounts (an API explorer, a status page). |
92
+ | `aiGuide` | A guide the mapping assistant reads: the provider's quirks, required fields, good defaults. |
93
+
94
+ ---
95
+
96
+ ## The functions
97
+
98
+ Each step names one of the app's server functions. Declare them as **http POST, `visibility:
99
+ "admin"`, `authority: "collection"`**: the engine calls them with no signed-in user (a product edit, a
100
+ schedule), and the console calls the same functions while an admin configures the flow. The input
101
+ arrives as `event.body`; return the result as JSON. Capabilities still cap what each one can do:
102
+ declare the provider's hosts (`network:<host>`) and the secrets it reads (`secrets:<name>`).
103
+
104
+ | Step | Called by | Input (`event.body`) | Returns |
105
+ |---|---|---|---|
106
+ | `testConnection` | Connect form, **Test** | `{ installation }` | `ConnectorTestResult` `{ ok, message }` |
107
+ | `describeTarget` | Mapper opens | `{ installation }` | `ConnectorTargetSchema` `{ fields: [{ path, label, type, required, enum }] }`: the provider's fields *for this customer*. Outbound mappings target these paths. |
108
+ | `sample` | Mapper, AI assist, dry run | `{ installation, limit }` | `ConnectorRecords` `{ records }`: real provider records |
109
+ | `fetch` | Every inbound run | `ConnectorFetchInput` `{ installation, cursor, limit }` | `ConnectorRecords` `{ records, nextCursor }`: one page per call |
110
+ | `shape` | Every inbound run (and sample dry runs) | `ConnectorShapeInput` `{ installation, records }` | `ConnectorShapeResult` `{ documents, skipped }`: one provider-neutral document per record, same order; `null` skips one |
111
+ | `serialize` | Every outbound run | `ConnectorSerializeInput` `{ installation, documents, eventType? }`: documents core mapped to `describeTarget` paths; a deletion arrives as `{ id, _deleted: true }` | `ConnectorSerializeResult` `{ payloads, skipped }`: one provider payload per document, same order; `null` skips one |
112
+ | `send` | Every outbound run | `ConnectorSendInput` `{ installation, payloads }` | `ConnectorSendResult` `{ results: [{ index, ok, status, error, externalId }] }` |
113
+
114
+ **How a run flows:**
115
+ - **Inbound:** `fetch` (paged) → `shape` → core maps the documents with the flow's mapping → upsert.
116
+ - **Outbound:** gather the SmartLinks records → core maps them to the provider's fields → `serialize`
117
+ → `send` (or a generic transport).
118
+
119
+ **What happens when a step is left out:**
120
+ - **No `shape`:** core maps the provider records as they come.
121
+ - **No `serialize`:** the mapped documents are sent as they are.
122
+ - **No `send`:** the flow's own transport is used: HTTP (`config.connection`), or Azure Event Hub with
123
+ `config.transport: 'eventhub'` and `config.eventHub`.
124
+ - **No `describeTarget`:** the mapper offers free-text target paths.
125
+
126
+ **What `shape` should produce:** plain, predictable JSON a person can map without reading the
127
+ provider's documentation. Flatten the provider's envelopes, name things plainly, keep codes alongside
128
+ their meanings, and put repeated sections (ingredients, allergens, nutrients, images) in arrays. The
129
+ same provider record always gives the same document.
130
+
131
+ ### `installation`
132
+
133
+ Every step receives the installation it serves:
134
+
135
+ ```ts
136
+ interface ConnectorInstallation {
137
+ collectionId: string
138
+ appId: string // the connector app
139
+ flowId?: string | null // when called for a flow
140
+ connection: Record<string, unknown> // non-secret Connect-form fields
141
+ settings: Record<string, unknown> // installation settings, with the flow's overrides on top
142
+ }
143
+ ```
144
+
145
+ Secrets are never in it. Read them in the function with `ctx.secrets.get('<name>')`, which needs the
146
+ matching `secrets:<name>` capability. A secret the customer set on their collection wins; otherwise the
147
+ connector's own app-level secret of that name (set in Forge) is used, so a connector can ship with
148
+ SmartLinks' credentials as the default.
149
+
150
+ ### Example: `fetch` and `shape`
151
+
152
+ ```ts
153
+ // src/functions/index.ts (the app's functions bundle)
154
+ import type { ConnectorFetchInput, ConnectorRecords, ConnectorShapeInput, ConnectorShapeResult } from '@proveanything/smartlinks'
155
+
156
+ export async function fetch(ctx: any, event: any): Promise<ConnectorRecords> {
157
+ const { installation, cursor, limit = 50 } = event.body as ConnectorFetchInput
158
+ const appId = await ctx.secrets.get('provider-app-id')
159
+ const key = await ctx.secrets.get('provider-secret-key')
160
+ const res = await ctx.fetch(buildSignedUrl(installation, { cursor, limit }, appId, key))
161
+ const page = await res.json()
162
+ return { records: page.items, nextCursor: page.next ?? null }
163
+ }
164
+
165
+ export async function shape(_ctx: any, event: any): Promise<ConnectorShapeResult> {
166
+ const { records } = event.body as ConnectorShapeInput
167
+ return {
168
+ documents: records.map((r: any) => ({
169
+ gtin: r.item?.gtin,
170
+ name: r.item?.tradeItemDescription?.[0]?.value,
171
+ brand: r.item?.brandName,
172
+ ingredients: (r.item?.ingredients ?? []).map((i: any) => ({ name: i.name, percent: i.percentage })),
173
+ })),
174
+ }
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Where configuration lives
181
+
182
+ | What | Where |
183
+ |---|---|
184
+ | Connect-form values (non-secret), installation settings | The connector app's config on the collection, in its admin-only zone (`admin.connector.connection`, `admin.connector.settings`). |
185
+ | Credentials | The connector app's secrets, by the names its fields declare (the customer's on their collection, else the connector's default). |
186
+ | One flow's use of the connector | The flow: `config.connector = { appId, preset?, settings? }` (per-flow settings override the installation's), plus `config.fieldMappings`, which core runs. |
187
+
188
+ ```ts
189
+ await integrations.createFlow(collectionId, {
190
+ appId: 'core-integrations',
191
+ direction: 'inbound',
192
+ name: 'Import products from 1WorldSync',
193
+ status: 'active',
194
+ schedule: '0 * * * *',
195
+ targetEntity: 'product',
196
+ config: {
197
+ connector: { appId: 'oneworldsync', preset: 'import-products' },
198
+ // Maps the connector's shaped documents (not the raw provider records) onto the product.
199
+ fieldMappings: [ { targetPath: 'name', sourcePath: 'name', transformType: 'direct' } ],
200
+ },
201
+ })
202
+ ```
203
+
204
+ ---
205
+
206
+ ## From the console
207
+
208
+ ```ts
209
+ import { integrations, secrets } from '@proveanything/smartlinks'
210
+
211
+ const { connectors } = await integrations.listConnectors(collectionId)
212
+ // → [{ appId, name, version, connector, installation: { connection, settings }, secrets: { 'provider-key': { set, hint } } }]
213
+
214
+ await secrets.put(collectionId, 'provider-key', value, { appId }) // a secret field
215
+ await integrations.saveConnectorInstallation(collectionId, appId, { connection: { environment: 'production' } })
216
+ const test = await integrations.runConnectorStep(collectionId, appId, 'testConnection')
217
+ const target = await integrations.runConnectorStep(collectionId, appId, 'describeTarget')
218
+ const { records } = await integrations.runConnectorStep(collectionId, appId, 'sample', { limit: 5 })
219
+ const shaped = await integrations.runConnectorStep(collectionId, appId, 'shape', { records }) // dry run
220
+ const wire = await integrations.runConnectorStep(collectionId, appId, 'serialize', { documents }) // dry run
221
+ ```
222
+
223
+ `send` and `fetch` only run inside a flow. A flow run's summary carries `sent` (outbound) or
224
+ `created` / `updated` / `skipped` (inbound), and every run, inbound or outbound, is in the run history.
225
+
226
+ ---
227
+
228
+ ## Panels
229
+
230
+ A connector can contribute screens to the integrations console (an API explorer, say). Each
231
+ `panels[]` entry names a component exported from the app's widgets bundle; the console mounts it
232
+ host-side and passes it the collection and the installation. Panels are admin-only and call the
233
+ connector's own functions.
234
+
235
+ ---
236
+
237
+ ## Who can build connectors
238
+
239
+ First-party connectors run in-process, like any app server function. Third-party connectors will run
240
+ in the isolated runner (see [server-functions.md](server-functions.md)) once that opens up; the contract
241
+ does not change.
@@ -15,6 +15,10 @@ Flows are triggered three ways, all converging on the same executor:
15
15
  automatically when that entity changes.
16
16
  - **schedule** — a flow carrying a cron/interval `schedule` is run by the scan job. *(next)*
17
17
 
18
+ Provider-specific logic (a provider's payload format, its API, its connect form) lives in a
19
+ **connector app**, not in the engine: see **[Connectors](connectors.md)**. A flow uses one with
20
+ `config.connector = { appId }`.
21
+
18
22
  Credentials are **never** stored on the flow. The connection holds an opaque
19
23
  `credentialRef` into the **sealed-secret store** (`secrets` namespace); the value is sealed
20
24
  at rest and resolved server-side only, at execution.
package/docs/overview.md CHANGED
@@ -94,6 +94,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
94
94
  | **Lots** | `docs/lots.md` | Cross-SKU production "Lot" entity |
95
95
  | **Loyalty** | `docs/loyalty.md` | Points, members, earning rules |
96
96
  | **Integrations** | `docs/integrations.md` | Inbound/outbound external-system integration flows |
97
+ | **Connectors** | `docs/connectors.md` | Connector apps: the manifest `connector` block + the functions the integration engine calls |
97
98
  | **Item Context** | `docs/item-context.md` | The `itemContext` container prop (serial / NFC authenticity context) |
98
99
  | **Native Facade** | `docs/native-facade.md` | `host.native` / `SL.native` device-capability facade |
99
100
  | **Product Facets** | `docs/PRODUCT_FACETS_SDK.md` | Facet API reference (admin + public endpoints, types) |
package/docs/site-seo.md CHANGED
@@ -1,68 +1,69 @@
1
- # Websites: search engines and AI crawlers (SEO + GEO)
2
-
3
- For apps served as websites (an app site at `<name>.smartlinks.host` or a custom domain). Hub sites
4
- get all of this from Hub.
5
-
6
- ## What the platform does for you
7
-
8
- - **`/robots.txt`, `/sitemap.xml`, `/llms.txt`** are generated on the site's own address. Don't ship
9
- `robots.txt` or `sitemap.xml`: one build serves many sites, and sitemap URLs must be absolute on
10
- the site's host, which the build can't know.
11
- - **List every page** in `public/sitemap-paths.txt`, one `<path> [Title]` per line, in menu order
12
- (`/ Home`, `/menu Menu`, `/book Book a table`). They go into the sitemap and `llms.txt` (titled)
13
- on the right host, and Forge's preview uses the same list as its page menu. Keep it in step with
14
- the router. Pre-rendered pages (`about/index.html`) are found automatically.
15
- - **Canonical address.** A site can answer on its automatic address, a chosen name and a custom
16
- domain. Every page gets `Link: <https://{canonical}{path}>; rel="canonical"`, so search engines
17
- consolidate on one: the custom domain, else the chosen name, else the automatic address. Don't set
18
- your own canonical unless a page has a different canonical page.
19
- - **Previews stay out of search.** A sandbox collection's sites, and any test build (dev, alpha,
20
- beta), are served with `X-Robots-Tag: noindex` and a `robots.txt` that disallows everything,
21
- whatever the build ships.
22
-
23
- ## What you do: `SL.seo` and `SL.site`
24
-
25
- ```ts
26
- import * as SL from '@proveanything/smartlinks'
27
-
28
- // Per route, from its data — on every route change:
29
- SL.seo.head({
30
- title: `${product.name} — ${brand}`,
31
- description: product.description, // ~150 characters, says what the page is
32
- image: product.heroImage?.url, // absolute; used for link previews
33
- type: 'product', // 'website' | 'article' | 'product'
34
- })
35
-
36
- // Structured data (schema.org JSON-LD). The id names the block, so calling again replaces it:
37
- SL.seo.jsonLd('page', SL.seo.schema.product(product, { url: location.href, brand }))
38
- SL.seo.jsonLd('faq', SL.seo.schema.faqPage(faqs.map((f) => ({ question: f.q, answer: f.a }))))
39
- SL.seo.jsonLd('org', SL.seo.schema.organization(collection))
40
-
41
- // When the page's data has rendered:
42
- SL.site.ready()
43
- ```
44
-
45
- | Builder | For |
46
- |---|---|
47
- | `schema.product(product, { url, brand, offer, rating })` | product pages (`offer` only when it's really sold) |
48
- | `schema.faqPage([{ question, answer }])` | FAQs: rich results, and the answer-shaped content AI search cites |
49
- | `schema.organization(collection)` | the brand behind the site (home page) |
50
- | `schema.localBusiness(collection, { type, address, telephone, openingHours })` | a business with a place (`type: 'Florist'`, `'Restaurant'`…) |
51
- | `schema.breadcrumbs([{ name, url }])` | nested pages |
52
- | `schema.article({ headline, datePublished, … })` | posts, guides, news |
53
-
54
- `seo.head` removes tags a previous route set and the next one doesn't. Both are no-ops without a
55
- `document` (SSR, tests).
56
-
57
- `site.ready()` tells the platform's page renderer the page is complete, so it can snapshot the
58
- content for crawlers that don't run JavaScript. Without it, the renderer waits for the network to go
59
- quiet.
60
-
61
- ## Content that gets found
62
-
63
- - **Content in the page, not behind interaction.** FAQ answers in `<details>` are fine; answers
64
- fetched only when a question is clicked aren't seen.
65
- - **One `<h1>` per page**, a logical heading order, `alt` text on content images.
66
- - **Answer-shaped writing.** A short summary near the top, question-style headings where they fit.
67
- This is what AI assistants quote.
68
- - **Real links** (`<a href>`) between pages, never hash routes.
1
+ # Websites: search engines and AI crawlers (SEO + GEO)
2
+
3
+ For apps served as websites (an app site at `<name>.smartlinks.host` or a custom domain). Hub sites
4
+ get all of this from Hub.
5
+
6
+ ## What the platform does for you
7
+
8
+ - **Every page is server-rendered.** A website runs on the SmartLinks site runtime (see
9
+ [websites.md](websites.md)): the first response for any URL is the finished HTML, with its title,
10
+ description, link-preview image, structured data and real status codes (404s are 404s). Search
11
+ engines, AI crawlers and link previews read it without running JavaScript.
12
+ - **`/robots.txt`, `/sitemap.xml`, `/llms.txt`** are generated on the site's own address. Don't ship
13
+ `robots.txt` or `sitemap.xml`: one build serves many sites, and sitemap URLs must be absolute on
14
+ the site's host, which the build can't know.
15
+ - **List every page** in `public/sitemap-paths.txt`, one `<path> [Title]` per line, in menu order
16
+ (`/ Home`, `/menu Menu`, `/book Book a table`). They go into the sitemap and `llms.txt` (titled)
17
+ on the right host, and Forge's preview uses the same list as its page menu. Keep it in step with
18
+ the router.
19
+ - **Canonical address.** A site can answer on its automatic address, a chosen name and a custom
20
+ domain. Every page gets `Link: <https://{canonical}{path}>; rel="canonical"`, so search engines
21
+ consolidate on one: the custom domain, else the chosen name, else the automatic address. Don't set
22
+ your own canonical unless a page has a different canonical page.
23
+ - **Previews stay out of search.** A sandbox collection's sites, and any test build (dev, alpha,
24
+ beta), are served with `X-Robots-Tag: noindex` and a `robots.txt` that disallows everything,
25
+ whatever the build ships.
26
+
27
+ ## What you do: `SL.seo` and `SL.site`
28
+
29
+ ```ts
30
+ import * as SL from '@proveanything/smartlinks'
31
+
32
+ // Per route, from its data — on every route change:
33
+ SL.seo.head({
34
+ title: `${product.name} — ${brand}`,
35
+ description: product.description, // ~150 characters, says what the page is
36
+ image: product.heroImage?.url, // absolute; used for link previews
37
+ type: 'product', // 'website' | 'article' | 'product'
38
+ })
39
+
40
+ // Structured data (schema.org JSON-LD). The id names the block, so calling again replaces it:
41
+ SL.seo.jsonLd('page', SL.seo.schema.product(product, { url: location.href, brand }))
42
+ SL.seo.jsonLd('faq', SL.seo.schema.faqPage(faqs.map((f) => ({ question: f.q, answer: f.a }))))
43
+ SL.seo.jsonLd('org', SL.seo.schema.organization(collection))
44
+
45
+ ```
46
+
47
+ | Builder | For |
48
+ |---|---|
49
+ | `schema.product(product, { url, brand, offer, rating })` | product pages (`offer` only when it's really sold) |
50
+ | `schema.faqPage([{ question, answer }])` | FAQs: rich results, and the answer-shaped content AI search cites |
51
+ | `schema.organization(collection)` | the brand behind the site (home page) |
52
+ | `schema.localBusiness(collection, { type, address, telephone, openingHours })` | a business with a place (`type: 'Florist'`, `'Restaurant'`…) |
53
+ | `schema.breadcrumbs([{ name, url }])` | nested pages |
54
+ | `schema.article({ headline, datePublished, … })` | posts, guides, news |
55
+
56
+ `seo.head` removes tags a previous route set and the next one doesn't. Both are no-ops without a
57
+ `document`. In a server-rendered site, prefer the router's own head support (TanStack Start's route
58
+ `head()`), so the tags are in the server's HTML; use `SL.seo.schema.*` to build the JSON-LD for it.
59
+
60
+ `SL.site.ready()` is no longer needed: pages arrive finished from the server.
61
+
62
+ ## Content that gets found
63
+
64
+ - **Content in the page, not behind interaction.** FAQ answers in `<details>` are fine; answers
65
+ fetched only when a question is clicked aren't seen.
66
+ - **One `<h1>` per page**, a logical heading order, `alt` text on content images.
67
+ - **Answer-shaped writing.** A short summary near the top, question-style headings where they fit.
68
+ This is what AI assistants quote.
69
+ - **Real links** (`<a href>`) between pages, never hash routes.
@@ -0,0 +1,91 @@
1
+ # Websites: server-rendered on the SmartLinks site runtime
2
+
3
+ A website built on SmartLinks is an app whose public side is a **server-rendered site** at its own
4
+ address: an automatic `*.smartlinks.host` name, a chosen name, or the business's own domain. Every
5
+ page arrives as finished HTML, so search engines, AI crawlers and link previews see the real
6
+ content, and visitors see it before any JavaScript runs.
7
+
8
+ ## How a request is served
9
+
10
+ ```
11
+ visitor / crawler / agent
12
+ └─► SmartLinks site router (edge)
13
+ ├─ /robots.txt, /sitemap.xml, /llms.txt made by the platform (site-seo.md)
14
+ ├─ a file in your build (has an extension) served from the build, cached at the edge
15
+ └─ anything else your site's server renders it
16
+ • runs in an isolated worker, close to the SmartLinks API
17
+ • may call the SmartLinks API and the hosts you declare, nothing else
18
+ • request header x-sl-site says which site it is (SL.site.fromRequest)
19
+ ```
20
+
21
+ The router also adds the canonical or noindex header, the security headers, and
22
+ `window.__SMARTLINKS_SITE__` to every HTML page (read in the browser with `SL.site.current()` or
23
+ `SL.readContext()`).
24
+
25
+ ## The build
26
+
27
+ The website template (TanStack Start) produces this; you don't write it by hand.
28
+
29
+ ```
30
+ dist/
31
+ ├── index.html? / assets/… the client build (hashed files: cached for a year)
32
+ ├── sitemap-paths.txt your pages, "<path> [Title]" per line (site-seo.md)
33
+ ├── _server/ the server bundle: one ES module entry + its chunks
34
+ │ └── index.mjs exports default { fetch(request, env, ctx) } (WinterTC)
35
+ └── app.manifest.json
36
+ ```
37
+
38
+ Manifest:
39
+
40
+ ```jsonc
41
+ "site": {
42
+ "server": {
43
+ "main": "_server/index.mjs", // the server entry, inside the build
44
+ "compatibilityFlags": ["nodejs_compat"] // allowed: nodejs_compat, nodejs_compat_v2, nodejs_als
45
+ },
46
+ "network": ["api.example-weather.com"] // other hosts the server may call (optional)
47
+ }
48
+ ```
49
+
50
+ On publish, the server folder becomes the site's server for that channel. Promote copies the same
51
+ bytes. `_server/` is never served to visitors.
52
+
53
+ ## The server's rules
54
+
55
+ - **Which site:** `const site = SL.site.fromRequest(request)` gives `{ host, collectionId, appId,
56
+ channel, canonicalHost }`. One build serves every site it's installed on, so always take the
57
+ collection from here, never from a constant.
58
+ - **Data:** call the SmartLinks API with the SDK as you would in the browser. The server reads what
59
+ an anonymous visitor could read on that site, and only that collection. It holds no keys and no
60
+ signed-in user.
61
+ - **Writes** (contact forms, entries, sign-ups) go to your app's **server functions**
62
+ (server-functions.md), called from the browser as today. Their capabilities, secrets and limits
63
+ apply.
64
+ - **Other hosts:** only those in `site.network`. Anything else is refused (`403 Blocked`).
65
+ - **Limits per request:** a CPU-time budget and a cap on outgoing calls. A page that needs more is
66
+ doing work that belongs in a server function or a scheduled job.
67
+ - **No secrets** in the server bundle and no Node built-ins beyond `nodejs_compat`.
68
+
69
+ ## Caching
70
+
71
+ The router keeps a rendered page at the edge when your response says it may:
72
+
73
+ | Your response | At the edge |
74
+ |---|---|
75
+ | `Cache-Control: public, s-maxage=300` (the template's default for pages) | kept up to 5 minutes |
76
+ | `private`, `no-store`, `no-cache`, or a `Set-Cookie` | never kept |
77
+ | no `Cache-Control` | never kept |
78
+
79
+ Cached pages are keyed by the release and the collection's **content generation**: when products,
80
+ public app records, app settings or the collection change, the generation moves and every page of
81
+ the collection's sites re-renders on its next visit (within about 30 seconds). Publishing a release
82
+ does the same. Tracking parameters (`utm_*`, `gclid`, `fbclid`…) don't create separate entries.
83
+
84
+ Keep pages the same for every visitor. Anything personal (a signed-in owner's products, a basket)
85
+ renders in the browser after load.
86
+
87
+ ## Locally
88
+
89
+ `npm run dev` runs the site with your dev collection (`.env`: `VITE_SL_COLLECTION_ID`), because
90
+ there's no router to say which site it is: `SL.site.fromRequest()` is null, so fall back to it.
91
+ Forge's preview runs the same server code the site runtime does.