@xanots/sdk 0.0.3 → 0.0.4
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 +80 -0
- package/README.md +34 -1
- package/dist/agent-file-refresh-IJ7GN2FJ.js +17 -0
- package/dist/bin.js +12 -153
- package/dist/{capture-HUV5BNTC.js → capture-7PO6SGB4.js} +2 -2
- package/dist/{chunk-CMZLPTGW.js → chunk-7SISR3CS.js} +2 -2
- package/dist/{chunk-7DKX2SPN.js → chunk-ACQCK4DT.js} +4 -4
- package/dist/{chunk-XQ22GLYS.js → chunk-AJ7JQ4OU.js} +5 -5
- package/dist/{chunk-ZO3HJOCJ.js → chunk-ALNQCWAW.js} +2 -2
- package/dist/{chunk-55VHDNW5.js → chunk-BPEEGJJA.js} +108 -3
- package/dist/{chunk-U3G2UW65.js → chunk-CYJ7R3AD.js} +3 -3
- package/dist/{agent-file-refresh-6M5M7T24.js → chunk-D6OGU2AI.js} +5 -6
- package/dist/{chunk-3NFUWXOC.js → chunk-DUC3PXDG.js} +142 -45
- package/dist/{chunk-HQ2CBRVI.js → chunk-EAIR6VUL.js} +10 -8
- package/dist/chunk-ERQZFWIW.js +22 -0
- package/dist/{chunk-TQUO2OXY.js → chunk-FN4OQT2J.js} +4 -4
- package/dist/{chunk-6USV65XA.js → chunk-KGNJM4LN.js} +2 -2
- package/dist/{chunk-HJPTWBLH.js → chunk-MFIHIS6B.js} +2 -2
- package/dist/{chunk-F6JCFRKO.js → chunk-PXXLBXOP.js} +2 -2
- package/dist/{chunk-YZ4GU6F5.js → chunk-QUUB7HYK.js} +5 -838
- package/dist/{chunk-W2G2WPTB.js → chunk-SWIXWJIY.js} +3 -3
- package/dist/{chunk-76QBEIGO.js → chunk-T4XPCJRF.js} +3 -2
- package/dist/chunk-T6N3VMMO.js +173 -0
- package/dist/{chunk-2V4YE6QC.js → chunk-VKD3EBKG.js} +112 -21
- package/dist/{chunk-BT2CSEC5.js → chunk-VWGJQTNA.js} +3 -3
- package/dist/chunk-WGDAOOXG.js +845 -0
- package/dist/{chunk-4YMD2OOZ.js → chunk-XHEXOES3.js} +1 -1
- package/dist/{chunk-4HT3BNZ7.js → chunk-YUBJLB6G.js} +10 -2
- package/dist/{chunk-5L4X5LS6.js → chunk-YUPQOLFX.js} +77 -2
- package/dist/{chunk-DYCLVQXW.js → chunk-YYSJAAJM.js} +25 -1
- package/dist/{chunk-P3TTMWUP.js → chunk-ZOYMZZ3S.js} +8 -1
- package/dist/chunk-ZSYZTGJH.js +81 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +8 -8
- package/dist/{codegen-command-FIADGXRN.js → codegen-command-Z7WQMOJ7.js} +21 -20
- package/dist/{completion-BKAFCZBE.js → completion-4GYN752B.js} +2 -2
- package/dist/{deploy-command-4R6BYC6G.js → deploy-command-M2S4HHLC.js} +26 -26
- package/dist/{env-target-XWS2ZZ2Y.js → env-target-GSFCYHKZ.js} +6 -6
- package/dist/{ephemeral-command-JL4TPIRQ.js → ephemeral-command-6WJW7LP6.js} +24 -24
- package/dist/index.d.ts +2 -2
- package/dist/index.js +13 -7
- package/dist/init-command-GJUPWTKA.js +30 -0
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +50 -5
- package/dist/{io-AMIKRLPC.js → io-7VIA5SON.js} +3 -3
- package/dist/{live-diff-RXSJCVJ7.js → live-diff-FP4SFNT4.js} +2 -2
- package/dist/{lock-3CVKALKT.js → lock-KXOJIGCG.js} +2 -2
- package/dist/{lock-commands-XOYS75YQ.js → lock-commands-7IH7RSSP.js} +9 -9
- package/dist/{login-command-ACJF6KWQ.js → login-command-QM5SBBI2.js} +2 -2
- package/dist/{logout-command-MX3MJS5U.js → logout-command-THPASOHM.js} +2 -2
- package/dist/{loop-SAWAOUFO.js → loop-7SAIGRCZ.js} +3 -3
- package/dist/{marketplace-command-UVV3XAOL.js → marketplace-command-463DT7O7.js} +11 -25
- package/dist/meta-client-OW5WKWW7.js +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.js +18 -12
- package/dist/onboard-command-EHOHKQQU.js +36 -0
- package/dist/{profile-command-SWJ3SPKR.js → profile-command-EUWPVWED.js} +6 -6
- package/dist/{release-command-HZUY2XZX.js → release-command-M4CVNTWL.js} +25 -25
- package/dist/{sandbox-details-command-HJE5SPVG.js → sandbox-details-command-BRII3QHG.js} +5 -5
- package/dist/{sandbox-export-command-QCJY4GMV.js → sandbox-export-command-PCZ5FL4U.js} +8 -8
- package/dist/scaffold.js +4 -2
- package/dist/{store-g45zwB33.d.ts → store-BJONDJoZ.d.ts} +171 -2
- package/dist/{test-command-72Y5S22H.js → test-command-U4G5Y3UM.js} +10 -10
- package/dist/upgrade-command-CKJ4BS2P.js +177 -0
- package/dist/{validate-command-KDGH537H.js → validate-command-S5N37JRP.js} +12 -12
- package/dist/{workspace-command-ICSI6PKO.js → workspace-command-RALSEJSW.js} +28 -27
- package/dist/workspace-export-AJMGN3CQ.js +1 -1
- package/guides/README.md +30 -0
- package/guides/authoring.md +642 -0
- package/guides/cli.md +191 -0
- package/guides/codegen.md +83 -0
- package/guides/coverage.md +67 -0
- package/guides/deploying.md +340 -0
- package/guides/environment.md +132 -0
- package/guides/object-kinds.md +376 -0
- package/guides/project-structure.md +43 -0
- package/guides/scaffold.md +198 -0
- package/guides/typed-frontend.md +201 -0
- package/llms/kinds-knowledge.md +20 -0
- package/llms/object-kinds.md +1 -0
- package/llms-full.txt +25 -2
- package/llms.txt +3 -2
- package/manifest.json +26 -3
- package/package.json +5 -2
- package/dist/.build-fingerprint +0 -1
- package/dist/chunk-WUSKBXXD.js +0 -25
- package/dist/init-command-LOK75W64.js +0 -29
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# Object kinds
|
|
2
|
+
|
|
3
|
+
Every object kind XanoTS can author, and how to split a workspace across microservices.
|
|
4
|
+
|
|
5
|
+
## Object kinds
|
|
6
|
+
|
|
7
|
+
Every top-level Xano object is a registered kind with a factory and a `Xano.register*`
|
|
8
|
+
method: `defineFunction`, `table`, `query`, `apiGroup`, `tool`, `mcpServer`, `agent`,
|
|
9
|
+
`task`, `workflowTest`, `middleware`, `addon`, `realtimeServer`, `realtimeChannel`,
|
|
10
|
+
`realtimeMessage`, `knowledge`, `microservice` (its own section below), `workspaceConfig`,
|
|
11
|
+
and the seven trigger factories below. Signatures and payload keys are in
|
|
12
|
+
`llms/object-kinds.md` and `llms/triggers.md`; what follows is what
|
|
13
|
+
the types don't tell you.
|
|
14
|
+
|
|
15
|
+
**A knowledge item's body is a file, and `mode` is a running cost.** `knowledge()` is the
|
|
16
|
+
markdown a workspace's AI agents read before they act, and it is the one kind whose payload
|
|
17
|
+
is prose — so the body is named by path (`knowledgeFile("./runbook.md", import.meta.url)`)
|
|
18
|
+
rather than written as a string, and `refs: knowledgeDir(...)` ships a whole folder the
|
|
19
|
+
agent searches on demand. What the agent actually receives is decided by `type` and `mode`,
|
|
20
|
+
and nothing in the types warns you: an `agents.md` item is injected in full on every turn
|
|
21
|
+
whatever `mode` says, `mode: "always"` spends the body's whole length on every request, and
|
|
22
|
+
the default `mode: "auto"` sends only the name and `description` until a request matches.
|
|
23
|
+
Write that `description` to be matched against a request rather than as a title. Full shape
|
|
24
|
+
in `llms/kinds-knowledge.md`.
|
|
25
|
+
|
|
26
|
+
**Triggers take a callback stack.** `stack: (t) => [...]`, not the plain array every other
|
|
27
|
+
kind uses — because a trigger's inputs are **implied by its type** (fixed by Xano, not
|
|
28
|
+
editable) and injected automatically. So triggers take no `input` field, and the typed
|
|
29
|
+
handle `t` is the only way to read them (`response: (t) => ...` on response-bearing types).
|
|
30
|
+
The seven types are `tableTrigger`, `realtimeServerTrigger`, `realtimeChannelTrigger`,
|
|
31
|
+
`mcpServerTrigger`, `agentTrigger`, `workspaceTrigger`, and `errorTrigger`; they share one
|
|
32
|
+
stored envelope discriminated by `obj_type`.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
tableTrigger({
|
|
36
|
+
name: "on-user-insert",
|
|
37
|
+
table: users,
|
|
38
|
+
actions: { insert: true },
|
|
39
|
+
// Optional row filter, evaluated by the DATABASE before the stack runs — so it
|
|
40
|
+
// names the SQL pseudo-tables with col(), NOT the t handle. Rejected with
|
|
41
|
+
// `truncate`; insert cannot read OLD.*, delete cannot read NEW.*.
|
|
42
|
+
search: cmp(col("NEW.email"), "!=", c.text("")),
|
|
43
|
+
stack: (t) => [
|
|
44
|
+
// t.new("email") is typed to the row; t.action is the op; t.old is null (insert-only).
|
|
45
|
+
s.db.add({ table: auditLog, row: { email: t.new("email"), event: t.action } }),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**A workflow test is an end-to-end test, and its `datasource` is the trap.** `workflowTest`
|
|
51
|
+
takes no `input` and no `response` — it calls other objects and asserts on what they bind.
|
|
52
|
+
Leave `datasource` off: the default `""` runs against an **empty** datasource. Naming one
|
|
53
|
+
makes the engine **clone** that datasource before every run, so pointing a test at
|
|
54
|
+
production-sized data is slow enough to fail the run outright. `"live"` warns at compile
|
|
55
|
+
time; every other name is your call.
|
|
56
|
+
|
|
57
|
+
Empty means empty: **no `table({ seed })` rows exist while the test runs**, so every `db`
|
|
58
|
+
read misses unless the test creates what it needs first — typically a `defineFunction`
|
|
59
|
+
fixture the stack calls before anything else. A test written against a seeded row fails
|
|
60
|
+
with your own precondition message, which reads as a wrong id rather than an empty
|
|
61
|
+
database. A failing `s.api.call` is the other surprise: it **binds the error envelope**
|
|
62
|
+
(`{code, message}`) to its `as` and carries on rather than raising, so a later assertion
|
|
63
|
+
gets blamed for a call that failed several statements earlier — assert on `ref("r.code")`
|
|
64
|
+
when a call may fail. `llms/tests.md` carries the rest, including what `s.api.call` can and
|
|
65
|
+
cannot do about authentication.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
workflowTest({
|
|
69
|
+
name: "signup_works",
|
|
70
|
+
tags: ["smoke"],
|
|
71
|
+
// datasource omitted on purpose — "" is an EMPTY datasource, not "no datasource".
|
|
72
|
+
stack: [
|
|
73
|
+
s.function.call({ fn: createUser, input: { email: "a@b.c" }, as: "created" }),
|
|
74
|
+
s.expect.to_be_defined({ expr: ref("created") }),
|
|
75
|
+
s.expect.to_equal({ expr: ref("created.status"), value: c.text("ok") }),
|
|
76
|
+
// A regex assertion takes a PATTERN, so build it with `c.regex(...)`.
|
|
77
|
+
s.expect.to_match({ expr: ref("created.id"), value: c.regex("^usr_[a-z0-9]+$") }),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Saved unit tests are a different thing, and they hang off the object.** A `query`,
|
|
83
|
+
`defineFunction` or `middleware` takes a `tests` array — the tests the Xano editor shows.
|
|
84
|
+
Each is a named set of inputs run against *that* object, asserted with the top-level
|
|
85
|
+
`expect.*` helpers. (`s.expect.*` — see [Authoring reference](authoring.md) — builds a
|
|
86
|
+
*statement* for a workflow-test stack;
|
|
87
|
+
`expect.*` builds a record stored on a test. They are not interchangeable, and the types
|
|
88
|
+
enforce it.) Any statement in the stack can return a **mock** instead of doing its work,
|
|
89
|
+
keyed by test name — and only while that named test runs, so a mock changes nothing about
|
|
90
|
+
a normal request.
|
|
91
|
+
|
|
92
|
+
A unit test's `datasource` is the same trap as a workflow test's, with the same default:
|
|
93
|
+
`""` is an **empty** datasource, so **no `table({ seed })` rows are visible while a unit
|
|
94
|
+
test runs** either. Every `db` read misses, and an assertion on the first row fails against
|
|
95
|
+
a deployment whose endpoint returns those rows over HTTP a second later. Create what the
|
|
96
|
+
test needs inside the run — a `defineFunction` fixture the stack calls first — or `mock`
|
|
97
|
+
the read.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
query({
|
|
101
|
+
name: "score",
|
|
102
|
+
verb: "POST",
|
|
103
|
+
input: { score: input.int({ required: true }) },
|
|
104
|
+
tests: [
|
|
105
|
+
{
|
|
106
|
+
name: "adds one",
|
|
107
|
+
input: { score: c.int(1) },
|
|
108
|
+
// Subject first — argument order is the assertion.
|
|
109
|
+
expect: [expect.to_equal(resp(), c.int(2))],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
stack: [
|
|
113
|
+
// Returns 2 while "adds one" runs; does nothing on a real request. A name
|
|
114
|
+
// no test declares throws at compile time.
|
|
115
|
+
s.set_var("total", c.expression("$input.score + 1"), {
|
|
116
|
+
mock: { "adds one": c.int(2) },
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
119
|
+
response: ref("total"),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A pull brings tests back, along with a query's saved request/response `example`. The one
|
|
124
|
+
thing it withholds is a test's auth `token` — that is an expiring credential rather than
|
|
125
|
+
authored configuration, so `xanots codegen` reports it as a deliberate omission instead of
|
|
126
|
+
writing it into a committed tree.
|
|
127
|
+
|
|
128
|
+
**Four of the `Run …` statements only run inside a workflow test.** `s.api.call`,
|
|
129
|
+
`s.task.call`, `s.trigger.call` and `s.workflow_test.call` are resolved by the engine at
|
|
130
|
+
run time, and outside a `workflowTest` stack it cannot reach the target — so one of them in
|
|
131
|
+
a query, function or task type-checks, exports, imports and **deploys clean**, then answers
|
|
132
|
+
the first real request
|
|
133
|
+
with `ERROR_FATAL: <Type> does not exist`. It is not per host kind: the same call fails
|
|
134
|
+
identically from a function that a query runs. `xanots export` refuses them outside a
|
|
135
|
+
workflow test. `s.function.call`, `s.tool.call`, `s.middleware.call` and `s.addon.call` run
|
|
136
|
+
from any stack, as does `s.function.run` — the ordinary way to invoke a function. To share
|
|
137
|
+
logic between two endpoints, put it in a `defineFunction` and `s.function.run` it from both.
|
|
138
|
+
|
|
139
|
+
**`s.expect.to_match` takes a regex PATTERN, not text.** The engine runs it through PHP
|
|
140
|
+
`preg_*`, which reads the first character as the delimiter — so a `c.text("^usr_.*$")` there
|
|
141
|
+
is a pattern the engine cannot run, and the assertion fails against the very string it was
|
|
142
|
+
written for. `c.regex("^usr_.*$")` (or `c.regex(/^usr_.*$/)`) wraps and escapes it; a bare
|
|
143
|
+
`c.text` pattern is refused at compile time and pointed here. A `ref`/`inp` pattern, whose
|
|
144
|
+
text isn't visible to the check, is passed through untouched.
|
|
145
|
+
|
|
146
|
+
**Realtime** — the only three-level containment chain in the SDK: `realtimeServer` owns
|
|
147
|
+
`realtimeChannel`s, which own `realtimeMessage` handlers (a message is the realtime
|
|
148
|
+
analogue of a query — its own typed payload and stack). Pass the **handle**, not a name: a
|
|
149
|
+
channel path is unique only within its server. A channel's `input` types its **path** params
|
|
150
|
+
(`rooms/{room_id}`); a message's `input` types the message **payload**. A server is off
|
|
151
|
+
until `enabled: true`.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const chat = realtimeServer({ name: "chat", enabled: true });
|
|
155
|
+
|
|
156
|
+
const room = realtimeChannel({
|
|
157
|
+
name: "rooms/{room_id}", // `input` types the PATH params
|
|
158
|
+
server: chat,
|
|
159
|
+
input: { room_id: input.int() },
|
|
160
|
+
publish: { who: "authenticated" },
|
|
161
|
+
conversation: { enabled: true, limit: 50 }, // client-visible transcript
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
realtimeMessage({
|
|
165
|
+
name: "send", // `input` types the message PAYLOAD
|
|
166
|
+
channel: room, // the handle carries the server too
|
|
167
|
+
input: { body: input.text({ required: true }) },
|
|
168
|
+
deliverTo: "channel", // or "sender" (request/response) / "others"
|
|
169
|
+
stack: [s.debug.log({ value: inp("body") })],
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The client side is derived too, the same way `query().getPath()` works — `chat.getUrl(BASE)`
|
|
174
|
+
builds the socket URL (`wss://…/ws/<canonical>`, with a tenant base URL translated into the
|
|
175
|
+
socket's `/ws/<tenant>:<canonical>` form) and `room.getChannel({ room_id: 42 })` builds the
|
|
176
|
+
path a client joins. Both throw rather than guess. In a **browser bundle**, reach for the
|
|
177
|
+
generated manifest's `socketUrl`/`channelPath` instead — same addresses, same checks, without
|
|
178
|
+
importing the defs (see
|
|
179
|
+
[The payoff: a type-safe frontend, for free](#the-payoff-a-type-safe-frontend-for-free)).
|
|
180
|
+
|
|
181
|
+
Five traps account for most realtime bugs. The full wire protocol — every server frame,
|
|
182
|
+
the presence roster shape, the at-least-once client contract — is in `llms/kinds-realtime.md`.
|
|
183
|
+
|
|
184
|
+
- **An empty return denies, and so does a crash.** `connect` and `join` are gates: return
|
|
185
|
+
`{ allowed: c.bool(true) }` or any truthy value to admit. A stack that falls through, or a gating
|
|
186
|
+
trigger with no `response`, refuses everyone — and a raise refuses too, because the gate is
|
|
187
|
+
seeded with a deny it keeps when the stack throws. Both failure modes lock the door, so the
|
|
188
|
+
risk to plan for is a self-inflicted lockout, not a breach: guard every drill inside a gate
|
|
189
|
+
with `ref(path, { safe: true })`, since `db.get` binds `null` on a miss. `export()` warns on
|
|
190
|
+
the missing `response`; nothing can warn about the raise. Gating is opt-in — a server with
|
|
191
|
+
no `connect` trigger admits everyone.
|
|
192
|
+
- **Only `null` drops a message.** In a `deliver` trigger (per recipient) and in a message
|
|
193
|
+
handler, `false`/`0`/`""` all deliver the message unchanged, and a crash broadcasts the
|
|
194
|
+
sender's original unvalidated payload. Return `null` to suppress. So a redaction check
|
|
195
|
+
written as a boolean sends the very message it was meant to hide. Per-viewer redaction
|
|
196
|
+
also takes **two objects**: the `deliver` trigger *and* `delivery: { perRecipient: true }`
|
|
197
|
+
on its channel. Either half alone delivers the payload unchanged to everyone, so a gate
|
|
198
|
+
whose return semantics are perfect still ships unredacted if the flag is missing — and the
|
|
199
|
+
flag costs a stack per recipient per message, so it is opt-in. `export()` warns on both
|
|
200
|
+
halves.
|
|
201
|
+
- **`conversation: { enabled: true }` alone stores nothing.** `limit` defaults to `0`, and
|
|
202
|
+
`0` means retain none. Always pass a `limit`. What a handler broadcasts *is* the stored
|
|
203
|
+
row, so broadcast everything a future joiner needs to render it.
|
|
204
|
+
- **An idle socket is reaped after ~10 minutes.** A listen-only client (feed, dashboard,
|
|
205
|
+
presence sidebar) must send `{ action: "ping" }` or any frame periodically, or it silently
|
|
206
|
+
drops and reconnects forever.
|
|
207
|
+
- **`s.realtime.publish` is the push direction, and it is fail-soft.** It bypasses the
|
|
208
|
+
channel's `publish.who` (authorization belongs in your stack), does not invoke the named
|
|
209
|
+
message's handler, and swallows a missing or disabled server — a mis-targeted publish is
|
|
210
|
+
silent. Pass the server handle and a filled-in path (`room.getChannel({ room_id: 42 })`),
|
|
211
|
+
never the template — a constant channel still carrying `{param}` throws at author time, and
|
|
212
|
+
a constant server or channel naming nothing this workspace registers warns at export.
|
|
213
|
+
|
|
214
|
+
**The superseded realtime layer.** Xano has had two realtime generations and they reuse the
|
|
215
|
+
same words. `realtimeTrigger(...)` and `s.api.realtime_event(...)` belong to the old
|
|
216
|
+
workspace-global layer; they are supported only so `codegen` can bring back a workspace that
|
|
217
|
+
holds them, and they are named in `llms/legacy.md` rather than in the authoring catalogs.
|
|
218
|
+
Aiming `s.api.realtime_event` at a current-layer channel publishes into the void — use
|
|
219
|
+
`s.realtime.publish({ server, channel, data })`, which names the owning server and so can
|
|
220
|
+
resolve the channel.
|
|
221
|
+
|
|
222
|
+
**MCP servers & agents** — both persist under the `toolset` payload key, so an `mcpServer`
|
|
223
|
+
and an `agent` **sharing a name collide**. A `tool({...})` is its own kind, referenced by
|
|
224
|
+
handle from either.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// Auth is PER-TOOL and works like a query's: name an auth table({ auth: true }).
|
|
228
|
+
mcpServer({ name: "books", tools: [{ tool: searchTool, auth: users }] });
|
|
229
|
+
|
|
230
|
+
const assistant = agent({
|
|
231
|
+
name: "assistant",
|
|
232
|
+
llm: { type: "xano-free", systemPrompt: "Be helpful.", prompt: "Answer the question." },
|
|
233
|
+
// Pass the handles directly; the `{ tool, enabled?, auth? }` wrapper (above)
|
|
234
|
+
// is only for per-tool auth or `enabled: false`.
|
|
235
|
+
tools: [searchTool],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Agents have NO public endpoint — invoke them in-stack from any host with a stack.
|
|
239
|
+
query({
|
|
240
|
+
name: "ask", verb: "POST", apiGroup: api,
|
|
241
|
+
input: { question: input.text({ required: true }) },
|
|
242
|
+
stack: [s.ai.agent.run({ agent: assistant, args: obj({ question: inp("question") }), as: "answer" })],
|
|
243
|
+
response: { text: ref("answer.result") },
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- **The run result is an envelope, not the completion.** The model's text is at **`.result`**
|
|
248
|
+
— `ref("answer")` is the whole metadata object (`finishReason`, `steps`, …). Both are
|
|
249
|
+
typed, so `InferResponse` reflects either.
|
|
250
|
+
- **`llm` is a provider-discriminated union** — `anthropic` / `openai` / `google-genai` /
|
|
251
|
+
`xano-free` (which needs no API key) — each with its provider's typed fields.
|
|
252
|
+
- **Structured output types the call site.** Author `output: { schema: { … } }` on the agent
|
|
253
|
+
with the `input.*` catalog and `.result` is typed from it wherever the handle is passed —
|
|
254
|
+
no second witness. The type-only `resultShape` is only for overriding that, or for an
|
|
255
|
+
agent referenced by bare name.
|
|
256
|
+
- **String settings are Twig-templated at run time.** The `args` you pass to
|
|
257
|
+
`s.ai.agent.run` become `{{ $args }}` (env vars are `{{ $env.NAME }}`), which is how an
|
|
258
|
+
endpoint's inputs reach the prompt. Numeric and boolean fields are not templated. Build a
|
|
259
|
+
dynamic arg with `obj({...})`, not `c.obj`.
|
|
260
|
+
- **`mcpServer().getUrl(HOST)`** derives the Streamable-HTTP endpoint from the def, the same
|
|
261
|
+
contract as `query.getPath()`. Resolve once — handing the result back in as a `HOST` throws
|
|
262
|
+
rather than append a second endpoint path. Agents expose only `getCanonical()`.
|
|
263
|
+
|
|
264
|
+
**Background execution.** `s.function.run` and `s.ai.agent.run` take a `runtime` block
|
|
265
|
+
(`{ mode: "async-shared" }`, or `"async-dedicated"` with `cpu`/`memory`/`timeout`/`maxRetry`)
|
|
266
|
+
that moves the call off the request path. This is **not** a performance knob: Xano rewrites
|
|
267
|
+
an async call to a statement that dispatches and continues, so it does not return the
|
|
268
|
+
function's result — don't bind `as` expecting a value. Collect results later with
|
|
269
|
+
`s.await({ ids })`.
|
|
270
|
+
|
|
271
|
+
## Microservices
|
|
272
|
+
|
|
273
|
+
A microservice is a container workload deployed alongside the workspace and called from a
|
|
274
|
+
stack with `s.microservice.request`. Two mutually exclusive shapes chosen by `kind`: `builtin`
|
|
275
|
+
declares containers (image/ports/resources/env/command/args) plus optional `ingresses`, and
|
|
276
|
+
`helm` points at a chart and its `values`; passing both throws.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
export const echo = microservice({
|
|
280
|
+
name: "echo",
|
|
281
|
+
deployment: {
|
|
282
|
+
replicas: 2,
|
|
283
|
+
containers: [{
|
|
284
|
+
name: "echo",
|
|
285
|
+
image: "ealen/echo-server:latest",
|
|
286
|
+
ports: [{ servicePort: "8080", containerPort: "80" }],
|
|
287
|
+
resources: { cpu: "50m", ram: "256Mi" },
|
|
288
|
+
}],
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Call it by passing the def itself. `port` folds into the single `"name:port"` host string
|
|
294
|
+
the engine reads, and is optional — a microservice exposing exactly one `servicePort`
|
|
295
|
+
resolves to it, and one exposing several requires it. A port the microservice doesn't expose
|
|
296
|
+
is a type error where the def's ports are known, and a build-time throw otherwise:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
s.microservice.request({ as: "res", host: echo, path: "/health" });
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Only `host` and `path` are required. `method`, `params`, `headers`, `timeout`, and
|
|
303
|
+
`follow_location` default to the engine's own values (`GET`, `{}`, `[]`, `10`, `true`) and are
|
|
304
|
+
always written — this statement's schema requires them, so they can't be left off the wire;
|
|
305
|
+
you just don't have to type them.
|
|
306
|
+
|
|
307
|
+
`host` binds by name, not by guid, because that is how the engine resolves it — so renaming
|
|
308
|
+
a microservice fixes every call site at once. A plain `"name:port"` string is also accepted
|
|
309
|
+
and is the only way to reach an instance-level microservice, which isn't a workspace object;
|
|
310
|
+
nothing checks that spelling, so prefer the def wherever there is one.
|
|
311
|
+
|
|
312
|
+
A container takes time to come up, so `xanots deploy` waits for it: after the import it
|
|
313
|
+
reads each microservice and reports whether it is ready, still starting, or failed, then
|
|
314
|
+
lists them. Skip the wait with `--no-verify`. The same report is available any time from
|
|
315
|
+
`xanots ephemeral get <env>`, `xanots sandbox details`, and `xanots workspace details`.
|
|
316
|
+
|
|
317
|
+
Two outcomes, and only one of them is a warning:
|
|
318
|
+
|
|
319
|
+
- **The engine reports the microservice broken** (an image that won't pull, a container that
|
|
320
|
+
won't start) — the deploy **exits 4**. Waiting longer cannot change that answer, and a URL
|
|
321
|
+
and a ✓ printed over a dead workload is not a successful deploy. This is the default; there
|
|
322
|
+
is no flag to turn it off.
|
|
323
|
+
- **It simply hasn't reported ready by the end of the wait** — a warning, exit `0`. The
|
|
324
|
+
backend is live and a slow container usually follows moments later. Pass
|
|
325
|
+
**`--require-microservices`** to make that exit 4 too, which is what CI wants: nobody is
|
|
326
|
+
there to find out whether "should come up shortly" happened.
|
|
327
|
+
|
|
328
|
+
Exit 4 is the microservice sibling of exit 3 (a `--static` upload that failed while the
|
|
329
|
+
backend deploy stood): the import committed, and something it deployed is not serving. The
|
|
330
|
+
URL and the JSON summary still print either way — the exit code is what carries the
|
|
331
|
+
difference.
|
|
332
|
+
|
|
333
|
+
`tenantDeploy: "manual"` rows are reported but never waited on — nothing starts them for you.
|
|
334
|
+
Reach for it when the row should exist without a workload behind it; `examples/sandbox` uses
|
|
335
|
+
it so deploying the examples doesn't wait on containers.
|
|
336
|
+
|
|
337
|
+
Container names are free-form: they need not match the microservice's own name, and nothing
|
|
338
|
+
about addressing depends on them. A stack reaches the **microservice** name (plus a
|
|
339
|
+
`servicePort`), whichever containers sit behind it, so a multi-container workload names each
|
|
340
|
+
one for what it is.
|
|
341
|
+
|
|
342
|
+
**This surface is early and expected to change**, and every export of a workspace declaring a
|
|
343
|
+
microservice prints a notice saying so — the docs are read before writing, which is not where
|
|
344
|
+
you are when it matters. `configs` and `volumes` are typed and `@deprecated` but **not
|
|
345
|
+
deployable**: the engine rejects an import carrying either, so `export()` fails the build
|
|
346
|
+
rather than letting the deploy fatal minutes in, after provisioning has begun. Declare a value
|
|
347
|
+
the workload reads as a container `env` entry, and storage as a container `volumes` entry
|
|
348
|
+
(`emptyDir`, `persistent`, or `config`). Both fields stay typed so a pulled workspace holding
|
|
349
|
+
one still decodes.
|
|
350
|
+
|
|
351
|
+
Two fields carry secrets into the bundle — and into a pulled tree — verbatim:
|
|
352
|
+
`chart.values` and `registryAuth.dockerconfigjson`, because otherwise a pulled microservice
|
|
353
|
+
could not be redeployed.
|
|
354
|
+
|
|
355
|
+
**What "out of band" can and cannot mean here.** Both are stored strings the engine keeps
|
|
356
|
+
exactly as given, with no deploy-time indirection — no `env()` form, no template the tenant
|
|
357
|
+
resolves. So the spelling that looks safe is the wrong one:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
// WRONG — `process.env` resolves at EXPORT time. The literal credential is written
|
|
361
|
+
// into workspace.json, and into git with it.
|
|
362
|
+
microservice({ name: "app", registryAuth: { dockerconfigjson: process.env.REGISTRY_JSON! } });
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Two honest options, both about where the bytes live rather than about hiding them:
|
|
366
|
+
|
|
367
|
+
1. **Leave `registryAuth` unset** — a public image, or a pull credential attached to the
|
|
368
|
+
microservice outside this workspace. Nothing then carries a credential.
|
|
369
|
+
2. **Accept that the tree is secret-bearing** — keep `workspace.json` and any pulled tree out
|
|
370
|
+
of git, or rotate the credential once it lands there.
|
|
371
|
+
|
|
372
|
+
Export prints a notice naming every microservice whose bundle bytes carry either field, so
|
|
373
|
+
this can't happen quietly; `--strict` does **not** promote it, since shipping a
|
|
374
|
+
private-registry workload is a legitimate end state. When what you actually need is a secret
|
|
375
|
+
your *stack* reads, the mapped surface is `workspaceConfig({ env })` + `env("NAME")` — see
|
|
376
|
+
[Middleware, request history & env vars](#middleware-request-history--env-vars).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Project structure
|
|
2
|
+
|
|
3
|
+
How a XanoTS project is laid out on disk, and why registration is explicit.
|
|
4
|
+
|
|
5
|
+
Lay objects out however you like and register them explicitly — there's no folder
|
|
6
|
+
auto-discovery magic (deliberately):
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
xano/
|
|
10
|
+
├── function/ get_user.ts export const getUser = defineFunction({...})
|
|
11
|
+
├── table/ table.ts export const user = table({...})
|
|
12
|
+
│ └── trigger/ on_insert.ts export const onInsert = tableTrigger({...})
|
|
13
|
+
├── query/ public.ts export const publicApi = apiGroup({...})
|
|
14
|
+
│ public/posts_GET.ts export const posts = query({...})
|
|
15
|
+
├── agent/ assistant.ts export const assistant = agent({...})
|
|
16
|
+
├── realtime_server/ chat.ts export const chat = realtimeServer({...})
|
|
17
|
+
│ chat/room.ts export const room = realtimeChannel({...})
|
|
18
|
+
│ chat/room/send.ts export const send = realtimeMessage({...})
|
|
19
|
+
├── workspace.ts export const workspaceSettings = workspaceConfig({...})
|
|
20
|
+
└── index.ts workspace("my-app").registerTables([...]).registerFunctions([...])…
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Objects nest under whatever owns them. Anything with children — an API group, a
|
|
24
|
+
realtime server, a channel — is a file named for itself sitting *beside* the folder
|
|
25
|
+
holding its children, so `chat.ts` opens in a tab you can tell apart and a group with
|
|
26
|
+
no queries needs no folder at all. Realtime is the deepest, being the only three-level
|
|
27
|
+
hierarchy in a workspace — server, then channel, then message — and a trigger sits in
|
|
28
|
+
a `trigger/` folder at whichever level it fires on.
|
|
29
|
+
|
|
30
|
+
Paths are lower case throughout — an HTTP verb is the one exception, because it is
|
|
31
|
+
the method rather than a word. Bindings keep the object's own casing, so a file name
|
|
32
|
+
and the symbol it exports can differ.
|
|
33
|
+
|
|
34
|
+
That is the shape `xanots codegen` writes, and its `index.ts` re-exports every object
|
|
35
|
+
by name — import from the tree's root rather than from a file, since a file path moves
|
|
36
|
+
when an object's parent or its `_shared.ts` placement changes. Hand-authored projects are
|
|
37
|
+
free to use any other layout; only `index.ts` registering the objects matters.
|
|
38
|
+
|
|
39
|
+
`workspace("my-app")` is the natural entry point — sugar for
|
|
40
|
+
`new Xano().registerWorkspace({ name: "my-app" })`, returning the same chainable registry.
|
|
41
|
+
Authoring is **declarative def-objects** passed to factories; there is no callback/chaining
|
|
42
|
+
builder. `xano.export()` returns the importable `packageExport` bundle, and
|
|
43
|
+
`xanots export`/`deploy` read the module's default export.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# The scaffolded project
|
|
2
|
+
|
|
3
|
+
What `xanots init` writes, the two frontend presets, theming, add-ons, and the SvelteKit prerendering rules.
|
|
4
|
+
|
|
5
|
+
## init flags and add-ons
|
|
6
|
+
|
|
7
|
+
`init` flags: `--framework <react|svelte>` (default: `react`), `--name <name>`
|
|
8
|
+
(default: the folder name), `--theme <id>` / `--radius <len>` / `--dark <mode>` /
|
|
9
|
+
`--font <id>` / `--font-mono <id>` / `--font-heading <id>` / `--icons <id>`
|
|
10
|
+
(the look — see [Theming](#theming)), `--ai <claude|codex|cursor|none>`
|
|
11
|
+
(repeatable; writes `CLAUDE.md`/`AGENTS.md`/Cursor rules — none by default),
|
|
12
|
+
`--marketplace <pkg>` (repeatable, comma-separated; installs add-ons and
|
|
13
|
+
registers them — below), `--force` (scaffold into a non-empty folder),
|
|
14
|
+
`--no-install` (skip `npm install`).
|
|
15
|
+
In a terminal, `init` prompts for the framework, the theme, and the AI files;
|
|
16
|
+
every prompt has a default, so pressing enter three times is a valid answer.
|
|
17
|
+
The starter backend is empty but already compiles and deploys — grow it from the
|
|
18
|
+
walkthrough in `xano/EXAMPLE.md`.
|
|
19
|
+
|
|
20
|
+
A scaffold ships `@xanots/sdk` and nothing else from the `@xanots` scope. Add-ons
|
|
21
|
+
install on demand:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
xanots marketplace list # every published add-on
|
|
25
|
+
xanots marketplace search auth # …or narrow by keyword
|
|
26
|
+
xanots marketplace details @xanots/auth # what it installs + how to register it
|
|
27
|
+
xanots marketplace install @xanots/auth # add it to the project you're in
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The three read verbs hit a public catalogue, so they work before you log in.
|
|
31
|
+
Every add-on is optional and none is assumed by anything in the scaffold —
|
|
32
|
+
install one when you need it.
|
|
33
|
+
|
|
34
|
+
`init` takes the same package names, so a project can be scaffolded with its
|
|
35
|
+
add-ons already wired:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
xanots init my-app --framework react --marketplace @xanots/auth,@xanots/vector
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That installs each package **and** writes its registration into `xano/index.ts`.
|
|
42
|
+
Installing alone would leave dependencies nothing imports — a different project
|
|
43
|
+
wearing the same name. A module declares how it registers in its own
|
|
44
|
+
`package.json` (`"xanots": { "register": "registerAuth" }`); one that has not
|
|
45
|
+
adopted the field is read for a single `register*` export, and two of those
|
|
46
|
+
without the field is a refusal rather than a guess.
|
|
47
|
+
|
|
48
|
+
`@xanots/auth` is **authentication, not authorization** — user/login/signup
|
|
49
|
+
tables and the endpoints over them. It ships no roles, permissions, or route
|
|
50
|
+
guards, so it is not the RBAC answer. Build role guards natively: a role column
|
|
51
|
+
on the auth table, then a `s.precondition` on each endpoint gating
|
|
52
|
+
`auth("role")`.
|
|
53
|
+
|
|
54
|
+
`details` is the one to reach for when wiring an add-on: it prints the objects
|
|
55
|
+
the add-on puts on your workspace, what you have to supply, and the
|
|
56
|
+
`xano/index.ts` registration to copy. Piped, it emits JSON; `--prompt` emits
|
|
57
|
+
instructions written to be handed straight to a coding agent.
|
|
58
|
+
|
|
59
|
+
That is `npm install` with two additions: add-ons are discoverable from `xanots
|
|
60
|
+
--help`, and the command refuses before npm runs when you are not standing in a
|
|
61
|
+
project — the mistake npm answers by silently writing to the wrong `package.json`.
|
|
62
|
+
The package name is passed through exactly as typed, so version specifiers, tags,
|
|
63
|
+
and third-party packages all work.
|
|
64
|
+
|
|
65
|
+
## The frontend preset
|
|
66
|
+
|
|
67
|
+
To point `npm run dev` at a real backend, copy `.env.example` to `.env.local` — both
|
|
68
|
+
live at the **project root**, next to `vite.config.ts` — and set `VITE_XANO_HOST` to a
|
|
69
|
+
deployed URL. Deployed builds don't need it: `xanots deploy <entry> --static <dir>` injects the
|
|
70
|
+
backend URL as `window.XANO_HOST`, which takes precedence.
|
|
71
|
+
|
|
72
|
+
The frontend ships `Button` and `Card` already vendored, plus a pre-configured
|
|
73
|
+
`components.json`, so `npx shadcn@latest add dialog form input` (or
|
|
74
|
+
`npx shadcn-svelte@latest add …` on a Svelte scaffold) works immediately — no
|
|
75
|
+
`init` step for either CLI. Components are copied into your repo rather than
|
|
76
|
+
installed, so you own and edit them directly. [Lucide](https://lucide.dev/icons)
|
|
77
|
+
is installed on both scaffolds — `lucide-react` on React, `@lucide/svelte` on
|
|
78
|
+
Svelte — and the landing page already uses it.
|
|
79
|
+
|
|
80
|
+
## Theming
|
|
81
|
+
|
|
82
|
+
shadcn components carry no colors of their own: they are Tailwind utilities over
|
|
83
|
+
a fixed set of semantic tokens (`--primary`, `--muted-foreground`, `--border`,
|
|
84
|
+
the `--chart-*` ramp, the `--sidebar-*` set). Those tokens live at the top of
|
|
85
|
+
`frontend/src/index.css`, which is the whole theme — Tailwind v4 keeps it in CSS,
|
|
86
|
+
and there is no `tailwind.config.js`. That one stylesheet backs both frameworks.
|
|
87
|
+
|
|
88
|
+
`init` renders it from a theme you choose, using shadcn's own two-part model:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
xanots init my-app --theme zinc-blue # a base color, plus an accent over it
|
|
92
|
+
xanots init my-app --theme stone # a base color alone
|
|
93
|
+
xanots init my-app --theme zinc --radius 0
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Base colors — the full token set: `neutral` (default), `stone`, `zinc`, `mauve`,
|
|
97
|
+
`olive`, `mist`, `taupe`. Accents — a partial override of `primary`, `secondary`,
|
|
98
|
+
the chart ramp, and the sidebar primary: `amber`, `blue`, `cyan`, `emerald`,
|
|
99
|
+
`fuchsia`, `green`, `indigo`, `lime`, `orange`, `pink`, `purple`, `red`, `rose`,
|
|
100
|
+
`sky`, `teal`, `violet`, `yellow`. The values are shadcn's, verbatim, so
|
|
101
|
+
`--theme zinc-blue` is what ui.shadcn.com hands out for the same pair.
|
|
102
|
+
|
|
103
|
+
`--theme` also takes any shadcn **registry theme** — its own, a third-party
|
|
104
|
+
generator's, or your team's:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
xanots init my-app --theme https://ui.shadcn.com/r/themes/slate.json
|
|
108
|
+
xanots init my-app --theme ./brand-theme.json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`--radius <len>` overrides the corner radius (a bare number is rem). Everything
|
|
112
|
+
else about the project is identical whichever theme you pick, and you can change
|
|
113
|
+
your mind later by editing the token values — or by applying another theme over
|
|
114
|
+
them with `npx shadcn@latest add <registry-theme-url>`.
|
|
115
|
+
|
|
116
|
+
### Dark mode
|
|
117
|
+
|
|
118
|
+
Every theme ships a complete dark palette. `--dark` decides what turns it on:
|
|
119
|
+
|
|
120
|
+
- `system` (default) — an inline script in the HTML entry applies the OS setting
|
|
121
|
+
before first paint, so the page never flashes light first. No UI.
|
|
122
|
+
- `toggle` — that, plus `frontend/src/lib/theme.ts` (the persisted mode) and a
|
|
123
|
+
mode toggle on the landing page cycling system → light → dark.
|
|
124
|
+
- `off` — light only. The `.dark` block is still there and still complete.
|
|
125
|
+
|
|
126
|
+
Whichever you pick, style with the token classes (`bg-primary`,
|
|
127
|
+
`text-muted-foreground`) rather than raw palette classes like `bg-gray-100`:
|
|
128
|
+
raw ones ignore the theme and are unreadable in dark mode. The scaffolded AI
|
|
129
|
+
instruction files say so too.
|
|
130
|
+
|
|
131
|
+
### Typefaces and icons
|
|
132
|
+
|
|
133
|
+
Fonts are opt-in and **self-hosted**: each choice installs an `@fontsource`
|
|
134
|
+
package rather than linking Google's CDN, which would be a third-party request
|
|
135
|
+
on every page load of your app, a failure behind a firewall, and a privacy
|
|
136
|
+
question someone inherits later.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
xanots init my-app --font geist --font-heading instrument-serif --icons tabler
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `--font <id>` — body text (Tailwind's `--font-sans`, which v4 also uses as the
|
|
143
|
+
page default). Sans faces only: `geist`, `inter`, `figtree`, `manrope`,
|
|
144
|
+
`dm-sans`, `space-grotesk`, `outfit`, and 10 more.
|
|
145
|
+
- `--font-mono <id>` — code. `jetbrains-mono` or `geist-mono`.
|
|
146
|
+
- `--font-heading <id>` — headings. Accepts **any** face, sans or serif, since a
|
|
147
|
+
display serif over a sans body is the reason the slot exists. It emits a
|
|
148
|
+
base-layer rule, so headings pick it up without touching every `<h1>`.
|
|
149
|
+
- Omit a slot and it keeps Tailwind's default stack, installing nothing for it.
|
|
150
|
+
|
|
151
|
+
An unknown id fails at `init` naming the slot it was resolving, so `--font-mono
|
|
152
|
+
inter` is an error rather than a proportional face quietly rendering your code
|
|
153
|
+
blocks.
|
|
154
|
+
|
|
155
|
+
`--icons <id>` picks the icon set: `lucide` (default), `tabler`, or `phosphor`.
|
|
156
|
+
The binding covers the dark-mode toggle's icons as well as the landing page's —
|
|
157
|
+
without that, `--icons tabler --dark toggle` would emit a toggle importing a
|
|
158
|
+
library the project no longer installs, a build failure from a flag with nothing
|
|
159
|
+
to do with dark mode.
|
|
160
|
+
|
|
161
|
+
> On a Svelte scaffold `npm run typecheck` runs `svelte-kit sync && svelte-check`
|
|
162
|
+
> rather than `tsc`. It checks the backend and the components together — `tsc`
|
|
163
|
+
> cannot read `.svelte` files at all.
|
|
164
|
+
|
|
165
|
+
> **The SvelteKit scaffold prerenders every route.** `frontend/src/routes/+layout.ts`
|
|
166
|
+
> sets `prerender = true`, so each route becomes its own HTML document at build
|
|
167
|
+
> time and loads as a real page. Pages live in `frontend/src/routes/`, and `files`
|
|
168
|
+
> in the `sveltekit()` plugin config keeps the project single-rooted with `xano/`
|
|
169
|
+
> as a peer. That config lives in `vite.config.ts` — there is no
|
|
170
|
+
> `svelte.config.js`, matching where SvelteKit's own scaffold now puts it.
|
|
171
|
+
>
|
|
172
|
+
> There is still no server at runtime — Xano is the backend and `deploy --static`
|
|
173
|
+
> ships to a host with no runtime, so `+page.server.ts`, form actions, and server
|
|
174
|
+
> `load` have nothing to run on, and the build does not stop you. Treat them as
|
|
175
|
+
> unavailable rather than trusting a green build.
|
|
176
|
+
>
|
|
177
|
+
> Two things follow from prerendering. Because it renders at build time,
|
|
178
|
+
> module-scope `window`/`document` access fails the **build** rather than the
|
|
179
|
+
> browser — use `onMount`, or guard with `browser`. And a dynamic route like
|
|
180
|
+
> `/posts/[id]` **fails the build** unless it declares which ids exist:
|
|
181
|
+
>
|
|
182
|
+
> ```ts
|
|
183
|
+
> // frontend/src/routes/posts/[id]/+page.ts
|
|
184
|
+
> export const entries = () => [{ id: "1" }, { id: "2" }];
|
|
185
|
+
> ```
|
|
186
|
+
>
|
|
187
|
+
> That is deliberate — a loud build error beats shipping a page that 404s for
|
|
188
|
+
> real users. Unmatched paths get a real 404 from
|
|
189
|
+
> `frontend/src/routes/404/+page.svelte`, which prerenders to `404.html`. It has
|
|
190
|
+
> to be a route: SvelteKit never prerenders `+error.svelte` to a file, so that
|
|
191
|
+
> alone would ship no `404.html` and every unknown path would serve the home page
|
|
192
|
+
> with a 200 instead.
|
|
193
|
+
>
|
|
194
|
+
> One more build-time check comes with prerendering: a hash link to an id that is
|
|
195
|
+
> not on the page it renders on — `<a href="#pricing">` with no `id="pricing"` —
|
|
196
|
+
> **fails the build**, naming the route and the id. A hash nav in
|
|
197
|
+
> `+layout.svelte` is exempt on `/404` only, since that route inherits the layout
|
|
198
|
+
> and by definition carries none of the page's sections.
|